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>