src/content.js (7973 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 } = 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 }; 17 18 let videoId = null, cues = [], settings = { ...DEFAULTS }, fileName = ''; 19 let box, panel, button, line, status, shownCue = null; 20 21 const get = (key) => new Promise((done) => (store ? store.get(key, (v) => done(v?.[key])) : done(undefined))); 22 const set = (key, value) => new Promise((done) => (store ? store.set({ [key]: value }, done) : done())); 23 const remove = (key) => new Promise((done) => (store ? store.remove(key, done) : done())); 24 25 const currentId = () => (location.pathname === '/watch' ? new URLSearchParams(location.search).get('v') : null); 26 const player = () => document.querySelector('#movie_player'); 27 const video = () => document.querySelector('#movie_player video'); 28 29 function el(tag, props = {}, children = []) { 30 const node = Object.assign(document.createElement(tag), props); 31 for (const c of children) node.append(c); 32 return node; 33 } 34 35 /* ------------------------------------------------------------------ build */ 36 37 function build() { 38 const host = player(); 39 if (!host || host.querySelector('.subread-box')) return !!host; 40 41 line = el('span', { className: 'subread-line' }); 42 box = el('div', { className: 'subread-box' }, [line]); 43 44 status = el('div', { className: 'subread-status' }); 45 const file = el('input', { type: 'file', accept: '.srt,.vtt,text/plain', className: 'subread-file' }); 46 file.addEventListener('change', async () => { 47 const f = file.files[0]; 48 if (!f) return; 49 await load(await f.text(), f.name); 50 file.value = ''; 51 }); 52 53 const nudge = (label, title, by) => { 54 const b = el('button', { type: 'button', textContent: label, title }); 55 b.addEventListener('click', () => change({ offset: Math.round((settings.offset + by) * 10) / 10 })); 56 return b; 57 }; 58 const offsetOut = el('output', { className: 'subread-offset' }); 59 const here = el('button', { type: 'button', textContent: 'First line is now', 60 title: 'Play to where the narrator reads the first line of the subtitles, then press this' }); 61 here.addEventListener('click', () => { 62 if (cues.length && video()) change({ offset: Math.round((video().currentTime - cues[0].start / settings.rate) * 10) / 10 }); 63 }); 64 65 const rate = el('input', { type: 'number', step: '0.0005', min: '0.9', max: '1.1', className: 'subread-rate', 66 title: 'Above 1 if the subtitles fall behind as the book goes on; below 1 if they run ahead' }); 67 rate.addEventListener('change', () => change({ rate: Math.min(1.1, Math.max(0.9, +rate.value || 1)) })); 68 const size = el('input', { type: 'range', min: '14', max: '48', className: 'subread-size', title: 'Text size' }); 69 size.addEventListener('input', () => change({ size: +size.value })); 70 const shown = el('input', { type: 'checkbox' }); 71 shown.addEventListener('change', () => change({ shown: shown.checked })); 72 const forget = el('button', { type: 'button', textContent: 'Remove' }); 73 forget.addEventListener('click', async () => { await remove('v:' + videoId); cues = []; fileName = ''; render(); }); 74 75 panel = el('div', { className: 'subread-panel', hidden: true }, [ 76 el('div', { className: 'subread-title', textContent: 'SubRead: your subtitles' }), 77 status, 78 el('label', { className: 'subread-row' }, ['Subtitles (.srt) ', file]), 79 el('div', { className: 'subread-row' }, ['Start ', nudge('−1s', 'Subtitles earlier', -1), nudge('−0.1', 'Subtitles earlier', -0.1), 80 offsetOut, nudge('+0.1', 'Subtitles later', 0.1), nudge('+1s', 'Subtitles later', 1)]), 81 el('div', { className: 'subread-row' }, [here]), 82 el('label', { className: 'subread-row' }, ['Speed ', rate]), 83 el('label', { className: 'subread-row' }, ['Size ', size]), 84 el('label', { className: 'subread-row' }, [shown, ' Show', ' ', forget]), 85 el('div', { className: 'subread-row subread-help' }, [ 86 'No subtitles for this book? ', 87 el('a', { href: 'https://subread.space', target: '_blank', rel: 'noopener', textContent: 'Make them from the audiobook and its ebook' }), 88 ': free, in your browser.', 89 ]), 90 ]); 91 panel._controls = { offsetOut, rate, size, shown }; 92 for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'keypress', 'wheel']) { 93 panel.addEventListener(type, (e) => e.stopPropagation()); // or the player takes the keys and the clicks 94 } 95 96 button = el('button', { type: 'button', className: 'subread-button', textContent: 'SR', title: 'SubRead: your subtitles' }); 97 button.addEventListener('click', (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; }); 98 99 host.append(box, button, panel); 100 return true; 101 } 102 103 /* ------------------------------------------------------------------- state */ 104 105 async function load(source, name) { 106 const parsed = parse(source); 107 if (!parsed.length) { status.textContent = `No subtitles could be read from ${name}.`; return; } 108 cues = parsed; 109 fileName = name; 110 settings = { ...settings, shown: true }; 111 await save(); 112 render(); 113 } 114 115 async function save() { 116 if (!videoId) return; 117 await set('v:' + videoId, { cues, fileName, settings, at: Date.now() }); 118 // Keep the newest few: a book of subtitles is megabytes. 119 const index = ((await get('index')) || []).filter((id) => id !== videoId); 120 index.unshift(videoId); 121 for (const old of index.splice(KEEP)) await remove('v:' + old); 122 await set('index', index); 123 } 124 125 function change(patch) { 126 settings = { ...settings, ...patch }; 127 render(); 128 if (cues.length) save(); 129 } 130 131 function render() { 132 if (!panel) return; 133 const c = panel._controls; 134 c.offsetOut.textContent = `${settings.offset >= 0 ? '+' : ''}${settings.offset.toFixed(1)} s`; 135 c.rate.value = settings.rate; 136 c.size.value = settings.size; 137 c.shown.checked = settings.shown; 138 status.textContent = cues.length ? `${fileName}: ${cues.length} lines` : 'Choose the .srt of this video.'; 139 box.style.fontSize = settings.size + 'px'; 140 button.classList.toggle('subread-on', cues.length > 0 && settings.shown); 141 shownCue = undefined; // draw again 142 tick(); 143 } 144 145 function tick() { 146 const v = video(); 147 if (!box || !v) return; 148 const cue = cues.length && settings.shown ? cueAt(cues, subtitleTime(v.currentTime, settings.offset, settings.rate)) : null; 149 if (cue === shownCue) return; 150 shownCue = cue; 151 line.textContent = cue ? cue.text : ''; 152 box.hidden = !cue; 153 } 154 155 async function arrive() { 156 const id = currentId(); 157 if (id === videoId && box?.isConnected) return; 158 videoId = id; 159 cues = []; fileName = ''; settings = { ...DEFAULTS }; 160 if (!id) { if (box) box.hidden = true; return; } 161 if (!build()) return; 162 const saved = await get('v:' + id); 163 if (saved && id === videoId) ({ cues, fileName } = saved), (settings = { ...DEFAULTS, ...saved.settings }); 164 render(); 165 } 166 167 // YouTube changes pages without loading a new one, and builds its player late. 168 document.addEventListener('yt-navigate-finish', arrive); 169 setInterval(() => { if (currentId() && (!box || !box.isConnected || currentId() !== videoId)) arrive(); }, 1500); 170 setInterval(tick, 100); 171 arrive(); 172 })();