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(); })();