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= is content-addressed: those exact bytes cannot change + // without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player + // that loses its network must still be able to render its widgets. Offline resilience is the + // point of the player's cache, and no-store made widgets the one thing it could never keep. + // + // A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render + // from the next, so a cached copy could serve content the operator has already changed. + if (req.query.rev) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } else { + res.setHeader('Cache-Control', 'no-store'); + } res.setHeader('Content-Type', 'text/html'); res.send(renderWidgetHtml(widget.widget_type, config)); }); diff --git a/tizen/js/player.js b/tizen/js/player.js index fdf7b81..a3fa7f4 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -148,7 +148,10 @@ PlaylistPlayer.prototype.load = function (assignments) { // transition-engine: include the per-item transition, or a transition change keeps the same // signature -> "unchanged" -> the player never applies the new transitions. // duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart. - return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || [], a.transition || null]; + // widget_rev for the same reason as schedules and transition above: a widget's IDENTITY + // is unchanged when it is EDITED, so a content edit produced an identical signature, the + // update was treated as unchanged, and the screen kept the old render until a restart. + return [a.content_id, a.widget_id, a.widget_rev || 0, a.remote_url, a.mime_type, a.schedules || [], a.transition || null]; })); if (sig === this.sig && this.items.length) { // In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect @@ -821,7 +824,7 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) { PlaylistPlayer.prototype.renderWidget = function (item, single) { var self = this; - var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : ''); + var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (item.widget_rev || 0); // Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the // current content and reveal it on load, THEN drop everything else — so a widget/directory-board // reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets). @@ -1037,7 +1040,7 @@ ZoneRenderer.prototype.showItem = function (zone, list, index) { zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media', yvert)); if (multi) this.scheduleAdvance(zone, dur, advance); } else if (a.widget_type || (a.widget_id && !a.content_id)) { - zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : ''))); + zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (a.widget_rev || 0))); if (multi) this.scheduleAdvance(zone, dur, advance); } else if (mime.indexOf('video/') === 0) { var v = document.createElement('video');