From ed9f93b0b78671eff7656b799aa07906b34321c6 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 2 Jul 2026 23:40:49 +0800 Subject: [PATCH] fix: stop playback stutter and save failures via async yt-dlp, per-download OPFS workers, lazy playback blobs, GPU composite trims, and landscape pane scrolling --- frontend/app.js | 15 +++++- frontend/opfs-worker.js | 110 ++++++++++++++++++++++++++++++++++++++++ frontend/opfs.js | 61 +++++++++++++++++++--- frontend/styles.css | 31 +++++++++++ frontend/sw.js | 1 + server/server.js | 44 ++++++++++------ 6 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 frontend/opfs-worker.js diff --git a/frontend/app.js b/frontend/app.js index e0ada3b..3dde6cf 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -75,9 +75,20 @@ async function opfsDownload(videoId) { if (!window.OPFS || !window.OPFS.isSupported()) { return { ok: false, error: 'OPFS not supported in this browser' }; } + const fp = window.getFingerprint ? window.getFingerprint() : ''; + const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`; + + // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, + // so a big save never touches the main thread (no UI jank, no audio + // stutter). Falls back to the legacy main-thread streaming below only when + // the worker path is unsupported. + if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { + const w = await window.OPFS.downloadVideo(videoId, url); + if (w.ok) return { ok: true, cached: true }; + if (!w.fallback) return { ok: false, error: w.error || 'download failed' }; + } + try { - const fp = window.getFingerprint ? window.getFingerprint() : ''; - const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`; const res = await fetch(url); if (!res.ok) { const j = await res.json().catch(() => ({})); diff --git a/frontend/opfs-worker.js b/frontend/opfs-worker.js new file mode 100644 index 0000000..dc92780 --- /dev/null +++ b/frontend/opfs-worker.js @@ -0,0 +1,110 @@ +/* ============================================================================ + * opfs-worker.js — off-main-thread video download → OPFS + * + * One dedicated Worker per download (spawned by OPFS.downloadVideo in + * opfs.js, terminated when finished). The worker does the whole job itself — + * fetch from /api/download plus streaming writes via createSyncAccessHandle — + * so a multi-hundred-MB save never allocates buffers or runs stream pumps on + * the main thread. createSyncAccessHandle is worker-only but has wider + * support than createWritable (Safari 15.2+ vs 18.2+), which also removes + * the whole-file ArrayBuffer fallback the main-thread path needs on WebKit. + * + * In message: { videoId, url } + * Out messages: + * { type: 'unsupported' } → caller falls back to main thread + * { type: 'progress', received } → bytes written so far + * { type: 'done', ext } → file stored as . + * { type: 'error', error } → failed; .part cleaned up + * ========================================================================== */ + +'use strict'; + +async function getVideosDir() { + const root = await navigator.storage.getDirectory(); + return root.getDirectoryHandle('videos', { create: true }); +} + +function extFromContentType(ct) { + ct = ct || 'video/mp4'; + return ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4'; +} + +self.onmessage = async (e) => { + const { videoId, url } = e.data || {}; + + if ( + typeof navigator === 'undefined' || + !navigator.storage || + typeof navigator.storage.getDirectory !== 'function' || + typeof FileSystemFileHandle === 'undefined' || + typeof FileSystemFileHandle.prototype.createSyncAccessHandle !== 'function' + ) { + self.postMessage({ type: 'unsupported' }); + return; + } + + let dir = null; + let partName = null; + try { + const res = await fetch(url); + if (!res.ok) { + let msg = 'HTTP ' + res.status; + try { msg = (await res.json()).error || msg; } catch { /* non-JSON */ } + throw new Error(msg); + } + + const ext = extFromContentType(res.headers.get('content-type')); + const filename = videoId + '.' + ext; + partName = filename + '.part'; + + dir = await getVideosDir(); + const partHandle = await dir.getFileHandle(partName, { create: true }); + const access = await partHandle.createSyncAccessHandle(); + let offset = 0; + try { + const reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + access.write(value, { at: offset }); + offset += value.byteLength; + self.postMessage({ type: 'progress', received: offset }); + } + access.truncate(offset); + access.flush(); + } finally { + access.close(); + } + + // Finalize: .part → permanent name. Prefer the native rename; fall back + // to a chunked copy (still fully inside the worker, small fixed buffers). + try { await dir.removeEntry(filename); } catch { /* no previous copy */ } + if (typeof partHandle.move === 'function') { + await partHandle.move(filename); + } else { + const finalHandle = await dir.getFileHandle(filename, { create: true }); + const out = await finalHandle.createSyncAccessHandle(); + try { + const file = await partHandle.getFile(); + const CHUNK = 8 * 1024 * 1024; + let pos = 0; + while (pos < file.size) { + const buf = await file.slice(pos, pos + CHUNK).arrayBuffer(); + out.write(new Uint8Array(buf), { at: pos }); + pos += buf.byteLength; + } + out.truncate(file.size); + out.flush(); + } finally { + out.close(); + } + await dir.removeEntry(partName); + } + + self.postMessage({ type: 'done', ext }); + } catch (err) { + // Never leave a corrupt partial behind + try { if (dir && partName) await dir.removeEntry(partName); } catch { /* gone */ } + self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); + } +}; diff --git a/frontend/opfs.js b/frontend/opfs.js index 08f6845..dcc997a 100644 --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -71,7 +71,11 @@ const [handle, name] = found; const file = await handle.getFile(); const ext = name.slice(name.lastIndexOf('.') + 1); - const blob = new Blob([await file.arrayBuffer()], { type: extToMime(ext) }); + // Wrap the File (a lazy disk-backed Blob) instead of buffering it: + // `await file.arrayBuffer()` here pulled the ENTIRE video into main- + // thread memory just to retype it, which froze the UI and stuttered + // audio on phones. Blob parts reference the File without reading it. + const blob = new Blob([file], { type: extToMime(ext) }); const url = URL.createObjectURL(blob); _blobUrls.add(url); return url; @@ -88,9 +92,42 @@ } }, + // Download a video entirely inside a dedicated Web Worker — the fetch and + // the OPFS writes both happen off the main thread, so saves can never + // jank the UI. One worker per download; concurrent saves get concurrent + // workers. Resolves { ok:true } on success, { ok:false, error } on a real + // failure, or { ok:false, fallback:true } when the worker path is + // unavailable and the caller should use writeFromResponse instead. + downloadVideo(videoId, url) { + return new Promise((resolve) => { + let worker; + try { + worker = new Worker('/opfs-worker.js'); + } catch { + resolve({ ok: false, fallback: true }); + return; + } + const finish = (result) => { + worker.terminate(); + resolve(result); + }; + worker.onmessage = (e) => { + const m = e.data || {}; + if (m.type === 'done') finish({ ok: true }); + else if (m.type === 'unsupported') finish({ ok: false, fallback: true }); + else if (m.type === 'error') finish({ ok: false, error: m.error }); + // 'progress' messages are informational; ignored here + }; + worker.onerror = () => finish({ ok: false, fallback: true }); + worker.postMessage({ videoId, url }); + }); + }, + // Stream a fetch Response body into OPFS. Uses a writable stream so only // a small chunk lives in memory at a time (no full-file buffering). // Falls back to ArrayBuffer if WritableStream is unavailable. + // Main-thread fallback for downloadVideo — used when Workers or + // createSyncAccessHandle are unavailable. async writeFromResponse(videoId, ext, response) { const dir = await getRoot(); const filename = videoId + '.' + (ext || 'mp4'); @@ -124,12 +161,22 @@ await writable.close(); } - // Rename tmp → final. OPFS doesn't have rename, so: read + write + delete. - const finalHandle = await dir.getFileHandle(filename, { create: true }); - const finalWritable = await finalHandle.createWritable(); - const tmpFile = await tmpHandle.getFile(); - await finalWritable.write(await tmpFile.arrayBuffer()); - await finalWritable.close(); + // Rename tmp → final. Prefer the native rename; else stream-copy so + // the whole file is never buffered in main-thread memory at once. + if (typeof tmpHandle.move === 'function') { + await tmpHandle.move(filename); + } else { + const finalHandle = await dir.getFileHandle(filename, { create: true }); + const finalWritable = await finalHandle.createWritable(); + const tmpFile = await tmpHandle.getFile(); + const tmpStream = typeof tmpFile.stream === 'function' ? tmpFile.stream() : null; + if (tmpStream && typeof tmpStream.pipeTo === 'function') { + await tmpStream.pipeTo(finalWritable); // pipeTo closes the writable + } else { + await finalWritable.write(await tmpFile.arrayBuffer()); + await finalWritable.close(); + } + } } finally { // Remove .part file regardless try { await dir.removeEntry(tmpName); } catch { /* already gone */ } diff --git a/frontend/styles.css b/frontend/styles.css index 5e950d6..bae4e35 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1305,6 +1305,12 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } @media (max-width: 1080px) { .body { flex-direction: column; } .list-pane { width: auto; border-left: none; border-top: 1px solid var(--line-soft); } + /* In the stacked layout both panes must be allowed to shrink below their + content height (flex min-height defaults to auto), or they overflow the + hidden .body instead of engaging their own scrollbars — on a landscape + phone that made "Up next" unreachable. */ + .player-pane { min-height: 0; } + .list-pane { min-height: 0; } } /* ============================================================================ @@ -1878,6 +1884,31 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } .hero-tagline { font-size: 13px; } } +/* ============================================================================ + * INSTALLED PWA ON TOUCH DEVICES — drop expensive full-screen composites + * + * The film-grain overlay (mix-blend-mode over the whole viewport), the fixed + * gradient wash, and the chrome backdrop-blurs all re-composite over the + * playing