diff --git a/frontend/app.js b/frontend/app.js index c151cf0..9de4d7e 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 — '; + 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); diff --git a/frontend/index.html b/frontend/index.html index 43e6f2f..b0c79f5 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,17 @@ + + + + + YT Player + + + diff --git a/frontend/styles.css b/frontend/styles.css index edcacb8..1f8b869 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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 { diff --git a/package.json b/package.json index 143602e..59cd5e0 100755 --- a/package.json +++ b/package.json @@ -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",