Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


frontend/app.js (40802 bytes)

1 /* SubRead web UI. No framework, no build step - one file, served as-is. */
2 
3 const $ = (id) => document.getElementById(id);
4 
5 const el = {
6   dropzone: $('dropzone'), picker: $('filepicker'), browse: $('browse'),
7   uploading: $('uploading'), upbar: $('upbar'), uptext: $('uptext'),
8   error: $('error'),
9   confirm: $('confirm'), cAudio: $('c-audio'), cText: $('c-text'),
10   language: $('language'), detected: $('detected'), splitnote: $('splitnote'),
11   start: $('start'), discard: $('discard'), eta: $('eta'),
12   inBrowser: $('in-browser'), modeNote: $('mode-note'),
13   jobsSection: $('jobs-section'), jobs: $('jobs'), quota: $('quota'),
14   freetier: $('freetier'), staged: $('staged'), dzTitle: $('dz-title'),
15   match: $('match'), matchBadge: $('match-badge'),
16   matchSummary: $('match-summary'), matchWarnings: $('match-warnings'),
17   who: $('who'), buyBtn: $('buy-btn'), portalBtn: $('portal-btn'),
18   signinBtn: $('signin-btn'), signoutBtn: $('signout-btn'),
19   signinDialog: $('signin-dialog'), signinForm: $('signin-form'),
20   signinEmail: $('signin-email'), signinSend: $('signin-send'),
21   signinNote: $('signin-note'),
22   pricingDialog: $('pricing-dialog'), pricingWhy: $('pricing-why'),
23   pricingSignin: $('pricing-signin'), pricingSigninBtn: $('pricing-signin-btn'),
24   plans: $('plans'), toast: $('toast'),
25   working: $('working'), workTitle: $('work-title'), workBar: $('work-bar'),
26   workMeta: $('work-meta'), stop: $('stop'), results: $('results'),
27   resultFiles: $('result-files'), another: $('another'),
28 };
29 
30 let languages = [];
31 let languagesReady = null;  // resolves once the <select> is populated
32 let draft = null;           // the job awaiting confirmation
33 let pollTimer = null;
34 let account = null;         // last /api/account response
35 
36 /* ---------------- helpers ---------------- */
37 
38 const fmtBytes = (n) => {
39   if (!n) return '';
40   const u = ['B', 'KB', 'MB', 'GB'];
41   let i = 0, v = n;
42   while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
43   return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
44 };
45 
46 const fmtDuration = (s) => {
47   if (!s && s !== 0) return '';
48   const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
49   return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`;
50 };
51 
52 function showError(msg) {
53   el.error.textContent = msg;
54   el.error.hidden = false;
55   el.error.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
56 }
57 const clearError = () => { el.error.hidden = true; };
58 
59 async function api(path, opts = {}) {
60   const res = await fetch(path, { credentials: 'same-origin', ...opts });
61   if (!res.ok) {
62     let detail = `${res.status} ${res.statusText}`;
63     try {
64       const body = await res.json();
65       if (body.detail) detail = typeof body.detail === 'string'
66         ? body.detail : JSON.stringify(body.detail);
67     } catch { /* non-JSON error body */ }
68     const err = new Error(detail);
69     err.status = res.status;
70     throw err;
71   }
72   return res.status === 204 ? null : res.json();
73 }
74 
75 /* ---------------- setup ---------------- */
76 
77 async function loadLanguages() {
78   // Whisper's own list: the speech model is the only thing that cares.
79   ({ LANGUAGES: languages } = await import('/engine/languages.js'));
80   el.language.innerHTML = '';
81   for (const l of languages) {
82     const o = document.createElement('option');
83     o.value = l.code;
84     o.textContent = `${l.name} (${l.code})`;
85     el.language.appendChild(o);
86   }
87 }
88 
89 /* Setting .value on a <select> silently does nothing when the option is not
90    there yet, which would leave whatever was selected before on screen while the
91    badge says something else. Make the mismatch impossible instead. */
92 function setLanguage(code) {
93   el.language.value = code;
94   if (el.language.value !== code) {
95     const o = document.createElement('option');
96     o.value = code;
97     o.textContent = code;
98     el.language.appendChild(o);
99     el.language.value = code;
100   }
101 }
102 
103 async function refreshAccount() {
104   try { account = await api('/api/account'); }
105   catch { return; /* cosmetic; never block the UI on it */ }
106   renderAccount();
107 }
108 
109 function renderAccount() {
110   const a = account;
111   if (!a) return;
112   signedIn = !!a.signed_in;
113 
114   const hint = document.getElementById('cover-hint');
115   if (hint) {
116     hint.innerHTML = signedIn
117       ? 'Optional: drop a jpg or png to use as the video background'
118       : 'Optional: drop a jpg or png to use as the video background — ' +
119         '<strong>sign in required</strong>';
120   }
121   if (a.free_tier_summary && el.freetier) {
122     el.freetier.textContent =
123       a.free_tier_summary.replace(/^./, (c) => c.toUpperCase()) + '.';
124   }
125 
126   el.who.hidden = !signedIn;
127   el.who.textContent = signedIn ? a.email : '';
128   el.signoutBtn.hidden = !signedIn;
129   // A buyer has an email from Stripe that is not verified yet. The same
130   // sign-in link verifies it.
131   el.signinBtn.hidden = !a.email_sign_in_available || (signedIn && a.email_verified);
132   const offer = signInOffer();
133   const verb = signedIn ? 'Verify your email' : 'Sign in';
134   el.signinBtn.textContent = offer ? `${verb} for ${offer}` : verb;
135 
136   // What is sold is conversion on this server's hardware. Nothing is for sale
137   // until that is connected; in this tab each output is free.
138   const selling = a.billing_enabled && a.cloud_available;
139   // A monthly plan with a limit can still add a pack; an unlimited one needs none.
140   el.buyBtn.hidden = !selling || a.unlimited;
141   el.portalBtn.hidden = !(selling && a.subscribed);
142   el.quota.hidden = !selling;
143   if (selling) {
144     // Say "free" while each credit in hand is a free one.
145     const free = a.credits > 0 && a.free_credits === a.credits ? 'free ' : '';
146     el.quota.textContent = a.unlimited ? 'Unlimited plan'
147       : `${a.credits} ${free}cloud credit${a.credits === 1 ? '' : 's'}`;
148   }
149   renderMode();
150 }
151 
152 /* What a verified email gives this visitor, in words: "2 free credits + 1 a
153    day". Empty when there is nothing to offer: the email is verified, the
154    server sends no sign-in email, or billing is off. */
155 function signInOffer() {
156   const a = account;
157   if (!a || a.email_verified || !a.email_sign_in_available || !a.billing_enabled) return '';
158   const n = a.free_credits_with_account || 0;
159   const parts = [];
160   if (n) parts.push(`${n} free credit${n === 1 ? '' : 's'}`);
161   if (a.daily_free_credit) parts.push(n ? '1 a day' : '1 free credit a day');
162   return parts.join(' + ');
163 }
164 
165 let toastTimer = null;
166 function toast(msg, ms = 6000) {
167   el.toast.textContent = msg;
168   el.toast.hidden = false;
169   clearTimeout(toastTimer);
170   toastTimer = setTimeout(() => { el.toast.hidden = true; }, ms);
171 }
172 
173 /* ---------------- upload ---------------- */
174 
175 /* Files can arrive one drop at a time, so collect them until both halves are
176    here, then upload. Ignores the cover art and readme files that ride along
177    inside audiobook folders. */
178 const AUDIO_RE = /\.(m4b|m4a|mp3|opus|ogg|oga|flac|wav|aac|wma|mka|mkv|mp4|webm|avi|mov)$/i;
179 const TEXT_RE = /(\.fb2\.zip|\.(epub|txt|srt|vtt|ass|fb2|mobi|azw|azw3|prc))$/i;
180 const IMAGE_RE = /\.(jpe?g|png|webp|bmp)$/i;
181 
182 const staged = { audio: [], text: null, cover: null };
183 let signedIn = false;
184 
185 function addFiles(files) {
186   clearError();
187   const list = [...files];
188   const audio = list.filter((f) => AUDIO_RE.test(f.name));
189   const text = list.filter((f) => TEXT_RE.test(f.name));
190   const images = list.filter((f) => IMAGE_RE.test(f.name));
191 
192   // Say up front that a cover needs an account, rather than accepting the file
193   // and springing it on them after they have waited for a render.
194   if (images.length && !signedIn) {
195     showError('Using your own cover image needs an account — sign in first, ' +
196               'and it will be applied to the video. The rest works without one.');
197   } else if (images.length) {
198     staged.cover = images[images.length - 1];
199   }
200 
201   if (!audio.length && !text.length && !images.length) {
202     showError('Nothing usable in that drop — expected an audiobook or an ebook.');
203     return;
204   }
205 
206   // Audio accumulates, so a per-chapter set can arrive in batches. Only one
207   // book makes sense, so a second one replaces the first.
208   for (const f of audio) {
209     if (!staged.audio.some((g) => g.name === f.name && g.size === f.size)) {
210       staged.audio.push(f);
211     }
212   }
213   if (text.length) staged.text = text[text.length - 1];
214 
215   renderStaged();
216   if (!audio.length && !text.length) return;  // an image alone is not a job
217   if (staged.audio.length && staged.text) {
218     prepare();
219   }
220 }
221 
222 function renderStaged() {
223   const have = staged.audio.length || staged.text;
224   el.staged.hidden = !have;
225 
226   const a = $('slot-audio');
227   a.classList.toggle('filled', staged.audio.length > 0);
228   a.querySelector('.slot-value').textContent = staged.audio.length
229     ? (staged.audio.length > 1
230         ? `${staged.audio[0].name} + ${staged.audio.length - 1} more`
231         : staged.audio[0].name)
232     : 'Waiting for an audio file';
233 
234   const cv = $('slot-cover');
235   cv.hidden = !staged.cover;
236   if (staged.cover) {
237     cv.classList.add('filled');
238     cv.querySelector('.slot-value').textContent = staged.cover.name;
239   }
240 
241   const t = $('slot-text');
242   t.classList.toggle('filled', !!staged.text);
243   t.querySelector('.slot-value').textContent =
244     staged.text ? staged.text.name : 'Waiting for an ebook';
245 
246   // Name the missing half, but only once one half is actually here.
247   const missing = !have ? null
248     : !staged.audio.length ? 'audiobook'
249     : !staged.text ? 'ebook'
250     : null;
251   el.dzTitle.innerHTML = missing
252     ? `Now drop the <em>${missing}</em>`
253     : 'Drop your audiobook <em>and</em> ebook here';
254 }
255 
256 function clearStaged() {
257   staged.audio = [];
258   staged.text = null;
259   staged.cover = null;
260   renderStaged();
261 }
262 
263 document.querySelectorAll('.slot-x').forEach((btn) => {
264   btn.addEventListener('click', () => {
265     if (btn.dataset.slot === 'audio') staged.audio = [];
266     else if (btn.dataset.slot === 'cover') staged.cover = null;
267     else staged.text = null;
268     renderStaged();
269   });
270 });
271 
272 /* Nothing is uploaded. Once both halves are here the book is read in this tab,
273    its language guessed, and the visitor asked to confirm before hours of work. */
274 const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
275 
276 async function prepare() {
277   clearError();
278   el.dropzone.hidden = true;
279   el.staged.hidden = true;
280   el.confirm.hidden = true;
281   el.uploading.hidden = false;
282   el.upbar.style.width = '100%';
283   el.uptext.textContent = 'Reading the book…';
284 
285   try {
286     const { readBook, detectLanguage } = await import('/engine/book.js');
287     const book = staged.text;
288     const parsed = await readBook(book);
289     if (!parsed.paragraphs.length) {
290       throw new Error(`No text could be read from ${book.name}. A scanned, image-only book cannot be aligned.`);
291     }
292     // 9.mp3 before 10.mp3: playback order is what the timeline is built from.
293     const audio = staged.audio.slice().sort((a, b) => natural.compare(a.name, b.name));
294     draft = { audio, book, cover: staged.cover, detected: detectLanguage(parsed.paragraphs) };
295     el.uploading.hidden = true;
296     showConfirm();
297   } catch (e) {
298     el.uploading.hidden = true;
299     showError(e.message);
300     resetToDrop();
301   }
302 }
303 
304 function resetToDrop() {
305   draft = null;
306   clearStaged();
307   el.dropzone.hidden = false;
308   el.confirm.hidden = true;
309   el.uploading.hidden = true;
310   el.working.hidden = true;
311   el.picker.value = '';
312 }
313 
314 /* ---------------- confirm ---------------- */
315 
316 async function showConfirm() {
317   const bytes = draft.audio.reduce((n, f) => n + f.size, 0);
318   const name = draft.audio.length > 1
319     ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name;
320   el.cAudio.innerHTML = `${escapeHtml(name)} <span class="meta">${fmtBytes(bytes)}</span>`;
321   el.cText.textContent = draft.book.name;
322 
323   if (draft.detected) {
324     setLanguage(draft.detected);
325     el.detected.textContent = 'detected from the book';
326     el.detected.classList.remove('low');
327   } else {
328     el.detected.textContent = 'could not tell — please choose';
329     el.detected.classList.add('low');
330   }
331   el.detected.hidden = false;
332   el.match.hidden = true;
333 
334   // Only the speech model decides how long this takes, and only the GPU
335   // decides how fast the speech model is. Say which it will be.
336   const { hasWebGpu } = await import('/engine/asr.js');
337   const gpu = await hasWebGpu();
338   const { savedProgress } = await import('/engine/job.js');
339   const saved = await savedProgress(draft.audio, el.language.value);
340   browserPlan = {
341     note: (saved?.complete ? 'This audio was transcribed here before, so this will take seconds. '
342       : saved?.doneUntil ? `Picks up where it stopped, ${fmtDuration(saved.doneUntil)} in. ` : '') +
343       'Everything runs in this tab, and it has to stay open. ' +
344       (saved?.complete ? '' : gpu ? 'Expect roughly a quarter of the book\'s length.'
345         : 'This browser has no WebGPU, so expect about the book\'s own length — Chrome or Edge on a computer with a graphics card is several times faster.'),
346     label: saved?.doneUntil && !saved.complete ? 'Continue' : 'Start',
347   };
348 
349   el.confirm.hidden = false;
350   el.start.disabled = false;
351   renderMode();
352   el.confirm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
353 }
354 
355 el.discard.addEventListener('click', () => { clearError(); resetToDrop(); });
356 
357 /* ---------------- where the work is done ---------------- */
358 
359 /* In this browser (JavaScript, free, nothing uploaded) is the default. The
360    switch is above the drop zone, so the choice shows before a file is chosen,
361    and each visit starts with the switch on. Off sends the job to the server:
362    for a phone, an old computer, or a tab that has to close. */
363 let browserPlan = { note: '', label: 'Start' };
364 
365 function serverOffered() { return !!account?.cloud_available; }
366 
367 function inBrowser() { return !serverOffered() || el.inBrowser.checked; }
368 
369 /* What a job on the server costs this visitor, as a sentence. */
370 function serverPrice() {
371   const a = account;
372   if (!a?.billing_enabled) return '';
373   if (a.unlimited) return ' Included in your plan.';
374   if (a.credits > 0) {
375     const free = a.free_credits === a.credits ? ' free' : '';
376     return ` One credit a book; you have ${a.credits}${free}.`;
377   }
378   return ' One credit a book.';
379 }
380 
381 function renderMode() {
382   const offered = serverOffered();
383   el.inBrowser.disabled = !offered;
384   if (!offered) el.inBrowser.checked = true;
385   if (inBrowser()) {
386     el.modeNote.textContent = 'The JavaScript version does not use any cloud credits.';
387     el.start.textContent = browserPlan.label;
388     el.eta.textContent = browserPlan.note +
389       (offered ? '' : ' Our server is not taking conversions at the moment, so this is the one way.');
390     return;
391   }
392   el.modeNote.textContent = 'Off: your files are uploaded, and our server does the work.' + serverPrice();
393   el.start.textContent = 'Upload and convert on our server';
394   el.eta.textContent =
395     'The files are uploaded, the work is done on our server, and the result waits for you on this page, ' +
396     'so you can close this tab. We keep the files on our server to find faults, and delete them on request.' +
397     serverPrice();
398 }
399 
400 el.inBrowser.addEventListener('change', renderMode);
401 
402 function startOnServer() {
403   if (!draft || running) return;
404   clearError();
405   // Ask for the credit before the upload, not after an hour of it.
406   if (account.billing_enabled && !account.cloud_allowed) {
407     openPricing('A conversion on our server takes one credit. In this tab it is free, without limit.');
408     return;
409   }
410   const { audio, book, cover } = draft;
411   const language = el.language.value;
412   el.confirm.hidden = true;
413   el.uploading.hidden = false;
414   el.upbar.style.width = '0%';
415   el.uptext.textContent = 'Starting…';
416 
417   const form = new FormData();
418   for (const f of [...audio, book]) form.append('files', f, f.name);
419   if (cover && signedIn) form.append('files', cover, cover.name);
420 
421   const back = (message) => { el.uploading.hidden = true; el.confirm.hidden = false; showError(message); };
422   // XHR and not fetch: it reports upload progress, and these files are big.
423   const xhr = new XMLHttpRequest();
424   xhr.open('POST', '/api/uploads');
425   xhr.withCredentials = true;
426   xhr.upload.onprogress = (e) => {
427     if (!e.lengthComputable) return;
428     const pct = Math.round((e.loaded / e.total) * 100);
429     el.upbar.style.width = `${pct}%`;
430     el.uptext.textContent = pct < 100 ? `${pct}% — ${fmtBytes(e.loaded)} of ${fmtBytes(e.total)}` : 'Checking the book…';
431   };
432   xhr.onerror = () => back('The upload failed: the connection to the server was lost.');
433   xhr.onload = async () => {
434     let body = null;
435     try { body = JSON.parse(xhr.responseText); } catch { /* handled below */ }
436     if (xhr.status < 200 || xhr.status >= 300 || !body?.job) {
437       back(typeof body?.detail === 'string' ? body.detail : `The upload failed (${xhr.status}).`);
438       return;
439     }
440     try {
441       await api(`/api/jobs/${body.job.id}/start`, {
442         method: 'POST',
443         headers: { 'Content-Type': 'application/json' },
444         body: JSON.stringify({ language }),
445       });
446     } catch (e) {
447       // The files are on the server but nothing runs: do not leave them there.
448       api(`/api/jobs/${body.job.id}`, { method: 'DELETE' }).catch(() => {});
449       if (e.status === 402) { el.uploading.hidden = true; el.confirm.hidden = false; openPricing(e.message); }
450       else back(e.message);
451       return;
452     }
453     resetToDrop();
454     toast('The server has the book. You can close this tab; the result will be under "Your conversions" on this page.', 12000);
455     refreshJobs();
456     refreshAccount();
457   };
458   xhr.send(form);
459 }
460 
461 /* ---------------- the job, in this tab ---------------- */
462 
463 let running = null;    // { job, serverId }
464 let wakeLock = null;
465 
466 el.start.addEventListener('click', async () => {
467   if (!draft || running) return;
468   if (!inBrowser()) { startOnServer(); return; }
469   el.start.disabled = true;
470   clearError();
471   const language = el.language.value;
472 
473   // The server keeps the list of this visitor's books, and nothing more: a job
474   // in this tab costs nothing and needs nothing from it. A server that is down
475   // or missing only loses the history entry.
476   const registered = await api('/api/local/jobs', {
477     method: 'POST',
478     headers: { 'Content-Type': 'application/json' },
479     body: JSON.stringify({
480       audio_filename: draft.audio.length > 1 ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name,
481       audio_parts: draft.audio.length,
482       audio_bytes: draft.audio.reduce((n, f) => n + f.size, 0),
483       text_filename: draft.book.name, language,
484     }),
485   }).catch(() => null);
486   if (registered && account?.browser_copy) sendCopy(registered.id, draft.audio, draft.book);
487   const report = (path, body) => (registered
488     ? api(`/api/local/jobs/${registered.id}/${path}`, {
489       method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
490     }).catch(() => {})
491     : Promise.resolve());
492 
493   const { Job, Cancelled } = await import('/engine/job.js');
494   const job = new Job({ audio: draft.audio, book: draft.book, language, onStatus: renderWorking });
495   running = { job, serverId: registered?.id, cover: draft.cover };
496   el.confirm.hidden = true;
497   el.working.hidden = false;
498   el.results.hidden = true;
499   el.stop.hidden = false;
500   renderWorking({ phase: 'preparing', detail: 'Starting', fraction: 0 });
501   refreshAccount();
502   try { wakeLock = await navigator.wakeLock?.request('screen'); } catch { /* not granted: fine */ }
503 
504   try {
505     const result = await job.run();
506     // The subtitles are here to download whatever the server says; only the history entry can go missing.
507     await report('finish', {
508       srt: result.srt, filename: result.srtName,
509       metadata: {
510         source: { audio_parts: draft.audio.length, text_filename: draft.book.name,
511                   audio_duration_seconds: result.duration },
512         alignment: { where: 'in the browser', device: job.device ?? 'cached transcript', model: 'whisper-tiny',
513                      language, mode: 'forced alignment against supplied text' },
514         output: { filename: result.srtName, cue_count: result.cues.length,
515                   match_rate: result.matchRate, paragraphs_dropped: result.paragraphsDropped },
516       },
517     });
518     showResults(result);
519   } catch (e) {
520     const stopped = e instanceof Cancelled;
521     await report('fail', { error: stopped ? 'Stopped.' : String(e.message || e) });
522     job.close();
523     running = null;
524     el.working.hidden = true;
525     if (stopped) { toast('Stopped. What was transcribed is kept — the same files will carry on from there.'); el.confirm.hidden = false; el.start.disabled = false; }
526     else { showError(e.message || String(e)); resetToDrop(); }
527   } finally {
528     wakeLock?.release?.().catch(() => {});
529     refreshAccount();
530     refreshJobs();
531   }
532 });
533 
534 el.stop.addEventListener('click', () => { running?.job.cancel(); el.stop.disabled = true; });
535 
536 /* During the beta, a copy of the audio and the book goes to the server, for
537    debugging, as the privacy and user data policy says. It runs beside the job
538    and costs no credit. If it fails, nothing changes for the visitor. */
539 function sendCopy(jobId, audio, book) {
540   const form = new FormData();
541   for (const f of [...audio, book]) form.append('files', f, f.name);
542   fetch(`/api/local/jobs/${jobId}/files`, { method: 'POST', body: form, credentials: 'same-origin' })
543     .catch(() => { /* a copy for debugging only */ });
544 }
545 
546 function renderWorking(st) {
547   el.workTitle.textContent = st.detail;
548   el.workBar.style.width = `${Math.round((st.fraction || 0) * 100)}%`;
549   el.workBar.parentElement.classList.toggle('indeterminate', !!st.indeterminate);
550   el.workMeta.textContent = [
551     `${Math.round((st.fraction || 0) * 100)}%`,
552     st.eta != null ? `about ${fmtDuration(st.eta)} left` : null,
553     st.speed ? `${st.speed.toFixed(1)}× real time` : null,
554   ].filter(Boolean).join('  ·  ');
555 }
556 
557 function showResults(r) {
558   // The ask goes to people who got a book for nothing, at the moment it worked.
559   // Not to a customer: someone who pays for credits has done their part.
560   // Free credits do not make a customer, so count the bought ones only.
561   const chipIn = document.getElementById('chip-in');
562   const bought = (account?.credits ?? 0) - (account?.free_credits ?? 0)
563     - (account?.plan_credits ?? 0);
564   if (chipIn) chipIn.hidden = Boolean(bought > 0 || account?.subscribed);
565   el.stop.hidden = true;
566   el.stop.disabled = false;
567   el.results.hidden = false;
568   const pct = Math.round(r.matchRate * 100);
569   el.workTitle.textContent = `Done — ${r.cues.length} lines, ${pct}% found in the book`;
570   el.workBar.style.width = '100%';
571   el.workMeta.textContent =
572     (pct < 80 ? 'That is low: usually a different edition or translation, or the wrong language. ' : '') +
573     (r.paragraphsDropped ? `${r.paragraphsDropped} paragraphs of the book were never narrated (front matter, notes) and were left out.` : '');
574   renderResultButtons();
575 }
576 
577 function renderResultButtons() {
578   const r = running.job.result;
579   el.resultFiles.innerHTML = '';
580   const button = (label, hint, cls, onClick) => {
581     const b = document.createElement('button');
582     b.type = 'button'; b.className = `dl ${cls}`; b.title = hint; b.textContent = label;
583     b.addEventListener('click', () => onClick(b));
584     el.resultFiles.appendChild(b);
585   };
586   button('⬇ Subtitles (.srt)', 'For Hoshi Reader, or to upload alongside the YouTube video', '',
587     () => save(new File([r.srt], r.srtName, { type: 'application/x-subrip' })));
588   if (/\.epub$/i.test(running.job.bookFile.name)) {
589     button('⬇ Read-along book (.epub)', 'The book with the narration inside: Thorium, Storyteller and other EPUB 3 readers highlight each line as it is read', '',
590       (b) => makeEpub(b));
591   }
592   button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '',
593     (b) => makeVideo('mkv', b));
594   button('⬇ Video for YouTube (.mp4)', 'No subtitles baked in — add the .srt in YouTube Studio', '',
595     (b) => makeVideo('mp4', b));
596   // honjimaku is a library for Japanese books.
597   if (running.job.language === 'ja') {
598     button('⇪ Share on honjimaku', 'Give the subtitles to honjimaku.com, a free library for Japanese audiobooks', 'secondary',
599       () => openShare());
600   }
601 }
602 
603 /* ---------------- share on honjimaku ---------------- */
604 
605 // A test can point this at a local honjimaku.
606 const honjimaku = () => window.SUBREAD_HONJIMAKU || 'https://honjimaku.com';
607 const shareEl = {
608   dialog: $('share-dialog'), form: $('share-form'), title: $('share-title'), bookId: $('share-book-id'),
609   key: $('share-key'), send: $('share-send'), status: $('share-status'),
610 };
611 
612 function openShare() {
613   const r = running.job.result;
614   shareEl.title.value = shareEl.title.value || r.stem;
615   try { shareEl.key.value = localStorage.getItem('honjimaku-key') || ''; } catch { /* private window */ }
616   shareEl.status.hidden = true;
617   shareEl.dialog.showModal();
618 }
619 
620 shareEl.form.addEventListener('submit', async (e) => {
621   e.preventDefault();
622   const r = running?.job.result;
623   if (!r) return;
624   const say = (text, link) => {
625     shareEl.status.hidden = false;
626     shareEl.status.textContent = text;
627     if (link) {
628       const a = document.createElement('a');
629       a.href = link; a.target = '_blank'; a.rel = 'noopener'; a.textContent = ' Open it on honjimaku.';
630       shareEl.status.append(a);
631     }
632   };
633   const key = shareEl.key.value.trim();
634   const call = async (path, options) => {
635     const res = await fetch(honjimaku() + path, { ...options, credentials: 'omit', headers: { Authorization: key, ...(options.headers || {}) } });
636     const body = await res.json().catch(() => ({}));
637     if (!res.ok) throw new Error(body.error || `honjimaku answered ${res.status}.`);
638     return body;
639   };
640   shareEl.send.disabled = true;
641   say('Sending…');
642   try {
643     // Finds the book if honjimaku has it, however the title is typed, or makes it.
644     const bookId = shareEl.bookId.value.trim();
645     const { entry_id: entry } = await call('/api/entries', {
646       method: 'POST',
647       headers: { 'Content-Type': 'application/json' },
648       body: JSON.stringify({ name: shareEl.title.value.trim(), ...(bookId ? { book_id: bookId } : {}) }),
649     });
650     const form = new FormData();
651     form.append('file', new File([r.srt], r.srtName, { type: 'application/x-subrip' }));
652     const done = await call(`/api/entries/${entry}/upload`, { method: 'POST', body: form });
653     try { localStorage.setItem('honjimaku-key', key); } catch { /* private window */ }
654     if (done.problems?.length) say(`honjimaku did not take the file. ${done.problems.join(' ')}`);
655     else say('Shared. Thank you.', `${honjimaku()}/entry/${entry}`);
656   } catch (err) {
657     say(err.message === 'Failed to fetch' ? 'honjimaku.com could not be reached.' : err.message);
658   }
659   shareEl.send.disabled = false;
660 });
661 
662 async function makeEpub(btn) {
663   const label = btn.textContent;
664   btn.disabled = true;
665   btn.textContent = 'Making the read-along book…';
666   try {
667     const { file, located, of } = await running.job.epub();
668     save(file);
669     if (located < of) toast(`${of - located} of ${of} lines could not be placed in the book's pages; the rest are in.`);
670   } catch (e) { showError(`Could not make the read-along book: ${e.message}`); }
671   btn.textContent = label;
672   btn.disabled = false;
673 }
674 
675 async function makeVideo(kind, btn) {
676   const label = btn.textContent;
677   btn.disabled = true;
678   try {
679     const file = await running.job.video(kind, {
680       coverFile: signedIn ? running.cover : null,
681       onProgress: (f) => { btn.textContent = `Making the ${kind}… ${Math.round(f * 100)}%`; },
682     });
683     save(file);
684   } catch (e) { showError(`Could not make the ${kind}: ${e.message}`); }
685   btn.textContent = label;
686   btn.disabled = false;
687 }
688 
689 function save(file) {
690   const a = document.createElement('a');
691   a.href = URL.createObjectURL(file);
692   a.download = file.name;
693   document.body.appendChild(a);
694   a.click();
695   a.remove();
696   setTimeout(() => URL.revokeObjectURL(a.href), 60_000);
697 }
698 
699 el.another.addEventListener('click', () => { running?.job.close(); running = null; resetToDrop(); });
700 
701 // Hours of work live in this tab. Make closing it a decision, not an accident.
702 window.addEventListener('beforeunload', (e) => {
703   if (running && !running.job.result) { e.preventDefault(); e.returnValue = ''; }
704 });
705 
706 /* ---------------- jobs ---------------- */
707 
708 function escapeHtml(s) {
709   return String(s).replace(/[&<>"']/g, (c) => (
710     { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
711   ));
712 }
713 
714 function jobCard(j) {
715   const li = document.createElement('li');
716   li.className = 'job';
717 
718   // A browser-run job reports no progress to the server; it is either ours
719   // (shown above, in the working panel) or running in some other tab.
720   const active = !j.local && (j.status === 'running' || j.status === 'queued');
721   const pct = Math.round(j.progress * 100);
722 
723   const meta = [
724     j.language_name,
725     j.audio_parts > 1 ? `${j.audio_parts} parts` : null,
726     fmtDuration(j.audio_duration_seconds),
727     fmtBytes(j.audio_bytes),
728   ].filter(Boolean).join(' · ');
729 
730   let html = `
731     <div class="job-head">
732       <div style="min-width:0">
733         <div class="job-name">${escapeHtml(j.audio_filename)}</div>
734         <div class="job-sub">${escapeHtml(meta)}</div>
735       </div>
736       <span class="pill ${j.status}">${j.status}</span>
737     </div>`;
738 
739   if (active) {
740     html += `
741       <div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div>
742       <div class="job-stage"><span>${escapeHtml(j.stage)}</span><span>${pct}%</span></div>`;
743   }
744 
745   if (j.local && j.status === 'running') {
746     html += '<div class="job-stage"><span>Running in a browser tab</span></div>';
747   }
748   if (j.error) html += `<div class="job-err">${escapeHtml(j.error)}</div>`;
749 
750   const files = j.artifacts || [];
751   if (files.length) {
752     const order = { srt: 0, video: 1, video_embedded: 2, metadata: 3, log: 4 };
753     // Three deliverables for three different uses; say which is which, because
754     // "two video files" is otherwise baffling.
755     const label = {
756       srt: '⬇ Subtitles (.srt)',
757       video: '⬇ Video for YouTube (.mp4)',
758       video_embedded: '⬇ Video with subs built in (.mkv)',
759       metadata: 'Metadata',
760       log: 'Run log',
761     };
762     const hint = {
763       srt: 'For HoshiReader, or upload alongside the YouTube video',
764       video: 'No subtitles baked in — add the .srt in YouTube Studio',
765       video_embedded: 'Subtitles inside the file, for MPV/VLC',
766     };
767     const primary = new Set(['srt', 'video', 'video_embedded']);
768     html += '<div class="job-files">' + files
769       .slice()
770       .sort((a, b) => (order[a.kind] ?? 9) - (order[b.kind] ?? 9))
771       .map((a) => {
772         const size = a.size_bytes ? ` <span class="dl-size">${fmtBytes(a.size_bytes)}</span>` : '';
773         const t = hint[a.kind] ? ` title="${escapeHtml(hint[a.kind])}"` : '';
774         return `<a class="dl ${primary.has(a.kind) ? '' : 'secondary'}"${t}
775                    href="${a.url}" download>${label[a.kind] || a.kind}${size}</a>`;
776       })
777       .join('') + '</div>';
778   }
779 
780   li.innerHTML = html;
781 
782   if (active) {
783     const cancel = document.createElement('button');
784     cancel.className = 'ghost';
785     cancel.style.marginTop = '13px';
786     cancel.textContent = 'Cancel';
787     cancel.onclick = async () => {
788       cancel.disabled = true;
789       try { await api(`/api/jobs/${j.id}/cancel`, { method: 'POST' }); }
790       catch (e) { showError(e.message); }
791       refreshJobs(); refreshAccount();
792     };
793     li.appendChild(cancel);
794   }
795   return li;
796 }
797 
798 async function refreshJobs() {
799   let jobs;
800   try { jobs = await api('/api/jobs'); }
801   catch { return; }
802 
803   // Drafts live in the confirm panel, not the list.
804   const visible = jobs.filter((j) => j.status !== 'draft' && j.id !== running?.serverId);
805   el.jobsSection.hidden = visible.length === 0;
806   el.jobs.innerHTML = '';
807   for (const j of visible) el.jobs.appendChild(jobCard(j));
808 
809   const anyActive = visible.some((j) => !j.local && (j.status === 'running' || j.status === 'queued'));
810   clearTimeout(pollTimer);
811   if (anyActive) pollTimer = setTimeout(refreshJobs, 1500);
812 }
813 
814 /* ---------------- drag & drop ---------------- */
815 
816 ['dragenter', 'dragover'].forEach((ev) =>
817   el.dropzone.addEventListener(ev, (e) => {
818     e.preventDefault();
819     el.dropzone.classList.add('drag');
820   }));
821 
822 ['dragleave', 'drop'].forEach((ev) =>
823   el.dropzone.addEventListener(ev, (e) => {
824     e.preventDefault();
825     if (ev === 'dragleave' && el.dropzone.contains(e.relatedTarget)) return;
826     el.dropzone.classList.remove('drag');
827   }));
828 
829 /* Dropping a folder gives directory entries, not files - audiobooks often
830    arrive as a folder of per-chapter mp3s, so walk it. */
831 function readEntries(reader) {
832   return new Promise((resolve, reject) => reader.readEntries(resolve, reject));
833 }
834 
835 async function walkEntry(entry, out, depth = 0) {
836   if (!entry || depth > 4) return;
837   if (entry.isFile) {
838     out.push(await new Promise((res, rej) => entry.file(res, rej)));
839     return;
840   }
841   if (entry.isDirectory) {
842     const reader = entry.createReader();
843     // readEntries returns at most 100 per call, so keep going until it is empty.
844     for (;;) {
845       const batch = await readEntries(reader);
846       if (!batch.length) break;
847       for (const child of batch) await walkEntry(child, out, depth + 1);
848     }
849   }
850 }
851 
852 el.dropzone.addEventListener('drop', async (e) => {
853   const items = e.dataTransfer?.items;
854   const hasEntries = items && [...items].some(
855     (i) => i.kind === 'file' && typeof i.webkitGetAsEntry === 'function'
856   );
857 
858   if (hasEntries) {
859     const entries = [...items]
860       .filter((i) => i.kind === 'file')
861       .map((i) => i.webkitGetAsEntry())
862       .filter(Boolean);
863     if (entries.some((en) => en.isDirectory)) {
864       el.dropzone.hidden = true;
865       el.uploading.hidden = false;
866       el.uptext.textContent = 'Reading folder…';
867       const files = [];
868       try {
869         for (const en of entries) await walkEntry(en, files);
870       } catch (err) {
871         el.uploading.hidden = true;
872         showError(`Could not read the dropped folder: ${err.message}`);
873         resetToDrop();
874         return;
875       }
876       el.uploading.hidden = true;
877       el.dropzone.hidden = false;
878       if (files.length) addFiles(files);
879       return;
880     }
881   }
882 
883   const files = e.dataTransfer?.files;
884   if (files?.length) addFiles(files);
885 });
886 
887 // Dropping anywhere else must not make the browser navigate to the file.
888 ['dragover', 'drop'].forEach((ev) =>
889   window.addEventListener(ev, (e) => e.preventDefault()));
890 
891 el.dropzone.addEventListener('click', (e) => {
892   if (e.target !== el.browse) el.picker.click();
893 });
894 el.browse.addEventListener('click', (e) => { e.stopPropagation(); el.picker.click(); });
895 el.dropzone.addEventListener('keydown', (e) => {
896   if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.picker.click(); }
897 });
898 el.picker.addEventListener('change', () => {
899   if (el.picker.files.length) addFiles(el.picker.files);
900   el.picker.value = '';  // let the same file be chosen again after a removal
901 });
902 
903 /* ---------------- checkout ---------------- */
904 
905 async function openPricing(why) {
906   el.pricingWhy.textContent = why ||
907     'In this tab a conversion is free, without limit. A credit converts a book on our server: from any device, and you can close the tab.';
908   const offer = signInOffer();
909   el.pricingSignin.hidden = !offer;
910   el.pricingSigninBtn.textContent = signedIn
911     ? `Or verify your email and get ${offer}.`
912     : `Or sign in with your email and get ${offer}.`;
913   el.plans.innerHTML = '<p class="muted">Loading…</p>';
914   if (!el.pricingDialog.open) el.pricingDialog.showModal();
915 
916   let catalogue;
917   try { catalogue = await api('/api/pricing'); }
918   catch (e) { el.plans.innerHTML = ''; showError(e.message); return; }
919 
920   el.plans.innerHTML = '';
921   for (const p of catalogue.plans) {
922     const card = document.createElement('div');
923     card.className = 'plan' + (p.id === 'pack10' ? ' featured' : '');
924     const per = p.per_book_cents && p.credits > 1
925       ? `<span class="plan-per">$${(p.per_book_cents / 100).toFixed(2)} a book</span>` : '';
926     card.innerHTML = `
927       <div class="plan-name">${escapeHtml(p.name)}</div>
928       <div class="plan-price">${escapeHtml(p.price_display)}${p.recurring ? '<small>/month</small>' : ''}</div>
929       ${per}
930       <p class="plan-blurb">${escapeHtml(p.blurb)}</p>`;
931     const buy = document.createElement('button');
932     buy.className = 'primary';
933     buy.textContent = catalogue.payments_available
934       ? (p.recurring ? 'Subscribe' : 'Buy') : 'Opening soon';
935     buy.disabled = !catalogue.payments_available;
936     buy.onclick = () => checkout(p.id, buy);
937     card.appendChild(buy);
938     el.plans.appendChild(card);
939   }
940 }
941 
942 async function checkout(planId, btn) {
943   btn.disabled = true;
944   try {
945     const { url } = await api('/api/billing/checkout', {
946       method: 'POST',
947       headers: { 'Content-Type': 'application/json' },
948       body: JSON.stringify({ plan_id: planId }),
949     });
950     if (running) {
951       // The finished job only exists in this tab. Pay in another one.
952       window.open(url, '_blank', 'noopener');
953       el.pricingDialog.close();
954       toast('Checkout opened in a new tab. Come back here when you have paid.', 12000);
955       btn.disabled = false;
956       return;
957     }
958     window.location.href = url;  // Stripe's page; we come back via /api/billing/return
959   } catch (e) {
960     el.pricingDialog.close();
961     showError(e.message);
962     btn.disabled = false;
963   }
964 }
965 
966 el.buyBtn.addEventListener('click', () => openPricing(
967   'One credit converts one book on our server: from any device, and you can close the tab.'));
968 
969 el.portalBtn.addEventListener('click', async () => {
970   try {
971     const { url } = await api('/api/billing/portal', { method: 'POST' });
972     window.location.href = url;
973   } catch (e) { showError(e.message); }
974 });
975 
976 /* ---------------- sign in ---------------- */
977 
978 function openSignIn() {
979   el.signinNote.hidden = true;
980   // A buyer verifies the address that Stripe has.
981   if (account?.email && !el.signinEmail.value) el.signinEmail.value = account.email;
982   el.signinDialog.showModal();
983   el.signinEmail.focus();
984 }
985 
986 el.signinBtn.addEventListener('click', openSignIn);
987 el.pricingSigninBtn.addEventListener('click', () => { el.pricingDialog.close(); openSignIn(); });
988 
989 el.signinForm.addEventListener('submit', async (e) => {
990   e.preventDefault();
991   el.signinSend.disabled = true;
992   try {
993     const r = await api('/api/auth/request', {
994       method: 'POST',
995       headers: { 'Content-Type': 'application/json' },
996       body: JSON.stringify({ email: el.signinEmail.value }),
997     });
998     el.signinNote.hidden = false;
999     if (r.dev_link) {
1000       // Localhost has no mail server, so the server hands the link back.
1001       el.signinNote.innerHTML =
1002         `No mail server here — <a href="${escapeHtml(r.dev_link)}">open your sign-in link</a>.`;
1003     } else {
1004       el.signinNote.textContent =
1005         `Link sent to ${r.email}. It works once and expires in 30 minutes.`;
1006     }
1007   } catch (err) {
1008     el.signinNote.hidden = false;
1009     el.signinNote.textContent = err.message;
1010   }
1011   el.signinSend.disabled = false;
1012 });
1013 
1014 el.signoutBtn.addEventListener('click', async () => {
1015   try { await api('/api/auth/signout', { method: 'POST' }); } catch {}
1016   window.location.replace('/');
1017 });
1018 
1019 /* The sign-in link and Stripe's return trip both land here with a query
1020    string. Act on it once, then clean the URL so a reload does not repeat it. */
1021 async function handleArrival() {
1022   const q = new URLSearchParams(window.location.search);
1023   const token = q.get('login');
1024   const paid = q.get('checkout');
1025   if (!token && !paid) return;
1026   window.history.replaceState({}, '', '/');
1027 
1028   if (token) {
1029     try {
1030       account = await api('/api/auth/verify', {
1031         method: 'POST',
1032         headers: { 'Content-Type': 'application/json' },
1033         body: JSON.stringify({ token }),
1034       });
1035       toast(`Signed in as ${account.email}.`);
1036     } catch (e) { showError(e.message); }
1037   }
1038   if (paid === 'paid') toast('Payment received — thank you. Your credits are ready.');
1039   if (paid === 'pending') {
1040     toast('Payment is still being confirmed. Credits appear here as soon as it clears.', 9000);
1041   }
1042 }
1043 
1044 // Back from paying in the other tab.
1045 window.addEventListener('focus', () => { if (running?.job.result) refreshAccount().catch(() => {}); });
1046 
1047 /* ---------------- boot ---------------- */
1048 
1049 (async function init() {
1050   languagesReady = loadLanguages();
1051   try { await languagesReady; }
1052   catch (e) { showError(`Could not reach the server: ${e.message}`); }
1053   await handleArrival();
1054   // In sequence, not together: on a first visit each request without a cookie
1055   // would mint its own anonymous account, and the browser keeps only one.
1056   await refreshAccount();
1057   refreshJobs();
1058 })();