Recently Written · git

subread-extension

Show your own .srt over a YouTube video, in time with it. Chrome and Firefox.

git clone https://github.com/equwal/subread-extension

Log | Files | Refs


commit b829f021f5f110cab13838b16d96180457d834da
equwal <truex@equwal.com>
2026-09-24 12:08:56 -0700

Add a SubRead transcript panel, filled with the loaded SRT

Add a Transcript checkbox next to Show. When on, it builds SubRead's
own transcript panel (not YouTube's), lists every cue with its time on
the video's clock, highlights and scrolls to the cue now playing, and
seeks the video when a line is clicked.

Place the panel at the top of the related-videos column. When that
column is not there, place it below the player instead. Keep the
panel through YouTube's page changes, and take it down when the
subtitles are removed.

 src/content.css |  26 ++++++++++++
 src/content.js  | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 138 insertions(+), 10 deletions(-)
diff --git a/src/content.css b/src/content.css
index abb1895..c0d1807 100644
--- a/src/content.css
+++ b/src/content.css
@@ -36,3 +36,29 @@
 #movie_player .subread-file { max-width: 190px; color: #ccc; font-size: 12px; }
 #movie_player .subread-help { color: #bbb; display: block; font-size: 12px; }
 #movie_player .subread-help a { color: #9cc3ff; }
+
+/* The transcript panel: SubRead's own, styled to sit next to YouTube's related videos. */
+.subread-transcript {
+  margin: 0 0 12px; border-radius: 12px; overflow: hidden;
+  background: var(--yt-spec-badge-chip-background, #f2f2f2);
+  color: var(--yt-spec-text-primary, #0f0f0f);
+  /* Latin and Cyrillic fonts come first. The Japanese fonts have full-width Cyrillic letters. */
+  font-family: "Noto Sans", Roboto, Arial, "Hiragino Sans", "Yu Gothic", sans-serif;
+  font-size: 14px;
+}
+.subread-transcript-header {
+  display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-weight: 700;
+}
+.subread-transcript-close {
+  background: none; border: none; color: inherit; font: inherit; font-size: 20px; line-height: 1; padding: 0 4px; cursor: pointer;
+}
+.subread-transcript-list { max-height: 60vh; overflow-y: auto; padding: 0 8px 12px; }
+.subread-transcript-row {
+  display: flex; gap: 12px; padding: 6px 8px; border-radius: 6px; cursor: pointer; line-height: 1.4;
+}
+.subread-transcript-row:hover { background: var(--yt-spec-10-percent-layer, rgba(0, 0, 0, .06)); }
+.subread-transcript-row.subread-current { background: var(--yt-spec-10-percent-layer, rgba(0, 0, 0, .1)); }
+.subread-transcript-stamp {
+  flex: none; width: 44px; color: var(--yt-spec-text-secondary, #606060); font-variant-numeric: tabular-nums;
+}
+.subread-transcript-text { white-space: pre-line; }
diff --git a/src/content.js b/src/content.js
index 18e17ca..7dcd263 100644
--- a/src/content.js
+++ b/src/content.js
@@ -10,14 +10,19 @@
   if (window.__subreadLoaded) return;
   window.__subreadLoaded = true;
 
-  const { parse, cueAt, subtitleTime } = globalThis.SubReadSrt;
+  const { parse, cueAt, subtitleTime, videoTime } = globalThis.SubReadSrt;
   const store = (typeof chrome !== 'undefined' && chrome.storage?.local) || null;
   const KEEP = 8;                     // videos whose subtitles are kept
-  const DEFAULTS = { offset: 0, rate: 1, size: 26, shown: true };
+  const DEFAULTS = { offset: 0, rate: 1, size: 26, shown: true, transcript: false };
 
   let videoId = null, cues = [], settings = { ...DEFAULTS }, fileName = '';
   let box, panel, button, line, status, shownCue = null;
 
+  // The transcript panel: built once, moved in and out of the page as needed.
+  let transcriptPanel, transcriptList, transcriptRowByCue = new WeakMap();
+  let transcriptCuesRef = null, transcriptClock = { offset: NaN, rate: NaN };
+  let transcriptCurrentRow = null, transcriptUserScrollAt = 0, transcriptLastAutoScroll = 0;
+
   const get = (key) => new Promise((done) => (store ? store.get(key, (v) => done(v?.[key])) : done(undefined)));
   const set = (key, value) => new Promise((done) => (store ? store.set({ [key]: value }, done) : done()));
   const remove = (key) => new Promise((done) => (store ? store.remove(key, done) : done()));
@@ -69,6 +74,8 @@
     size.addEventListener('input', () => change({ size: +size.value }));
     const shown = el('input', { type: 'checkbox' });
     shown.addEventListener('change', () => change({ shown: shown.checked }));
+    const transcript = el('input', { type: 'checkbox' });
+    transcript.addEventListener('change', () => change({ transcript: transcript.checked }));
     const forget = el('button', { type: 'button', textContent: 'Remove' });
     forget.addEventListener('click', async () => { await remove('v:' + videoId); cues = []; fileName = ''; render(); });
 
@@ -81,14 +88,14 @@
       el('div', { className: 'subread-row' }, [here]),
       el('label', { className: 'subread-row' }, ['Speed ', rate]),
       el('label', { className: 'subread-row' }, ['Size ', size]),
-      el('label', { className: 'subread-row' }, [shown, ' Show', ' ', forget]),
+      el('label', { className: 'subread-row' }, [shown, ' Show', ' ', transcript, ' Transcript', ' ', forget]),
       el('div', { className: 'subread-row subread-help' }, [
         'No subtitles for this book? ',
         el('a', { href: 'https://subread.space', target: '_blank', rel: 'noopener', textContent: 'Make them from the audiobook and its ebook' }),
         ': free, in your browser.',
       ]),
     ]);
-    panel._controls = { offsetOut, rate, size, shown };
+    panel._controls = { offsetOut, rate, size, shown, transcript };
     for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'keypress', 'wheel']) {
       panel.addEventListener(type, (e) => e.stopPropagation());       // or the player takes the keys and the clicks
     }
@@ -100,6 +107,93 @@
     return true;
   }
 
+  /* ------------------------------------------------------------- transcript */
+
+  // m:ss, or h:mm:ss past the first hour.
+  function stamp(t) {
+    t = Math.max(0, Math.round(t));
+    const h = Math.floor(t / 3600), m = Math.floor(t / 60) % 60, s = t % 60;
+    return (h ? h + ':' + String(m).padStart(2, '0') : String(m)) + ':' + String(s).padStart(2, '0');
+  }
+
+  function buildTranscript() {
+    const list = el('div', { className: 'subread-transcript-list' });
+    list.addEventListener('scroll', () => {
+      // Setting scrollTop ourselves also fires 'scroll'; tell it apart by when it happened.
+      if (Date.now() - transcriptLastAutoScroll > 150) transcriptUserScrollAt = Date.now();
+    });
+    const close = el('button', { type: 'button', className: 'subread-transcript-close', textContent: '×', title: 'Close the transcript' });
+    close.addEventListener('click', () => change({ transcript: false }));
+    const header = el('div', { className: 'subread-transcript-header' }, [
+      el('span', { className: 'subread-transcript-title', textContent: 'SubRead transcript' }),
+      close,
+    ]);
+    transcriptPanel = el('div', { className: 'subread-transcript' }, [header, list]);
+    transcriptList = list;
+  }
+
+  // Put the panel at the top of the related-videos column, or below the player when that column is not there.
+  function attachTranscript() {
+    const side = document.querySelector('#secondary-inner') || document.querySelector('#secondary');
+    if (side) { side.insertBefore(transcriptPanel, side.firstChild); return; }
+    const below = document.querySelector('#below');
+    if (below) below.insertBefore(transcriptPanel, below.firstChild);
+  }
+
+  // One row per cue, with the cue's start shown on the clock of the video.
+  function renderTranscriptRows() {
+    transcriptList.textContent = '';
+    transcriptRowByCue = new WeakMap();
+    transcriptCurrentRow = null;
+    for (const cue of cues) {
+      const time = el('span', { className: 'subread-transcript-stamp', textContent: stamp(videoTime(cue.start, settings.offset, settings.rate)) });
+      const text = el('span', { className: 'subread-transcript-text', textContent: cue.text });
+      const row = el('div', { className: 'subread-transcript-row' }, [time, text]);
+      row.addEventListener('click', () => { const v = video(); if (v) v.currentTime = videoTime(cue.start, settings.offset, settings.rate); });
+      transcriptList.append(row);
+      transcriptRowByCue.set(cue, row);
+    }
+  }
+
+  // The Start offset or Speed changed: the video time shown for each cue moves, but not the rows.
+  function updateTranscriptStamps() {
+    const rows = transcriptList.children;
+    cues.forEach((cue, i) => { rows[i].firstChild.textContent = stamp(videoTime(cue.start, settings.offset, settings.rate)); });
+  }
+
+  // Build, attach, and fill the panel if the toggle is on and there are cues; else take it down.
+  function ensureTranscript() {
+    const want = settings.transcript && cues.length > 0;
+    if (!want) { if (transcriptPanel) transcriptPanel.remove(); return; }
+    if (!transcriptPanel) buildTranscript();
+    if (!transcriptPanel.isConnected) attachTranscript();
+    if (transcriptCuesRef !== cues) {
+      renderTranscriptRows();
+      transcriptCuesRef = cues;
+      transcriptClock = { offset: settings.offset, rate: settings.rate };
+    } else if (transcriptClock.offset !== settings.offset || transcriptClock.rate !== settings.rate) {
+      updateTranscriptStamps();
+      transcriptClock = { offset: settings.offset, rate: settings.rate };
+    }
+  }
+
+  // Highlight the row of the cue on screen now, and scroll it into view unless the user just scrolled the list.
+  function highlightTranscript(cue) {
+    if (!transcriptPanel || !transcriptPanel.isConnected) return;
+    const row = cue ? transcriptRowByCue.get(cue) : null;
+    if (row === transcriptCurrentRow) return;
+    if (transcriptCurrentRow) transcriptCurrentRow.classList.remove('subread-current');
+    transcriptCurrentRow = row || null;
+    if (!row) return;
+    row.classList.add('subread-current');
+    if (Date.now() - transcriptUserScrollAt < 4000) return;
+    const list = transcriptList;
+    const target = Math.max(0, Math.min(row.offsetTop - list.clientHeight / 2 + row.offsetHeight / 2, list.scrollHeight - list.clientHeight));
+    if (Math.abs(list.scrollTop - target) < 1) return;
+    transcriptLastAutoScroll = Date.now();
+    list.scrollTop = target;
+  }
+
   /* ------------------------------------------------------------------- state */
 
   async function load(source, name) {
@@ -135,21 +229,25 @@
     c.rate.value = settings.rate;
     c.size.value = settings.size;
     c.shown.checked = settings.shown;
+    c.transcript.checked = settings.transcript;
     status.textContent = cues.length ? `${fileName}: ${cues.length} lines` : 'Choose the .srt of this video.';
     box.style.fontSize = settings.size + 'px';
     button.classList.toggle('subread-on', cues.length > 0 && settings.shown);
     shownCue = undefined;               // draw again
+    ensureTranscript();
     tick();
   }
 
   function tick() {
     const v = video();
     if (!box || !v) return;
-    const cue = cues.length && settings.shown ? cueAt(cues, subtitleTime(v.currentTime, settings.offset, settings.rate)) : null;
-    if (cue === shownCue) return;
-    shownCue = cue;
-    line.textContent = cue ? cue.text : '';
-    box.hidden = !cue;
+    const cue = cues.length ? cueAt(cues, subtitleTime(v.currentTime, settings.offset, settings.rate)) : null;
+    highlightTranscript(cue);
+    const shown = settings.shown ? cue : null;
+    if (shown === shownCue) return;
+    shownCue = shown;
+    line.textContent = shown ? shown.text : '';
+    box.hidden = !shown;
   }
 
   async function arrive() {
@@ -166,7 +264,11 @@
 
   // YouTube changes pages without loading a new one, and builds its player late.
   document.addEventListener('yt-navigate-finish', arrive);
-  setInterval(() => { if (currentId() && (!box || !box.isConnected || currentId() !== videoId)) arrive(); }, 1500);
+  setInterval(() => {
+    if (currentId() && (!box || !box.isConnected || currentId() !== videoId)) { arrive(); return; }
+    // The related-videos column can be rebuilt (theatre mode, layout change) without a navigation.
+    if (transcriptPanel && settings.transcript && !transcriptPanel.isConnected) ensureTranscript();
+  }, 1500);
   setInterval(tick, 100);
   arrive();
 })();