feat(preview): draft-aware device-free playlist preview via player reuse (#104)

Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).

Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
  buildPlaylistPayload so the device path and preview can't drift. Pure refactor
  (all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
  Draft-aware via buildSnapshotItems (live items, not published_snapshot);
  derivePreviewLayout() resolves layout from the playlist's own zone-bound items
  (0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
  crashes). orientation validated/passthrough; wall_config/timezone null.

Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
  token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
  so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
  refusal is provably indistinguishable client-side from a working embed).

Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
  bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.

Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-15 14:11:05 -05:00
parent d64244b5ac
commit 1c748b8d3b
12 changed files with 367 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

@ -493,6 +493,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

@ -533,6 +533,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

@ -492,6 +492,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

@ -493,6 +493,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

@ -479,6 +479,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

@ -493,6 +493,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

@ -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')) {
bootPreview(_previewQS.get('playlist'), _previewQS.get('orientation'));
} 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,58 @@
document.getElementById('connectBtn').onclick = connectBtnFunc; document.getElementById('connectBtn').onclick = connectBtnFunc;
// ==================== #104 Device-free preview ====================
// Fetch a draft playlist's preview payload (same shape the device socket sends)
// and hand it straight to the UNMODIFIED renderer. No socket, no pairing.
async function bootPreview(playlistId, orientation) {
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 q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
const res = await fetch('/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q, {
headers: token ? { Authorization: 'Bearer ' + token } : {},
});
if (!res.ok) return showPreviewError(res.status);
const payload = await res.json();
// #104: items span >1 layout (rare) — server picked the dominant one; say so.
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 +1585,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 +1668,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

@ -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');