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);