Files
ytplayer/frontend/opfs.js

245 lines
9.2 KiB
JavaScript

/* ============================================================================
* opfs.js — Origin Private File System video storage for PWA mode
*
* Exposes window.OPFS with methods that mirror the Tauri cache_* commands
* so app.js can call them transparently in WEB mode.
*
* Videos are stored under the OPFS root at:
* videos/<videoId>.<ext>
*
* Object URLs created by getFileUrl() are tracked so they can be revoked
* when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
*
* OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
* Safari 15.2+). Calls degrade gracefully if the API is absent.
* ========================================================================== */
(function () {
'use strict';
// Root directory handle, lazily initialised
let _rootPromise = null;
async function getRoot() {
if (!_rootPromise) {
_rootPromise = (async () => {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle('videos', { create: true });
})();
}
return _rootPromise;
}
// Iterate the videos/ directory and find a file whose stem matches videoId
// (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
async function findHandle(videoId) {
const dir = await getRoot();
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
const dot = name.lastIndexOf('.');
const stem = dot > -1 ? name.slice(0, dot) : name;
if (stem === videoId) return [handle, name];
}
return null;
}
// Extension → MIME type for Content-Type headers on playback
function extToMime(ext) {
const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
return map[ext] || 'application/octet-stream';
}
const _blobUrls = new Set();
window.OPFS = {
// Does a file exist for this videoId?
async hasVideo(videoId) {
try {
return (await findHandle(videoId)) !== null;
} catch {
return false;
}
},
// Return a blob: URL usable as <video src>. Returns null if not cached.
// The caller is responsible for calling OPFS.revokeUrl(url) when done.
async getFileUrl(videoId) {
try {
const found = await findHandle(videoId);
if (!found) return null;
const [handle, name] = found;
const file = await handle.getFile();
const ext = name.slice(name.lastIndexOf('.') + 1);
// 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;
} catch {
return null;
}
},
// Revoke an object URL previously returned by getFileUrl().
revokeUrl(url) {
if (url && _blobUrls.has(url)) {
URL.revokeObjectURL(url);
_blobUrls.delete(url);
}
},
// 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');
// Write to a temporary file first so a partial download doesn't leave
// a corrupt permanent entry.
const tmpName = filename + '.part';
const tmpHandle = await dir.getFileHandle(tmpName, { create: true });
try {
if ('createWritable' in tmpHandle) {
const writable = await tmpHandle.createWritable();
try {
if (response.body && typeof response.body.pipeTo === 'function') {
await response.body.pipeTo(writable);
} else {
// Safari < 16.4 doesn't support pipeTo — buffer the whole response
const buf = await response.arrayBuffer();
await writable.write(buf);
await writable.close();
}
} catch (err) {
await writable.abort();
throw err;
}
} else {
// No createWritable on this browser — main-thread OPFS writes are
// impossible (sync access handles are worker-only). Surface a real
// error instead of the old branch that called the missing API.
throw new Error('Offline saving is not supported in this browser');
}
// Rename tmp → final. Prefer the native rename; else stream-copy so
// the whole file is never buffered in main-thread memory at once.
// move() failures (WebKit's signature differs from Chrome's and
// throws TypeError) fall back to the copy path too.
let renamed = false;
if (typeof tmpHandle.move === 'function') {
try { await tmpHandle.move(filename); renamed = true; } catch { /* copy below */ }
}
if (!renamed) {
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 */ }
}
},
// List all cached videos — returns [{ id, size, name }]
async listVideos() {
const dir = await getRoot();
const items = [];
try {
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
// Skip .part temporary files
if (name.endsWith('.part')) continue;
const dot = name.lastIndexOf('.');
const id = dot > -1 ? name.slice(0, dot) : name;
const file = await handle.getFile();
items.push({ id, name, size: file.size });
}
} catch { /* OPFS not available */ }
return items;
},
// Total bytes stored
async totalSize() {
const items = await this.listVideos();
return items.reduce((s, i) => s + i.size, 0);
},
// Delete one video
async deleteVideo(videoId) {
try {
const found = await findHandle(videoId);
if (!found) return;
const [, name] = found;
const dir = await getRoot();
await dir.removeEntry(name);
} catch { /* already gone */ }
},
// Delete all cached videos
async clearAll() {
try {
const dir = await getRoot();
const names = [];
for await (const [name] of dir.entries()) names.push(name);
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
// Revoke any outstanding blob URLs
for (const url of _blobUrls) URL.revokeObjectURL(url);
_blobUrls.clear();
} catch { /* OPFS not available */ }
},
// Is the OPFS API supported in this browser?
isSupported() {
return typeof navigator !== 'undefined' &&
typeof navigator.storage !== 'undefined' &&
typeof navigator.storage.getDirectory === 'function';
},
};
}());