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 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-08 16:25:05 -05:00
parent e2879fff58
commit e7081a579c
5 changed files with 79 additions and 14 deletions

View file

@ -508,16 +508,19 @@ function updateLayers() {
function generateInnerHTML() { function generateInnerHTML() {
let html = ''; let html = '';
elements.forEach((el, i) => { 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) { switch (el.type) {
case 'text': case 'text':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap">${el.text}</div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${fs}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap">${el.text}</div>`;
break; break;
case 'clock': case 'clock':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;font-family:${el.fontFamily};color:${el.color};font-weight:bold" id="c${i}"></div> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${fs}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold" id="c${i}"></div>
<script>setInterval(()=>{const o={hour:'2-digit',minute:'2-digit'${el.showSeconds ? ",second:'2-digit'" : ''},hour12:${el.format !== '24h'}};document.getElementById('c${i}').textContent=new Date().toLocaleTimeString('en-US',o)},1000)</script>`; <script>setInterval(()=>{const o={hour:'2-digit',minute:'2-digit'${el.showSeconds ? ",second:'2-digit'" : ''},hour12:${el.format !== '24h'}};document.getElementById('c${i}').textContent=new Date().toLocaleTimeString('en-US',o)},1000)</script>`;
break; break;
case 'date': case 'date':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;font-family:${el.fontFamily};color:${el.color}" id="d${i}"></div> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${fs}vw;font-family:${el.fontFamily};color:${el.color}" id="d${i}"></div>
<script>document.getElementById('d${i}').textContent=new Date().toLocaleDateString('en-US',{weekday:'long',year:'numeric',month:'long',day:'numeric'})</script>`; <script>document.getElementById('d${i}').textContent=new Date().toLocaleDateString('en-US',{weekday:'long',year:'numeric',month:'long',day:'numeric'})</script>`;
break; break;
case 'image': case 'image':
@ -530,19 +533,19 @@ function generateInnerHTML() {
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};${el.shape === 'circle' ? 'border-radius:50%' : `border-radius:${el.radius}px`}"></div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};${el.shape === 'circle' ? 'border-radius:50%' : `border-radius:${el.radius}px`}"></div>`;
break; break;
case 'weather': case 'weather':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;color:${el.color}" id="w${i}">Loading...</div> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${fs}vw;color:${el.color}" id="w${i}">Loading...</div>
<script>fetch('https://wttr.in/${encodeURIComponent(el.location)}?format=j1').then(r=>r.json()).then(d=>{const c=d.current_condition[0];document.getElementById('w${i}').textContent=c.temp_${el.units === 'metric' ? 'C' : 'F'}+'°${el.units === 'metric' ? 'C' : 'F'} '+c.weatherDesc[0].value}).catch(()=>{})</script>`; <script>fetch('https://wttr.in/${encodeURIComponent(el.location)}?format=j1').then(r=>r.json()).then(d=>{const c=d.current_condition[0];document.getElementById('w${i}').textContent=c.temp_${el.units === 'metric' ? 'C' : 'F'}+'°${el.units === 'metric' ? 'C' : 'F'} '+c.weatherDesc[0].value}).catch(()=>{})</script>`;
break; break;
case 'ticker': case 'ticker':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center"> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center">
<div style="white-space:nowrap;animation:t ${el.speed}s linear infinite;font-size:${el.fontSize * 10.8}px;color:${el.color}" id="t${i}">Loading...</div></div> <div style="white-space:nowrap;animation:t ${el.speed}s linear infinite;font-size:${fs}vw;color:${el.color}" id="t${i}">Loading...</div></div>
<style>@keyframes t{0%{transform:translateX(100%)}100%{transform:translateX(-100%)}}</style> <style>@keyframes t{0%{transform:translateX(100%)}100%{transform:translateX(-100%)}}</style>
<script>fetch('https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}').then(r=>r.json()).then(d=>{document.getElementById('t${i}').textContent=d.items.map(i=>i.title).join(' • ')}).catch(()=>{})</script>`; <script>fetch('https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}').then(r=>r.json()).then(d=>{document.getElementById('t${i}').textContent=d.items.map(i=>i.title).join(' • ')}).catch(()=>{})</script>`;
break; break;
case 'countdown': case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color}"> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color}">
<div style="font-size:${el.fontSize * 7}px;opacity:0.8">${el.label}</div> <div style="font-size:${fsLabel}vw;opacity:0.8">${el.label}</div>
<div style="font-size:${el.fontSize * 10.8}px;font-weight:bold" id="cd${i}"></div></div> <div style="font-size:${fs}vw;font-weight:bold" id="cd${i}"></div></div>
<script>setInterval(()=>{const d=new Date('${el.targetDate}')-new Date();if(d<=0){document.getElementById('cd${i}').textContent='NOW!';return}document.getElementById('cd${i}').textContent=Math.floor(d/864e5)+'d '+Math.floor(d%864e5/36e5)+'h '+Math.floor(d%36e5/6e4)+'m'},6e4)</script>`; <script>setInterval(()=>{const d=new Date('${el.targetDate}')-new Date();if(d<=0){document.getElementById('cd${i}').textContent='NOW!';return}document.getElementById('cd${i}').textContent=Math.floor(d/864e5)+'d '+Math.floor(d%864e5/36e5)+'h '+Math.floor(d%36e5/6e4)+'m'},6e4)</script>`;
break; break;
case 'webpage': case 'webpage':

View file

@ -59,6 +59,38 @@ for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ } 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) // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) { function pruneTelemetry(deviceId) {
db.prepare(` db.prepare(`

View file

@ -436,7 +436,7 @@
const newItems = data.assignments || []; const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change // 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 newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist); const oldFp = fingerprint(playlist);
@ -693,6 +693,14 @@
container.appendChild(img); container.appendChild(img);
// Auto advance for images // Auto advance for images
setTimeout(nextItem, (item.duration_sec || 10) * 1000); 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);
} }
} }
} }

View file

@ -207,18 +207,26 @@ load(); setInterval(load, 300000);
} }
function renderText(c) { 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 || '<p style="color:white;padding:20px">Empty text widget</p>';
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
});
return `<!DOCTYPE html><html><head><style> return `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
body { background:${c.background || 'transparent'}; height:100vh; overflow:hidden; } body { background:${c.background || 'transparent'}; width:100vw; height:100vh; overflow:hidden; }
${c.css || ''} ${c.css || ''}
</style></head><body>${c.html || '<p style="color:white;padding:20px">Empty text widget</p>'}</body></html>`; </style></head><body>${html}</body></html>`;
// NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget // NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget
} }
function renderWebpage(c) { function renderWebpage(c) {
const zoom = (c.zoom || 100) / 100;
const invZoom = 100 / (c.zoom || 100) * 100;
return `<!DOCTYPE html><html><head><style> return `<!DOCTYPE html><html><head><style>
* { margin:0; } body { height:100vh; overflow:hidden; } * { margin:0; } body { height:100vh; overflow:hidden; }
iframe { width:100%; height:100%; border:0; transform:scale(${(c.zoom || 100) / 100}); transform-origin:0 0; } iframe { width:${invZoom}%; height:${invZoom}%; border:0; transform:scale(${zoom}); transform-origin:0 0; }
</style></head><body> </style></head><body>
<iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="allow-scripts allow-same-origin"></iframe> <iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="allow-scripts allow-same-origin"></iframe>
${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('iframe').src=document.querySelector('iframe').src,${c.refresh_interval * 1000});</script>` : ''} ${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('iframe').src=document.querySelector('iframe').src,${c.refresh_interval * 1000});</script>` : ''}

View file

@ -69,10 +69,22 @@ app.get('/app', (req, res) => {
}); });
// Serve frontend static files // 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 // Serve web player at /player (same no-cache for JS/HTML)
app.use('/player', express.static(path.join(__dirname, 'player'))); 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 // Serve setup scripts
app.use('/scripts', express.static(path.join(__dirname, '..', '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) // (Screenshot route moved above protected routes)
// Serve uploaded content files directly (with CORS for web player canvas capture) // 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) => { app.use('/uploads/content', (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 days
next(); next();
}, express.static(config.contentDir)); }, express.static(config.contentDir));