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

This commit is contained in:
Jonathan Sykes
2026-07-02 23:40:49 +08:00
parent 2af2eebacc
commit ed9f93b0b7
6 changed files with 237 additions and 25 deletions

View File

@@ -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(() => ({}));

110
frontend/opfs-worker.js Normal file
View File

@@ -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 <videoId>.<ext>
* { 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) });
}
};

View File

@@ -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 */ }

View File

@@ -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 <video> every frame. On iPhones that GPU contention audibly
* stutters audio — backgrounding the PWA (which stops rendering) made
* playback smooth, which is how this was diagnosed. Same cuts as
* data-perf="on", applied automatically where they hurt most; desktop
* browsers and fine-pointer devices are untouched.
* ========================================================================== */
@media (display-mode: standalone) and (pointer: coarse) {
body::before,
body::after { display: none; }
.topbar,
.bottom-nav,
.mini-bar-inner {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.player-pane:not(.empty) .player-stage {
box-shadow: 0 0 0 1px var(--line) inset;
}
}
/* ============================================================================
* PORTRAIT PWA — mobile app design refresh
*

View File

@@ -47,6 +47,7 @@ const SHELL = [
'/sw-update.js',
'/fingerprint.js',
'/opfs.js',
'/opfs-worker.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',