overlay.html (4649 bytes)
1 <!doctype html> 2 <html lang="fi"> 3 <head> 4 <meta charset="utf-8"> 5 <title>Live Captions</title> 6 <style> 7 :root { 8 --size: 42px; 9 --fg: #ffffff; 10 --box: rgba(0, 0, 0, .62); 11 --accent: #7dd3fc; 12 } 13 html, body { 14 margin: 0; padding: 0; height: 100%; 15 background: transparent; 16 overflow: hidden; 17 font-family: "Inter", "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; 18 } 19 #stage { 20 position: absolute; inset: 0; 21 display: flex; flex-direction: column; 22 justify-content: flex-end; align-items: center; 23 padding: 0 4vw 3vh; 24 box-sizing: border-box; 25 gap: .28em; 26 transition: opacity .45s ease; 27 } 28 #stage.idle { opacity: 0; } 29 .line { 30 max-width: 100%; 31 font-size: var(--size); 32 font-weight: 650; 33 line-height: 1.25; 34 letter-spacing: .005em; 35 color: var(--fg); 36 text-align: center; 37 text-wrap: balance; 38 background: var(--box); 39 padding: .16em .5em; 40 border-radius: .22em; 41 box-decoration-break: clone; 42 -webkit-box-decoration-break: clone; 43 text-shadow: 0 2px 6px rgba(0,0,0,.85); 44 animation: rise .22s ease-out; 45 } 46 .line.partial { opacity: .74; } 47 .line.tr { 48 font-size: calc(var(--size) * .62); 49 font-weight: 550; 50 color: var(--accent); 51 opacity: .95; 52 } 53 @keyframes rise { 54 from { opacity: 0; transform: translateY(.35em); } 55 to { opacity: 1; transform: none; } 56 } 57 #dot { 58 position: absolute; left: 8px; bottom: 8px; 59 width: 10px; height: 10px; border-radius: 50%; 60 background: #ef4444; box-shadow: 0 0 8px #ef4444; 61 opacity: 0; transition: opacity .3s; 62 } 63 #dot.show { opacity: .9; } 64 </style> 65 </head> 66 <body> 67 <div id="stage"><div id="dot" title="disconnected"></div></div> 68 69 <script> 70 (function () { 71 const q = new URLSearchParams(location.search); 72 const num = (k, d) => { const v = parseFloat(q.get(k)); return isNaN(v) ? d : v; }; 73 74 const MAXLINES = Math.max(1, num("lines", 2)); 75 const HIDE_MS = num("hide", 8) * 1000; 76 const WS_PORT = num("ws", 8765); 77 const SHOW_TR = q.get("tr") === "1"; 78 79 document.documentElement.style.setProperty("--size", num("size", 42) + "px"); 80 if (q.get("fg")) document.documentElement.style.setProperty("--fg", "#" + q.get("fg")); 81 if (q.get("accent")) document.documentElement.style.setProperty("--accent", "#" + q.get("accent")); 82 if (q.get("box") === "0") document.documentElement.style.setProperty("--box", "transparent"); 83 if (q.get("align") === "top") document.getElementById("stage").style.justifyContent = "flex-start"; 84 if (q.get("align") === "center") document.getElementById("stage").style.justifyContent = "center"; 85 86 const stage = document.getElementById("stage"); 87 const dot = document.getElementById("dot"); 88 89 let finals = []; // [{text, tr}] 90 let partial = ""; 91 let idleTimer = null; 92 93 function render() { 94 const rows = []; 95 const shown = finals.slice(-MAXLINES + (partial ? 1 : 0)); 96 for (const f of shown) { 97 rows.push({ cls: "line", text: f.text }); 98 if (SHOW_TR && f.tr) rows.push({ cls: "line tr", text: f.tr }); 99 } 100 if (partial) rows.push({ cls: "line partial", text: partial }); 101 102 // Rebuild only when content actually changed, so animations do not restart. 103 const key = rows.map(r => r.cls + "|" + r.text).join("\n"); 104 if (key === stage.dataset.key) return; 105 stage.dataset.key = key; 106 107 stage.querySelectorAll(".line").forEach(n => n.remove()); 108 for (const r of rows) { 109 const el = document.createElement("div"); 110 el.className = r.cls; 111 el.textContent = r.text; 112 stage.appendChild(el); 113 } 114 } 115 116 function poke() { 117 stage.classList.remove("idle"); 118 clearTimeout(idleTimer); 119 if (HIDE_MS > 0) { 120 idleTimer = setTimeout(() => { 121 stage.classList.add("idle"); 122 setTimeout(() => { finals = []; partial = ""; render(); }, 500); 123 }, HIDE_MS); 124 } 125 } 126 127 function connect() { 128 const ws = new WebSocket("ws://127.0.0.1:" + WS_PORT); 129 ws.onopen = () => dot.classList.remove("show"); 130 ws.onclose = () => { dot.classList.add("show"); setTimeout(connect, 1500); }; 131 ws.onerror = () => ws.close(); 132 ws.onmessage = (ev) => { 133 let m; 134 try { m = JSON.parse(ev.data); } catch (e) { return; } 135 if (m.type === "partial") { 136 partial = m.text; 137 } else if (m.type === "final") { 138 partial = ""; 139 finals.push({ text: m.text, tr: m.tr || "" }); 140 if (finals.length > 8) finals.shift(); 141 } else if (m.type === "clear") { 142 finals = []; partial = ""; 143 } 144 render(); 145 poke(); 146 }; 147 } 148 149 render(); 150 connect(); 151 })(); 152 </script> 153 </body> 154 </html>