diff --git a/frontend/app.js b/frontend/app.js index 935176a..728c297 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1704,14 +1704,9 @@ async function renderSettings() { const btn = $('refreshUiBtn'); btn.disabled = true; btn.textContent = 'Refreshing…'; - try { - await Promise.all([ - fetch('/', { cache: 'reload' }), - fetch('/app.js', { cache: 'reload' }), - fetch('/app.css', { cache: 'reload' }), - ]); - } catch { /* network hiccup — reload anyway */ } - location.reload(); + // hardReloadUI() deletes all caches and unregisters the SW so the + // next page load always fetches fresh assets from the network. + await hardReloadUI(); }); $('resetAppBtn').addEventListener('click', () => { @@ -2796,53 +2791,70 @@ function pollBuildTag() { // PWA — service worker registration and update banner (WEB mode only) // ============================================================================ +// Hard-reload helper: wipes every SW cache and unregisters the SW so the +// next page load fetches fresh assets from the network, not stale cache. +// Called from both the update dialog and the Settings → Force refresh UI button. +async function hardReloadUI() { + try { + // 1. Delete every named cache (app shell, thumbs, fonts, etc.) + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + } + // 2. Unregister every service worker registration so the browser + // fetches sw.js fresh on the next load rather than serving a + // cached copy that could re-populate the caches. + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map((r) => r.unregister())); + } + } catch { /* best effort — reload regardless */ } + // Hard navigation to the root forces the browser to fetch index.html from + // the network (no SW is registered any more to intercept it). + window.location.href = window.location.origin + '/'; +} + let _updateBannerShown = false; -function showUpdateBanner(waitingSW) { - // Only show the banner once per page load +function showUpdateBanner() { + // Only show the dialog once per page load if (_updateBannerShown) return; _updateBannerShown = true; - // 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', () => { - if (waitingSW) { - // Signal the *waiting* SW (not the active controller) to take over. - // Once it does, controllerchange fires and we reload. - waitingSW.postMessage({ type: 'SKIP_WAITING' }); - navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true }); - } else { - // No waiting SW (e.g. build-tag or SW_UPDATE_AVAILABLE path) — just reload. - window.location.reload(); - } - }); + // Show a modal dialog instead of a fleeting toast so the user can't miss it. + const body = document.createElement('p'); + body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.'; + showModal('⬆ Update available', body, [ + { label: 'Later', onClick: closeModal }, + { label: 'Refresh UI', primary: true, onClick: async () => { + closeModal(); + await hardReloadUI(); + }}, + ]); } async function registerServiceWorker() { if (!WEB || !('serviceWorker' in navigator)) return; try { - const reg = await navigator.serviceWorker.register('/sw.js'); + // updateViaCache:'none' prevents the browser from serving a stale + // cached copy of sw.js — the server always returns it fresh (no-store). + const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); // If a new SW is already waiting (e.g. user refreshed after an update), - // show the banner right away. - if (reg.waiting) { showUpdateBanner(reg.waiting); return; } + // show the dialog 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(reg.waiting); + if (sw.state === 'installed' && reg.waiting) showUpdateBanner(); }); }); // The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate. - // In this case the new SW is already active, so just reload directly. navigator.serviceWorker.addEventListener('message', (e) => { - if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner(null); + if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner(); }); // Check for updates in the background (useful for long-lived sessions) diff --git a/frontend/sw.js b/frontend/sw.js index 7a9cda6..d7880f3 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -16,7 +16,10 @@ * 5. SW calls skipWaiting() → takes over → client reloads. * ========================================================================== */ -const VERSION = 'v1.0.3'; // ← bump this on every deploy to bust the cache +// BUILD_TAG is injected by the server at request time (GET /sw.js). +// It changes on every deploy/restart so the cache is busted automatically +// without any manual version bump. +const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.3'; const CACHE = 'ytplayer-' + VERSION; // Files that form the installable app shell. diff --git a/server/server.js b/server/server.js index 830038d..243da0b 100644 --- a/server/server.js +++ b/server/server.js @@ -24,6 +24,7 @@ import { serveStatic } from 'hono/bun'; import { logger } from 'hono/logger'; import { spawnSync } from 'node:child_process'; import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js'; const PORT = parseInt(process.env.PORT || '3000', 10); @@ -333,6 +334,35 @@ app.get('/api/user/data', async (c) => { } }); +// ============================================================================ +// GET /sw.js — serve the service worker with BUILD_TAG injected +// +// The raw sw.js file contains the placeholder `__BUILD_TAG__` which is +// replaced here with the actual BUILD_TAG string so the SW's cache name +// tracks the deployment automatically — no manual version bump needed. +// Served with no-store cache headers so browsers always re-fetch it and +// pick up the substituted value rather than a browser-cached stale copy. +// ============================================================================ +let _swSource = null; +app.get('/sw.js', (c) => { + if (!_swSource) { + try { + _swSource = readFileSync('./public/sw.js', 'utf8'); + } catch { + return c.text('Service worker not found', 404); + } + } + // Inject the build tag: replace the placeholder with the real value. + const src = _swSource.replace( + "typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.3'", + JSON.stringify(BUILD_TAG) + ); + return c.text(src, 200, { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }); +}); + // ============================================================================ // Static files — serve the frontend/public directory // Must come AFTER all /api routes so API takes priority