From e7081a579cf460ea87dc1ee8d313624a40ea9aef Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 8 Apr 2026 16:25:05 -0500 Subject: [PATCH] Fix widget assignments, designer scaling, and cache strategy - Make assignments.content_id nullable so widgets can be assigned to playlists - Fix designer publish to use vw units matching preview (was hardcoded px) - Add px-to-vw conversion in text widget renderer for backward compat - Fix webpage widget zoom scaling - Add widget rendering support in fullscreen player mode - Set no-cache headers on JS/CSS/HTML for instant updates (ETag/304) - Set 30-day cache on media files and uploaded content for Cloudflare Co-Authored-By: Claude Opus 4.6 --- frontend/js/views/designer.js | 17 ++++++++++------- server/db/database.js | 32 ++++++++++++++++++++++++++++++++ server/player/index.html | 10 +++++++++- server/routes/widgets.js | 14 +++++++++++--- server/server.js | 20 +++++++++++++++++--- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/frontend/js/views/designer.js b/frontend/js/views/designer.js index 745dd6c..72b2ade 100644 --- a/frontend/js/views/designer.js +++ b/frontend/js/views/designer.js @@ -508,16 +508,19 @@ function updateLayers() { function generateInnerHTML() { let html = ''; elements.forEach((el, i) => { + // Use vw units for font sizes (same as designer preview) so output scales to any viewport + const fs = el.fontSize / 10; + const fsLabel = el.fontSize / 15; switch (el.type) { case 'text': - html += `
${el.text}
`; + html += `
${el.text}
`; break; case 'clock': - html += `
+ html += `
`; break; case 'date': - html += `
+ html += `
`; break; case 'image': @@ -530,19 +533,19 @@ function generateInnerHTML() { html += `
`; break; case 'weather': - html += `
Loading...
+ html += `
Loading...
`; break; case 'ticker': html += `
-
Loading...
+
Loading...
`; break; case 'countdown': html += `
-
${el.label}
-
+
${el.label}
+
`; break; case 'webpage': diff --git a/server/db/database.js b/server/db/database.js index 78cace2..56f5218 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -59,6 +59,38 @@ for (const sql of migrations) { try { db.exec(sql); } catch (e) { /* already exists */ } } +// Fix assignments table: make content_id nullable (SQLite requires table rebuild) +try { + const colInfo = db.prepare("PRAGMA table_info(assignments)").all(); + const contentCol = colInfo.find(c => c.name === 'content_id'); + if (contentCol && contentCol.notnull === 1) { + console.log('Migrating assignments table: making content_id nullable...'); + db.exec(` + CREATE TABLE assignments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + content_id TEXT REFERENCES content(id) ON DELETE CASCADE, + widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, + zone_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + duration_sec INTEGER NOT NULL DEFAULT 10, + schedule_start TEXT, + schedule_end TEXT, + schedule_days TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + muted INTEGER DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + INSERT INTO assignments_new SELECT id, device_id, content_id, widget_id, zone_id, sort_order, duration_sec, schedule_start, schedule_end, schedule_days, enabled, muted, created_at FROM assignments; + DROP TABLE assignments; + ALTER TABLE assignments_new RENAME TO assignments; + `); + console.log('Assignments table migrated successfully.'); + } +} catch (e) { + console.error('Assignments migration error:', e.message); +} + // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) function pruneTelemetry(deviceId) { db.prepare(` diff --git a/server/player/index.html b/server/player/index.html index f76eb18..9bd2838 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -436,7 +436,7 @@ const newItems = data.assignments || []; // Build fingerprint from id + url + filename to detect any content change - const fingerprint = (items) => items.map(a => `${a.content_id}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(','); + const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(','); const newFp = fingerprint(newItems); const oldFp = fingerprint(playlist); @@ -693,6 +693,14 @@ container.appendChild(img); // Auto advance for images 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`; + iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000'; + iframe.allow = 'autoplay; fullscreen'; + container.appendChild(iframe); + // Auto advance for widgets + setTimeout(nextItem, (item.duration_sec || 30) * 1000); } } } diff --git a/server/routes/widgets.js b/server/routes/widgets.js index cbb3b93..c180165 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -207,18 +207,26 @@ load(); setInterval(load, 300000); } function renderText(c) { + // Designer preview uses fontSize/10 vw, but older published HTML used fontSize*10.8 px. + // Convert any px-based font sizes to vw so they scale to any viewport: px / 108 = vw + let html = c.html || '

Empty text widget

'; + html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => { + return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`; + }); return `${c.html || '

Empty text widget

'}`; +${html}`; // NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget } function renderWebpage(c) { + const zoom = (c.zoom || 100) / 100; + const invZoom = 100 / (c.zoom || 100) * 100; return ` ${c.refresh_interval > 0 ? `` : ''} diff --git a/server/server.js b/server/server.js index 7b8809e..75102de 100644 --- a/server/server.js +++ b/server/server.js @@ -69,10 +69,22 @@ app.get('/app', (req, res) => { }); // Serve frontend static files -app.use(express.static(config.frontendDir, { index: false })); +// JS/CSS/HTML: no-cache (always revalidate, uses ETag/304) +// Images/fonts/icons: long cache for Cloudflare + browser +app.use(express.static(config.frontendDir, { index: false, etag: true, lastModified: true, setHeaders: (res, filePath) => { + if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } else if (/\.(png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|webp|mp4|webm)$/i.test(filePath)) { + res.setHeader('Cache-Control', 'public, max-age=2592000'); // 30 days + } +}})); -// Serve web player at /player -app.use('/player', express.static(path.join(__dirname, 'player'))); +// Serve web player at /player (same no-cache for JS/HTML) +app.use('/player', express.static(path.join(__dirname, 'player'), { etag: true, lastModified: true, setHeaders: (res, filePath) => { + if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } +}})); // Serve setup scripts app.use('/scripts', express.static(path.join(__dirname, '..', 'scripts'))); @@ -267,9 +279,11 @@ app.get('/api/update/check', (req, res) => { // (Screenshot route moved above protected routes) // Serve uploaded content files directly (with CORS for web player canvas capture) +// Long cache for media files — Cloudflare and browsers can cache these aggressively app.use('/uploads/content', (req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 days next(); }, express.static(config.contentDir));