commit c0e9deddc82ccf23d36b9f9d4e8308ddafe4c58c equwal <truex@equwal.com> 2026-08-26 03:53:50 -0700 Restore session scrollback in the reader after a reload Mining means going back to something said minutes ago, and a page refresh previously threw the whole session away. The reader now replays this session's lines from captions.log, which livecap marks with a session header on startup so earlier sessions are not dragged in. The log is written with CRLF, so splitting it on \n left a trailing carriage return on every line. That broke de-duplication against the server's replay buffer - the last few lines appeared twice - and, worse, put an invisible \r inside the text Yomitan would mine. Split on /\r?\n/ and trim fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
livecap.py | 9 +++++++++ reader.html | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/livecap.py b/livecap.py index 2b4201b..96303c7 100644 --- a/livecap.py +++ b/livecap.py @@ -215,6 +215,14 @@ class Bus: def attach(self, loop): self.loop = loop + def mark_session(self): + """A marker so the reader can show this session's history after a reload.""" + try: + with self.logf.open("a", encoding="utf-8") as fh: + fh.write("=== session %s ===\n" % time.strftime("%Y-%m-%d %H:%M:%S")) + except OSError as e: + log("could not write captions.log:", e) + def publish(self, msg): if msg["type"] == "final": self.history.append(msg) @@ -899,6 +907,7 @@ def main(): stop_ev = threading.Event() jobs = queue.Queue(maxsize=3) + bus.mark_session() ctl = Controller(args, bus) tr = Transcriber(args, bus) ctl.tr = tr diff --git a/reader.html b/reader.html index 77289f5..5930cfa 100644 --- a/reader.html +++ b/reader.html @@ -99,6 +99,7 @@ const jump = document.getElementById("jump"); let follow = true; let partialEl = null; + const historyTail = new Set(); let size = parseInt(localStorage.getItem("livecap.size") || q.get("size") || "30", 10); function applySize() { @@ -155,6 +156,12 @@ } function onFinal(m) { + // The server replays its last few finals on connect; those may already be + // on screen from captions.log. Drop only that overlap, once. + if (historyTail.has(m.text)) { + historyTail.delete(m.text); + return; + } clearEmpty(); if (partialEl) { partialEl.remove(); partialEl = null; } const wasBottom = atBottom(); @@ -199,6 +206,44 @@ }; jump.onclick = scrollDown; + // Reloading the page would otherwise lose the session's scrollback, which is + // exactly what you want when going back to something said minutes ago. + async function loadHistory() { + try { + const res = await fetch("captions.log", { cache: "no-store" }); + if (!res.ok) return; + // The log is written with CRLF on Windows; a stray \r would both break + // the de-duplication below and end up inside mined sentences. + const lines = (await res.text()).split(/\r?\n/); + let from = 0; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith("=== session ")) { from = i + 1; break; } + } + const rows = lines.slice(from).filter((l) => l.trim() && !l.startsWith("===")); + if (!rows.length) return; + clearEmpty(); + for (const row of rows.slice(-400)) { + const parts = row.split("\t").map((p) => p.trim()); + if (parts.length < 2 || !parts[1]) continue; + const trIdx = parts.findIndex((p) => p.startsWith("|| ")); + const m = { + text: parts[1], + tr: trIdx > 0 ? parts[trIdx].slice(3) : "", + ts: null + }; + const el = makeLine(m, false); + el.dataset.time = (parts[0].split(" ")[1] || parts[0]); + log.appendChild(el); + historyTail.add(m.text); + } + // Only the newest few can collide with the server's replay buffer. + const keep = [...historyTail].slice(-6); + historyTail.clear(); + keep.forEach((t) => historyTail.add(t)); + scrollDown(); + } catch (e) { /* history is a nicety, never block the live feed */ } + } + function connect() { const ws = new WebSocket("ws://127.0.0.1:" + WS_PORT); const conn = document.getElementById("conn"); @@ -225,7 +270,7 @@ }; } - connect(); + loadHistory().then(connect); })(); </script> </body>