/* ============================================================================ * 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) }); } };