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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 })();