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