Compare commits
4
Commits
7c07454e7a
...
f86b0411d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86b0411d5 | ||
|
|
807ce639a7 | ||
|
|
c294c8c85e | ||
|
|
b69d830fc8 |
+8
-1
@@ -1,7 +1,14 @@
|
||||
FROM bun:latest
|
||||
FROM oven/bun:latest
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.bigoscloud.com/biggy/samsung-tv-server" \
|
||||
org.opencontainers.image.title="Samsung TV Server" \
|
||||
org.opencontainers.image.description="Samsung TV Server — Bun runtime" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y chromium && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --production
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
samsung-tv-server:
|
||||
image: gitea.bigoscloud.com/biggy/samsung-tv-server:latest
|
||||
build: .
|
||||
ports:
|
||||
- "3099:3099"
|
||||
volumes:
|
||||
- views_db:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
views_db:
|
||||
+58
-6
@@ -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);
|
||||
@@ -299,6 +303,36 @@
|
||||
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);
|
||||
}
|
||||
.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);
|
||||
@@ -700,10 +734,14 @@
|
||||
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 ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
|
||||
).join(" → ");
|
||||
const anyBake = v.urls.some((u) => u.bakeStyles);
|
||||
const badges = [
|
||||
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
||||
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
|
||||
anyBake ? '<span class="badge badge-bake">BAKE</span>' : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
@@ -795,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))
|
||||
.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;
|
||||
@@ -862,14 +900,24 @@
|
||||
</form>`;
|
||||
}
|
||||
|
||||
function buildUrlRowHTML(url, durationSec, index, total) {
|
||||
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 `
|
||||
<div class="url-row" data-index="${index}">
|
||||
<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>
|
||||
<label class="dark-toggle" title="Force dark mode">
|
||||
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
|
||||
🌙
|
||||
</label>
|
||||
<label class="bake-toggle" title="Bake computed styles (Samsung TV compatibility)">
|
||||
<input type="checkbox" class="bake-styles-input" ${bakeChecked}>
|
||||
🍞
|
||||
</label>
|
||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeUrlRow(this)" ${isSingle ? "disabled" : ""}>✕</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -882,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);
|
||||
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false);
|
||||
container.appendChild(div.firstElementChild);
|
||||
updateUrlRowStates();
|
||||
}
|
||||
@@ -937,9 +985,13 @@
|
||||
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;
|
||||
const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
|
||||
urls.push({
|
||||
url: urlVal,
|
||||
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
|
||||
forceDarkMode,
|
||||
bakeStyles,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -952,8 +1004,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight };
|
||||
if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled;
|
||||
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled };
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
@@ -1010,6 +1061,7 @@
|
||||
debugSocket.close();
|
||||
debugSocket = null;
|
||||
}
|
||||
if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; }
|
||||
}
|
||||
|
||||
function setDebugInterval(ms) {
|
||||
|
||||
+146
-4
@@ -25,6 +25,8 @@ const CONFIG = {
|
||||
interface URLItem {
|
||||
url: string;
|
||||
durationSec: number;
|
||||
forceDarkMode?: boolean;
|
||||
bakeStyles?: boolean;
|
||||
}
|
||||
|
||||
interface View {
|
||||
@@ -96,7 +98,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 +110,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 +125,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 {
|
||||
@@ -345,6 +351,7 @@ class PagePool {
|
||||
async resetPage(page: Page): Promise<boolean> {
|
||||
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();
|
||||
@@ -509,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);
|
||||
},
|
||||
);
|
||||
@@ -520,13 +529,19 @@ function drainRenderQueue(): void {
|
||||
|
||||
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise<string> {
|
||||
const urlItem = view.urls[urlIndex];
|
||||
const forceDarkMode = urlItem.forceDarkMode ?? false;
|
||||
const bakeStyles = urlItem.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();
|
||||
@@ -602,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,
|
||||
@@ -704,6 +719,112 @@ 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).
|
||||
* 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<void> {
|
||||
await page.evaluate((): void => {
|
||||
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-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-color", "background-image", "background-size", "background-position",
|
||||
"background-repeat", "background-attachment", "background-clip", "background-origin",
|
||||
// Typography
|
||||
"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-x", "overflow-y",
|
||||
"box-shadow", "text-shadow", "transform", "transform-origin",
|
||||
// List/Table
|
||||
"list-style-type", "list-style-position", "border-collapse", "border-spacing",
|
||||
// Cursor/pointer
|
||||
"cursor", "pointer-events",
|
||||
];
|
||||
|
||||
// Cache of default computed values keyed by tagName
|
||||
const defaultCache = new Map<string, Map<string, string>>();
|
||||
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<string, string> {
|
||||
let cache = defaultCache.get(tag);
|
||||
if (cache) return cache;
|
||||
cache = new Map<string, string>();
|
||||
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(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<HTMLElement>("*");
|
||||
for (const el of elements) {
|
||||
if (el === hiddenContainer) continue;
|
||||
// Skip non-visual tags
|
||||
const tag = el.tagName.toLowerCase();
|
||||
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);
|
||||
const defaultValue = defaults.get(prop) ?? "";
|
||||
if (shouldBake(value, defaultValue)) {
|
||||
stylesToBake.push(`${prop}:${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stylesToBake.length > 0) {
|
||||
// Existing inline styles take precedence — put them after baked styles
|
||||
const existingInline = el.getAttribute("style") || "";
|
||||
const bakedStyles = stylesToBake.join(";");
|
||||
el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
|
||||
}
|
||||
}
|
||||
|
||||
hiddenContainer.remove();
|
||||
});
|
||||
}
|
||||
|
||||
async function inlineAllResources(page: Page, html: string): Promise<string> {
|
||||
interface InlinePayload {
|
||||
css: Array<{ url: string; text: string }>;
|
||||
@@ -865,10 +986,15 @@ async function renderSSR(
|
||||
url: string,
|
||||
viewportWidth = 1920,
|
||||
viewportHeight = 1080,
|
||||
forceDarkMode = false,
|
||||
bakeStyles = false,
|
||||
): Promise<string> {
|
||||
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 +1009,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 +1031,14 @@ async function renderScreenshotFrame(
|
||||
url: string,
|
||||
viewportWidth = 1920,
|
||||
viewportHeight = 1080,
|
||||
forceDarkMode = false,
|
||||
): Promise<Buffer> {
|
||||
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,6 +1183,8 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
urls: body.urls.map((u) => ({
|
||||
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,
|
||||
@@ -1087,6 +1226,8 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
view.urls = body.urls.map((u) => ({
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
}));
|
||||
}
|
||||
if (body.method !== undefined) {
|
||||
@@ -1132,6 +1273,7 @@ const STATIC_DIR = new URL("../public", import.meta.url).pathname;
|
||||
|
||||
async function serveStatic(path: string): Promise<Response | null> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user