src/content.js (13324 bytes)
1 /* Your own subtitles over a YouTube video. 2 * 3 * The extension reads an .srt that you give it, and shows each line over the 4 * player in time with the video. It does not read, record or download the 5 * video or its sound, and it sends nothing anywhere: the subtitles stay in 6 * this browser, kept for each video so that they are there the next time. 7 */ 8 (function () { 9 'use strict'; 10 if (window.__subreadLoaded) return; 11 window.__subreadLoaded = true; 12 13 const { parse, cueAt, subtitleTime, videoTime } = globalThis.SubReadSrt; 14 const store = (typeof chrome !== 'undefined' && chrome.storage?.local) || null; 15 const KEEP = 8; // videos whose subtitles are kept 16 const DEFAULTS = { offset: 0, rate: 1, size: 26, shown: true, transcript: false }; 17 18 let videoId = null, cues = [], settings = { ...DEFAULTS }, fileName = ''; 19 let box, panel, button, line, status, shownCue = null; 20 21 // The transcript panel: built once, moved in and out of the page as needed. 22 let transcriptPanel, transcriptList, transcriptRowByCue = new WeakMap(); 23 let transcriptCuesRef = null, transcriptClock = { offset: NaN, rate: NaN }; 24 let transcriptCurrentRow = null, transcriptUserScrollAt = 0, transcriptLastAutoScroll = 0; 25 26 const get = (key) => new Promise((done) => (store ? store.get(key, (v) => done(v?.[key])) : done(undefined))); 27 const set = (key, value) => new Promise((done) => (store ? store.set({ [key]: value }, done) : done())); 28 const remove = (key) => new Promise((done) => (store ? store.remove(key, done) : done())); 29 30 const currentId = () => (location.pathname === '/watch' ? new URLSearchParams(location.search).get('v') : null); 31 const player = () => document.querySelector('#movie_player'); 32 const video = () => document.querySelector('#movie_player video'); 33 34 function el(tag, props = {}, children = []) { 35 const node = Object.assign(document.createElement(tag), props); 36 for (const c of children) node.append(c); 37 return node; 38 } 39 40 /* ------------------------------------------------------------------ build */ 41 42 function build() { 43 const host = player(); 44 if (!host || host.querySelector('.subread-box')) return !!host; 45 46 line = el('span', { className: 'subread-line' }); 47 box = el('div', { className: 'subread-box' }, [line]); 48 49 status = el('div', { className: 'subread-status' }); 50 const file = el('input', { type: 'file', accept: '.srt,.vtt,text/plain', className: 'subread-file' }); 51 file.addEventListener('change', async () => { 52 const f = file.files[0]; 53 if (!f) return; 54 await load(await f.text(), f.name); 55 file.value = ''; 56 }); 57 58 const nudge = (label, title, by) => { 59 const b = el('button', { type: 'button', textContent: label, title }); 60 b.addEventListener('click', () => change({ offset: Math.round((settings.offset + by) * 10) / 10 })); 61 return b; 62 }; 63 const offsetOut = el('output', { className: 'subread-offset' }); 64 const here = el('button', { type: 'button', textContent: 'First line is now', 65 title: 'Play to where the narrator reads the first line of the subtitles, then press this' }); 66 here.addEventListener('click', () => { 67 if (cues.length && video()) change({ offset: Math.round((video().currentTime - cues[0].start / settings.rate) * 10) / 10 }); 68 }); 69 70 const rate = el('input', { type: 'number', step: '0.0005', min: '0.9', max: '1.1', className: 'subread-rate', 71 title: 'Above 1 if the subtitles fall behind as the book goes on; below 1 if they run ahead' }); 72 rate.addEventListener('change', () => change({ rate: Math.min(1.1, Math.max(0.9, +rate.value || 1)) })); 73 const size = el('input', { type: 'range', min: '14', max: '48', className: 'subread-size', title: 'Text size' }); 74 size.addEventListener('input', () => change({ size: +size.value })); 75 const shown = el('input', { type: 'checkbox' }); 76 shown.addEventListener('change', () => change({ shown: shown.checked })); 77 const transcript = el('input', { type: 'checkbox' }); 78 transcript.addEventListener('change', () => change({ transcript: transcript.checked })); 79 const forget = el('button', { type: 'button', textContent: 'Remove' }); 80 forget.addEventListener('click', async () => { await remove('v:' + videoId); cues = []; fileName = ''; render(); }); 81 82 panel = el('div', { className: 'subread-panel', hidden: true }, [ 83 el('div', { className: 'subread-title', textContent: 'SubRead: your subtitles' }), 84 status, 85 el('label', { className: 'subread-row' }, ['Subtitles (.srt) ', file]), 86 el('div', { className: 'subread-row' }, ['Start ', nudge('−1s', 'Subtitles earlier', -1), nudge('−0.1', 'Subtitles earlier', -0.1), 87 offsetOut, nudge('+0.1', 'Subtitles later', 0.1), nudge('+1s', 'Subtitles later', 1)]), 88 el('div', { className: 'subread-row' }, [here]), 89 el('label', { className: 'subread-row' }, ['Speed ', rate]), 90 el('label', { className: 'subread-row' }, ['Size ', size]), 91 el('label', { className: 'subread-row' }, [shown, ' Show', ' ', transcript, ' Transcript', ' ', forget]), 92 el('div', { className: 'subread-row subread-help' }, [ 93 'No subtitles for this book? ', 94 el('a', { href: 'https://subread.space', target: '_blank', rel: 'noopener', textContent: 'Make them from the audiobook and its ebook' }), 95 ': free, in your browser.', 96 ]), 97 ]); 98 panel._controls = { offsetOut, rate, size, shown, transcript }; 99 for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'keypress', 'wheel']) { 100 panel.addEventListener(type, (e) => e.stopPropagation()); // or the player takes the keys and the clicks 101 } 102 103 button = el('button', { type: 'button', className: 'subread-button', textContent: 'SR', title: 'SubRead: your subtitles' }); 104 button.addEventListener('click', (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; }); 105 106 host.append(box, button, panel); 107 return true; 108 } 109 110 /* ------------------------------------------------------------- transcript */ 111 112 // m:ss, or h:mm:ss past the first hour. 113 function stamp(t) { 114 t = Math.max(0, Math.round(t)); 115 const h = Math.floor(t / 3600), m = Math.floor(t / 60) % 60, s = t % 60; 116 return (h ? h + ':' + String(m).padStart(2, '0') : String(m)) + ':' + String(s).padStart(2, '0'); 117 } 118 119 function buildTranscript() { 120 const list = el('div', { className: 'subread-transcript-list' }); 121 list.addEventListener('scroll', () => { 122 // Setting scrollTop ourselves also fires 'scroll'; tell it apart by when it happened. 123 if (Date.now() - transcriptLastAutoScroll > 150) transcriptUserScrollAt = Date.now(); 124 }); 125 const close = el('button', { type: 'button', className: 'subread-transcript-close', textContent: '×', title: 'Close the transcript' }); 126 close.addEventListener('click', () => change({ transcript: false })); 127 const header = el('div', { className: 'subread-transcript-header' }, [ 128 el('span', { className: 'subread-transcript-title', textContent: 'SubRead transcript' }), 129 close, 130 ]); 131 transcriptPanel = el('div', { className: 'subread-transcript' }, [header, list]); 132 transcriptList = list; 133 } 134 135 // Put the panel at the top of the related-videos column, or below the player when that column is not there. 136 function attachTranscript() { 137 const side = document.querySelector('#secondary-inner') || document.querySelector('#secondary'); 138 if (side) { side.insertBefore(transcriptPanel, side.firstChild); return; } 139 const below = document.querySelector('#below'); 140 if (below) below.insertBefore(transcriptPanel, below.firstChild); 141 } 142 143 // One row per cue, with the cue's start shown on the clock of the video. 144 function renderTranscriptRows() { 145 transcriptList.textContent = ''; 146 transcriptRowByCue = new WeakMap(); 147 transcriptCurrentRow = null; 148 for (const cue of cues) { 149 const time = el('span', { className: 'subread-transcript-stamp', textContent: stamp(videoTime(cue.start, settings.offset, settings.rate)) }); 150 const text = el('span', { className: 'subread-transcript-text', textContent: cue.text }); 151 const row = el('div', { className: 'subread-transcript-row' }, [time, text]); 152 row.addEventListener('click', () => { const v = video(); if (v) v.currentTime = videoTime(cue.start, settings.offset, settings.rate); }); 153 transcriptList.append(row); 154 transcriptRowByCue.set(cue, row); 155 } 156 } 157 158 // The Start offset or Speed changed: the video time shown for each cue moves, but not the rows. 159 function updateTranscriptStamps() { 160 const rows = transcriptList.children; 161 cues.forEach((cue, i) => { rows[i].firstChild.textContent = stamp(videoTime(cue.start, settings.offset, settings.rate)); }); 162 } 163 164 // Build, attach, and fill the panel if the toggle is on and there are cues; else take it down. 165 function ensureTranscript() { 166 const want = settings.transcript && cues.length > 0; 167 if (!want) { if (transcriptPanel) transcriptPanel.remove(); return; } 168 if (!transcriptPanel) buildTranscript(); 169 if (!transcriptPanel.isConnected) attachTranscript(); 170 if (transcriptCuesRef !== cues) { 171 renderTranscriptRows(); 172 transcriptCuesRef = cues; 173 transcriptClock = { offset: settings.offset, rate: settings.rate }; 174 } else if (transcriptClock.offset !== settings.offset || transcriptClock.rate !== settings.rate) { 175 updateTranscriptStamps(); 176 transcriptClock = { offset: settings.offset, rate: settings.rate }; 177 } 178 } 179 180 // Highlight the row of the cue on screen now, and scroll it into view unless the user just scrolled the list. 181 function highlightTranscript(cue) { 182 if (!transcriptPanel || !transcriptPanel.isConnected) return; 183 const row = cue ? transcriptRowByCue.get(cue) : null; 184 if (row === transcriptCurrentRow) return; 185 if (transcriptCurrentRow) transcriptCurrentRow.classList.remove('subread-current'); 186 transcriptCurrentRow = row || null; 187 if (!row) return; 188 row.classList.add('subread-current'); 189 if (Date.now() - transcriptUserScrollAt < 4000) return; 190 const list = transcriptList; 191 const target = Math.max(0, Math.min(row.offsetTop - list.clientHeight / 2 + row.offsetHeight / 2, list.scrollHeight - list.clientHeight)); 192 if (Math.abs(list.scrollTop - target) < 1) return; 193 transcriptLastAutoScroll = Date.now(); 194 list.scrollTop = target; 195 } 196 197 /* ------------------------------------------------------------------- state */ 198 199 async function load(source, name) { 200 const parsed = parse(source); 201 if (!parsed.length) { status.textContent = `No subtitles could be read from ${name}.`; return; } 202 cues = parsed; 203 fileName = name; 204 settings = { ...settings, shown: true }; 205 await save(); 206 render(); 207 } 208 209 async function save() { 210 if (!videoId) return; 211 await set('v:' + videoId, { cues, fileName, settings, at: Date.now() }); 212 // Keep the newest few: a book of subtitles is megabytes. 213 const index = ((await get('index')) || []).filter((id) => id !== videoId); 214 index.unshift(videoId); 215 for (const old of index.splice(KEEP)) await remove('v:' + old); 216 await set('index', index); 217 } 218 219 function change(patch) { 220 settings = { ...settings, ...patch }; 221 render(); 222 if (cues.length) save(); 223 } 224 225 function render() { 226 if (!panel) return; 227 const c = panel._controls; 228 c.offsetOut.textContent = `${settings.offset >= 0 ? '+' : ''}${settings.offset.toFixed(1)} s`; 229 c.rate.value = settings.rate; 230 c.size.value = settings.size; 231 c.shown.checked = settings.shown; 232 c.transcript.checked = settings.transcript; 233 status.textContent = cues.length ? `${fileName}: ${cues.length} lines` : 'Choose the .srt of this video.'; 234 box.style.fontSize = settings.size + 'px'; 235 button.classList.toggle('subread-on', cues.length > 0 && settings.shown); 236 shownCue = undefined; // draw again 237 ensureTranscript(); 238 tick(); 239 } 240 241 function tick() { 242 const v = video(); 243 if (!box || !v) return; 244 const cue = cues.length ? cueAt(cues, subtitleTime(v.currentTime, settings.offset, settings.rate)) : null; 245 highlightTranscript(cue); 246 const shown = settings.shown ? cue : null; 247 if (shown === shownCue) return; 248 shownCue = shown; 249 line.textContent = shown ? shown.text : ''; 250 box.hidden = !shown; 251 } 252 253 async function arrive() { 254 const id = currentId(); 255 if (id === videoId && box?.isConnected) return; 256 videoId = id; 257 cues = []; fileName = ''; settings = { ...DEFAULTS }; 258 if (!id) { if (box) box.hidden = true; return; } 259 if (!build()) return; 260 const saved = await get('v:' + id); 261 if (saved && id === videoId) ({ cues, fileName } = saved), (settings = { ...DEFAULTS, ...saved.settings }); 262 render(); 263 } 264 265 // YouTube changes pages without loading a new one, and builds its player late. 266 document.addEventListener('yt-navigate-finish', arrive); 267 setInterval(() => { 268 if (currentId() && (!box || !box.isConnected || currentId() !== videoId)) { arrive(); return; } 269 // The related-videos column can be rebuilt (theatre mode, layout change) without a navigation. 270 if (transcriptPanel && settings.transcript && !transcriptPanel.isConnected) ensureTranscript(); 271 }, 1500); 272 setInterval(tick, 100); 273 arrive(); 274 })();