From 1c748b8d3b5efc4d9108929a5d65212a356ec2c9 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 15 Jun 2026 14:11:05 -0500 Subject: [PATCH] feat(preview): draft-aware device-free playlist preview via player reuse (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/104-draft-preview-build-plan.md | 166 +++++++++++++++++++++++++++ frontend/js/i18n/de.js | 1 + frontend/js/i18n/en.js | 1 + frontend/js/i18n/es.js | 1 + frontend/js/i18n/fr.js | 1 + frontend/js/i18n/it.js | 1 + frontend/js/i18n/pt.js | 1 + frontend/js/views/playlists.js | 50 ++++++++ frontend/js/views/widgets.js | 11 +- server/player/index.html | 75 +++++++++++- server/routes/playlists.js | 43 +++++++ server/ws/deviceSocket.js | 33 ++++-- 12 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 docs/104-draft-preview-build-plan.md diff --git a/docs/104-draft-preview-build-plan.md b/docs/104-draft-preview-build-plan.md new file mode 100644 index 0000000..2987e38 --- /dev/null +++ b/docs/104-draft-preview-build-plan.md @@ -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 " + return layout; +} +``` +- **0 zoned → fullscreen**, **1 → use it**, **>1 distinct → dominant + `_preview_ambiguous` flag** so the dashboard can caption "Previewing layout: ". 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`): +``` +
{{ t('preview.webpage_blocked_note') }}
+``` +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 ` + + `; + 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) { const isDraft = playlist.status === 'draft'; const hasPublished = !!playlist.published_snapshot; @@ -236,6 +282,7 @@ function renderDetailContent(container, playlist) {
+
@@ -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'); if (discardBtn) { discardBtn.addEventListener('click', async () => { diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index d2e9526..60dfa49 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -105,9 +105,15 @@ function openContentPicker({ multiple = false, title } = {}) { }); } -function showPreviewModal(html) { +function showPreviewModal(html, widgetType) { 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'; + // #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' + ? `
${t('widget.webpage_blocked_note')}
` + : ''; overlay.innerHTML = `
@@ -115,6 +121,7 @@ function showPreviewModal(html) {
+ ${webpageNote}
`; document.body.appendChild(overlay); // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin @@ -551,7 +558,7 @@ export async function render(container) { }); if (!res.ok) throw new Error(t('widget.toast.preview_failed')); const html = await res.text(); - showPreviewModal(html); + showPreviewModal(html, type); } catch (err) { showToast(err.message, 'error'); } }; diff --git a/server/player/index.html b/server/player/index.html index c745a4d..6fa9610 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -253,18 +253,19 @@ info_na: 'N/A', info_sw: 'Service Worker', 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: { - 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: { - web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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 d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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 l’intégration dans un navigateur ; il s’affichera quand même sur l’écran de l’appareil.', }, 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: { - 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 = (() => { @@ -299,6 +300,9 @@ const STORAGE_KEY = 'rd_web_player'; const HEARTBEAT_INTERVAL = 15000; 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() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; } @@ -514,6 +518,14 @@ 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 if (!config.serverUrl) { config.serverUrl = window.location.origin; @@ -593,6 +605,7 @@ } }); } + } // #104: end preview-mode gate (else branch wrapping the normal boot) // ==================== Setup UI ==================== const savedUrl = config.serverUrl || window.location.origin; @@ -628,6 +641,58 @@ 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 ==================== function connect(serverUrl) { if (socket) { socket.disconnect(); socket = null; } @@ -1520,6 +1585,7 @@ // state (localStorage / JWT). allow-scripts keeps inline widget code running. iframe.setAttribute('sandbox', 'allow-scripts'); mount.appendChild(iframe); + if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104 if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000); } } @@ -1602,6 +1668,7 @@ // state (localStorage / JWT). allow-scripts keeps inline widget code running. iframe.setAttribute('sandbox', 'allow-scripts'); div.appendChild(iframe); + if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104 if (multi) zoneTimers[zone.id] = setTimeout(advance, dur); } else if (isYoutube) { createYoutubeEmbed(src, a, div); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index e1bdc69..ee97aed 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -88,6 +88,35 @@ function buildSnapshotItems(playlistId) { 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. function schedulesForItem(itemId) { 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 }); }); +// #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 router.put('/:id', requirePlaylistWrite, (req, res) => { const { name, description } = req.body; diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 2b03cd7..a9ae681 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -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. // 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. const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.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 @@ -219,6 +229,7 @@ module.exports = function setupDeviceSocket(io) { // Expose helpers for use by route handlers module.exports.lastScreenshots = lastScreenshots; module.exports.buildPlaylistPayload = buildPlaylistPayload; + module.exports.assemblePayload = assemblePayload; module.exports.generateDeviceToken = generateDeviceToken; const deviceNs = io.of('/device'); const dashboardNs = io.of('/dashboard');