mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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:
parent
d64244b5ac
commit
1c748b8d3b
166
docs/104-draft-preview-build-plan.md
Normal file
166
docs/104-draft-preview-build-plan.md
Normal 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.
|
||||||
|
|
@ -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}',
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
|
|
|
||||||
|
|
@ -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 l’intégration dans un navigateur ; il s’affichera quand même sur l’écran de l’appareil.',
|
||||||
'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}',
|
||||||
|
|
|
||||||
|
|
@ -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 quest’area è vuota, il sito blocca l’incorporamento 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}',
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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: {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue