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
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
}
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;