diff --git a/scraper/internal/server/ui.go b/scraper/internal/server/ui.go index e894037..ad217c1 100644 --- a/scraper/internal/server/ui.go +++ b/scraper/internal/server/ui.go @@ -837,7 +837,22 @@ const chapterTmpl = ` speedSlider.disabled = false; } - // ── MSE helpers ─────────────────────────────────────────────────────────────── + // ── capability detection ────────────────────────────────────────────────────── + // MSE streaming is not supported on iOS Safari (or is unreliable on older + // versions). Fall back to a full-download blob URL on those platforms so + // audio works on all devices. The downside is playback starts only after the + // full chapter audio has been downloaded, but it is universally compatible. + + var USE_MSE = (function () { + if (typeof MediaSource === 'undefined') return false; + // iOS Safari reports MediaSource but does not support it reliably for audio/mpeg + var ua = navigator.userAgent; + var isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; + if (isIOS) return false; + try { return MediaSource.isTypeSupported('audio/mpeg'); } catch(_) { return false; } + })(); + + // ── MSE helpers (desktop / non-iOS) ────────────────────────────────────────── function flushQueue() { if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return; @@ -895,7 +910,6 @@ const chapterTmpl = ` function startPrefetch() { if (!KOKORO_URL || !NEXT_N) return; - // Fetch the next chapter's plain text from the server's chapter-text endpoint. fetch('/ui/chapter-text/' + SLUG + '/' + NEXT_N) .then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); }) .then(function (text) { @@ -903,74 +917,90 @@ const chapterTmpl = ` if (!text) return; prefetchAbort = new AbortController(); - prefetchMSE = new MediaSource(); - prefetchQueue = []; - prefetchAppending = false; - prefetchDone = false; - prefetchReady = false; - prefetchAudio = document.createElement('audio'); prefetchAudio.style.display = 'none'; - prefetchAudio.src = URL.createObjectURL(prefetchMSE); + prefetchReady = false; - prefetchAudio.addEventListener('canplay', function () { - prefetchReady = true; - }); + prefetchAudio.addEventListener('canplay', function () { prefetchReady = true; }); - prefetchMSE.addEventListener('sourceopen', function () { - try { - prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg'); - } catch (e) { return; } + if (USE_MSE) { + // ── MSE prefetch path ──────────────────────────────────────────────── + prefetchMSE = new MediaSource(); + prefetchQueue = []; + prefetchAppending = false; + prefetchDone = false; - prefetchBuf.addEventListener('updateend', function () { - prefetchAppending = false; - if (prefetchDone && prefetchQueue.length === 0) { - if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) { - try { prefetchMSE.endOfStream(); } catch(_) {} + prefetchAudio.src = URL.createObjectURL(prefetchMSE); + + prefetchMSE.addEventListener('sourceopen', function () { + try { + prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg'); + } catch (e) { return; } + + prefetchBuf.addEventListener('updateend', function () { + prefetchAppending = false; + if (prefetchDone && prefetchQueue.length === 0) { + if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) { + try { prefetchMSE.endOfStream(); } catch(_) {} + } + } else { + flushPrefetchQueue(); } - } else { - flushPrefetchQueue(); - } + }); + + fetch(KOKORO_URL + '/v1/audio/speech', { + method: 'POST', + signal: prefetchAbort.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'kokoro', input: text, voice: voiceSel.value, + response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true + }) + }) + .then(function (res) { + if (!res.ok) return; + var reader = res.body.getReader(); + function pump() { + reader.read().then(function (ref) { + if (ref.done) { + prefetchDone = true; + if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) { + if (prefetchMSE && prefetchMSE.readyState === 'open') { + try { prefetchMSE.endOfStream(); } catch(_) {} + } + } + return; + } + prefetchQueue.push(ref.value); + flushPrefetchQueue(); + pump(); + }).catch(function () {}); + } + pump(); + }) + .catch(function () {}); }); + prefetchAudio.load(); + } else { + // ── Blob prefetch path (iOS / no MSE) ──────────────────────────────── fetch(KOKORO_URL + '/v1/audio/speech', { method: 'POST', signal: prefetchAbort.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'kokoro', - input: text, - voice: voiceSel.value, - response_format: 'mp3', - speed: parseFloat(speedSlider.value), - stream: true + model: 'kokoro', input: text, voice: voiceSel.value, + response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false }) }) - .then(function (res) { - if (!res.ok) return; - var reader = res.body.getReader(); - function pump() { - reader.read().then(function (ref) { - if (ref.done) { - prefetchDone = true; - if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) { - if (prefetchMSE && prefetchMSE.readyState === 'open') { - try { prefetchMSE.endOfStream(); } catch(_) {} - } - } - return; - } - prefetchQueue.push(ref.value); - flushPrefetchQueue(); - pump(); - }).catch(function () {}); - } - pump(); + .then(function (res) { return res.ok ? res.blob() : Promise.reject(res.status); }) + .then(function (blob) { + prefetchAudio.src = URL.createObjectURL(blob); + prefetchDone = true; + prefetchAudio.load(); }) .catch(function () {}); - }); - - prefetchAudio.load(); + } }) .catch(function () {}); } @@ -994,70 +1024,104 @@ const chapterTmpl = ` function startStream(text, paraIdx) { abortCtrl = new AbortController(); - mse = new MediaSource(); - queue = []; - appending = false; - fetchDone = false; - audio.src = URL.createObjectURL(mse); + if (USE_MSE) { + // ── MSE streaming path (desktop) ───────────────────────────────────────── + mse = new MediaSource(); + queue = []; + appending = false; + fetchDone = false; - mse.addEventListener('sourceopen', function () { - try { - srcBuf = mse.addSourceBuffer('audio/mpeg'); - } catch (e) { - setError('MSE: ' + e.message); - return; - } + audio.src = URL.createObjectURL(mse); - srcBuf.addEventListener('updateend', function () { - appending = false; - if (fetchDone && queue.length === 0) { - endStream(); - } else { - flushQueue(); + mse.addEventListener('sourceopen', function () { + try { + srcBuf = mse.addSourceBuffer('audio/mpeg'); + } catch (e) { + setError('MSE: ' + e.message); + return; } + + srcBuf.addEventListener('updateend', function () { + appending = false; + if (fetchDone && queue.length === 0) { + endStream(); + } else { + flushQueue(); + } + }); + + fetch(KOKORO_URL + '/v1/audio/speech', { + method: 'POST', + signal: abortCtrl.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'kokoro', input: text, voice: voiceSel.value, + response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true + }) + }) + .then(function (res) { + if (!res.ok) { + return res.text().then(function (t) { throw new Error(res.status + ': ' + t); }); + } + var reader = res.body.getReader(); + function pump() { + reader.read().then(function (ref) { + if (ref.done) { + fetchDone = true; + if (!srcBuf.updating && queue.length === 0) { endStream(); } + return; + } + queue.push(ref.value); + flushQueue(); + pump(); + }).catch(function (e) { + if (e.name !== 'AbortError') setError(e.message); + }); + } + pump(); + }) + .catch(function (e) { + if (e.name !== 'AbortError') setError(e.message); + }); }); + audio.load(); + } else { + // ── Blob path (iOS / no MSE) ────────────────────────────────────────────── + // Fetch the full audio as a single blob then hand it to the