Recently Written · git

desktop-subtitle-replay

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

Log | Files | Refs


reader.html (9730 bytes)

1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>livecap reader</title>
7 <style>
8   :root {
9     --bg: #0e1116; --panel: #161a21; --line: #252b36;
10     --fg: #edf1f7; --dim: #8b95a7; --accent: #7dd3fc; --pin: #fbbf24;
11     --size: 30px;
12   }
13   * { box-sizing: border-box; }
14   html, body { height: 100%; margin: 0; }
15   body {
16     background: var(--bg); color: var(--fg);
17     display: flex; flex-direction: column;
18     font-family: "Inter", "Segoe UI", "Yu Gothic UI", "Meiryo",
19                  "Noto Sans CJK JP", "Noto Sans", system-ui, sans-serif;
20   }
21 
22   header {
23     display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
24     padding: 10px 16px; background: var(--panel);
25     border-bottom: 1px solid var(--line); font-size: 13px;
26   }
27   header .grow { flex: 1; }
28   button {
29     font: inherit; font-size: 13px; color: var(--fg); cursor: pointer;
30     background: #1f2531; border: 1px solid var(--line);
31     border-radius: 6px; padding: 6px 11px;
32   }
33   button:hover { background: #29313f; }
34   button.on { background: var(--accent); border-color: var(--accent); color: #06202c; }
35   .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
36   .dot.up { background: #4ade80; box-shadow: 0 0 7px #4ade80; }
37   .dot.down { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
38   .lang { color: var(--dim); }
39 
40   /* The mining surface. Everything here must stay real, selectable DOM text
41      so Yomitan can scan it and lift the surrounding sentence. */
42   #log {
43     flex: 1; overflow-y: auto; padding: 24px 28px 40vh;
44     display: flex; flex-direction: column; gap: 14px;
45   }
46   .line {
47     font-size: var(--size); line-height: 1.75;
48     max-width: 34em; padding: 6px 10px; border-radius: 6px;
49     border-left: 3px solid transparent;
50     user-select: text; -webkit-user-select: text;
51     cursor: text; scroll-margin-bottom: 40vh;
52   }
53   .line:hover { background: #151a22; border-left-color: var(--line); }
54   .line.partial { color: var(--dim); border-left-color: var(--pin); }
55   .line.pinned { background: #1a1f29; border-left-color: var(--pin); }
56   /* Rendered from a data attribute so the timestamp is not DOM text -
57      otherwise Yomitan can absorb it into the sentence it mines. */
58   .line::before {
59     content: attr(data-time);
60     display: block; font-size: 12px; color: var(--dim);
61     margin-bottom: 2px; user-select: none; font-variant-numeric: tabular-nums;
62   }
63   .line .tr {
64     display: block; font-size: calc(var(--size) * .52);
65     color: var(--accent); margin-top: 4px; line-height: 1.5;
66   }
67   .empty { color: var(--dim); font-size: 15px; padding: 8px 10px; }
68 
69   #jump {
70     position: fixed; right: 26px; bottom: 26px; display: none;
71     background: var(--accent); color: #06202c; border-color: var(--accent);
72     font-weight: 600; box-shadow: 0 4px 16px rgba(0,0,0,.5);
73   }
74   #jump.show { display: block; }
75 </style>
76 </head>
77 <body>
78 
79 <header>
80   <span id="conn"><span class="dot down"></span></span>
81   <span class="lang" id="lang">—</span>
82   <span class="grow"></span>
83   <button id="smaller" title="Smaller text">A−</button>
84   <button id="bigger" title="Larger text">A+</button>
85   <button id="follow" class="on" title="Auto-scroll to newest">Follow</button>
86   <button id="copy" title="Copy the whole transcript">Copy all</button>
87   <button id="clear">Clear</button>
88 </header>
89 
90 <div id="log"><div class="empty">Waiting for speech… hover any word with Yomitan to mine it.</div></div>
91 <button id="jump">↓ New captions</button>
92 
93 <script>
94 (function () {
95   const q = new URLSearchParams(location.search);
96   const WS_PORT = parseInt(q.get("ws") || "8765", 10);
97 
98   const log = document.getElementById("log");
99   const jump = document.getElementById("jump");
100   let follow = true;
101   let partialEl = null;
102   const historyTail = new Set();
103   let size = parseInt(localStorage.getItem("livecap.size") || q.get("size") || "30", 10);
104 
105   function applySize() {
106     document.documentElement.style.setProperty("--size", size + "px");
107     try { localStorage.setItem("livecap.size", String(size)); } catch (e) {}
108   }
109   applySize();
110 
111   function atBottom() {
112     return log.scrollHeight - log.scrollTop - log.clientHeight < 80;
113   }
114 
115   function scrollDown() {
116     log.scrollTop = log.scrollHeight;
117     jump.classList.remove("show");
118   }
119 
120   log.addEventListener("scroll", () => {
121     if (!follow) return;
122     if (!atBottom()) jump.classList.add("show");
123     else jump.classList.remove("show");
124   });
125 
126   function stamp(ts) {
127     const d = ts ? new Date(ts * 1000) : new Date();
128     return d.toTimeString().slice(0, 8);
129   }
130 
131   function clearEmpty() {
132     const e = log.querySelector(".empty");
133     if (e) e.remove();
134   }
135 
136   function makeLine(m, isPartial) {
137     const div = document.createElement("div");
138     div.className = "line" + (isPartial ? " partial" : "");
139 
140     div.dataset.time = stamp(m.ts) + (isPartial ? " · …" : "");
141 
142     // Sentence text as a bare text node: Yomitan reads this directly.
143     div.appendChild(document.createTextNode(m.text));
144 
145     if (m.tr) {
146       const tr = document.createElement("span");
147       tr.className = "tr";
148       tr.textContent = m.tr;
149       div.appendChild(tr);
150     }
151 
152     div.addEventListener("dblclick", () => {
153       div.classList.toggle("pinned");
154     });
155     return div;
156   }
157 
158   function onFinal(m) {
159     // The server replays its last few finals on connect; those may already be
160     // on screen from captions.log. Drop only that overlap, once.
161     if (historyTail.has(m.text)) {
162       historyTail.delete(m.text);
163       return;
164     }
165     clearEmpty();
166     if (partialEl) { partialEl.remove(); partialEl = null; }
167     const wasBottom = atBottom();
168     log.appendChild(makeLine(m, false));
169     while (log.children.length > 500) log.removeChild(log.firstChild);
170     if (follow && wasBottom) scrollDown();
171     else if (follow) jump.classList.add("show");
172   }
173 
174   function onPartial(m) {
175     clearEmpty();
176     const wasBottom = atBottom();
177     if (partialEl) partialEl.remove();
178     partialEl = makeLine(m, true);
179     log.appendChild(partialEl);
180     if (follow && wasBottom) scrollDown();
181   }
182 
183   document.getElementById("bigger").onclick = () => { size = Math.min(72, size + 3); applySize(); };
184   document.getElementById("smaller").onclick = () => { size = Math.max(14, size - 3); applySize(); };
185   document.getElementById("follow").onclick = (e) => {
186     follow = !follow;
187     e.target.className = follow ? "on" : "";
188     if (follow) scrollDown();
189   };
190   document.getElementById("clear").onclick = () => {
191     log.innerHTML = '<div class="empty">Cleared.</div>';
192     partialEl = null;
193   };
194   document.getElementById("copy").onclick = async () => {
195     const text = [...log.querySelectorAll(".line")]
196       .filter((n) => !n.classList.contains("partial"))
197       .map((n) => [...n.childNodes].filter((c) => c.nodeType === 3)
198                     .map((c) => c.textContent).join(""))
199       .join("\n");
200     try {
201       await navigator.clipboard.writeText(text);
202       const b = document.getElementById("copy");
203       b.textContent = "Copied";
204       setTimeout(() => (b.textContent = "Copy all"), 1200);
205     } catch (e) {}
206   };
207   jump.onclick = scrollDown;
208 
209   // Reloading the page would otherwise lose the session's scrollback, which is
210   // exactly what you want when going back to something said minutes ago.
211   async function loadHistory() {
212     try {
213       const res = await fetch("captions.log", { cache: "no-store" });
214       if (!res.ok) return;
215       // The log is written with CRLF on Windows; a stray \r would both break
216       // the de-duplication below and end up inside mined sentences.
217       const lines = (await res.text()).split(/\r?\n/);
218       let from = 0;
219       for (let i = lines.length - 1; i >= 0; i--) {
220         if (lines[i].startsWith("=== session ")) { from = i + 1; break; }
221       }
222       const rows = lines.slice(from).filter((l) => l.trim() && !l.startsWith("==="));
223       if (!rows.length) return;
224       clearEmpty();
225       for (const row of rows.slice(-400)) {
226         const parts = row.split("\t").map((p) => p.trim());
227         if (parts.length < 2 || !parts[1]) continue;
228         const trIdx = parts.findIndex((p) => p.startsWith("|| "));
229         const m = {
230           text: parts[1],
231           tr: trIdx > 0 ? parts[trIdx].slice(3) : "",
232           ts: null
233         };
234         const el = makeLine(m, false);
235         el.dataset.time = (parts[0].split(" ")[1] || parts[0]);
236         log.appendChild(el);
237         historyTail.add(m.text);
238       }
239       // Only the newest few can collide with the server's replay buffer.
240       const keep = [...historyTail].slice(-6);
241       historyTail.clear();
242       keep.forEach((t) => historyTail.add(t));
243       scrollDown();
244     } catch (e) { /* history is a nicety, never block the live feed */ }
245   }
246 
247   function connect() {
248     const ws = new WebSocket("ws://127.0.0.1:" + WS_PORT);
249     const conn = document.getElementById("conn");
250 
251     ws.onopen = () => {
252       conn.innerHTML = '<span class="dot up"></span>';
253       ws.send(JSON.stringify({ cmd: "status" }));
254     };
255     ws.onclose = () => {
256       conn.innerHTML = '<span class="dot down"></span>';
257       setTimeout(connect, 1500);
258     };
259     ws.onerror = () => ws.close();
260     ws.onmessage = (ev) => {
261       let m;
262       try { m = JSON.parse(ev.data); } catch (e) { return; }
263       if (m.type === "final") onFinal(m);
264       else if (m.type === "partial") onPartial(m);
265       else if (m.type === "status") {
266         document.getElementById("lang").textContent = m.lang + " · " + m.model;
267       } else if (m.type === "clear") {
268         if (partialEl) { partialEl.remove(); partialEl = null; }
269       }
270     };
271   }
272 
273   loadHistory().then(connect);
274 })();
275 </script>
276 </body>
277 </html>