Files
samsung-tv-server/public/index.html
T
biggy 3a21070d62 chore: static analysis baseline
Pre-analysis checkpoint before implementing fixes for:
- bakeStyles field mapping
- error logging improvements
- HTML sanitization hardening
2026-06-24 07:46:05 +02:00

1422 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Samsung TV Proxy</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f0f1a;
--surface: #1a1a2e;
--surface2: #25253e;
--border: #2a2a4a;
--text: #e0e0f0;
--muted: #8888aa;
--accent: #7c5ce7;
--accent2: #a78bfa;
--danger: #e94560;
--green: #2ecc71;
--yellow: #ffc107;
--orange: #f59e0b;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 32px 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 12px;
}
header h1 {
font-size: 24px;
font-weight: 700;
}
header h1 span {
color: var(--accent2);
}
.header-actions {
display: flex;
gap: 8px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
font-family: var(--font);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: #6a4fd4;
}
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface2);
color: var(--text);
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-danger:hover {
background: var(--danger);
color: #fff;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.btn-xs {
padding: 3px 7px;
font-size: 11px;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 12px;
transition: border-color 0.15s;
}
.card:hover {
border-color: var(--accent);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
font-size: 16px;
word-break: break-all;
}
.card-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 6px;
letter-spacing: 0.5px;
}
.badge-mjpeg {
background: rgba(124, 92, 231, 0.15);
color: var(--accent2);
}
.badge-ssr {
background: rgba(46, 204, 113, 0.12);
color: var(--green);
}
.badge-refresh {
background: rgba(255, 193, 7, 0.12);
color: var(--yellow);
}
.badge-bake {
background: rgba(46, 204, 113, 0.1);
color: var(--green);
}
.badge-wait {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.card-url-preview {
font-size: 13px;
color: var(--muted);
margin-top: 8px;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.url-copy-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
background: var(--surface2);
border-radius: 8px;
padding: 8px 12px;
}
.url-copy-row code {
font-size: 12px;
color: var(--accent2);
word-break: break-all;
flex: 1;
user-select: all;
font-family: var(--mono);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--muted);
font-size: 20px;
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.modal-close:hover {
background: var(--surface2);
color: var(--text);
}
.modal h2 {
font-size: 20px;
margin-bottom: 24px;
padding-right: 40px;
}
/* Form */
.field {
margin-bottom: 20px;
}
.field label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field input,
.field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 14px;
font-family: var(--font);
outline: none;
transition: border-color 0.15s;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
}
.field select {
cursor: pointer;
}
.url-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
}
.url-row input[type="url"] {
flex: 3;
min-width: 180px;
}
.url-row input[type="number"] {
flex: 1;
min-width: 80px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent);
}
.checkbox-row label {
font-size: 14px;
cursor: pointer;
margin: 0;
text-transform: none;
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);
margin-top: 4px;
}
.modal-footer {
display: flex;
gap: 10px;
margin-top: 28px;
justify-content: flex-end;
}
/* Empty state */
.empty {
text-align: center;
padding: 60px 20px;
color: var(--muted);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* Toast */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
animation: slideIn 0.25s ease;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.toast-success {
background: var(--green);
color: #000;
}
.toast-error {
background: var(--danger);
color: #fff;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
/* DEBUG PANEL */
#debug-panel {
display: none;
margin-top: 40px;
border-top: 2px solid var(--accent);
padding-top: 24px;
}
#debug-panel.visible {
display: block;
}
.debug-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 8px;
}
.debug-header h2 {
font-size: 18px;
color: var(--accent2);
}
.debug-header .live-dot {
width: 10px;
height: 10px;
background: var(--green);
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.debug-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.debug-stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
text-align: center;
}
.debug-stat .value {
font-size: 28px;
font-weight: 700;
color: var(--accent2);
font-family: var(--mono);
}
.debug-stat .label {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.debug-section {
margin-bottom: 24px;
}
.debug-section h3 {
font-size: 14px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.debug-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.debug-table th {
text-align: left;
padding: 8px 10px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.debug-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(42, 42, 74, 0.5);
font-family: var(--mono);
font-size: 12px;
}
.debug-table tr:hover td {
background: rgba(124, 92, 231, 0.05);
}
.debug-table .url-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.debug-table .tick {
color: var(--yellow);
font-weight: 700;
}
.debug-table .fresh {
color: var(--green);
}
.debug-table .expired {
color: var(--danger);
}
.device-actions {
display: flex;
gap: 4px;
}
.confirm-text {
font-size: 15px;
margin-bottom: 24px;
color: var(--muted);
line-height: 1.6;
}
.confirm-text strong {
color: var(--text);
}
@media (max-width: 640px) {
.app {
padding: 16px 10px;
}
.card {
padding: 14px;
}
.debug-grid {
grid-template-columns: repeat(2, 1fr);
}
.debug-table {
font-size: 11px;
}
.debug-table td,
.debug-table th {
padding: 6px 6px;
}
}
</style>
</head>
<body>
<div class="app">
<header>
<h1>Samsung <span>TV Proxy</span></h1>
<div class="header-actions">
<button class="btn btn-ghost btn-sm" id="debug-toggle" onclick="toggleDebug()">
🔍 Debug
</button>
<button class="btn btn-ghost btn-sm" onclick="openImportModal()">📤 Import</button>
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
</div>
</header>
<div id="views-list"></div>
<!-- DEBUG PANEL -->
<div id="debug-panel">
<div class="debug-header">
<h2>🔍 Live Debug</h2>
<div style="display: flex; align-items: center; gap: 8px">
<div class="live-dot"></div>
<span style="font-size:12px;color:var(--muted);">every</span>
<select id="debug-interval" onchange="setDebugInterval(parseInt(this.value))"
style="font-size:12px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:2px 6px;margin:0 4px;">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500">500ms</option>
<option value="1000" selected>1s</option>
<option value="2000">2s</option>
<option value="5000">5s</option>
</select>
<button class="btn btn-ghost btn-xs" onclick="toggleDebug()"></button>
</div>
</div>
<div class="debug-grid" id="debug-stats">
<div class="debug-stat">
<div class="value" id="stat-uptime"></div>
<div class="label">Uptime (s)</div>
</div>
<div class="debug-stat">
<div class="value" id="stat-views"></div>
<div class="label">Views</div>
</div>
<div class="debug-stat">
<div class="value" id="stat-renders"></div>
<div class="label">Active Renders</div>
</div>
<div class="debug-stat">
<div class="value" id="stat-queue"></div>
<div class="label">Queue Depth</div>
</div>
<div class="debug-stat">
<div class="value" id="stat-cache"></div>
<div class="label">Cache Entries</div>
</div>
<div class="debug-stat">
<div class="value" id="stat-devices"></div>
<div class="label">Devices</div>
</div>
</div>
<div class="debug-section">
<h3>📺 Views &amp; Rotation</h3>
<div style="overflow-x: auto">
<table class="debug-table">
<thead>
<tr>
<th>View</th>
<th>Method</th>
<th>Viewport</th>
<th>Current URL</th>
<th>Idx</th>
<th class="tick">Remaining</th>
<th>Cycle</th>
<th>Cache TTL</th>
</tr>
</thead>
<tbody id="debug-views-tbody"></tbody>
</table>
</div>
</div>
<div class="debug-section">
<h3>🗄 Cache Entries</h3>
<div style="overflow-x: auto">
<table class="debug-table">
<thead>
<tr>
<th>View</th>
<th>Idx</th>
<th>URL</th>
<th class="tick">TTL left</th>
</tr>
</thead>
<tbody id="debug-cache-tbody"></tbody>
</table>
</div>
</div>
<div class="debug-section">
<h3>📡 Connected Devices</h3>
<div style="overflow-x: auto">
<table class="debug-table">
<thead>
<tr>
<th>IP</th>
<th>View</th>
<th>Requests</th>
<th>Last Seen</th>
<th>User Agent</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="debug-devices-tbody"></tbody>
</table>
</div>
</div>
<div class="debug-section">
<h3>🚫 Banned IPs</h3>
<div style="overflow-x: auto">
<table class="debug-table">
<thead>
<tr>
<th>IP</th>
<th>Banned At</th>
<th>Reason</th>
<th>Banned By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="modal-container"></div>
<div class="toast-container" id="toast-container"></div>
<script>
// ═══════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════
let views = [];
let debugSocket = null;
let debugReconnectTimer = null;
let debugVisible = false;
let debugIntervalMs = 1000;
const { origin } = location;
// ═══════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════
function toast(msg, type = "success") {
const el = document.getElementById("toast-container");
const t = document.createElement("div");
t.className = "toast toast-" + type;
t.textContent = msg;
el.appendChild(t);
setTimeout(() => {
t.style.opacity = "0";
t.style.transition = "opacity 0.3s";
setTimeout(() => t.remove(), 300);
}, 2500);
}
// ═══════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(origin + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
async function loadViews() {
try {
views = await api("GET", "/api/views");
renderViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// RENDER VIEWS LIST
// ═══════════════════════════════════════════════
function renderViews() {
const container = document.getElementById("views-list");
if (!views.length) {
container.innerHTML = `
<div class="empty">
<div class="empty-icon">📺</div>
<p>No views yet. Create one to start serving<br>dynamic content to your Samsung TV.</p>
<button class="btn btn-primary" onclick="openCreateModal()">+ Create First View</button>
</div>`;
return;
}
container.innerHTML = views
.map((v) => {
const viewUrl = origin + "/v/" + v.id;
const urlPreview = v.urls.map((u, i) =>
`${u.url} (${u.durationSec}s${u.waitBeforeCaptureMs ? ` +${u.waitBeforeCaptureMs}ms` : ""})${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
).join(" → ");
const anyBake = v.urls.some((u) => u.bakeStyles);
const anyWait = v.urls.some((u) => u.waitBeforeCaptureMs > 0);
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>' : "",
anyWait ? '<span class="badge badge-wait">WAIT</span>' : "",
]
.filter(Boolean)
.join(" ");
return `
<div class="card">
<div class="card-header">
<span class="card-title">${esc(v.name)}</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-icon btn-ghost btn-sm" onclick="exportView('${v.id}')" title="Export">📥</button>
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
</div>
</div>
<div class="card-badges">${badges}</div>
<div class="card-url-preview">${esc(urlPreview)}</div>
<div class="url-copy-row">
<code>${viewUrl}</code>
<button class="btn btn-sm btn-primary" onclick="copyUrl('${viewUrl}'); toast('Copied!')">Copy</button>
</div>
</div>`;
})
.join("");
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function copyUrl(url) {
navigator.clipboard.writeText(url).catch(() => {});
}
// ═══════════════════════════════════════════════
// MODAL SYSTEM
// ═══════════════════════════════════════════════
function openModal(html) {
document.getElementById("modal-container").innerHTML = `
<div class="modal-overlay">
<div class="modal">
<button class="modal-close" onclick="closeModal()">✕</button>
${html}
</div>
</div>`;
document.body.style.overflow = "hidden";
}
function closeModal() {
document.getElementById("modal-container").innerHTML = "";
document.body.style.overflow = "";
}
function openCreateModal() {
openModal(buildEditorHTML(null));
}
function openEditModal(id) {
const v = views.find((v) => v.id === id);
if (v) openModal(buildEditorHTML(v));
}
function confirmDelete(id, name) {
openModal(`
<h2>Delete View</h2>
<div class="confirm-text">
Remove <strong>${name}</strong>?<br>
This cannot be undone. URL <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;">/v/${id}</code> will stop.
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="doDelete('${id}')">Delete</button>
</div>`);
}
async function doDelete(id) {
try {
await api("DELETE", "/api/views/" + id);
closeModal();
toast("View deleted");
loadViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// EXPORT / IMPORT
// ═══════════════════════════════════════════════
async function exportView(id) {
try {
const response = await fetch("/api/views/" + id + "?export=true");
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `view-${id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
toast("View exported!");
} catch (err) {
toast(err.message, "error");
}
}
function openImportModal() {
openModal(`
<h2>Import View</h2>
<form id="import-form" onsubmit="handleImport(event)">
<div class="field">
<label>Paste View JSON</label>
<textarea id="import-json" rows="12" placeholder='{"name":"My View","urls":[{"url":"https://example.com","durationSec":30}],"method":"ssr"}' required style="font-family:monospace;font-size:13px;"></textarea>
<div class="hint">Paste the JSON content from an exported view file.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Import View</button>
</div>
</form>`);
}
async function handleImport(e) {
e.preventDefault();
const jsonText = document.getElementById("import-json").value.trim();
if (!jsonText) {
toast("Please paste JSON content", "error");
return;
}
try {
// Validate JSON structure
const data = JSON.parse(jsonText);
if (!data.name || !data.urls || !Array.isArray(data.urls)) {
toast("Invalid view JSON: missing required fields", "error");
return;
}
await api("POST", "/api/views/import", data);
closeModal();
toast("View imported successfully!");
loadViews();
} catch (err) {
if (err.message.includes("JSON")) {
toast("Invalid JSON format", "error");
} else {
toast(err.message, "error");
}
}
}
// ═══════════════════════════════════════════════
// EDITOR BUILDER
// ═══════════════════════════════════════════════
function buildEditorHTML(existing) {
const isEdit = !!existing;
const title = isEdit ? "Edit View" : "Create New View";
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
const urls = existing ? existing.urls : [{ url: "", durationSec: 30, waitBeforeCaptureMs: 0 }];
let urlRowsHTML = urls
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, u.waitBeforeCaptureMs, i, urls.length, u.forceDarkMode, u.bakeStyles))
.join("");
const method = existing ? existing.method : "ssr";
const metaChecked = existing ? existing.metaRefreshEnabled : false;
const metaSectionStyle = method === "ssr" ? "" : "display:none;";
const cacheTtl = existing ? existing.cacheTtlSec : 60;
const vpW = existing ? existing.viewportWidth : 1920;
const vpH = existing ? existing.viewportHeight : 1080;
return `
<h2>${title}</h2>
<form id="view-form" onsubmit="handleSave(event)"${idAttr}>
<div class="field">
<label>View Name</label>
<input type="text" id="view-name" placeholder="e.g. Office Dashboard" value="${existing ? esc(existing.name) : ""}" required autofocus>
</div>
<div class="field">
<label>URLs &amp; Durations</label>
<div id="url-rows">${urlRowsHTML}</div>
<button type="button" class="btn btn-ghost btn-sm" onclick="addUrlRow()" style="margin-top:8px;">+ Add URL</button>
<div class="hint">Each URL plays for its duration. Single URL = static display.</div>
</div>
<div class="field">
<label>Serving Method</label>
<select id="view-method" onchange="onMethodChange()">
<option value="ssr" ${method === "ssr" ? "selected" : ""}>SSR (Server-Side Render + inline)</option>
<option value="mjpeg" ${method === "mjpeg" ? "selected" : ""}>Screenshot (JPEG frame + meta-refresh)</option>
</select>
</div>
<div id="meta-section" style="${metaSectionStyle}">
<div class="checkbox-row">
<input type="checkbox" id="meta-refresh" ${metaChecked ? "checked" : ""}>
<label for="meta-refresh">Inject &lt;meta http-equiv="refresh"&gt; tag</label>
</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 class="field">
<label>Cache TTL (seconds)</label>
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
</div>
<div class="field" style="display:flex;gap:12px;">
<div style="flex:1;">
<label>Viewport Width</label>
<input type="number" id="viewport-width" value="${vpW}" min="320" max="7680" required>
<div class="hint">Browser render width (px)</div>
</div>
<div style="flex:1;">
<label>Viewport Height</label>
<input type="number" id="viewport-height" value="${vpH}" min="240" max="4320" required>
<div class="hint">Browser render height (px)</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? "Save Changes" : "Create View"}</button>
</div>
</form>`;
}
function buildUrlRowHTML(url, durationSec, waitBeforeCaptureMs, 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" : "";
const waitVal = waitBeforeCaptureMs ?? 0;
return `
<div class="url-row" data-index="${index}">
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
<div style="display:flex;gap:8px;flex:1;">
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required title="Display duration">
<input type="number" placeholder="Wait ms" value="${waitVal}" min="0" max="60000" class="wait-input" title="Wait before capture (ms)">
</div>
<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>`;
}
// ═══════════════════════════════════════════════
// EDITOR INTERACTIONS
// ═══════════════════════════════════════════════
function addUrlRow() {
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const div = document.createElement("div");
div.innerHTML = buildUrlRowHTML("", 30, 0, rows.length, rows.length + 1, false, false);
container.appendChild(div.firstElementChild);
updateUrlRowStates();
}
function removeUrlRow(btn) {
btn.closest(".url-row").remove();
updateUrlRowStates();
}
function updateUrlRowStates() {
const rows = document.querySelectorAll("#url-rows .url-row");
const total = rows.length;
rows.forEach((row, i) => {
row.setAttribute("data-index", i);
const dur = row.querySelector(".duration-input");
const del = row.querySelector(".btn-danger");
if (total <= 1) {
dur.disabled = true;
dur.style.opacity = "0.4";
del.disabled = true;
} else {
dur.disabled = false;
dur.style.opacity = "";
del.disabled = false;
}
});
}
function onMethodChange() {
const method = document.getElementById("view-method").value;
document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none";
}
// ═══════════════════════════════════════════════
// SAVE
// ═══════════════════════════════════════════════
async function handleSave(e) {
e.preventDefault();
const form = document.getElementById("view-form");
const isEdit = form.hasAttribute("data-id");
const viewId = form.getAttribute("data-id");
const name = document.getElementById("view-name").value.trim();
const method = document.getElementById("view-method").value;
const metaRefreshEnabled = document.getElementById("meta-refresh").checked;
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;
const urls = [];
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;
const waitBeforeCaptureMs = parseInt(row.querySelector(".wait-input")?.value) || 0;
urls.push({
url: urlVal,
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
waitBeforeCaptureMs,
forceDarkMode,
bakeStyles,
});
}
if (!name) {
toast("Name is required", "error");
return;
}
if (urls.length === 0) {
toast("At least one URL is required", "error");
return;
}
const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled };
try {
if (isEdit) {
await api("PUT", "/api/views/" + viewId, body);
toast("View updated!");
} else {
await api("POST", "/api/views", body);
toast("View created!");
}
closeModal();
loadViews();
} catch (err) {
toast(err.message, "error");
}
}
// ═══════════════════════════════════════════════
// DEBUG PANEL (WebSocket)
// ═══════════════════════════════════════════════
function connectDebugSocket() {
if (debugSocket) return;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${location.host}/ws?interval=${debugIntervalMs}`;
debugSocket = new WebSocket(wsUrl);
debugSocket.onopen = () => {
if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; }
};
debugSocket.onmessage = (event) => {
try {
lastDebugData = JSON.parse(event.data);
renderDebug(lastDebugData);
} catch { /* ignore */ }
};
debugSocket.onclose = () => {
debugSocket = null;
if (debugVisible) {
debugReconnectTimer = setTimeout(connectDebugSocket, 2000);
}
};
debugSocket.onerror = () => {
debugSocket?.close();
};
}
function disconnectDebugSocket() {
if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; }
if (debugSocket) {
debugSocket.onclose = null; // prevent auto-reconnect
debugSocket.close();
debugSocket = null;
}
if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; }
}
function setDebugInterval(ms) {
debugIntervalMs = ms;
if (debugSocket && debugSocket.readyState === WebSocket.OPEN) {
debugSocket.send(JSON.stringify({ type: "debug-interval", interval: ms }));
}
}
function toggleDebug() {
debugVisible = !debugVisible;
const panel = document.getElementById("debug-panel");
const btn = document.getElementById("debug-toggle");
if (debugVisible) {
panel.classList.add("visible");
btn.textContent = "🔍 Hide Debug";
connectDebugSocket();
} else {
panel.classList.remove("visible");
btn.textContent = "🔍 Debug";
disconnectDebugSocket();
}
}
let lastDebugData = null;
let debugTickInterval = null;
function renderDebug(data) {
const s = data.server;
document.getElementById("stat-uptime").textContent = s.uptime;
document.getElementById("stat-views").textContent = data.views.length;
document.getElementById("stat-renders").textContent = s.activeRenders;
document.getElementById("stat-queue").textContent = s.queueLength;
document.getElementById("stat-cache").textContent = s.cacheEntries;
document.getElementById("stat-devices").textContent = data.devices.length;
// ── Views table ──
document.getElementById("debug-views-tbody").innerHTML = data.views
.map((v) => {
const methodBadge =
v.method === "mjpeg"
? '<span class="badge badge-mjpeg">MJPEG</span>'
: '<span class="badge badge-ssr">SSR</span>';
return `
<tr>
<td title="${esc(v.id)}">${esc(v.name)}</td>
<td>${methodBadge}</td>
<td>${v.viewportWidth}×${v.viewportHeight}</td>
<td class="url-cell" title="${esc(v.currentUrl)}">${esc(v.currentUrl)}</td>
<td>${v.currentUrlIndex}/${v.urls.length}</td>
<td class="tick" data-remaining="${v.remainingSec}" data-view-id="${v.id}">${v.remainingSec}s</td>
<td>${v.totalCycleSec}s</td>
<td>${v.cacheTtlSec}s</td>
</tr>`;
})
.join("");
// ── Cache table ──
document.getElementById("debug-cache-tbody").innerHTML = data.views
.flatMap((v) =>
v.cacheEntries.map(
(c) => `
<tr>
<td title="${esc(v.id)}">${esc(v.name)}</td>
<td>${c.urlIndex}/${v.urls.length}</td>
<td class="url-cell" title="${esc(c.url)}">${esc(c.url)}</td>
<td class="tick" data-ttl="${c.ttlRemainingSec}" data-cache-key="${c.key}">${c.ttlRemainingSec}s</td>
</tr>`,
),
)
.join("");
// ── Devices table ──
document.getElementById("debug-devices-tbody").innerHTML = data.devices
.map((d) => {
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
const ipEnc = encodeURIComponent(d.ip);
return `
<tr>
<td>${esc(d.ip)}</td>
<td title="${esc(d.viewId)}">${esc(d.viewName)}</td>
<td>${d.requestCount}</td>
<td>${ago}s ago</td>
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
<td>
<div class="device-actions">
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
</div>
</td>
</tr>`;
})
.join("");
// ── Banned IPs table ──
fetchBannedIPs();
if (!debugTickInterval) {
debugTickInterval = setInterval(tickDebugValues, 500);
}
}
function tickDebugValues() {
document.querySelectorAll("[data-remaining]").forEach((el) => {
let val = parseFloat(el.getAttribute("data-remaining"));
val = Math.max(0, val - 0.5);
el.setAttribute("data-remaining", val);
el.textContent = val.toFixed(1) + "s";
if (val <= 3) el.style.color = "var(--danger)";
else if (val <= 10) el.style.color = "var(--orange)";
else el.style.color = "var(--yellow)";
});
document.querySelectorAll("[data-ttl]").forEach((el) => {
let val = parseFloat(el.getAttribute("data-ttl"));
val = Math.max(0, val - 0.5);
el.setAttribute("data-ttl", val);
el.textContent = val.toFixed(1) + "s";
if (val <= 0) {
el.classList.remove("fresh");
el.classList.add("expired");
}
});
}
// ═══════════════════════════════════════════════
// ADMIN ACTIONS
// ═══════════════════════════════════════════════
async function adminDisconnect(ipEnc) {
try {
await api("POST", "/api/admin/disconnect/" + ipEnc);
toast("Device disconnected");
} catch (err) {
toast(err.message, "error");
}
}
async function adminRefresh(ipEnc) {
try {
await api("POST", "/api/admin/refresh/" + ipEnc);
toast("Cache refreshed");
} catch (err) {
toast(err.message, "error");
}
}
function adminBan(ipEnc, ipDisplay) {
openModal(`
<h2>Ban IP</h2>
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
<div class="field">
<label>Reason</label>
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
</div>`);
}
async function doAdminBan(ipEnc, ipDisplay) {
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
try {
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
closeModal();
toast(`Banned ${decodeURIComponent(ipEnc)}`);
fetchBannedIPs();
} catch (err) {
toast(err.message, "error");
}
}
async function adminUnban(ipEnc) {
try {
await api("POST", "/api/admin/unban/" + ipEnc);
toast("IP unbanned");
fetchBannedIPs();
} catch (err) {
toast(err.message, "error");
}
}
async function fetchBannedIPs() {
try {
const banned = await api("GET", "/api/banned");
const tbody = document.getElementById("debug-banned-tbody");
if (!tbody) return;
if (!banned.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
return;
}
tbody.innerHTML = banned.map((b) => {
const ipEnc = encodeURIComponent(b.ip);
const dt = new Date(b.banned_at * 1000).toLocaleString();
return `
<tr>
<td>${esc(b.ip)}</td>
<td>${esc(dt)}</td>
<td>${esc(b.reason || "—")}</td>
<td>${esc(b.banned_by || "—")}</td>
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
</tr>`;
}).join("");
} catch { /* silent */ }
}
// ═══════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
loadViews();
</script>
</body>
</html>