feat: add dark mode and style baking support for Samsung TV compatibility
This commit is contained in:
+44
-5
@@ -299,6 +299,21 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.dark-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dark-toggle input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -700,7 +715,9 @@
|
|||||||
container.innerHTML = views
|
container.innerHTML = views
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const viewUrl = origin + "/v/" + v.id;
|
const viewUrl = origin + "/v/" + v.id;
|
||||||
const urlPreview = v.urls.map((u, i) => `${u.url} (${u.durationSec}s)`).join(" → ");
|
const urlPreview = v.urls.map((u, i) =>
|
||||||
|
`${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}`
|
||||||
|
).join(" → ");
|
||||||
const badges = [
|
const badges = [
|
||||||
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
||||||
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
|
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
|
||||||
@@ -795,7 +812,7 @@
|
|||||||
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
|
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
|
||||||
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
|
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
|
||||||
let urlRowsHTML = urls
|
let urlRowsHTML = urls
|
||||||
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length))
|
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode))
|
||||||
.join("");
|
.join("");
|
||||||
const method = existing ? existing.method : "ssr";
|
const method = existing ? existing.method : "ssr";
|
||||||
const metaChecked = existing ? existing.metaRefreshEnabled : false;
|
const metaChecked = existing ? existing.metaRefreshEnabled : false;
|
||||||
@@ -803,6 +820,8 @@
|
|||||||
const cacheTtl = existing ? existing.cacheTtlSec : 60;
|
const cacheTtl = existing ? existing.cacheTtlSec : 60;
|
||||||
const vpW = existing ? existing.viewportWidth : 1920;
|
const vpW = existing ? existing.viewportWidth : 1920;
|
||||||
const vpH = existing ? existing.viewportHeight : 1080;
|
const vpH = existing ? existing.viewportHeight : 1080;
|
||||||
|
const bakeStylesChecked = existing ? existing.bakeStyles : false;
|
||||||
|
const bakeSectionStyle = method === "ssr" ? "" : "display:none;";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<h2>${title}</h2>
|
<h2>${title}</h2>
|
||||||
@@ -836,6 +855,14 @@
|
|||||||
<div class="hint" style="margin-top:-8px;margin-bottom:16px;">TV reloads when rotation advances. Required for multi-URL on non-JS browsers.</div>
|
<div class="hint" style="margin-top:-8px;margin-bottom:16px;">TV reloads when rotation advances. Required for multi-URL on non-JS browsers.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="bake-section" style="${bakeSectionStyle}">
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="bake-styles" ${bakeStylesChecked ? "checked" : ""}>
|
||||||
|
<label for="bake-styles">Bake computed styles into HTML (Samsung TV compatibility)</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint" style="margin-top:-8px;margin-bottom:16px;">Fixes styling issues on browsers with limited CSS support (e.g., DuckDuckGo). Increases HTML size.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Cache TTL (seconds)</label>
|
<label>Cache TTL (seconds)</label>
|
||||||
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
||||||
@@ -862,14 +889,19 @@
|
|||||||
</form>`;
|
</form>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrlRowHTML(url, durationSec, index, total) {
|
function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) {
|
||||||
const isSingle = total <= 1;
|
const isSingle = total <= 1;
|
||||||
const disabledAttr = isSingle ? "disabled" : "";
|
const disabledAttr = isSingle ? "disabled" : "";
|
||||||
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
|
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
|
||||||
|
const darkChecked = forceDarkMode ? "checked" : "";
|
||||||
return `
|
return `
|
||||||
<div class="url-row" data-index="${index}">
|
<div class="url-row" data-index="${index}">
|
||||||
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
|
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
|
||||||
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required>
|
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required>
|
||||||
|
<label class="dark-toggle" title="Force dark mode">
|
||||||
|
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
|
||||||
|
🌙
|
||||||
|
</label>
|
||||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${isSingle ? "disabled" : ""}>✕</button>
|
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${isSingle ? "disabled" : ""}>✕</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -882,7 +914,7 @@
|
|||||||
const container = document.getElementById("url-rows");
|
const container = document.getElementById("url-rows");
|
||||||
const rows = container.querySelectorAll(".url-row");
|
const rows = container.querySelectorAll(".url-row");
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1);
|
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false);
|
||||||
container.appendChild(div.firstElementChild);
|
container.appendChild(div.firstElementChild);
|
||||||
updateUrlRowStates();
|
updateUrlRowStates();
|
||||||
}
|
}
|
||||||
@@ -914,6 +946,7 @@
|
|||||||
function onMethodChange() {
|
function onMethodChange() {
|
||||||
const method = document.getElementById("view-method").value;
|
const method = document.getElementById("view-method").value;
|
||||||
document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none";
|
document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none";
|
||||||
|
document.getElementById("bake-section").style.display = method === "ssr" ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
@@ -929,6 +962,7 @@
|
|||||||
const name = document.getElementById("view-name").value.trim();
|
const name = document.getElementById("view-name").value.trim();
|
||||||
const method = document.getElementById("view-method").value;
|
const method = document.getElementById("view-method").value;
|
||||||
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
|
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
|
||||||
|
const bakeStyles = document.getElementById("bake-styles")?.checked ?? false;
|
||||||
const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60;
|
const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60;
|
||||||
const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920;
|
const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920;
|
||||||
const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080;
|
const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080;
|
||||||
@@ -937,9 +971,11 @@
|
|||||||
for (const row of document.querySelectorAll("#url-rows .url-row")) {
|
for (const row of document.querySelectorAll("#url-rows .url-row")) {
|
||||||
const urlVal = row.querySelector(".url-input").value.trim();
|
const urlVal = row.querySelector(".url-input").value.trim();
|
||||||
if (!urlVal) continue;
|
if (!urlVal) continue;
|
||||||
|
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
|
||||||
urls.push({
|
urls.push({
|
||||||
url: urlVal,
|
url: urlVal,
|
||||||
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
|
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
|
||||||
|
forceDarkMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,7 +989,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight };
|
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight };
|
||||||
if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled;
|
if (method === "ssr") {
|
||||||
|
body.metaRefreshEnabled = metaRefreshEnabled;
|
||||||
|
body.bakeStyles = bakeStyles;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
|
|||||||
+132
-6
@@ -25,6 +25,7 @@ const CONFIG = {
|
|||||||
interface URLItem {
|
interface URLItem {
|
||||||
url: string;
|
url: string;
|
||||||
durationSec: number;
|
durationSec: number;
|
||||||
|
forceDarkMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface View {
|
interface View {
|
||||||
@@ -36,6 +37,7 @@ interface View {
|
|||||||
cacheTtlSec: number;
|
cacheTtlSec: number;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
|
bakeStyles: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ interface ViewRow {
|
|||||||
cache_ttl_sec: number;
|
cache_ttl_sec: number;
|
||||||
viewport_width: number;
|
viewport_width: number;
|
||||||
viewport_height: number;
|
viewport_height: number;
|
||||||
|
bake_styles: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ interface CreateViewBody {
|
|||||||
cacheTtlSec?: number;
|
cacheTtlSec?: number;
|
||||||
viewportWidth?: number;
|
viewportWidth?: number;
|
||||||
viewportHeight?: number;
|
viewportHeight?: number;
|
||||||
|
bakeStyles?: boolean;
|
||||||
}
|
}
|
||||||
type UpdateViewBody = Partial<CreateViewBody>;
|
type UpdateViewBody = Partial<CreateViewBody>;
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@ interface DeviceEntry {
|
|||||||
// DATABASE
|
// DATABASE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const db = new Database("views.db", { create: true });
|
const db = new Database("/app/data/views.db", { create: true });
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS views (
|
CREATE TABLE IF NOT EXISTS views (
|
||||||
@@ -108,6 +112,7 @@ db.run(`
|
|||||||
cache_ttl_sec INTEGER NOT NULL DEFAULT 60,
|
cache_ttl_sec INTEGER NOT NULL DEFAULT 60,
|
||||||
viewport_width INTEGER NOT NULL DEFAULT 1920,
|
viewport_width INTEGER NOT NULL DEFAULT 1920,
|
||||||
viewport_height INTEGER NOT NULL DEFAULT 1080,
|
viewport_height INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
bake_styles INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
@@ -122,6 +127,9 @@ try {
|
|||||||
try {
|
try {
|
||||||
db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080");
|
db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080");
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
db.run("ALTER TABLE views ADD COLUMN bake_styles INTEGER NOT NULL DEFAULT 0");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
function rowToView(row: ViewRow): View {
|
function rowToView(row: ViewRow): View {
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +141,7 @@ function rowToView(row: ViewRow): View {
|
|||||||
cacheTtlSec: row.cache_ttl_sec ?? 60,
|
cacheTtlSec: row.cache_ttl_sec ?? 60,
|
||||||
viewportWidth: row.viewport_width ?? 1920,
|
viewportWidth: row.viewport_width ?? 1920,
|
||||||
viewportHeight: row.viewport_height ?? 1080,
|
viewportHeight: row.viewport_height ?? 1080,
|
||||||
|
bakeStyles: row.bake_styles === 1,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -150,8 +159,8 @@ function dbLoad(id: string): View | null {
|
|||||||
|
|
||||||
function dbInsert(v: View): void {
|
function dbInsert(v: View): void {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,bake_styles,created_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||||
[
|
[
|
||||||
v.id,
|
v.id,
|
||||||
v.name,
|
v.name,
|
||||||
@@ -161,6 +170,7 @@ function dbInsert(v: View): void {
|
|||||||
v.cacheTtlSec,
|
v.cacheTtlSec,
|
||||||
v.viewportWidth,
|
v.viewportWidth,
|
||||||
v.viewportHeight,
|
v.viewportHeight,
|
||||||
|
v.bakeStyles ? 1 : 0,
|
||||||
v.createdAt,
|
v.createdAt,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -168,7 +178,7 @@ function dbInsert(v: View): void {
|
|||||||
|
|
||||||
function dbUpdate(v: View): void {
|
function dbUpdate(v: View): void {
|
||||||
db.run(
|
db.run(
|
||||||
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?",
|
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=?,bake_styles=? WHERE id=?",
|
||||||
[
|
[
|
||||||
v.name,
|
v.name,
|
||||||
JSON.stringify(v.urls),
|
JSON.stringify(v.urls),
|
||||||
@@ -177,6 +187,7 @@ function dbUpdate(v: View): void {
|
|||||||
v.cacheTtlSec,
|
v.cacheTtlSec,
|
||||||
v.viewportWidth,
|
v.viewportWidth,
|
||||||
v.viewportHeight,
|
v.viewportHeight,
|
||||||
|
v.bakeStyles ? 1 : 0,
|
||||||
v.id,
|
v.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -345,6 +356,7 @@ class PagePool {
|
|||||||
async resetPage(page: Page): Promise<boolean> {
|
async resetPage(page: Page): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
||||||
|
await page.emulateMediaFeatures([]);
|
||||||
|
|
||||||
// Clear storage via CDP — avoids opaque-origin SecurityError
|
// Clear storage via CDP — avoids opaque-origin SecurityError
|
||||||
const client = await page.createCDPSession();
|
const client = await page.createCDPSession();
|
||||||
@@ -520,13 +532,19 @@ function drainRenderQueue(): void {
|
|||||||
|
|
||||||
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise<string> {
|
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise<string> {
|
||||||
const urlItem = view.urls[urlIndex];
|
const urlItem = view.urls[urlIndex];
|
||||||
|
const forceDarkMode = urlItem.forceDarkMode ?? false;
|
||||||
|
const bakeStyles = view.bakeStyles ?? false;
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (view.method === "mjpeg") {
|
if (view.method === "mjpeg") {
|
||||||
const jpeg = await renderScreenshotFrame(urlItem.url, view.viewportWidth, view.viewportHeight);
|
const jpeg = await renderScreenshotFrame(
|
||||||
|
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode,
|
||||||
|
);
|
||||||
body = jpeg.toString("base64");
|
body = jpeg.toString("base64");
|
||||||
} else {
|
} else {
|
||||||
body = await renderSSR(urlItem.url, view.viewportWidth, view.viewportHeight);
|
body = await renderSSR(
|
||||||
|
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -704,6 +722,94 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
|
|||||||
return cssText;
|
return cssText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// COMPUTED STYLE BAKING
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bake computed styles into inline style attributes.
|
||||||
|
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
|
||||||
|
* Only bakes styles that differ from the element's default computed styles.
|
||||||
|
*/
|
||||||
|
async function bakeComputedStyles(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((): void => {
|
||||||
|
// Create a dummy element to get default styles for comparison
|
||||||
|
const dummy = document.createElement("div");
|
||||||
|
dummy.style.cssText = "position:absolute;visibility:hidden;";
|
||||||
|
document.body.appendChild(dummy);
|
||||||
|
|
||||||
|
const computedProperties = [
|
||||||
|
// Layout
|
||||||
|
"display", "position", "top", "right", "bottom", "left",
|
||||||
|
"width", "height", "min-width", "min-height", "max-width", "max-height",
|
||||||
|
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||||
|
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||||
|
// Flex/Grid
|
||||||
|
"flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis",
|
||||||
|
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
|
||||||
|
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
|
||||||
|
// Box model
|
||||||
|
"box-sizing", "border", "border-radius", "border-top", "border-right", "border-bottom", "border-left",
|
||||||
|
"border-width", "border-style", "border-color",
|
||||||
|
// Colors & Background
|
||||||
|
"color", "background", "background-color", "background-image", "background-size", "background-position",
|
||||||
|
"background-repeat", "background-attachment",
|
||||||
|
// Typography
|
||||||
|
"font", "font-family", "font-size", "font-weight", "font-style", "font-variant",
|
||||||
|
"line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing",
|
||||||
|
"white-space", "overflow-wrap", "word-break",
|
||||||
|
// Visual
|
||||||
|
"opacity", "visibility", "z-index", "overflow", "overflow-x", "overflow-y",
|
||||||
|
"box-shadow", "text-shadow", "transform", "transform-origin",
|
||||||
|
// List/Table
|
||||||
|
"list-style", "border-collapse", "border-spacing",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDefaultValue(prop: string): string {
|
||||||
|
return window.getComputedStyle(dummy).getPropertyValue(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBake(prop: string, value: string): boolean {
|
||||||
|
if (!value || value === "none" || value === "auto" || value === "normal") return false;
|
||||||
|
if (value === "0px" || value === "0" || value === "rgba(0, 0, 0, 0)") return false;
|
||||||
|
if (value.includes("initial") || value.includes("inherit")) return false;
|
||||||
|
return value !== getDefaultValue(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk all elements and bake computed styles
|
||||||
|
const elements = document.querySelectorAll<HTMLElement>("*");
|
||||||
|
for (const el of elements) {
|
||||||
|
// Skip script, style, meta, link tags
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue;
|
||||||
|
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
const stylesToBake: string[] = [];
|
||||||
|
|
||||||
|
for (const prop of computedProperties) {
|
||||||
|
const value = computed.getPropertyValue(prop);
|
||||||
|
if (shouldBake(prop, value)) {
|
||||||
|
stylesToBake.push(`${prop}:${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stylesToBake.length > 0) {
|
||||||
|
// Merge with existing inline styles, preserving user-set inline styles
|
||||||
|
const existingInline = el.getAttribute("style") || "";
|
||||||
|
const bakedStyles = stylesToBake.join(";");
|
||||||
|
if (existingInline) {
|
||||||
|
// User inline styles take precedence - put them last
|
||||||
|
el.setAttribute("style", `${bakedStyles};${existingInline}`);
|
||||||
|
} else {
|
||||||
|
el.setAttribute("style", bakedStyles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dummy.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function inlineAllResources(page: Page, html: string): Promise<string> {
|
async function inlineAllResources(page: Page, html: string): Promise<string> {
|
||||||
interface InlinePayload {
|
interface InlinePayload {
|
||||||
css: Array<{ url: string; text: string }>;
|
css: Array<{ url: string; text: string }>;
|
||||||
@@ -865,10 +971,15 @@ async function renderSSR(
|
|||||||
url: string,
|
url: string,
|
||||||
viewportWidth = 1920,
|
viewportWidth = 1920,
|
||||||
viewportHeight = 1080,
|
viewportHeight = 1080,
|
||||||
|
forceDarkMode = false,
|
||||||
|
bakeStyles = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const page = await pagePool.acquire();
|
const page = await pagePool.acquire();
|
||||||
try {
|
try {
|
||||||
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||||
|
if (forceDarkMode) {
|
||||||
|
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
|
||||||
|
}
|
||||||
|
|
||||||
// Try networkidle0 first; fall back to whatever we have on timeout
|
// Try networkidle0 first; fall back to whatever we have on timeout
|
||||||
try {
|
try {
|
||||||
@@ -883,6 +994,13 @@ async function renderSSR(
|
|||||||
|
|
||||||
let html = await page.content();
|
let html = await page.content();
|
||||||
html = await inlineAllResources(page, html);
|
html = await inlineAllResources(page, html);
|
||||||
|
|
||||||
|
// Bake computed styles into inline attributes (for Samsung TV compatibility)
|
||||||
|
if (bakeStyles) {
|
||||||
|
await bakeComputedStyles(page);
|
||||||
|
html = await page.content();
|
||||||
|
}
|
||||||
|
|
||||||
html = stripScriptTags(html);
|
html = stripScriptTags(html);
|
||||||
return html;
|
return html;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -898,10 +1016,14 @@ async function renderScreenshotFrame(
|
|||||||
url: string,
|
url: string,
|
||||||
viewportWidth = 1920,
|
viewportWidth = 1920,
|
||||||
viewportHeight = 1080,
|
viewportHeight = 1080,
|
||||||
|
forceDarkMode = false,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const page = await pagePool.acquire();
|
const page = await pagePool.acquire();
|
||||||
try {
|
try {
|
||||||
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||||
|
if (forceDarkMode) {
|
||||||
|
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
||||||
@@ -1046,12 +1168,14 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
urls: body.urls.map((u) => ({
|
urls: body.urls.map((u) => ({
|
||||||
url: u.url.trim(),
|
url: u.url.trim(),
|
||||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||||
|
forceDarkMode: u.forceDarkMode ?? false,
|
||||||
})),
|
})),
|
||||||
method: body.method,
|
method: body.method,
|
||||||
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
|
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
|
||||||
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
|
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
|
||||||
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
|
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
|
||||||
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
|
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
|
||||||
|
bakeStyles: body.bakeStyles ?? false,
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1087,6 +1211,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
view.urls = body.urls.map((u) => ({
|
view.urls = body.urls.map((u) => ({
|
||||||
url: u.url.trim(),
|
url: u.url.trim(),
|
||||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||||
|
forceDarkMode: u.forceDarkMode ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (body.method !== undefined) {
|
if (body.method !== undefined) {
|
||||||
@@ -1101,6 +1226,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
|
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
|
||||||
if (body.viewportHeight !== undefined)
|
if (body.viewportHeight !== undefined)
|
||||||
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
|
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
|
||||||
|
if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles;
|
||||||
|
|
||||||
dbUpdate(view);
|
dbUpdate(view);
|
||||||
cacheInvalidate(viewId);
|
cacheInvalidate(viewId);
|
||||||
|
|||||||
Reference in New Issue
Block a user