Recently Written · git

subplz-web

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

Log | Files | Refs


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