width cap
This commit is contained in:
309
src/templates.js
309
src/templates.js
@@ -7,9 +7,9 @@ function escapeHtml(value = "") {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function commonHead({ title = "oEmbed Graphics" } = {}) {
|
||||
function commonHead({ title = "oEmbed Graphics", htmlClass = "" } = {}) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en"${htmlClass ? ` class="${escapeHtml(htmlClass)}"` : ""}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -18,6 +18,284 @@ function commonHead({ title = "oEmbed Graphics" } = {}) {
|
||||
</head>`;
|
||||
}
|
||||
|
||||
function slugify(value = "") {
|
||||
return String(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "unknown";
|
||||
}
|
||||
|
||||
function numericPixelValue(value) {
|
||||
const number = Number(value);
|
||||
|
||||
return Number.isFinite(number) && number > 0 ? `${number}px` : "";
|
||||
}
|
||||
|
||||
function cappedPixelValue(value, cap) {
|
||||
const number = Number(value);
|
||||
|
||||
if (!Number.isFinite(number) || number <= 0) {
|
||||
return `${cap}px`;
|
||||
}
|
||||
|
||||
return `${Math.min(number, cap)}px`;
|
||||
}
|
||||
|
||||
function addIframePermissions(tag) {
|
||||
const autoplayPermission = "autoplay";
|
||||
|
||||
if (/allow=/i.test(tag)) {
|
||||
return tag.replace(/\sallow=(["'])(.*?)\1/i, (_match, quote, value) => {
|
||||
const permissions = value
|
||||
.split(";")
|
||||
.map((permission) => permission.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!permissions.includes(autoplayPermission)) {
|
||||
permissions.push(autoplayPermission);
|
||||
}
|
||||
|
||||
return ` allow=${quote}${permissions.join("; ")}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
return tag.replace(/<iframe/i, '<iframe allow="autoplay; fullscreen; picture-in-picture"');
|
||||
}
|
||||
|
||||
function addAutoplayToIframeSrc(tag, { autoplay, muted }) {
|
||||
if (!autoplay) {
|
||||
return tag;
|
||||
}
|
||||
|
||||
return tag.replace(/\ssrc=(["'])(.*?)\1/i, (_match, quote, src) => {
|
||||
try {
|
||||
const url = new URL(src.replaceAll("&", "&"));
|
||||
url.searchParams.set("autoplay", "1");
|
||||
url.searchParams.set("playsinline", "1");
|
||||
|
||||
if (muted) {
|
||||
url.searchParams.set("mute", "1");
|
||||
url.searchParams.set("muted", "1");
|
||||
}
|
||||
|
||||
return ` src=${quote}${escapeHtml(url.toString())}${quote}`;
|
||||
} catch {
|
||||
return ` src=${quote}${src}${quote}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addBooleanAttribute(tag, attribute, enabled) {
|
||||
if (!enabled || new RegExp(`\\s${attribute}(\\s|>|=)`, "i").test(tag)) {
|
||||
return tag;
|
||||
}
|
||||
|
||||
return tag.replace(/>$/, ` ${attribute}>`);
|
||||
}
|
||||
|
||||
export function prepareEmbedHtml(html, { autoplay = true, muted = true } = {}) {
|
||||
return html
|
||||
.replace(/<iframe\b[^>]*>/gi, (tag) => addIframePermissions(addAutoplayToIframeSrc(tag, { autoplay, muted })))
|
||||
.replace(/<video\b[^>]*>/gi, (tag) => {
|
||||
let videoTag = addBooleanAttribute(tag, "playsinline", autoplay);
|
||||
videoTag = addBooleanAttribute(videoTag, "autoplay", autoplay);
|
||||
videoTag = addBooleanAttribute(videoTag, "muted", muted);
|
||||
return videoTag;
|
||||
});
|
||||
}
|
||||
|
||||
function revealScript({ readyDelay, maxWait }) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const embed = document.querySelector(".embed");
|
||||
const readyDelay = ${JSON.stringify(readyDelay)};
|
||||
const maxWait = ${JSON.stringify(maxWait)};
|
||||
const pending = new Set();
|
||||
|
||||
let revealed = false;
|
||||
let quietTimer;
|
||||
|
||||
const reveal = () => {
|
||||
if (revealed) {
|
||||
return;
|
||||
}
|
||||
|
||||
revealed = true;
|
||||
clearTimeout(quietTimer);
|
||||
observer.disconnect();
|
||||
body.classList.add("is-ready");
|
||||
};
|
||||
|
||||
const scheduleReveal = () => {
|
||||
clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(reveal, readyDelay);
|
||||
};
|
||||
|
||||
const markReady = (element) => {
|
||||
pending.delete(element);
|
||||
if (!pending.size) {
|
||||
scheduleReveal();
|
||||
}
|
||||
};
|
||||
|
||||
const watchMedia = (element) => {
|
||||
if (!element || pending.has(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pending.add(element);
|
||||
|
||||
if (element.tagName === "IMG" && element.complete) {
|
||||
markReady(element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName === "VIDEO" && element.readyState >= 2) {
|
||||
markReady(element);
|
||||
return;
|
||||
}
|
||||
|
||||
element.addEventListener("load", () => markReady(element), { once: true });
|
||||
element.addEventListener("loadeddata", () => markReady(element), { once: true });
|
||||
element.addEventListener("error", () => markReady(element), { once: true });
|
||||
};
|
||||
|
||||
const watchNode = (node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.matches("iframe, img, video")) {
|
||||
watchMedia(node);
|
||||
}
|
||||
|
||||
for (const element of node.querySelectorAll("iframe, img, video")) {
|
||||
watchMedia(element);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
watchNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pending.size) {
|
||||
scheduleReveal();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(embed, { childList: true, subtree: true });
|
||||
|
||||
for (const element of embed.querySelectorAll("iframe, img, video")) {
|
||||
watchMedia(element);
|
||||
}
|
||||
|
||||
if (!pending.size) {
|
||||
scheduleReveal();
|
||||
}
|
||||
|
||||
window.addEventListener("load", scheduleReveal, { once: true });
|
||||
setTimeout(reveal, maxWait);
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function autoplayAssistScript({ muted }) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const muted = ${JSON.stringify(muted)};
|
||||
const root = document.querySelector(".embed");
|
||||
const attempts = new WeakMap();
|
||||
|
||||
const playVideo = (video) => {
|
||||
if (!(video instanceof HTMLVideoElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute("autoplay", "");
|
||||
video.setAttribute("playsinline", "");
|
||||
|
||||
if (muted) {
|
||||
video.muted = true;
|
||||
video.defaultMuted = true;
|
||||
video.setAttribute("muted", "");
|
||||
}
|
||||
|
||||
const count = attempts.get(video) || 0;
|
||||
if (count >= 12) {
|
||||
return;
|
||||
}
|
||||
|
||||
attempts.set(video, count + 1);
|
||||
const promise = video.play();
|
||||
|
||||
if (promise && typeof promise.catch === "function") {
|
||||
promise.catch(() => {
|
||||
setTimeout(() => playVideo(video), 500);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nudgeIframe = (iframe) => {
|
||||
iframe.setAttribute("allow", [
|
||||
iframe.getAttribute("allow") || "",
|
||||
"autoplay",
|
||||
"fullscreen",
|
||||
"picture-in-picture",
|
||||
].join("; "));
|
||||
|
||||
try {
|
||||
for (const video of iframe.contentDocument.querySelectorAll("video")) {
|
||||
playVideo(video);
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin provider frames cannot be inspected from this page.
|
||||
}
|
||||
};
|
||||
|
||||
const scan = (node = root) => {
|
||||
if (!node || !(node instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.matches("video")) {
|
||||
playVideo(node);
|
||||
}
|
||||
|
||||
if (node.matches("iframe")) {
|
||||
nudgeIframe(node);
|
||||
}
|
||||
|
||||
for (const video of node.querySelectorAll("video")) {
|
||||
playVideo(video);
|
||||
}
|
||||
|
||||
for (const iframe of node.querySelectorAll("iframe")) {
|
||||
nudgeIframe(iframe);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
scan(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
scan();
|
||||
window.addEventListener("load", scan, { once: true });
|
||||
setInterval(scan, 1000);
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
export function homePage({ providersCount = 0 } = {}) {
|
||||
return `${commonHead()}
|
||||
<body class="app">
|
||||
@@ -39,7 +317,10 @@ export function homePage({ providersCount = 0 } = {}) {
|
||||
<option value="cover">cover</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
||||
<label class="checkbox-label"><input name="wait" value="1" type="checkbox" checked> wait</label>
|
||||
<label class="checkbox-label"><input name="autoplay" value="1" type="checkbox" checked> autoplay</label>
|
||||
<label class="checkbox-label"><input name="muted" value="1" type="checkbox" checked> muted</label>
|
||||
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
||||
</div>
|
||||
</form>
|
||||
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
||||
@@ -58,20 +339,34 @@ export function graphicPage({
|
||||
transparent,
|
||||
scale,
|
||||
chroma,
|
||||
autoplay = true,
|
||||
muted = true,
|
||||
wait = true,
|
||||
readyDelay = 1000,
|
||||
maxWait = 10000,
|
||||
}) {
|
||||
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
||||
const html = embed.html || "";
|
||||
const providerClass = `provider-${slugify(embed.provider_name)}`;
|
||||
const embedWidth = cappedPixelValue(embed.width, 500);
|
||||
const embedHeight = numericPixelValue(embed.height);
|
||||
const embedStyle = [
|
||||
embedWidth ? `--embed-width:${embedWidth}` : "",
|
||||
embedHeight ? `--embed-height:${embedHeight}` : "",
|
||||
].filter(Boolean).join(";");
|
||||
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
|
||||
const media = embed.type === "photo" && embed.url
|
||||
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
|
||||
: html;
|
||||
|
||||
return `${commonHead({ title })}
|
||||
<body class="graphic ${transparent ? "transparent" : ""}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
||||
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
||||
<body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
||||
<main class="stage fit-${escapeHtml(fit)}">
|
||||
<section class="embed" data-source="${escapeHtml(targetUrl)}">
|
||||
<section class="embed ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
|
||||
${media}
|
||||
</section>
|
||||
</main>
|
||||
${wait ? revealScript({ readyDelay, maxWait }) : ""}
|
||||
${autoplay ? autoplayAssistScript({ muted }) : ""}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user