Recently Written · git

subplz-web

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

Log | Files | Refs


commit b055da9c9850324f4ef0bf408dc438844a566e12
equwal <truex@equwal.com>
2026-09-21 18:45:04 -0700

Add a switch between the browser and the server

The confirm screen has a switch, "Convert in this browser (JavaScript)",
on by default. Off, the Start button uploads the files and the server
does the job, which replaces the separate "Convert on our server"
button. The choice is kept in localStorage. When the server offers no
conversions the switch is locked on.

 frontend/app.js     | 60 +++++++++++++++++++++++++++++++++++++----------------
 frontend/index.html | 16 +++++++-------
 frontend/style.css  |  6 ++++--
 3 files changed, 55 insertions(+), 27 deletions(-)
diff --git a/frontend/app.js b/frontend/app.js
index 31af395..36d2418 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -9,7 +9,7 @@ const el = {
   confirm: $('confirm'), cAudio: $('c-audio'), cText: $('c-text'),
   language: $('language'), detected: $('detected'), splitnote: $('splitnote'),
   start: $('start'), discard: $('discard'), eta: $('eta'),
-  cloud: $('cloud'), cloudNote: $('cloud-note'), startCloud: $('start-cloud'),
+  inBrowser: $('in-browser'),
   jobsSection: $('jobs-section'), jobs: $('jobs'), quota: $('quota'),
   freetier: $('freetier'), staged: $('staged'), dzTitle: $('dz-title'),
   match: $('match'), matchBadge: $('match-badge'),
@@ -314,39 +314,62 @@ async function showConfirm() {
   const gpu = await hasWebGpu();
   const { savedProgress } = await import('/engine/job.js');
   const saved = await savedProgress(draft.audio, el.language.value);
-  el.eta.textContent =
-    (saved?.complete ? 'This audio was transcribed here before, so this will take seconds. '
+  browserPlan = {
+    note: (saved?.complete ? 'This audio was transcribed here before, so this will take seconds. '
       : saved?.doneUntil ? `Picks up where it stopped, ${fmtDuration(saved.doneUntil)} in. ` : '') +
-    'Everything runs in this tab: nothing is uploaded, and it has to stay open. ' +
-    (saved?.complete ? '' : gpu ? 'Expect roughly a quarter of the book\'s length.'
-      : '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.');
+      'Everything runs in this tab: nothing is uploaded, and it has to stay open. ' +
+      (saved?.complete ? '' : gpu ? 'Expect roughly a quarter of the book\'s length.'
+        : '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.'),
+    label: saved?.doneUntil && !saved.complete ? 'Continue' : 'Start',
+  };
 
-  el.start.textContent = saved?.doneUntil && !saved.complete ? 'Continue' : 'Start';
   el.confirm.hidden = false;
   el.start.disabled = false;
-  renderCloudOffer();
+  renderMode();
   el.confirm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
 }
 
 el.discard.addEventListener('click', () => { clearError(); resetToDrop(); });
 
-/* ---------------- the cloud: the same job, on the server's hardware ---------------- */
+/* ---------------- where the work is done ---------------- */
+
+/* In this browser (JavaScript, free, nothing uploaded) is the default. The
+   switch turns that off and sends the job to the server instead: for a phone,
+   an old computer, or a tab that has to close. The choice is kept. */
+const MODE_KEY = 'subread-mode';
+let browserPlan = { note: '', label: 'Start' };
 
-function renderCloudOffer() {
+function serverOffered() { return !!account?.cloud_available; }
+
+function inBrowser() { return !serverOffered() || el.inBrowser.checked; }
+
+function renderMode() {
+  const offered = serverOffered();
+  el.inBrowser.disabled = !offered;
+  el.inBrowser.checked = !offered || localStorage.getItem(MODE_KEY) !== 'server';
+  if (inBrowser()) {
+    el.start.textContent = browserPlan.label;
+    el.eta.textContent = browserPlan.note +
+      (offered ? '' : ' Our server is not taking conversions at the moment, so this is the one way.');
+    return;
+  }
   const a = account;
-  el.cloud.hidden = !a?.cloud_available;
-  if (el.cloud.hidden) return;
   const price = !a.billing_enabled ? ''
     : a.subscribed ? ' Included in your plan.'
     : a.credits > 0 ? ` One credit; you have ${a.credits}.`
     : ' One credit.';
-  el.cloudNote.textContent =
-    'For a phone, an old computer, or a tab you want to close: the files are uploaded, the work is done ' +
-    'there, and the result waits for you on this page. The files are deleted after the job.' + price;
-  el.startCloud.disabled = false;
+  el.start.textContent = 'Upload and convert on our server';
+  el.eta.textContent =
+    'The files are uploaded, the work is done on our server, and the result waits for you on this page, ' +
+    'so you can close this tab. The files are deleted after the job.' + price;
 }
 
-el.startCloud.addEventListener('click', () => {
+el.inBrowser.addEventListener('change', () => {
+  try { localStorage.setItem(MODE_KEY, el.inBrowser.checked ? 'browser' : 'server'); } catch { /* private window */ }
+  renderMode();
+});
+
+function startOnServer() {
   if (!draft || running) return;
   clearError();
   // Ask for the credit before the upload, not after an hour of it.
@@ -403,7 +426,7 @@ el.startCloud.addEventListener('click', () => {
     refreshAccount();
   };
   xhr.send(form);
-});
+}
 
 /* ---------------- the job, in this tab ---------------- */
 
@@ -412,6 +435,7 @@ let wakeLock = null;
 
 el.start.addEventListener('click', async () => {
   if (!draft || running) return;
+  if (!inBrowser()) { startOnServer(); return; }
   el.start.disabled = true;
   clearError();
   const language = el.language.value;
diff --git a/frontend/index.html b/frontend/index.html
index 8a252f1..e02ab3d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>SubRead — read along with your audiobook</title>
-<link rel="stylesheet" href="/style.css?v=11">
+<link rel="stylesheet" href="/style.css?v=12">
 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>">
 </head>
 <body>
@@ -134,16 +134,18 @@
       <ul id="match-warnings" class="match-warnings"></ul>
     </div>
 
+    <div class="mode">
+      <label class="switch">
+        <input type="checkbox" id="in-browser" checked>
+        <span>Convert in this browser (JavaScript). Off: our server does it.</span>
+      </label>
+    </div>
+
     <div class="actions">
       <button id="start" class="primary">Start alignment</button>
       <button id="discard" class="ghost">Discard</button>
     </div>
     <p class="muted small" id="eta"></p>
-
-    <div id="cloud" class="cloud" hidden>
-      <p><strong>Or let our server do it.</strong> <span id="cloud-note"></span></p>
-      <button id="start-cloud" class="ghost">Convert on our server</button>
-    </div>
   </section>
 
   <!-- step 3: the job, running in this tab -->
@@ -233,6 +235,6 @@
 
 <div id="toast" class="toast" hidden role="status"></div>
 
-<script type="module" src="/app.js?v=11"></script>
+<script type="module" src="/app.js?v=12"></script>
 </body>
 </html>
diff --git a/frontend/style.css b/frontend/style.css
index 96d7202..78f482a 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -300,8 +300,10 @@ button.compact { padding: 6px 12px; font-size: 13px; }
 }
 .tag.free { background: var(--ok-soft); color: var(--ok); }
 
-.cloud { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 14px; }
-.cloud p { margin: 0 0 10px; }
+.mode { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 14px; }
+.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
+.switch input { width: 18px; height: 18px; margin: 0; accent-color: var(--ok); flex: none; }
+.switch input:disabled + span { color: var(--muted); }
 
 .share-form { display: grid; gap: 6px; margin: 14px 0 8px; }
 .share-form label { font-size: 13px; font-weight: 600; margin-top: 6px; }