Recently Written · git

desktop-subtitle-replay

git clone https://github.com/equwal/desktop-subtitle-replay

Log | Files | Refs


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>