diff --git a/public/index.html b/public/index.html index ed7d68a..bc97ca1 100644 --- a/public/index.html +++ b/public/index.html @@ -299,6 +299,21 @@ letter-spacing: 0; 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 { font-size: 12px; color: var(--muted); @@ -700,7 +715,9 @@ container.innerHTML = views .map((v) => { 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 = [ `${v.method.toUpperCase()}`, v.metaRefreshEnabled ? 'META-REFRESH' : "", @@ -795,7 +812,7 @@ const idAttr = isEdit ? ` data-id="${existing.id}"` : ""; const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }]; 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(""); const method = existing ? existing.method : "ssr"; const metaChecked = existing ? existing.metaRefreshEnabled : false; @@ -803,6 +820,8 @@ const cacheTtl = existing ? existing.cacheTtlSec : 60; const vpW = existing ? existing.viewportWidth : 1920; const vpH = existing ? existing.viewportHeight : 1080; + const bakeStylesChecked = existing ? existing.bakeStyles : false; + const bakeSectionStyle = method === "ssr" ? "" : "display:none;"; return `

${title}

@@ -836,6 +855,14 @@
TV reloads when rotation advances. Required for multi-URL on non-JS browsers.
+
+
+ + +
+
Fixes styling issues on browsers with limited CSS support (e.g., DuckDuckGo). Increases HTML size.
+
+
@@ -862,14 +889,19 @@ `; } - function buildUrlRowHTML(url, durationSec, index, total) { + function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) { const isSingle = total <= 1; const disabledAttr = isSingle ? "disabled" : ""; const grayStyle = isSingle ? 'style="opacity:0.4;"' : ""; + const darkChecked = forceDarkMode ? "checked" : ""; return `
+
`; } @@ -882,7 +914,7 @@ const container = document.getElementById("url-rows"); const rows = container.querySelectorAll(".url-row"); 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); updateUrlRowStates(); } @@ -914,6 +946,7 @@ function onMethodChange() { const method = document.getElementById("view-method").value; 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 method = document.getElementById("view-method").value; 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 viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920; const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080; @@ -937,9 +971,11 @@ for (const row of document.querySelectorAll("#url-rows .url-row")) { const urlVal = row.querySelector(".url-input").value.trim(); if (!urlVal) continue; + const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false; urls.push({ url: urlVal, durationSec: parseInt(row.querySelector(".duration-input").value) || 30, + forceDarkMode, }); } @@ -953,7 +989,10 @@ } const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight }; - if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled; + if (method === "ssr") { + body.metaRefreshEnabled = metaRefreshEnabled; + body.bakeStyles = bakeStyles; + } try { if (isEdit) { diff --git a/src/server.ts b/src/server.ts index 81dd532..c8f74a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,7 @@ const CONFIG = { interface URLItem { url: string; durationSec: number; + forceDarkMode?: boolean; } interface View { @@ -36,6 +37,7 @@ interface View { cacheTtlSec: number; viewportWidth: number; viewportHeight: number; + bakeStyles: boolean; createdAt: number; } @@ -48,6 +50,7 @@ interface ViewRow { cache_ttl_sec: number; viewport_width: number; viewport_height: number; + bake_styles: number; created_at: number; } @@ -59,6 +62,7 @@ interface CreateViewBody { cacheTtlSec?: number; viewportWidth?: number; viewportHeight?: number; + bakeStyles?: boolean; } type UpdateViewBody = Partial; @@ -96,7 +100,7 @@ interface DeviceEntry { // DATABASE // ═══════════════════════════════════════════════════════════════ -const db = new Database("views.db", { create: true }); +const db = new Database("/app/data/views.db", { create: true }); db.run(` CREATE TABLE IF NOT EXISTS views ( @@ -108,6 +112,7 @@ db.run(` cache_ttl_sec INTEGER NOT NULL DEFAULT 60, viewport_width INTEGER NOT NULL DEFAULT 1920, viewport_height INTEGER NOT NULL DEFAULT 1080, + bake_styles INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); @@ -122,6 +127,9 @@ try { try { db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080"); } catch {} +try { + db.run("ALTER TABLE views ADD COLUMN bake_styles INTEGER NOT NULL DEFAULT 0"); +} catch {} function rowToView(row: ViewRow): View { return { @@ -133,6 +141,7 @@ function rowToView(row: ViewRow): View { cacheTtlSec: row.cache_ttl_sec ?? 60, viewportWidth: row.viewport_width ?? 1920, viewportHeight: row.viewport_height ?? 1080, + bakeStyles: row.bake_styles === 1, createdAt: row.created_at, }; } @@ -150,8 +159,8 @@ function dbLoad(id: string): View | null { function dbInsert(v: View): void { db.run( - `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) - VALUES (?,?,?,?,?,?,?,?,?)`, + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,bake_styles,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, [ v.id, v.name, @@ -161,6 +170,7 @@ function dbInsert(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, + v.bakeStyles ? 1 : 0, v.createdAt, ], ); @@ -168,7 +178,7 @@ function dbInsert(v: View): void { function dbUpdate(v: View): void { 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, JSON.stringify(v.urls), @@ -177,6 +187,7 @@ function dbUpdate(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, + v.bakeStyles ? 1 : 0, v.id, ], ); @@ -345,6 +356,7 @@ class PagePool { async resetPage(page: Page): Promise { try { await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); + await page.emulateMediaFeatures([]); // Clear storage via CDP — avoids opaque-origin SecurityError const client = await page.createCDPSession(); @@ -520,13 +532,19 @@ function drainRenderQueue(): void { async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { const urlItem = view.urls[urlIndex]; + const forceDarkMode = urlItem.forceDarkMode ?? false; + const bakeStyles = view.bakeStyles ?? false; let body: string; 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"); } 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(); @@ -704,6 +722,94 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P 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 { + 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("*"); + 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 { interface InlinePayload { css: Array<{ url: string; text: string }>; @@ -865,10 +971,15 @@ async function renderSSR( url: string, viewportWidth = 1920, viewportHeight = 1080, + forceDarkMode = false, + bakeStyles = false, ): Promise { const page = await pagePool.acquire(); try { 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 { @@ -883,6 +994,13 @@ async function renderSSR( let html = await page.content(); 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); return html; } finally { @@ -898,10 +1016,14 @@ async function renderScreenshotFrame( url: string, viewportWidth = 1920, viewportHeight = 1080, + forceDarkMode = false, ): Promise { const page = await pagePool.acquire(); try { await page.setViewport({ width: viewportWidth, height: viewportHeight }); + if (forceDarkMode) { + await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); + } try { await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); @@ -1046,12 +1168,14 @@ async function handleAPI(req: Request, path: string): Promise { urls: body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? false, })), method: body.method, metaRefreshEnabled: body.metaRefreshEnabled ?? false, cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60), viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)), viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)), + bakeStyles: body.bakeStyles ?? false, createdAt: Math.floor(Date.now() / 1000), }; @@ -1087,6 +1211,7 @@ async function handleAPI(req: Request, path: string): Promise { view.urls = body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? false, })); } if (body.method !== undefined) { @@ -1101,6 +1226,7 @@ async function handleAPI(req: Request, path: string): Promise { view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth)); if (body.viewportHeight !== undefined) view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight)); + if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles; dbUpdate(view); cacheInvalidate(viewId);