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:
@@ -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
110
frontend/opfs-worker.js
Normal 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) });
|
||||
}
|
||||
};
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -47,6 +47,7 @@ const SHELL = [
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
'/opfs-worker.js',
|
||||
'/app.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { logger } from 'hono/logger';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -82,19 +82,27 @@ const CHANNEL_LIMIT = 60;
|
||||
// yt-dlp helpers
|
||||
// ============================================================================
|
||||
|
||||
// Run yt-dlp synchronously and return stdout as a string.
|
||||
// Throws on non-zero exit.
|
||||
// Run yt-dlp asynchronously and resolve stdout as a string.
|
||||
// MUST stay async (spawn, not spawnSync): a sync child process blocks Bun's
|
||||
// event loop for the full yt-dlp runtime (~2-3s per call), which stalls every
|
||||
// concurrent request — including in-flight /api/download proxy streams, which
|
||||
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
|
||||
// Rejects on non-zero exit.
|
||||
function runYtdlp(args) {
|
||||
const result = spawnSync(YTDLP, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (d) => { out += d; });
|
||||
child.stderr.on('data', (d) => { err += d; });
|
||||
child.on('error', (e) => reject(new Error('yt-dlp not found: ' + e.message)));
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) reject(new Error(err.trim() || 'yt-dlp exited with code ' + code));
|
||||
else resolve(out);
|
||||
});
|
||||
});
|
||||
if (result.error) throw new Error('yt-dlp not found: ' + result.error.message);
|
||||
if (result.status !== 0) {
|
||||
const err = (result.stderr || '').trim();
|
||||
throw new Error(err || 'yt-dlp exited with code ' + result.status);
|
||||
}
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
// Helpers to pick the right field from a yt-dlp JSON record
|
||||
@@ -180,7 +188,7 @@ app.get('/api/search', async (c) => {
|
||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||
|
||||
try {
|
||||
const out = runYtdlp([
|
||||
const out = await runYtdlp([
|
||||
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||
'--dump-json', '--flat-playlist',
|
||||
'--no-warnings', '--ignore-errors',
|
||||
@@ -198,7 +206,7 @@ app.get('/api/channel', async (c) => {
|
||||
|
||||
try {
|
||||
const url = channelToUrl(chan);
|
||||
const out = runYtdlp([
|
||||
const out = await runYtdlp([
|
||||
url,
|
||||
'--dump-json', '--flat-playlist',
|
||||
'--no-warnings', '--ignore-errors',
|
||||
@@ -231,7 +239,7 @@ app.get('/api/streams', async (c) => {
|
||||
|
||||
try {
|
||||
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const out = runYtdlp(['-J', '--no-warnings', url]);
|
||||
const out = await runYtdlp(['-J', '--no-warnings', url]);
|
||||
const info = JSON.parse(out);
|
||||
const formats = Array.isArray(info.formats) ? info.formats : [];
|
||||
|
||||
@@ -300,7 +308,7 @@ app.get('/api/download/:videoId', async (c) => {
|
||||
// --get-url with bestvideo+bestaudio/best format is not what we want
|
||||
// here — we need a SINGLE FILE so no ffmpeg muxing is required in the
|
||||
// browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"
|
||||
const out = runYtdlp([
|
||||
const out = await runYtdlp([
|
||||
url,
|
||||
'--no-warnings',
|
||||
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
|
||||
@@ -430,6 +438,10 @@ async function main() {
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
// Default is 10s, which killed /api/download proxy streams whenever the
|
||||
// connection went idle mid-transfer. 240s covers slow saves; Bun caps
|
||||
// this field at 255.
|
||||
idleTimeout: 240,
|
||||
});
|
||||
|
||||
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user