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:
223
frontend/app.js
223
frontend/app.js
@@ -19,28 +19,149 @@ function sanitizeId(id) {
|
|||||||
}
|
}
|
||||||
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
|
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
|
||||||
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : 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 = {}) {
|
async function call(zeroName, tauriName, payload = {}) {
|
||||||
if (TAURI) return await TAURI.invoke(tauriName, payload);
|
if (TAURI) return await TAURI.invoke(tauriName, payload);
|
||||||
if (ZERO) return await ZERO.invoke(zeroName, payload);
|
if (ZERO) return await ZERO.invoke(zeroName, payload);
|
||||||
throw new Error('No native bridge available — run inside the YT Player app.');
|
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 = {
|
const API = {
|
||||||
search: (query) => call('yt.search', 'yt_search', { query }),
|
search: (query) => WEB
|
||||||
getChannel: (channel) => call('yt.channel', 'yt_channel', { channel }),
|
? webFetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||||
getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }),
|
: call('yt.search', 'yt_search', { query }),
|
||||||
loadData: () => call('store.load', 'store_load', {}),
|
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.
|
// data is sent pre-stringified so the native side can write it verbatim.
|
||||||
saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
|
saveData: (d) => WEB
|
||||||
// Offline cache (Tauri shell). Calls are wrapped where used so the Linux
|
? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d)))
|
||||||
// shell — which doesn't implement these yet — degrades gracefully.
|
: call('store.save', 'store_save', { data: JSON.stringify(d) }),
|
||||||
cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
|
// Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig)
|
||||||
cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
|
cacheDownload: (videoId) => WEB
|
||||||
cacheList: () => call('cache.list', 'cache_list', {}),
|
? opfsDownload(sanitizeId(videoId))
|
||||||
cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
|
: call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
|
||||||
cacheClear: () => call('cache.clear', 'cache_clear', {}),
|
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
|
// 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 } = {}) {
|
async loadVideo(videoObj, { preferStream = false } = {}) {
|
||||||
showSpinner(true);
|
showSpinner(true);
|
||||||
els.placeholder.classList.add('hidden');
|
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 {
|
try {
|
||||||
// Play from the offline cache when available — instant and works offline.
|
// Play from the offline cache when available — instant and works offline.
|
||||||
if (!preferStream && cachedIds.has(videoObj.id)) {
|
if (!preferStream && cachedIds.has(videoObj.id)) {
|
||||||
let localUrl = null;
|
let localUrl = null;
|
||||||
try {
|
try {
|
||||||
|
if (TAURI) {
|
||||||
const st = await API.cacheStatus(videoObj.id);
|
const st = await API.cacheStatus(videoObj.id);
|
||||||
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
|
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 */ }
|
} catch { /* fall through to streaming */ }
|
||||||
if (localUrl) {
|
if (localUrl) {
|
||||||
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
|
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
|
||||||
@@ -2397,6 +2529,58 @@ function importBackup(e) {
|
|||||||
e.target.value = '';
|
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
|
// Boot
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2436,6 +2620,19 @@ async function boot() {
|
|||||||
checkAutoBackup();
|
checkAutoBackup();
|
||||||
render();
|
render();
|
||||||
els.searchInput.focus();
|
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);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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>
|
<title>YT Player</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@@ -257,6 +263,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="fingerprint.js"></script>
|
||||||
|
<script src="opfs.js"></script>
|
||||||
<script src="async-guard.js"></script>
|
<script src="async-guard.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1017,6 +1017,24 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
from { opacity: 1; transform: translateY(0); }
|
from { opacity: 1; transform: translateY(0); }
|
||||||
to { opacity: 0; transform: translateY(-8px); }
|
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 ===================== */
|
/* ===================== Keyboard shortcut overlay ===================== */
|
||||||
.shortcut-overlay {
|
.shortcut-overlay {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"setup": "node scripts/setup-ytdlp.js",
|
"setup": "node scripts/setup-ytdlp.js",
|
||||||
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
|
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
|
||||||
"make-icon": "node scripts/make-icon.js",
|
"make-icon": "node scripts/make-icon.js",
|
||||||
|
"make-pwa-icons": "node scripts/make-pwa-icons.js",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
|
|||||||
Reference in New Issue
Block a user