diff --git a/server/player/index.html b/server/player/index.html index fae26bf..a107dbc 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -1848,7 +1848,10 @@ // stale cached playlist and never applies the new transitions. This bug hid every transition edit. // STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is // deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart). - const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(','); + // widget_rev is in here for the same reason as schedules and transition: a widget's IDENTITY + // does not change when it is EDITED, so a content edit produced an identical fingerprint, the + // update was treated as "unchanged", and the screen kept the old render until a reload. + const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.widget_rev || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(','); const newFp = fingerprint(newItems); const oldFp = fingerprint(playlist); @@ -2381,7 +2384,7 @@ discardPendingSwap(); const iframe = document.createElement('iframe'); - iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; + iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`; // Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it // loads so its black background never shows over the outgoing content. iframe.style.background = '#000'; @@ -2932,7 +2935,7 @@ if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000); } else if (item.widget_id) { const iframe = document.createElement('iframe'); - iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; + iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`; iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000'; iframe.allow = 'autoplay; fullscreen'; // Sandbox into a unique origin so widget scripts can't read window.parent @@ -3046,7 +3049,7 @@ // Android player, which keys off the assignment's widget_type. if (a.widget_id) { const iframe = document.createElement('iframe'); - iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; + iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`; // Sandbox into a unique origin so widget scripts can't read window.parent // state (localStorage / JWT). allow-scripts keeps inline widget code running. iframe.setAttribute('sandbox', 'allow-scripts'); diff --git a/server/player/sw.js b/server/player/sw.js index b3375c3..72f829a 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'rd-player-v17'; +const CACHE_NAME = 'rd-player-v18'; // Install: skip waiting to activate immediately self.addEventListener('install', (event) => { @@ -25,6 +25,31 @@ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); + // Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change + // without the rev changing. This is what lets a widget keep rendering when the network is gone — + // previously the server sent no-store for every render, so widgets were the one thing the + // player's offline cache could never hold, and a display that lost its uplink lost them. + // ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it + // would match a different revision's entry, which is the staleness we are trying to remove. + if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) { + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + if (response.ok && response.type !== 'opaque') { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + } + return response; + }).catch(() => new Response( + '
', + { status: 200, headers: { 'Content-Type': 'text/html' } } + )); + }) + ); + return; + } + // Player page and static assets: network-first, fall back to cache if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') { event.respondWith( diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 0337a69..08e1107 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -220,9 +220,20 @@ router.get('/:id/render', (req, res) => { // widgets render blank in the web player. Drop it here; the sandbox - not // X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT). res.removeHeader('X-Frame-Options'); - // Never cache the render: widget data (clock/weather/rss/directory) changes, and - // a cached copy from before the X-Frame-Options change would keep showing blank. - res.setHeader('Cache-Control', 'no-store'); + // Caching is keyed on whether the caller pinned a revision. + // + // A URL carrying ?rev=