feat: wire up WEB-mode OPFS offline cache for PWA

The PWA conversion shipped sw.js, opfs.js, manifest and icons, but app.js
was never updated to use them — it still routed cache_* calls through the
native bridge (call('cache.list', ...)), which throws 'No native bridge
available' in a browser. That surfaced as 'Offline cache unavailable /
not available in this build' on the Saved videos and Settings pages.

- Add WEB mode detection (no Tauri/zero bridge present)
- Route cache_* through OPFS (opfsDownload/Status/List/Delete/Clear)
- Proxy downloads via /api/download so the browser never hits YouTube CDN
- Load fingerprint.js + opfs.js; add manifest link and PWA meta tags
- Relax CSP to allow blob: media/img and worker-src 'self' for the SW
This commit is contained in:
Jonathan Sykes
2026-06-30 08:37:45 +08:00
parent 847e99cd51
commit 84d6487125
4 changed files with 240 additions and 16 deletions

View File

@@ -19,28 +19,149 @@ function sanitizeId(id) {
}
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
// WEB mode: running as a PWA served from the Hono server (no native bridge)
const WEB = !TAURI && !ZERO;
const APP_VERSION = '1.0.0';
// call(zeroName, tauriName, payload) — routes to whichever shell is present.
// call(zeroName, tauriName, payload) — routes to whichever native shell is present.
async function call(zeroName, tauriName, payload = {}) {
if (TAURI) return await TAURI.invoke(tauriName, payload);
if (ZERO) return await ZERO.invoke(zeroName, payload);
throw new Error('No native bridge available — run inside the YT Player app.');
}
// ---------- WEB-mode helpers ----------
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
async function webFetch(path, opts = {}) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
throw new Error(msg);
}
return res.json();
}
// Persist data to localStorage and fire-and-forget sync to the server.
function loadDataFromStorage() {
try {
const raw = localStorage.getItem('_ytpdata');
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function saveDataToStorage(jsonStr) {
try { localStorage.setItem('_ytpdata', jsonStr); } catch { /* storage full / blocked */ }
// Sync playlists to the server (non-blocking — failures are silent)
try {
const d = JSON.parse(jsonStr);
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
playlists: d.playlists || [],
appVersion: APP_VERSION,
}),
}).catch(() => {});
} catch { /* ignore parse errors */ }
}
// OPFS bridge wrappers — return the same shape as the Tauri cache_* commands
// so the rest of app.js works without changes.
async function opfsDownload(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' };
}
try {
const fp = window.getFingerprint ? window.getFingerprint() : '';
const url = `/api/download/${encodeURIComponent(videoId)}${fp ? '?fp=' + encodeURIComponent(fp) : ''}`;
const res = await fetch(url);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
return { ok: false, error: j.error || `HTTP ${res.status}` };
}
// Determine file extension from Content-Type
const ct = res.headers.get('content-type') || 'video/mp4';
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true };
} catch (err) {
return { ok: false, error: err.message };
}
}
async function opfsStatus(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false };
try {
const cached = await window.OPFS.hasVideo(videoId);
return { ok: true, cached };
} catch {
return { ok: true, cached: false };
}
}
async function opfsList() {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, items: [], total: 0 };
try {
const items = await window.OPFS.listVideos();
const total = items.reduce((s, i) => s + (i.size || 0), 0);
return { ok: true, items, total };
} catch {
return { ok: true, items: [], total: 0 };
}
}
async function opfsDelete(videoId) {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ }
return { ok: true };
}
async function opfsClear() {
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true };
try { await window.OPFS.clearAll(); } catch { /* ignore */ }
return { ok: true };
}
// Blob URL for the currently playing OPFS video — revoked on next video load.
let _currentBlobUrl = null;
const API = {
search: (query) => call('yt.search', 'yt_search', { query }),
getChannel: (channel) => call('yt.channel', 'yt_channel', { channel }),
getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }),
loadData: () => call('store.load', 'store_load', {}),
search: (query) => WEB
? webFetch(`/api/search?q=${encodeURIComponent(query)}`)
: call('yt.search', 'yt_search', { query }),
getChannel: (channel) => WEB
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
: call('yt.channel', 'yt_channel', { channel }),
getStreams: (videoId) => WEB
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
: call('yt.streams', 'yt_streams', { videoId }),
loadData: () => WEB
? Promise.resolve(loadDataFromStorage())
: call('store.load', 'store_load', {}),
// data is sent pre-stringified so the native side can write it verbatim.
saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
// Offline cache (Tauri shell). Calls are wrapped where used so the Linux
// shell — which doesn't implement these yet — degrades gracefully.
cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
cacheList: () => call('cache.list', 'cache_list', {}),
cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
cacheClear: () => call('cache.clear', 'cache_clear', {}),
saveData: (d) => WEB
? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d)))
: call('store.save', 'store_save', { data: JSON.stringify(d) }),
// Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig)
cacheDownload: (videoId) => WEB
? opfsDownload(sanitizeId(videoId))
: call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
cacheStatus: (videoId) => WEB
? opfsStatus(sanitizeId(videoId))
: call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
cacheList: () => WEB
? opfsList()
: call('cache.list', 'cache_list', {}),
cacheDelete: (videoId) => WEB
? opfsDelete(sanitizeId(videoId))
: call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
cacheClear: () => WEB
? opfsClear()
: call('cache.clear', 'cache_clear', {}),
};
// Resolve a native file path to a URL the WebView can load (Tauri asset
@@ -293,13 +414,24 @@ const Player = {
async loadVideo(videoObj, { preferStream = false } = {}) {
showSpinner(true);
els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory
if (_currentBlobUrl) {
if (window.OPFS) window.OPFS.revokeUrl(_currentBlobUrl);
else URL.revokeObjectURL(_currentBlobUrl);
_currentBlobUrl = null;
}
try {
// Play from the offline cache when available — instant and works offline.
if (!preferStream && cachedIds.has(videoObj.id)) {
let localUrl = null;
try {
const st = await API.cacheStatus(videoObj.id);
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
if (TAURI) {
const st = await API.cacheStatus(videoObj.id);
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
} else if (WEB && window.OPFS) {
localUrl = await window.OPFS.getFileUrl(videoObj.id);
if (localUrl) _currentBlobUrl = localUrl;
}
} catch { /* fall through to streaming */ }
if (localUrl) {
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
@@ -2397,6 +2529,58 @@ function importBackup(e) {
e.target.value = '';
}
// ============================================================================
// PWA — service worker registration and update banner (WEB mode only)
// ============================================================================
function showUpdateBanner() {
// Use a persistent, click-to-reload toast instead of the standard 2.2s one
const t = document.createElement('div');
t.className = 'toast toast-update';
t.innerHTML = '⬆ Update ready — <button class="toast-reload-btn">Reload now</button>';
const container = $('toastContainer');
container.appendChild(t);
t.querySelector('.toast-reload-btn').addEventListener('click', () => {
// Tell the waiting SW to skip waiting, then reload once it takes control.
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
}
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true });
// Fallback: reload after a short delay in case controllerchange already fired
setTimeout(() => window.location.reload(), 500);
});
}
async function registerServiceWorker() {
if (!WEB || !('serviceWorker' in navigator)) return;
try {
const reg = await navigator.serviceWorker.register('/sw.js');
// If a new SW is already waiting (e.g. user refreshed after an update),
// show the banner right away.
if (reg.waiting) { showUpdateBanner(); return; }
// Listen for a new SW installing after the page is open.
reg.addEventListener('updatefound', () => {
const sw = reg.installing;
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'installed' && reg.waiting) showUpdateBanner();
});
});
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner();
});
// Check for updates in the background (useful for long-lived sessions)
reg.update().catch(() => {});
} catch (err) {
console.warn('[sw] registration failed:', err.message);
}
}
// ============================================================================
// Boot
// ============================================================================
@@ -2436,6 +2620,19 @@ async function boot() {
checkAutoBackup();
render();
els.searchInput.focus();
// Register service worker + ping server with fingerprint (WEB mode only)
registerServiceWorker();
if (WEB) {
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
appVersion: APP_VERSION,
}),
}).catch(() => {});
}
}
document.addEventListener('DOMContentLoaded', boot);

View File

@@ -3,11 +3,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1311" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="YT Player" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost;"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
/>
<title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -257,6 +263,8 @@
</div>
</div>
<script src="fingerprint.js"></script>
<script src="opfs.js"></script>
<script src="async-guard.js"></script>
<script src="app.js"></script>
</body>

View File

@@ -1017,6 +1017,24 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
/* PWA update banner — persistent toast with inline action button */
.toast-update {
border-color: var(--accent);
display: flex;
align-items: center;
gap: 10px;
}
.toast-reload-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 10px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
/* ===================== Keyboard shortcut overlay ===================== */
.shortcut-overlay {

View File

@@ -6,6 +6,7 @@
"setup": "node scripts/setup-ytdlp.js",
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
"make-icon": "node scripts/make-icon.js",
"make-pwa-icons": "node scripts/make-pwa-icons.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",