Merge #111: device-free preview, playlist + device surfaces (#104)

This commit is contained in:
ScreenTinker 2026-06-15 15:20:57 -05:00
commit 7539603b17
14 changed files with 443 additions and 17 deletions

View file

@ -0,0 +1,166 @@
# #104 — Draft-aware, device-free preview via player reuse — BUILD PLAN
Status: **for review, not yet built.** Branch `investigate/preview-breakage-104` off `main`. No prod.
Backed by the investigation: the player already renders every item type correctly (YT.Player handshake, widget iframes); the failures were preview-specific. Reusing the player's renderer inherits its correctness for everything **except** webpage widgets pointing at frame-denying sites — which no in-browser surface can embed, and which (proven empirically + spec) **cannot be auto-detected client-side**. Hence: reuse the renderer, add an always-visible honest note for webpage widgets.
## Goals / non-goals
**Goal:** one preview surface that renders a *draft* playlist exactly as a device would, in a same-origin iframe in the dashboard — fixing "not all items load" (one renderer, full type union) and YouTube correctness, and telling the truth about un-embeddable webpage widgets.
**Non-goals:** server-side page proxying; screenshots; auto-detecting XFO refusal (proven impossible client-side); changing how devices render. The **renderer is untouched** — confirmed: `handlePlaylistUpdate(data)` is pure on `data`, every `socket.emit` is `socket?.connected`-guarded.
---
## Work item 1 — Server: `assemblePayload` refactor + preview endpoint
### 1a. Factor the payload tail out of `buildPlaylistPayload` (anti-drift)
`server/ws/deviceSocket.js:77-173`. Today `buildPlaylistPayload(deviceId)` does two things: (a) resolve device-bound fields, then (b) the **zone-reset + shape** tail (`:162-172` — strips `zone_id` when `zones.length < 2`, builds `{assignments, layout, orientation, wall_config, timezone}`).
Extract (b) into a pure function so device and preview can't drift:
```js
// deviceSocket.js
function assemblePayload({ assignments, layout, orientation, wall_config, timezone }) {
const zoneCount = layout?.zones?.length || 0;
let a = Array.isArray(assignments) ? assignments : [];
if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x));
return { assignments: a, layout: layout || null, orientation: orientation || 'landscape',
wall_config: wall_config || null, timezone: timezone || null };
}
module.exports.assemblePayload = assemblePayload;
```
`buildPlaylistPayload` keeps its device-field resolution and returns `assemblePayload({...})` at the end. **No behavior change for devices** — pure refactor; the existing device snapshot tests must stay green (that's the regression guard).
### 1b. `GET /api/playlists/:id/preview-payload`
`server/routes/playlists.js`. JWT-gated + workspace-scoped via the **existing** `requirePlaylistRead` (`:56`, `loadPlaylistAccess(req,res,false)`):
```js
router.get('/:id/preview-payload', requirePlaylistRead, (req, res) => {
const { assemblePayload } = require('../ws/deviceSocket');
const assignments = buildSnapshotItems(req.params.id); // DRAFT-aware: live items, confirmed clean
const layout = derivePreviewLayout(assignments); // see Work item 2
res.json(assemblePayload({
assignments, layout,
orientation: req.query.orientation || 'landscape', // optional toggle (validated)
wall_config: null, // preview is single-screen
timezone: null, // browser clock
}));
});
```
- `buildSnapshotItems` (`:67-89`) reads live `playlist_items`, never `published_snapshot` — works on a draft with zero special-casing. Confirmed: `published_snapshot === JSON.stringify(buildSnapshotItems(id))`, so preview ⇄ device shapes are identical by construction.
- Validate `orientation` against the renderer's set `{landscape, portrait, landscape-flipped, portrait-flipped}` (`index.html:1056`); default `landscape` on anything else.
---
## Work item 2 — Layout source (the one real design choice)
`playlists` has **no** `layout_id` (confirmed — layout is device-bound only). Derive the preview layout from the playlist's own zone-bound items via the FK chain `playlist_items.zone_id → layout_zones.id → layout_zones.layout_id`:
```js
function derivePreviewLayout(assignments) {
const zoneIds = [...new Set(assignments.map(a => a.zone_id).filter(Boolean))];
if (zoneIds.length === 0) return null; // 0 zoned -> fullscreen
const rows = db.prepare(
`SELECT DISTINCT layout_id FROM layout_zones WHERE id IN (${zoneIds.map(()=>'?').join(',')})`
).all(...zoneIds);
if (rows.length === 0) return null; // dangling zones -> fullscreen
// 1 -> that layout; >1 (rare/legacy) -> pick the one covering the MOST items, never crash
let layoutId = rows[0].layout_id;
let ambiguous = false;
if (rows.length > 1) {
ambiguous = true;
const z2l = new Map();
for (const r of db.prepare(`SELECT id, layout_id FROM layout_zones WHERE id IN (${zoneIds.map(()=>'?').join(',')})`).all(...zoneIds)) z2l.set(r.id, r.layout_id);
const tally = {};
for (const a of assignments) { const l = z2l.get(a.zone_id); if (l) tally[l] = (tally[l]||0)+1; }
layoutId = Object.entries(tally).sort((x,y)=>y[1]-x[1])[0][0];
}
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(layoutId);
if (!layout) return null;
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layoutId);
if (ambiguous) layout._preview_ambiguous = true; // dashboard shows "previewing layout <name>"
return layout;
}
```
- **0 zoned → fullscreen**, **1 → use it**, **>1 distinct → dominant + `_preview_ambiguous` flag** so the dashboard can caption "Previewing layout: <name>". Must not crash — covered.
- `orientation` → default landscape + optional toggle (Work item 5). `wall_config` → null. `timezone` → null (dayparting previews in the previewer's local clock; document this, it's expected not a bug).
---
## Work item 3 — Player: preview bootstrap branch (renderer untouched)
`server/player/index.html`. Today boot is: DOMContentLoaded (`:278`) → if `serverUrl && deviceId && paired``connect()` (`:523/557`) → socket `register()` (`:653`). Add a branch that runs **before** that when `?preview=1` is present, and never touches pairing/socket:
```js
// near the DOMContentLoaded entry (~:278), before the paired/connect path
const qs = new URLSearchParams(location.search);
if (qs.get('preview') === '1' && qs.get('playlist')) {
return bootPreview(qs.get('playlist'), qs.get('orientation'));
}
```
```js
async function bootPreview(playlistId, orientation) {
config.serverUrl = window.location.origin; // same-origin -> /uploads, /api/widgets resolve
const token = localStorage.getItem('token'); // same-origin: shares dashboard's Bearer token (api.js:4)
const url = `/api/playlists/${playlistId}/preview-payload` + (orientation ? `?orientation=${encodeURIComponent(orientation)}` : '');
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
if (!res.ok) { showPreviewError(res.status); return; }
const payload = await res.json();
PREVIEW_MODE = true; // gate: enables webpage note, disables proof-of-play/socket paths (already guarded)
handlePlaylistUpdate(payload); // UNTOUCHED renderer entry
}
```
- `socket` stays `undefined`; all `socket?.connected` emits (heartbeat `:867`, proof-of-play `:1193/1212`, wall-sync) no-op. The wall branch (`:1078`) needs `wallConfig` truthy → never entered (`wall_config:null`). Safe.
- `PREVIEW_MODE` is the single new global; used only by Work item 4 (the note) and to skip device-only UI (pairing screen, audio-unlock gestures optional).
- Auth note: `localStorage` is per-origin, and `/player` is same-origin as the dashboard, so the token is readable. (If we later want the player on a separate origin, switch to a short-lived scoped token in the iframe URL — out of scope now.)
---
## Work item 4 — Webpage-widget honest note (always visible, no detection)
Where the player renders a widget item (`index.html:1514-1523` fullscreen, `:1598-1625` zones), when `PREVIEW_MODE && item.widget_type === 'webpage'`, wrap the widget iframe with a persistent caption overlay (the assignment already carries `widget_type` from `buildSnapshotItems`):
```
<div class="preview-webpage-note">{{ t('preview.webpage_blocked_note') }}</div>
```
Caption text: **"If this area is blank, the site blocks embedding in a browser — it will still display on the device screen."** Styled as a small, non-blocking footer band over the iframe (never covers the content). Shown **only** in `PREVIEW_MODE` → never appears on real devices/Android.
**i18n:** add `preview.webpage_blocked_note` to all locale files — `frontend/js/i18n/{en,es,fr,de,it,pt}.js` (en + 5 translations).
> **Reviewer decision:** the *original* ImpactMaster "refused the connection" report was the **widget preview modal** (`frontend/js/views/widgets.js:108-132`), a separate surface from this new player preview. Recommend applying the same caption there too (one-line add) so the actually-reported symptom is closed. Flagging rather than assuming.
---
## Work item 5 — Dashboard preview surface + orientation toggle
- Add the preview trigger (button) on the playlist detail view (`frontend/js/views/playlists.js`) that opens a same-origin iframe `<iframe src="/player?preview=1&playlist=<id>">`. Same-origin → dashboard CSP `frame-src 'self'` (`server/server.js:65-86`) already permits it; no CSP change.
- **Orientation toggle:** a landscape/portrait control in the preview chrome that reloads the iframe with `&orientation=portrait`. Cheap — the server passes it through and the renderer already applies rotation/viewport (`index.html:1055-1062`).
- If `layout._preview_ambiguous`, show "Previewing layout: <name>" caption (from Work item 2).
**Second pass (scope separately if it balloons):** skip / fast-forward controls. The real player has no such controls (it's device-driven), so these are net-new player UI (prev/next item, pause). Keep out of the core build; ship the faithful preview first.
---
## Auth & security
- Endpoint: `requirePlaylistRead` → workspace-scoped, JWT-gated (same as `GET /api/playlists/:id`). A user can only preview playlists they can read.
- No new external surface, no proxy, no SSRF. Webpage widgets still render via the existing `/api/widgets/:id/render` (X-Frame-Options already dropped there for the player, `widgets.js:191`); browser still enforces the *inner* site's XFO — which is exactly why the note exists.
- Content (`/uploads`) loads via bare `<img/video src>` same-origin (already how the device player loads it) — confirm `/uploads/content` is publicly readable (it is for the device player; low risk).
## Test plan
1. **Refactor guard:** existing device snapshot/payload tests stay green (proves `assemblePayload` didn't change device behavior).
2. **Endpoint:** draft playlist with (a) no zones, (b) one-zone-layout, (c) multi-zone layout, (d) items spanning 2 layouts → assert layout derivation + payload shape == device payload for the same items after publish.
3. **Player preview:** image, video, `video/youtube`, each widget type incl. webpage → render in the iframe; YouTube plays via YT.Player; webpage widget shows the note.
4. **Auth:** preview-payload returns 401/403 without a valid token / for a playlist outside the workspace.
5. **No-socket safety:** confirm no console errors from socket emits in preview (all guarded).
## Files touched (core)
- `server/ws/deviceSocket.js` — extract `assemblePayload` (pure refactor).
- `server/routes/playlists.js``GET /:id/preview-payload` + `derivePreviewLayout`.
- `server/player/index.html``bootPreview` branch + `PREVIEW_MODE` + webpage note wrapper.
- `frontend/js/views/playlists.js` — preview button + iframe + orientation toggle.
- `frontend/js/i18n/{en,es,fr,de,it,pt}.js``preview.webpage_blocked_note` (+ "previewing layout" string).
- (reviewer-decision) `frontend/js/views/widgets.js` — same note on the existing widget preview modal.
## Risk register
- **Layout >1-distinct ambiguity** — handled (dominant + caption), can't crash. Lowest-likelihood path.
- **`/uploads` auth** — verify public-readable (expected). If token-gated, content needs a same-origin cookie or signed URL (would also affect device player — so it isn't).
- **Renderer drift** — mitigated by `assemblePayload` being the single shared shape source + the refactor regression test.
- **i18n completeness** — 6 locale files; missing a key falls back to en (acceptable) but add all 6.

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Zurück zu Bildschirmen', 'device.back': 'Zurück zu Bildschirmen',
'device.owner_label': 'Besitzer: {owner}', 'device.owner_label': 'Besitzer: {owner}',
'device.rename': 'Umbenennen', 'device.rename': 'Umbenennen',
'device.preview_btn': 'Vorschau',
'device.screenshot_btn': 'Screenshot', 'device.screenshot_btn': 'Screenshot',
'device.remove': 'Entfernen', 'device.remove': 'Entfernen',
'device.click_to_confirm': 'Erneut klicken zum Bestätigen', 'device.click_to_confirm': 'Erneut klicken zum Bestätigen',
@ -493,6 +494,7 @@ export default {
'widget.configure': 'Widget konfigurieren', 'widget.configure': 'Widget konfigurieren',
'widget.preview': 'Vorschau', 'widget.preview': 'Vorschau',
'widget.preview_title': 'Vorschau', 'widget.preview_title': 'Vorschau',
'widget.webpage_blocked_note': 'Wenn dieser Bereich leer ist, blockiert die Website die Einbettung im Browser auf dem Gerätebildschirm wird sie trotzdem angezeigt.',
'widget.close': 'Schließen', 'widget.close': 'Schließen',
'widget.edit_x': '{type} bearbeiten', 'widget.edit_x': '{type} bearbeiten',
'widget.new_x': 'Neues {type}', 'widget.new_x': 'Neues {type}',

View file

@ -214,6 +214,7 @@ export default {
'device.back': 'Back to Displays', 'device.back': 'Back to Displays',
'device.owner_label': 'Owner: {owner}', 'device.owner_label': 'Owner: {owner}',
'device.rename': 'Rename', 'device.rename': 'Rename',
'device.preview_btn': 'Preview',
'device.screenshot_btn': 'Screenshot', 'device.screenshot_btn': 'Screenshot',
'device.remove': 'Remove', 'device.remove': 'Remove',
'device.click_to_confirm': 'Click again to confirm', 'device.click_to_confirm': 'Click again to confirm',
@ -533,6 +534,7 @@ export default {
'widget.configure': 'Configure Widget', 'widget.configure': 'Configure Widget',
'widget.preview': 'Preview', 'widget.preview': 'Preview',
'widget.preview_title': 'Preview', 'widget.preview_title': 'Preview',
'widget.webpage_blocked_note': 'If this area is blank, the site blocks embedding in a browser — it will still display on the device screen.',
'widget.close': 'Close', 'widget.close': 'Close',
'widget.edit_x': 'Edit {type}', 'widget.edit_x': 'Edit {type}',
'widget.new_x': 'New {type}', 'widget.new_x': 'New {type}',

View file

@ -198,6 +198,7 @@ export default {
'device.back': 'Volver a Pantallas', 'device.back': 'Volver a Pantallas',
'device.owner_label': 'Propietario: {owner}', 'device.owner_label': 'Propietario: {owner}',
'device.rename': 'Renombrar', 'device.rename': 'Renombrar',
'device.preview_btn': 'Vista previa',
'device.screenshot_btn': 'Captura', 'device.screenshot_btn': 'Captura',
'device.remove': 'Eliminar', 'device.remove': 'Eliminar',
'device.click_to_confirm': 'Haz clic de nuevo para confirmar', 'device.click_to_confirm': 'Haz clic de nuevo para confirmar',
@ -492,6 +493,7 @@ export default {
'widget.configure': 'Configurar widget', 'widget.configure': 'Configurar widget',
'widget.preview': 'Previsualizar', 'widget.preview': 'Previsualizar',
'widget.preview_title': 'Previsualización', 'widget.preview_title': 'Previsualización',
'widget.webpage_blocked_note': 'Si esta área está en blanco, el sitio bloquea la inserción en un navegador; aún se mostrará en la pantalla del dispositivo.',
'widget.close': 'Cerrar', 'widget.close': 'Cerrar',
'widget.edit_x': 'Editar {type}', 'widget.edit_x': 'Editar {type}',
'widget.new_x': 'Nuevo {type}', 'widget.new_x': 'Nuevo {type}',

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Retour aux écrans', 'device.back': 'Retour aux écrans',
'device.owner_label': 'Propriétaire : {owner}', 'device.owner_label': 'Propriétaire : {owner}',
'device.rename': 'Renommer', 'device.rename': 'Renommer',
'device.preview_btn': 'Aperçu',
'device.screenshot_btn': 'Capture', 'device.screenshot_btn': 'Capture',
'device.remove': 'Retirer', 'device.remove': 'Retirer',
'device.click_to_confirm': 'Cliquez à nouveau pour confirmer', 'device.click_to_confirm': 'Cliquez à nouveau pour confirmer',
@ -493,6 +494,7 @@ export default {
'widget.configure': 'Configurer le widget', 'widget.configure': 'Configurer le widget',
'widget.preview': 'Aperçu', 'widget.preview': 'Aperçu',
'widget.preview_title': 'Aperçu', 'widget.preview_title': 'Aperçu',
'widget.webpage_blocked_note': 'Si cette zone est vide, le site bloque lintégration dans un navigateur ; il saffichera quand même sur lécran de lappareil.',
'widget.close': 'Fermer', 'widget.close': 'Fermer',
'widget.edit_x': 'Modifier {type}', 'widget.edit_x': 'Modifier {type}',
'widget.new_x': 'Nouveau {type}', 'widget.new_x': 'Nouveau {type}',

View file

@ -210,6 +210,7 @@ export default {
'device.back': 'Torna a Schermi', 'device.back': 'Torna a Schermi',
'device.owner_label': 'Proprietario: {owner}', 'device.owner_label': 'Proprietario: {owner}',
'device.rename': 'Rinomina', 'device.rename': 'Rinomina',
'device.preview_btn': 'Anteprima',
'device.screenshot_btn': 'Screenshot', 'device.screenshot_btn': 'Screenshot',
'device.remove': 'Rimuovi', 'device.remove': 'Rimuovi',
'device.click_to_confirm': 'Clicca di nuovo per confermare', 'device.click_to_confirm': 'Clicca di nuovo per confermare',
@ -479,6 +480,7 @@ export default {
'widget.configure': 'Configura Widget', 'widget.configure': 'Configura Widget',
'widget.preview': 'Anteprima', 'widget.preview': 'Anteprima',
'widget.preview_title': 'Anteprima', 'widget.preview_title': 'Anteprima',
'widget.webpage_blocked_note': 'Se questarea è vuota, il sito blocca lincorporamento in un browser; verrà comunque mostrato sullo schermo del dispositivo.',
'widget.close': 'Chiudi', 'widget.close': 'Chiudi',
'widget.edit_x': 'Modifica {type}', 'widget.edit_x': 'Modifica {type}',
'widget.new_x': 'Nuovo {type}', 'widget.new_x': 'Nuovo {type}',

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Voltar para Telas', 'device.back': 'Voltar para Telas',
'device.owner_label': 'Proprietário: {owner}', 'device.owner_label': 'Proprietário: {owner}',
'device.rename': 'Renomear', 'device.rename': 'Renomear',
'device.preview_btn': 'Pré-visualização',
'device.screenshot_btn': 'Captura', 'device.screenshot_btn': 'Captura',
'device.remove': 'Remover', 'device.remove': 'Remover',
'device.click_to_confirm': 'Clique novamente para confirmar', 'device.click_to_confirm': 'Clique novamente para confirmar',
@ -493,6 +494,7 @@ export default {
'widget.configure': 'Configurar widget', 'widget.configure': 'Configurar widget',
'widget.preview': 'Pré-visualizar', 'widget.preview': 'Pré-visualizar',
'widget.preview_title': 'Pré-visualização', 'widget.preview_title': 'Pré-visualização',
'widget.webpage_blocked_note': 'Se esta área estiver em branco, o site bloqueia a incorporação em um navegador; ainda assim será exibido na tela do dispositivo.',
'widget.close': 'Fechar', 'widget.close': 'Fechar',
'widget.edit_x': 'Editar {type}', 'widget.edit_x': 'Editar {type}',
'widget.new_x': 'Novo {type}', 'widget.new_x': 'Novo {type}',

View file

@ -153,6 +153,7 @@ async function loadDevice(deviceId, activeTab = null) {
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''} ${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="devicePreviewBtn">${t('device.preview_btn')}</button>
<button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button> <button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
<button class="btn btn-secondary btn-sm" id="screenshotBtn"> <button class="btn btn-secondary btn-sm" id="screenshotBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -581,7 +582,37 @@ function setupTabs() {
}); });
} }
// #104: device preview — reuse the player in device-free preview mode, iframed
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
// playlist in the device's OWN layout/orientation (server payload). wall members
// preview full-frame (server forces wall_config:null in v1).
function showDevicePreview(device) {
const portrait = (device.orientation || '').includes('portrait');
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
<div style="background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border);max-width:95vw;max-height:92vh">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border);gap:12px">
<strong style="color:var(--text-primary)">${t('device.preview_btn')} ${esc(device.name)}</strong>
<button class="btn btn-secondary btn-sm" id="dpvClose">${t('widget.close')}</button>
</div>
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
<iframe style="height:78vh;max-width:92vw;aspect-ratio:${portrait ? '9 / 16' : '16 / 9'};border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('#dpvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc2(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc2); }
});
}
async function setupActions(device) { async function setupActions(device) {
// #104 Preview button
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
// Screenshot button // Screenshot button
document.getElementById('screenshotBtn')?.addEventListener('click', () => { document.getElementById('screenshotBtn')?.addEventListener('click', () => {
requestScreenshot(device.id); requestScreenshot(device.id);

View file

@ -205,6 +205,52 @@ async function renderDetail(container, playlistId) {
} }
} }
// #104: draft preview by REUSING the player. Iframes /player in device-free preview
// mode (same-origin -> dashboard CSP frame-src 'self' allows it). The player fetches
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
// preview is byte-identical to what a device shows. Orientation toggle just reloads
// the iframe with &orientation; the server passes it through.
function showPlaylistPreview(playlist) {
let orientation = 'landscape';
const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9');
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
<div style="background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border);max-width:95vw;max-height:92vh">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border);gap:12px">
<strong style="color:var(--text-primary)">${t('widget.preview')} ${esc(playlist.name)}</strong>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-primary btn-sm" id="pvpLandscape">${t('device.form.orientation.landscape')}</button>
<button class="btn btn-secondary btn-sm" id="pvpPortrait">${t('device.form.orientation.portrait')}</button>
<button class="btn btn-secondary btn-sm" id="pvpClose">${t('widget.close')}</button>
</div>
</div>
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
<iframe id="pvpFrame" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};border:0;background:#000" src="${frameSrc()}"></iframe>
</div>
</div>`;
document.body.appendChild(overlay);
const frame = overlay.querySelector('#pvpFrame');
const btnL = overlay.querySelector('#pvpLandscape');
const btnP = overlay.querySelector('#pvpPortrait');
const setOrientation = (o) => {
orientation = o;
frame.style.aspectRatio = aspect();
frame.src = frameSrc();
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
};
btnL.onclick = () => setOrientation('landscape');
btnP.onclick = () => setOrientation('portrait');
const close = () => overlay.remove();
overlay.querySelector('#pvpClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
}
function renderDetailContent(container, playlist) { function renderDetailContent(container, playlist) {
const isDraft = playlist.status === 'draft'; const isDraft = playlist.status === 'draft';
const hasPublished = !!playlist.published_snapshot; const hasPublished = !!playlist.published_snapshot;
@ -236,6 +282,7 @@ function renderDetailContent(container, playlist) {
</div> </div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="previewPlaylistBtn">${t('widget.preview')}</button>
<button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button> <button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button>
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button> <button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button>
</div> </div>
@ -263,6 +310,9 @@ function renderDetailContent(container, playlist) {
} }
}); });
} }
const previewBtn = document.getElementById('previewPlaylistBtn');
if (previewBtn) previewBtn.addEventListener('click', () => showPlaylistPreview(playlist));
const discardBtn = document.getElementById('discardDraftBtn'); const discardBtn = document.getElementById('discardDraftBtn');
if (discardBtn) { if (discardBtn) {
discardBtn.addEventListener('click', async () => { discardBtn.addEventListener('click', async () => {

View file

@ -105,9 +105,15 @@ function openContentPicker({ multiple = false, title } = {}) {
}); });
} }
function showPreviewModal(html) { function showPreviewModal(html, widgetType) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
// #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be
// embedded in a browser preview — and an XFO refusal is provably indistinguishable
// client-side from a working embed, so we don't guess. Always show the honest note.
const webpageNote = widgetType === 'webpage'
? `<div style="padding:8px 16px;border-top:1px solid var(--border);color:var(--text-secondary);font-size:13px;text-align:center">${t('widget.webpage_blocked_note')}</div>`
: '';
overlay.innerHTML = ` overlay.innerHTML = `
<div style="width:100%;max-width:1400px;height:90vh;background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border)"> <div style="width:100%;max-width:1400px;height:90vh;background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border)"> <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border)">
@ -115,6 +121,7 @@ function showPreviewModal(html) {
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button> <button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
</div> </div>
<iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe> <iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe>
${webpageNote}
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
// srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin // srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin
@ -551,7 +558,7 @@ export async function render(container) {
}); });
if (!res.ok) throw new Error(t('widget.toast.preview_failed')); if (!res.ok) throw new Error(t('widget.toast.preview_failed'));
const html = await res.text(); const html = await res.text();
showPreviewModal(html); showPreviewModal(html, type);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };

View file

@ -253,18 +253,19 @@
info_na: 'N/A', info_na: 'N/A',
info_sw: 'Service Worker', info_sw: 'Service Worker',
nothing_scheduled: 'Nothing scheduled right now', nothing_scheduled: 'Nothing scheduled right now',
preview_webpage_blocked: 'If this area is blank, the site blocks embedding in a browser — it will still display on the device screen.',
}, },
es: { es: {
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'No hay nada programado en este momento', web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'No hay nada programado en este momento', preview_webpage_blocked: 'Si esta área está en blanco, el sitio bloquea la inserción en un navegador; aún se mostrará en la pantalla del dispositivo.',
}, },
fr: { fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', preview_webpage_blocked: 'Si cette zone est vide, le site bloque lintégration dans un navigateur ; il saffichera quand même sur lécran de lappareil.',
}, },
de: { de: {
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker', nothing_scheduled: 'Derzeit ist nichts geplant', web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker', nothing_scheduled: 'Derzeit ist nichts geplant', preview_webpage_blocked: 'Wenn dieser Bereich leer ist, blockiert die Website die Einbettung im Browser auf dem Gerätebildschirm wird sie trotzdem angezeigt.',
}, },
pt: { pt: {
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Nada programado no momento', web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Nada programado no momento', preview_webpage_blocked: 'Se esta área estiver em branco, o site bloqueia a incorporação em um navegador; ainda assim será exibido na tela do dispositivo.',
}, },
}; };
const PLAYER_LANG = (() => { const PLAYER_LANG = (() => {
@ -299,6 +300,9 @@
const STORAGE_KEY = 'rd_web_player'; const STORAGE_KEY = 'rd_web_player';
const HEARTBEAT_INTERVAL = 15000; const HEARTBEAT_INTERVAL = 15000;
const PLAYLIST_REFRESH_INTERVAL = 60000; const PLAYLIST_REFRESH_INTERVAL = 60000;
// #104: device-free dashboard preview mode (set by the ?preview=1 boot branch).
// Gates the webpage-widget honest note and keeps the pairing/socket path off.
let PREVIEW_MODE = false;
function getConfig() { function getConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; } try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
@ -514,6 +518,14 @@
connect(url); connect(url);
}; };
// #104: device-free dashboard preview. Render a draft playlist by id with NO
// pairing and NO socket. Gated here, before the normal boot, so the unpaired
// auto-connect timer below can never fire underneath a preview.
const _previewQS = new URLSearchParams(location.search);
if (_previewQS.get('preview') === '1' && (_previewQS.get('playlist') || _previewQS.get('device'))) {
bootPreview(_previewQS);
} else {
// Auto-detect server URL from origin since player is served from the same server // Auto-detect server URL from origin since player is served from the same server
if (!config.serverUrl) { if (!config.serverUrl) {
config.serverUrl = window.location.origin; config.serverUrl = window.location.origin;
@ -593,6 +605,7 @@
} }
}); });
} }
} // #104: end preview-mode gate (else branch wrapping the normal boot)
// ==================== Setup UI ==================== // ==================== Setup UI ====================
const savedUrl = config.serverUrl || window.location.origin; const savedUrl = config.serverUrl || window.location.origin;
@ -628,6 +641,76 @@
document.getElementById('connectBtn').onclick = connectBtnFunc; document.getElementById('connectBtn').onclick = connectBtnFunc;
// ==================== #104 Device-free preview ====================
// #104: device-free dashboard preview. Renders EITHER a draft playlist
// (?playlist=ID — layout DERIVED from the playlist's zones, orientation togglable)
// OR a device exactly as it shows now (?device=ID — layout/orientation from the
// DEVICE row). Both produce the same payload shape and feed the UNMODIFIED renderer.
function bootPreview(qs) {
const playlistId = qs.get('playlist');
const deviceId = qs.get('device');
let url;
if (playlistId) {
const orientation = qs.get('orientation');
const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
url = '/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q;
} else {
// Device preview: the device's own layout/orientation come from the server; no
// orientation override (we show what the device actually shows).
url = '/api/devices/' + encodeURIComponent(deviceId) + '/preview-payload';
}
return renderPreviewFromUrl(url);
}
// Shared: fetch a preview payload (same shape the device socket sends) and hand it
// straight to the UNMODIFIED renderer. No socket, no pairing.
async function renderPreviewFromUrl(url) {
PREVIEW_MODE = true;
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
const setup = document.getElementById('setupScreen');
if (setup) setup.style.display = 'none';
try {
const token = localStorage.getItem('token'); // same-origin: shares the dashboard's Bearer token
const res = await fetch(url, { headers: token ? { Authorization: 'Bearer ' + token } : {} });
if (!res.ok) return showPreviewError(res.status);
const payload = await res.json();
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
// Device payloads never carry this flag (layout is device-bound, unambiguous).
if (payload.layout && payload.layout._preview_ambiguous) {
const b = document.createElement('div');
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.92);color:#000;font:12px sans-serif;padding:4px 10px;text-align:center';
document.body.appendChild(b);
}
handlePlaylistUpdate(payload);
} catch (e) {
console.error('preview fetch failed', e);
showPreviewError(0);
}
}
function showPreviewError(status) {
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
const div = document.createElement('div');
div.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#e5e7eb;background:#111827;font:18px sans-serif;z-index:3000;text-align:center;padding:24px';
div.textContent = msg;
document.body.appendChild(div);
}
// #104: the always-visible honest note for webpage widgets. No auto-detection —
// an XFO-refused frame is provably indistinguishable client-side from a working
// one, so we never guess; we just tell the truth. Preview-only (never on device).
function addWebpageNote(container) {
if (!PREVIEW_MODE || !container) return;
try { if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; } catch (e) {}
const note = document.createElement('div');
note.className = 'preview-webpage-note';
note.textContent = _t('preview_webpage_blocked');
note.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:10;background:rgba(17,24,39,.82);color:#e5e7eb;font:13px/1.4 sans-serif;padding:6px 10px;text-align:center;pointer-events:none';
container.appendChild(note);
}
// ==================== Socket Connection ==================== // ==================== Socket Connection ====================
function connect(serverUrl) { function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; } if (socket) { socket.disconnect(); socket = null; }
@ -1520,6 +1603,7 @@
// state (localStorage / JWT). allow-scripts keeps inline widget code running. // state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts'); iframe.setAttribute('sandbox', 'allow-scripts');
mount.appendChild(iframe); mount.appendChild(iframe);
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000); if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
} }
} }
@ -1602,6 +1686,7 @@
// state (localStorage / JWT). allow-scripts keeps inline widget code running. // state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts'); iframe.setAttribute('sandbox', 'allow-scripts');
div.appendChild(iframe); div.appendChild(iframe);
if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur); if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
} else if (isYoutube) { } else if (isYoutube) {
createYoutubeEmbed(src, a, div); createYoutubeEmbed(src, a, div);

View file

@ -163,6 +163,27 @@ function checkDeviceOwnership(req, res) {
return device; return device;
} }
// #104: device-manager preview payload. Returns the device's CURRENT payload exactly
// as the device renders it — its OWN layout/orientation/wall from the device row and
// its published items — built by the same buildPlaylistPayload the device socket uses.
// Device-bound layout (the correct side of the layout seam); derivePreviewLayout is
// playlist-only and never touches this path. wall_config is forced null in v1: a wall
// FOLLOWER would otherwise freeze waiting for leader wall:sync that a socket-free
// preview can't deliver, so wall members preview full-frame. Device-READ gated
// (mirrors GET /:id — viewers allowed); NOT requirePlaylistRead, NOT the write gate.
router.get('/:id/preview-payload', (req, res) => {
const device = db.prepare('SELECT id, workspace_id FROM devices WHERE id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const payload = buildPlaylistPayload(req.params.id);
payload.wall_config = null; // v1: wall members preview full-frame (no socket-free follower freeze)
res.json(payload);
});
// Update device // Update device
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res); const device = checkDeviceOwnership(req, res);

View file

@ -88,6 +88,35 @@ function buildSnapshotItems(playlistId) {
return items; return items;
} }
// #104: a playlist isn't bound to a device, so it has no intrinsic layout. Derive
// one from the playlist's own zone-bound items via the FK chain
// playlist_items.zone_id -> layout_zones.id -> layout_zones.layout_id. 0 zoned items
// -> fullscreen (null); 1 distinct layout -> use it; >1 (rare/legacy: zones from
// different layouts) -> the layout covering the MOST items, flagged ambiguous so the
// dashboard can caption it. Never throws.
function derivePreviewLayout(assignments) {
const zoneIds = [...new Set((assignments || []).map(a => a && a.zone_id).filter(Boolean))];
if (zoneIds.length === 0) return null;
const ph = zoneIds.map(() => '?').join(',');
const zoneRows = db.prepare(`SELECT id, layout_id FROM layout_zones WHERE id IN (${ph})`).all(...zoneIds);
if (zoneRows.length === 0) return null; // dangling zone_ids -> fullscreen
const layoutIds = [...new Set(zoneRows.map(r => r.layout_id))];
let layoutId = layoutIds[0];
let ambiguous = false;
if (layoutIds.length > 1) {
ambiguous = true;
const z2l = new Map(zoneRows.map(r => [r.id, r.layout_id]));
const tally = {};
for (const a of assignments) { const l = z2l.get(a && a.zone_id); if (l) tally[l] = (tally[l] || 0) + 1; }
layoutId = Object.entries(tally).sort((x, y) => y[1] - x[1])[0][0];
}
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(layoutId);
if (!layout) return null;
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layoutId);
if (ambiguous) layout._preview_ambiguous = true;
return layout;
}
// Map an item's schedule rows into the evaluator's block shape. // Map an item's schedule rows into the evaluator's block shape.
function schedulesForItem(itemId) { function schedulesForItem(itemId) {
return db.prepare( return db.prepare(
@ -188,6 +217,20 @@ router.get('/:id', requirePlaylistRead, (req, res) => {
res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount }); res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount });
}); });
// #104: device-free draft preview payload. Same shape the device player consumes
// (via assemblePayload, so it can't drift), but built from LIVE items (draft-aware,
// not published_snapshot) with a layout derived from the playlist's own zones. JWT-
// gated + workspace-scoped by requirePlaylistRead. The dashboard iframes /player
// with ?preview=1&playlist=:id and renders this with the unmodified player renderer.
const PREVIEW_ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']);
router.get('/:id/preview-payload', requirePlaylistRead, (req, res) => {
const { assemblePayload } = require('../ws/deviceSocket');
const assignments = buildSnapshotItems(req.params.id);
const layout = derivePreviewLayout(assignments);
const orientation = PREVIEW_ORIENTATIONS.has(req.query.orientation) ? req.query.orientation : 'landscape';
res.json(assemblePayload({ assignments, layout, orientation, wall_config: null, timezone: null }));
});
// Update playlist // Update playlist
router.put('/:id', requirePlaylistWrite, (req, res) => { router.put('/:id', requirePlaylistWrite, (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;

View file

@ -154,22 +154,32 @@ function buildPlaylistPayload(deviceId) {
} }
} }
// Zone reset: if the device isn't in a real multi-zone layout (single zone or
// no layout), strip any leftover zone_id from assignments. Otherwise, after
// switching a device from a multi-zone layout back to single/fullscreen, the
// content stays bound to a now-gone left/right zone_id and never plays. With
// zone_id nulled, both players fall back to the default fullscreen renderer.
const zoneCount = layout?.zones?.length || 0;
if (zoneCount < 2 && Array.isArray(assignments)) {
assignments = assignments.map(a => (a && a.zone_id != null ? { ...a, zone_id: null } : a));
}
// #74/#75: the effective IANA timezone the player evaluates schedule blocks in. // #74/#75: the effective IANA timezone the player evaluates schedule blocks in.
// An explicit (non-default) devices.timezone override wins; otherwise the player's // An explicit (non-default) devices.timezone override wins; otherwise the player's
// last OS-reported zone; otherwise null = the player trusts its own OS clock. // last OS-reported zone; otherwise null = the player trusts its own OS clock.
const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null; const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null;
const timezone = tzOverride || device?.reported_timezone || null; const timezone = tzOverride || device?.reported_timezone || null;
return { assignments, layout, orientation: device?.orientation || 'landscape', wall_config, timezone }; // #104: shared shape + zone-reset tail so the device payload and the dashboard
// preview payload (GET /api/playlists/:id/preview-payload) can never drift.
return assemblePayload({ assignments, layout, orientation: device?.orientation || 'landscape', wall_config, timezone });
}
// #104: the canonical player payload shape, shared by the device path
// (buildPlaylistPayload) and the device-free dashboard preview.
// Zone reset: if this isn't a real multi-zone layout (single zone or no layout),
// strip any leftover zone_id so content falls back to the fullscreen renderer
// instead of binding to a now-gone left/right zone and never playing.
function assemblePayload({ assignments, layout, orientation, wall_config, timezone }) {
let a = Array.isArray(assignments) ? assignments : [];
const zoneCount = layout?.zones?.length || 0;
if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x));
return {
assignments: a,
layout: layout || null,
orientation: orientation || 'landscape',
wall_config: wall_config || null,
timezone: timezone || null,
};
} }
// Check if a device should show trial expired screen // Check if a device should show trial expired screen
@ -219,6 +229,7 @@ module.exports = function setupDeviceSocket(io) {
// Expose helpers for use by route handlers // Expose helpers for use by route handlers
module.exports.lastScreenshots = lastScreenshots; module.exports.lastScreenshots = lastScreenshots;
module.exports.buildPlaylistPayload = buildPlaylistPayload; module.exports.buildPlaylistPayload = buildPlaylistPayload;
module.exports.assemblePayload = assemblePayload;
module.exports.generateDeviceToken = generateDeviceToken; module.exports.generateDeviceToken = generateDeviceToken;
const deviceNs = io.of('/device'); const deviceNs = io.of('/device');
const dashboardNs = io.of('/dashboard'); const dashboardNs = io.of('/dashboard');