UNPKG

twitch-video-element

Version:

A custom element for the Twitch player with an API that matches the `<video>` API

416 lines (415 loc) 12.7 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 twitch_video_element_exports = {}; __export(twitch_video_element_exports, { default: () => twitch_video_element_default }); module.exports = __toCommonJS(twitch_video_element_exports); const EMBED_BASE = "https://player.twitch.tv"; const MATCH_VIDEO = /(?:www\.|go\.)?twitch\.tv\/(?:videos?\/|\?video=)(\d+)($|\?)/; const MATCH_CHANNEL = /(?:www\.|go\.)?twitch\.tv\/([a-zA-Z0-9_]+)($|\?)/; const PlaybackState = { IDLE: "Idle", READY: "Ready", BUFFERING: "Buffering", PLAYING: "Playing", ENDED: "Ended" }; const PlayerCommands = { DISABLE_CAPTIONS: 0, ENABLE_CAPTIONS: 1, PAUSE: 2, PLAY: 3, SEEK: 4, SET_CHANNEL: 5, SET_CHANNEL_ID: 6, SET_COLLECTION: 7, SET_QUALITY: 8, SET_VIDEO: 9, SET_MUTED: 10, SET_VOLUME: 11 }; function getTemplateHTML(attrs, props = {}) { const iframeAttrs = { src: serializeIframeUrl(attrs, props), frameborder: "0", width: "100%", height: "100%", allow: "accelerometer; fullscreen; autoplay; encrypted-media; picture-in-picture;", sandbox: "allow-modals allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox", scrolling: "no" }; if (props.config) { iframeAttrs["data-config"] = JSON.stringify(props.config); } return ( /*html*/ ` <style> :host { display: inline-block; min-width: 300px; min-height: 150px; position: relative; } iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } :host(:not([controls])) { pointer-events: none; } </style> <iframe${serializeAttributes(iframeAttrs)}></iframe> ` ); } function serializeIframeUrl(attrs, props) { var _a; if (!attrs.src) return; const videoMatch = attrs.src.match(MATCH_VIDEO); const channelMatch = attrs.src.match(MATCH_CHANNEL); const params = { parent: (_a = globalThis.location) == null ? void 0 : _a.hostname, // ?controls=true is enabled by default in the iframe controls: attrs.controls === "" ? null : false, autoplay: attrs.autoplay === "" ? null : false, muted: attrs.muted, preload: attrs.preload, ...props.config }; if (videoMatch) { const videoId = videoMatch[1]; return `${EMBED_BASE}/?video=v${videoId}&${serialize(params)}`; } else if (channelMatch) { const channel = channelMatch[1]; return `${EMBED_BASE}/?channel=${channel}&${serialize(params)}`; } return ""; } class TwitchVideoElement extends (globalThis.HTMLElement ?? class { }) { static getTemplateHTML = getTemplateHTML; static shadowRootOptions = { mode: "open" }; static observedAttributes = ["autoplay", "controls", "loop", "muted", "playsinline", "preload", "src"]; loadComplete = new PublicPromise(); #loadRequested; #hasLoaded; #iframe; #playerState = {}; #currentTime = 0; #muted = false; #volume = 1; #paused = !this.autoplay; #seeking = false; #readyState = 0; #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.#hasLoaded = true; await (this.#loadRequested = Promise.resolve()); this.#loadRequested = null; this.#readyState = 0; this.dispatchEvent(new Event("emptied")); if (!this.src) { this.shadowRoot.innerHTML = ""; globalThis.removeEventListener("message", this.#onMessage); return; } this.dispatchEvent(new Event("loadstart")); let iframe = this.shadowRoot.querySelector("iframe"); const 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"); } this.#iframe = iframe; globalThis.addEventListener("message", this.#onMessage); } attributeChangedCallback(attrName, oldValue, newValue) { if (oldValue === newValue) return; switch (attrName) { case "src": case "controls": { this.load(); break; } } } getVideoPlaybackQuality() { return this.#playerState.stats.videoStats; } get src() { return this.getAttribute("src"); } set src(value) { this.setAttribute("src", value); } get readyState() { return this.#readyState; } get seeking() { return this.#seeking; } get buffered() { var _a, _b; return createTimeRanges(0, ((_b = (_a = this.#playerState.stats) == null ? void 0 : _a.videoStats) == null ? void 0 : _b.bufferSize) ?? 0); } get paused() { if (!this.#playerState.playback) return this.#paused; return this.#playerState.playback === PlaybackState.IDLE; } get ended() { if (!this.#playerState.playback) return false; return this.#playerState.playback === PlaybackState.ENDED; } get duration() { return this.#playerState.duration ?? NaN; } get autoplay() { return this.hasAttribute("autoplay"); } set autoplay(val) { if (this.autoplay == val) return; this.toggleAttribute("autoplay", Boolean(val)); } get controls() { return this.hasAttribute("controls"); } set controls(val) { if (this.controls == val) return; this.toggleAttribute("controls", Boolean(val)); } get currentTime() { if (!this.#playerState.currentTime) return this.#currentTime; return this.#playerState.currentTime; } set currentTime(val) { this.#currentTime = val; this.loadComplete.then(() => { this.#sendCommand(PlayerCommands.SEEK, val); }); } get defaultMuted() { return this.hasAttribute("muted"); } set defaultMuted(val) { this.toggleAttribute("muted", Boolean(val)); } get loop() { return this.hasAttribute("loop"); } set loop(val) { this.toggleAttribute("loop", Boolean(val)); } get muted() { return this.#muted; } set muted(val) { this.#muted = val; this.loadComplete.then(() => { this.#sendCommand(PlayerCommands.SET_MUTED, val); }); } get volume() { return this.#volume; } set volume(val) { this.#volume = val; this.loadComplete.then(() => { this.#sendCommand(PlayerCommands.SET_VOLUME, val); }); } get playsInline() { return this.hasAttribute("playsinline"); } set playsInline(val) { this.toggleAttribute("playsinline", Boolean(val)); } play() { this.#paused = false; this.#sendCommand(PlayerCommands.PLAY); } pause() { this.#paused = true; this.#sendCommand(PlayerCommands.PAUSE); } #onMessage = async (event) => { var _a, _b, _c, _d; if (!this.#iframe.contentWindow) return; const { data, source } = event; const isFromEmbedWindow = source === this.#iframe.contentWindow; if (!isFromEmbedWindow) return; if (data.namespace === "twitch-embed") { await new Promise((resolve) => setTimeout(resolve, 10)); if (data.eventName === "ready") { this.dispatchEvent(new Event("loadcomplete")); this.loadComplete.resolve(); this.#readyState = 1; this.dispatchEvent(new Event("loadedmetadata")); } else if (data.eventName === "seek") { this.#seeking = true; this.dispatchEvent(new Event("seeking")); } else if (data.eventName === "playing") { if (this.#seeking) { this.#seeking = false; this.dispatchEvent(new Event("seeked")); } this.#readyState = 3; this.dispatchEvent(new Event("playing")); } else { this.dispatchEvent(new Event(data.eventName)); } } else if (data.namespace === "twitch-embed-player-proxy" && data.eventName === "UPDATE_STATE") { const oldDuration = this.#playerState.duration; const oldCurrentTime = this.#playerState.currentTime; const oldVolume = this.#playerState.volume; const oldMuted = this.#playerState.muted; const oldBuffered = (_b = (_a = this.#playerState.stats) == null ? void 0 : _a.videoStats) == null ? void 0 : _b.bufferSize; this.#playerState = { ...this.#playerState, ...data.params }; if (oldDuration !== this.#playerState.duration) { this.dispatchEvent(new Event("durationchange")); } if (oldCurrentTime !== this.#playerState.currentTime) { this.dispatchEvent(new Event("timeupdate")); } if (oldVolume !== this.#playerState.volume || oldMuted !== this.#playerState.muted) { if (this.#playerState.volume !== void 0) { this.#volume = this.#playerState.volume; } if (this.#playerState.muted !== void 0) { this.#muted = this.#playerState.muted; } this.dispatchEvent(new Event("volumechange")); } if (oldBuffered !== ((_d = (_c = this.#playerState.stats) == null ? void 0 : _c.videoStats) == null ? void 0 : _d.bufferSize)) { this.dispatchEvent(new Event("progress")); } } }; #sendCommand(command, params) { if (!this.#iframe.contentWindow) return; const message = { eventName: command, params, namespace: "twitch-embed-player-proxy" }; this.#iframe.contentWindow.postMessage(message, EMBED_BASE); } // 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 namedNodeMapToObject(namedNodeMap) { let obj = {}; for (let attr of namedNodeMap) { obj[attr.name] = attr.value; } return obj; } 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(filterParams(props))); } function filterParams(props) { let p = {}; for (let key in props) { let val = props[key]; if (val === true || val === "") p[key] = true; else if (val === false) p[key] = false; else if (val != null) p[key] = val; } return p; } 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("twitch-video")) { globalThis.customElements.define("twitch-video", TwitchVideoElement); } var twitch_video_element_default = TwitchVideoElement;