Files
ytplayer/frontend/opfs-worker.js
Jonathan Sykes 264bb26a36 iPhone: EQ and loudness levelling for saved songs that keep playing when locked
iOS stops Web Audio when the screen locks, so live processing can't reach a
locked phone. For saved songs, EqRender renders the same chain as the live EQ
(5 bands -> level gain -> limiter) offline into a 32 kHz stereo WAV in OPFS
(eq-renders/<id>.wav, written by the OPFS worker's new write op), one song at a
time in the background. Playback then takes the picture from the saved video
(muted) and the sound from that copy in dual mode — plain media playback, so it
continues with the screen locked; live gains are zeroed so nothing is applied
twice. Copies are re-rendered when the EQ curve or a song's loudness changes
and removed when nothing needs applying.

- Level volume is now on by default everywhere (on iPhone it reaches saved
  songs through these copies).
- New iPhone-only Settings -> Playback -> Live sound processing (off by
  default): live Web Audio EQ/levelling for streamed songs, stops when locked.
- The EQ dialog works on iPhone and explains where the curve applies.
2026-10-03 13:55:27 +08:00

288 lines
12 KiB
JavaScript

/* ============================================================================
* 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 } → one streamed request (legacy)
* { videoId, url, resumable: { etag, size, sha256, ext } }
* → ranged, resumable save: the .part and a small
* <file>.part.json sidecar (which server copy it belongs to)
* survive errors, reloads and app kills, and the next try
* continues from the bytes already on disk.
* Out messages:
* { type: 'unsupported' } → caller falls back to main thread
* { type: 'progress', received } → bytes written so far
* { type: 'done', ext, sha256, expectedSha, size }
* → file stored as <videoId>.<ext>;
* sha256 = hash of the stored bytes
* (P2P content id), expectedSha = the
* server's X-Content-SHA256 or null
* { type: 'paused', received, error } → resumable save stopped after its
* retries; .part kept for next time
* { type: 'changed' } → the server copy changed; .part
* dropped, caller should re-prepare
* { type: 'error', error } → failed; .part cleaned up
* ========================================================================== */
'use strict';
// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is
// written, so the device knows its content id without re-reading the file.
try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ }
importScripts('/resume-core.js');
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';
}
// .part → permanent name. Prefer the native rename, but treat ANY move()
// failure as "unavailable" and fall back to a chunked copy — WebKit's move()
// has a different signature/behavior than Chrome's and throws TypeError
// ("Not enough arguments") rather than being absent.
async function finalizePart(dir, partHandle, partName, filename) {
try { await dir.removeEntry(filename); } catch { /* no previous copy */ }
if (typeof partHandle.move === 'function') {
try { await partHandle.move(filename); return; } catch { /* copy below */ }
}
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);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function readSidecar(dir, name) {
try { return JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text()); } catch { return null; }
}
async function writeSidecar(dir, name, obj) {
const h = await dir.getFileHandle(name, { create: true });
const a = await h.createSyncAccessHandle();
try {
const bytes = new TextEncoder().encode(JSON.stringify(obj));
a.truncate(0);
a.write(bytes, { at: 0 });
a.flush();
} finally { a.close(); }
}
const MAX_TRIES = 6; // per chunk, with backoff 2 s … 30 s
const IDLE_MS = 30000; // abort a request that delivers nothing for 30 s
async function resumableSave(videoId, url, target) {
const R = self.ResumeCore;
const dir = await getVideosDir();
const filename = videoId + '.' + (target.ext || 'mp4');
const partName = filename + '.part';
const sideName = partName + '.json';
const partHandle = await dir.getFileHandle(partName, { create: true });
const sidecar = await readSidecar(dir, sideName);
let access = await partHandle.createSyncAccessHandle();
let pos = 0;
try {
pos = R.resumeFrom(sidecar, access.getSize(), target);
access.truncate(pos);
access.flush();
await writeSidecar(dir, sideName, { etag: target.etag, size: target.size, sha256: target.sha256 || null });
self.postMessage({ type: 'progress', received: pos, total: target.size, resumed: pos > 0 });
let tries = 0;
let lastPost = 0;
for (let r = R.nextRange(pos, target.size); r; r = R.nextRange(pos, target.size)) {
const ctl = new AbortController();
let idle = setTimeout(() => ctl.abort(), IDLE_MS);
const bump = () => { clearTimeout(idle); idle = setTimeout(() => ctl.abort(), IDLE_MS); };
try {
const res = await fetch(url, { headers: { Range: `bytes=${r.start}-${r.end}`, 'If-Range': target.etag }, signal: ctl.signal, cache: 'no-store' });
if (res.status === 200 || res.status === 416) {
// The server no longer has the copy this partial belongs to.
try { await res.body?.cancel(); } catch { /* fine */ }
access.truncate(0);
access.close(); access = null;
try { await dir.removeEntry(partName); } catch { /* gone */ }
try { await dir.removeEntry(sideName); } catch { /* gone */ }
self.postMessage({ type: 'changed' });
return;
}
if (res.status !== 206) throw new Error('HTTP ' + res.status);
const cr = R.parseContentRange(res.headers.get('content-range'));
if (!cr || cr.start !== pos) throw new Error('unexpected range from server');
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bump();
access.write(value, { at: pos });
pos += value.byteLength;
const now = Date.now();
if (now - lastPost > 250) { lastPost = now; self.postMessage({ type: 'progress', received: pos, total: target.size }); }
}
access.flush();
if (pos < r.end + 1) throw new Error('connection closed early');
tries = 0;
} catch (err) {
access.flush(); // keep every byte that did arrive
if (++tries >= MAX_TRIES) {
self.postMessage({ type: 'paused', received: pos, total: target.size, error: err && err.message ? err.message : String(err) });
return;
}
await sleep(R.backoffMs(tries - 1));
} finally {
clearTimeout(idle);
}
}
access.truncate(target.size);
access.flush();
} finally {
if (access) access.close();
}
// Verify the whole file once at the end (no hash state to carry across
// resumes): read it back in slices and compare with the server's hash.
self.postMessage({ type: 'progress', received: target.size, total: target.size, verifying: true });
let sha256 = null;
if (self.Sha256) {
const h = self.Sha256.create();
const file = await partHandle.getFile();
const CHUNK = 8 * 1024 * 1024;
for (let p = 0; p < file.size; p += CHUNK) h.update(new Uint8Array(await file.slice(p, p + CHUNK).arrayBuffer()));
sha256 = h.hex();
}
const expectedSha = /^[0-9a-f]{64}$/.test(String(target.sha256 || '')) ? target.sha256 : null;
if (sha256 && expectedSha && sha256 !== expectedSha) {
try { await dir.removeEntry(partName); } catch { /* gone */ }
try { await dir.removeEntry(sideName); } catch { /* gone */ }
throw new Error('integrity check failed (content hash mismatch)');
}
await finalizePart(dir, partHandle, partName, filename);
try { await dir.removeEntry(sideName); } catch { /* gone */ }
self.postMessage({ type: 'done', ext: target.ext || 'mp4', sha256, expectedSha, size: target.size });
}
// Write a whole file into an OPFS sub-directory (sync access handle: works on
// iOS Safari, where main-thread createWritable is missing). Used for the
// iPhone EQ/levelling renders (EqRender in app.js).
async function writeFile(dirName, name, buffer) {
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(dirName, { create: true });
const h = await dir.getFileHandle(name, { create: true });
const a = await h.createSyncAccessHandle();
try {
const bytes = new Uint8Array(buffer);
a.truncate(0);
a.write(bytes, { at: 0 });
a.truncate(bytes.byteLength);
a.flush();
} finally { a.close(); }
}
self.onmessage = async (e) => {
if (e.data && e.data.op === 'write') {
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
return;
}
const { videoId, url, resumable } = 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;
}
if (resumable) {
try { await resumableSave(videoId, url, resumable); }
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
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();
const hasher = self.Sha256 ? self.Sha256.create() : null;
let offset = 0;
try {
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
access.write(value, { at: offset });
if (hasher) hasher.update(value);
offset += value.byteLength;
self.postMessage({ type: 'progress', received: offset });
}
access.truncate(offset);
access.flush();
} finally {
access.close();
}
// A connection that ends early can close the body cleanly; never commit
// a truncated file, it would be badged "cached" yet fail to play.
const expected = Number(res.headers.get('content-length'));
if (expected > 0 && offset !== expected) {
throw new Error(`download cut short (${offset} of ${expected} bytes)`);
}
// The server names the hash of what it sent (media cache copies). A
// mismatch means the bytes were damaged on the way — never keep them.
const sha256 = hasher ? hasher.hex() : null;
const sent = (res.headers.get('x-content-sha256') || '').trim().toLowerCase();
const expectedSha = /^[0-9a-f]{64}$/.test(sent) ? sent : null;
if (sha256 && expectedSha && sha256 !== expectedSha) {
throw new Error('integrity check failed (content hash mismatch)');
}
await finalizePart(dir, partHandle, partName, filename);
self.postMessage({ type: 'done', ext, sha256, expectedSha, size: offset });
} 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) });
}
};