diff --git a/public/index.html b/public/index.html index bc97ca1..ca71948 100644 --- a/public/index.html +++ b/public/index.html @@ -175,6 +175,10 @@ background: rgba(255, 193, 7, 0.12); color: var(--yellow); } + .badge-bake { + background: rgba(46, 204, 113, 0.1); + color: var(--green); + } .card-url-preview { font-size: 13px; color: var(--muted); @@ -314,6 +318,21 @@ cursor: pointer; accent-color: var(--accent); } + .bake-toggle { + display: flex; + align-items: center; + gap: 3px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + user-select: none; + } + .bake-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--green); + } .hint { font-size: 12px; color: var(--muted); @@ -716,11 +735,13 @@ .map((v) => { const viewUrl = origin + "/v/" + v.id; const urlPreview = v.urls.map((u, i) => - `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}` + `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}` ).join(" → "); + const anyBake = v.urls.some((u) => u.bakeStyles); const badges = [ `${v.method.toUpperCase()}`, v.metaRefreshEnabled ? 'META-REFRESH' : "", + anyBake ? 'BAKE' : "", ] .filter(Boolean) .join(" "); @@ -812,7 +833,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, u.forceDarkMode)) + .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles)) .join(""); const method = existing ? existing.method : "ssr"; const metaChecked = existing ? existing.metaRefreshEnabled : false; @@ -820,8 +841,6 @@ 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}

@@ -855,14 +874,6 @@
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.
-
-
@@ -889,11 +900,12 @@ `; } - function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) { + function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) { const isSingle = total <= 1; const disabledAttr = isSingle ? "disabled" : ""; const grayStyle = isSingle ? 'style="opacity:0.4;"' : ""; const darkChecked = forceDarkMode ? "checked" : ""; + const bakeChecked = bakeStyles ? "checked" : ""; return `
@@ -902,6 +914,10 @@ 🌙 +
`; } @@ -914,7 +930,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, false); + div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false); container.appendChild(div.firstElementChild); updateUrlRowStates(); } @@ -946,7 +962,6 @@ 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"; } // ═══════════════════════════════════════════════ @@ -962,7 +977,6 @@ 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; @@ -972,10 +986,12 @@ const urlVal = row.querySelector(".url-input").value.trim(); if (!urlVal) continue; const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false; + const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false; urls.push({ url: urlVal, durationSec: parseInt(row.querySelector(".duration-input").value) || 30, forceDarkMode, + bakeStyles, }); } @@ -988,11 +1004,7 @@ return; } - const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight }; - if (method === "ssr") { - body.metaRefreshEnabled = metaRefreshEnabled; - body.bakeStyles = bakeStyles; - } + const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled }; try { if (isEdit) { @@ -1049,6 +1061,7 @@ debugSocket.close(); debugSocket = null; } + if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; } } function setDebugInterval(ms) { diff --git a/src/server.ts b/src/server.ts index c8f74a0..8bce0db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,7 @@ interface URLItem { url: string; durationSec: number; forceDarkMode?: boolean; + bakeStyles?: boolean; } interface View { @@ -37,7 +38,6 @@ interface View { cacheTtlSec: number; viewportWidth: number; viewportHeight: number; - bakeStyles: boolean; createdAt: number; } @@ -50,7 +50,6 @@ interface ViewRow { cache_ttl_sec: number; viewport_width: number; viewport_height: number; - bake_styles: number; created_at: number; } @@ -62,7 +61,6 @@ interface CreateViewBody { cacheTtlSec?: number; viewportWidth?: number; viewportHeight?: number; - bakeStyles?: boolean; } type UpdateViewBody = Partial; @@ -141,7 +139,6 @@ 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, }; } @@ -159,8 +156,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,bake_styles,created_at) - VALUES (?,?,?,?,?,?,?,?,?,?)`, + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, [ v.id, v.name, @@ -170,7 +167,6 @@ function dbInsert(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, - v.bakeStyles ? 1 : 0, v.createdAt, ], ); @@ -178,7 +174,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=?,bake_styles=? WHERE id=?", + "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?", [ v.name, JSON.stringify(v.urls), @@ -187,7 +183,6 @@ function dbUpdate(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, - v.bakeStyles ? 1 : 0, v.id, ], ); @@ -521,9 +516,11 @@ function drainRenderQueue(): void { inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex }); renderPromise.then( (body) => { + inFlightRenders.delete(next.key); next.resolve(body); }, (err) => { + inFlightRenders.delete(next.key); next.reject(err); }, ); @@ -533,7 +530,7 @@ 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; + const bakeStyles = urlItem.bakeStyles ?? false; let body: string; if (view.method === "mjpeg") { @@ -620,7 +617,7 @@ function cacheStats(): Array<{ }> = []; for (const [key, entry] of renderCache) { - const [viewId, idxStr] = key.split(":"); + const [viewId] = key.split(":"); const view = viewCacheGet(viewId); result.push({ key, @@ -729,15 +726,11 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P /** * 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. + * Compares each element against a same-tagname fresh element so tag-specific defaults + * (e.g. block display on div, inline on span, bold on h1) are accounted for. */ 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", @@ -749,64 +742,86 @@ async function bakeComputedStyles(page: Page): Promise { "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", + "box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", + "border-top-style", "border-right-style", "border-bottom-style", "border-left-style", + "border-top-color", "border-right-color", "border-bottom-color", "border-left-color", + "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius", // Colors & Background - "color", "background", "background-color", "background-image", "background-size", "background-position", - "background-repeat", "background-attachment", + "color", "background-color", "background-image", "background-size", "background-position", + "background-repeat", "background-attachment", "background-clip", "background-origin", // Typography - "font", "font-family", "font-size", "font-weight", "font-style", "font-variant", - "line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing", + "font-family", "font-size", "font-weight", "font-style", "font-variant", + "line-height", "text-align", "text-decoration-line", "text-decoration-color", + "text-transform", "letter-spacing", "word-spacing", "white-space", "overflow-wrap", "word-break", // Visual - "opacity", "visibility", "z-index", "overflow", "overflow-x", "overflow-y", + "opacity", "visibility", "z-index", "overflow-x", "overflow-y", "box-shadow", "text-shadow", "transform", "transform-origin", // List/Table - "list-style", "border-collapse", "border-spacing", + "list-style-type", "list-style-position", "border-collapse", "border-spacing", + // Cursor/pointer + "cursor", "pointer-events", ]; - function getDefaultValue(prop: string): string { - return window.getComputedStyle(dummy).getPropertyValue(prop); + // Cache of default computed values keyed by tagName + const defaultCache = new Map>(); + const hiddenContainer = document.createElement("div"); + hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;"; + document.body.appendChild(hiddenContainer); + + function getTagDefaults(tag: string): Map { + let cache = defaultCache.get(tag); + if (cache) return cache; + cache = new Map(); + try { + const el = document.createElement(tag); + hiddenContainer.appendChild(el); + const cs = window.getComputedStyle(el); + for (const prop of computedProperties) { + cache.set(prop, cs.getPropertyValue(prop)); + } + el.remove(); + } catch { /* unknown tag, cache will be empty */ } + defaultCache.set(tag, cache); + return cache; } - 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); + function shouldBake(value: string, defaultValue: string): boolean { + if (!value) return false; + // Skip browser keywords that cannot be serialized as inline style values + if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false; + return value !== defaultValue; } // Walk all elements and bake computed styles const elements = document.querySelectorAll("*"); for (const el of elements) { - // Skip script, style, meta, link tags + if (el === hiddenContainer) continue; + // Skip non-visual tags const tag = el.tagName.toLowerCase(); - if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue; + if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue; const computed = window.getComputedStyle(el); + const defaults = getTagDefaults(tag); const stylesToBake: string[] = []; for (const prop of computedProperties) { const value = computed.getPropertyValue(prop); - if (shouldBake(prop, value)) { + const defaultValue = defaults.get(prop) ?? ""; + if (shouldBake(value, defaultValue)) { stylesToBake.push(`${prop}:${value}`); } } if (stylesToBake.length > 0) { - // Merge with existing inline styles, preserving user-set inline styles + // Existing inline styles take precedence — put them after baked 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); - } + el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles); } } - dummy.remove(); + hiddenContainer.remove(); }); } @@ -1169,13 +1184,13 @@ async function handleAPI(req: Request, path: string): Promise { url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), forceDarkMode: u.forceDarkMode ?? false, + bakeStyles: u.bakeStyles ?? 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), }; @@ -1212,6 +1227,7 @@ async function handleAPI(req: Request, path: string): Promise { url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), forceDarkMode: u.forceDarkMode ?? false, + bakeStyles: u.bakeStyles ?? false, })); } if (body.method !== undefined) { @@ -1226,7 +1242,6 @@ 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); @@ -1258,6 +1273,7 @@ const STATIC_DIR = new URL("../public", import.meta.url).pathname; async function serveStatic(path: string): Promise { const filePath = path === "/" ? "/index.html" : path; + if (!filePath.startsWith("/") || filePath.includes("..")) return null; try { const file = Bun.file(STATIC_DIR + filePath); await file.slice(0, 0).text();