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


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