Recently Written · git

desktop-subtitle-replay

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

Log | Files | Refs


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>