- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
API object routes to WEB helpers when no native bridge present,
Player.loadVideo handles OPFS blob URLs + revokes them on next load,
SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
195 lines
6.7 KiB
JavaScript
195 lines
6.7 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);
|
|
const blob = new Blob([await file.arrayBuffer()], { 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);
|
|
}
|
|
},
|
|
|
|
// 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.
|
|
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 {
|
|
// Fallback: buffer entirely (older browsers)
|
|
const buf = await response.arrayBuffer();
|
|
const writable = await tmpHandle.createWritable();
|
|
await writable.write(buf);
|
|
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();
|
|
} 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';
|
|
},
|
|
};
|
|
}());
|