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