Files
samsung-tv-server/public/index.html
T

1144 lines
36 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);
}
.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;
}
.modal h2 {
font-size: 20px;
margin-bottom: 24px;
}
/* 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);
}
.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);
}
.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-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>
</tr>
</thead>
<tbody id="debug-devices-tbody"></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)`).join(" → ");
const badges = [
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</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="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" onclick="if(event.target === this) closeModal()">
<div class="modal">${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");
}
}
// ═══════════════════════════════════════════════
// 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 }];
let urlRowsHTML = urls
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length))
.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. Each access extends TTL.</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, index, total) {
const isSingle = total <= 1;
const disabledAttr = isSingle ? "disabled" : "";
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
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>
<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, rows.length, rows.length + 1);
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;
urls.push({
url: urlVal,
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
});
}
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 };
if (method === "ssr") body.metaRefreshEnabled = 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;
}
}
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);
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>
</tr>`;
})
.join("");
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");
}
});
}
// ═══════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
loadViews();
</script>
</body>
</html>