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()) {
|
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
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 {
|
try {
|
||||||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
|
||||||
const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`;
|
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const j = await res.json().catch(() => ({}));
|
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 [handle, name] = found;
|
||||||
const file = await handle.getFile();
|
const file = await handle.getFile();
|
||||||
const ext = name.slice(name.lastIndexOf('.') + 1);
|
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);
|
const url = URL.createObjectURL(blob);
|
||||||
_blobUrls.add(url);
|
_blobUrls.add(url);
|
||||||
return 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
|
// 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).
|
// a small chunk lives in memory at a time (no full-file buffering).
|
||||||
// Falls back to ArrayBuffer if WritableStream is unavailable.
|
// 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) {
|
async writeFromResponse(videoId, ext, response) {
|
||||||
const dir = await getRoot();
|
const dir = await getRoot();
|
||||||
const filename = videoId + '.' + (ext || 'mp4');
|
const filename = videoId + '.' + (ext || 'mp4');
|
||||||
@@ -124,12 +161,22 @@
|
|||||||
await writable.close();
|
await writable.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename tmp → final. OPFS doesn't have rename, so: read + write + delete.
|
// Rename tmp → final. Prefer the native rename; else stream-copy so
|
||||||
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
// the whole file is never buffered in main-thread memory at once.
|
||||||
const finalWritable = await finalHandle.createWritable();
|
if (typeof tmpHandle.move === 'function') {
|
||||||
const tmpFile = await tmpHandle.getFile();
|
await tmpHandle.move(filename);
|
||||||
await finalWritable.write(await tmpFile.arrayBuffer());
|
} else {
|
||||||
await finalWritable.close();
|
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 {
|
} finally {
|
||||||
// Remove .part file regardless
|
// Remove .part file regardless
|
||||||
try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
|
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) {
|
@media (max-width: 1080px) {
|
||||||
.body { flex-direction: column; }
|
.body { flex-direction: column; }
|
||||||
.list-pane { width: auto; border-left: none; border-top: 1px solid var(--line-soft); }
|
.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; }
|
.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
|
* PORTRAIT PWA — mobile app design refresh
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const SHELL = [
|
|||||||
'/sw-update.js',
|
'/sw-update.js',
|
||||||
'/fingerprint.js',
|
'/fingerprint.js',
|
||||||
'/opfs.js',
|
'/opfs.js',
|
||||||
|
'/opfs-worker.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
@@ -82,19 +82,27 @@ const CHANNEL_LIMIT = 60;
|
|||||||
// yt-dlp helpers
|
// yt-dlp helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Run yt-dlp synchronously and return stdout as a string.
|
// Run yt-dlp asynchronously and resolve stdout as a string.
|
||||||
// Throws on non-zero exit.
|
// 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) {
|
function runYtdlp(args) {
|
||||||
const result = spawnSync(YTDLP, args, {
|
return new Promise((resolve, reject) => {
|
||||||
encoding: 'utf8',
|
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
|
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
|
// 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);
|
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = runYtdlp([
|
const out = await runYtdlp([
|
||||||
`ytsearch${SEARCH_LIMIT}:${q}`,
|
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||||
'--dump-json', '--flat-playlist',
|
'--dump-json', '--flat-playlist',
|
||||||
'--no-warnings', '--ignore-errors',
|
'--no-warnings', '--ignore-errors',
|
||||||
@@ -198,7 +206,7 @@ app.get('/api/channel', async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = channelToUrl(chan);
|
const url = channelToUrl(chan);
|
||||||
const out = runYtdlp([
|
const out = await runYtdlp([
|
||||||
url,
|
url,
|
||||||
'--dump-json', '--flat-playlist',
|
'--dump-json', '--flat-playlist',
|
||||||
'--no-warnings', '--ignore-errors',
|
'--no-warnings', '--ignore-errors',
|
||||||
@@ -231,7 +239,7 @@ app.get('/api/streams', async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
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 info = JSON.parse(out);
|
||||||
const formats = Array.isArray(info.formats) ? info.formats : [];
|
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
|
// --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
|
// 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"
|
// browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"
|
||||||
const out = runYtdlp([
|
const out = await runYtdlp([
|
||||||
url,
|
url,
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
|
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
|
||||||
@@ -430,6 +438,10 @@ async function main() {
|
|||||||
Bun.serve({
|
Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
fetch: app.fetch,
|
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}`);
|
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user