feat(preview): device-manager preview — second surface for #104 (combined)

Completes #104's two surfaces by reusing the now-generalized player preview
for devices, seam-safe (device-bound layout, NOT playlist-derived).

Server:
- GET /api/devices/:id/preview-payload returns buildPlaylistPayload(deviceId)
  — the device's OWN layout/orientation (device row) + its published items —
  with wall_config forced null (v1: wall members preview full-frame; a
  socket-free follower would otherwise freeze waiting for leader wall:sync).
  Device-READ gate (mirrors GET /:id, viewers allowed); NOT requirePlaylistRead.

Player (generalized, shared seam):
- Boot dispatch now accepts ?preview=1 with EITHER playlist=ID OR device=ID.
- bootPreview(qs) builds the right URL; shared body factored into
  renderPreviewFromUrl(url) used by both. Renderer still UNTOUCHED.
- derivePreviewLayout stays PLAYLIST-only; never touches the device path.

Dashboard:
- Device manager gets a Preview button -> /player?preview=1&device=ID
  (modal iframe, aspect from device orientation). Playlist-view button as-is.
- i18n x6 (device.preview_btn).

Validated (not just tests): 149 server tests green (generalization didn't
break the playlist path); device preview renders socket-free in headless
Chrome; layout proven device-bound on real data (device playlist has 0 zoned
items -> playlist-derivation would give NULL, but payload returns the device
row's "Vertical Full HD"); wall-member device previews full-frame (inWallMode
false) without freezing; auth gate outsider->403, no-token->401; playlist
path still renders the webpage note post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-15 14:57:19 -05:00
parent 1c748b8d3b
commit cbabbeb78c
9 changed files with 86 additions and 10 deletions

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Zurück zu Bildschirmen',
'device.owner_label': 'Besitzer: {owner}',
'device.rename': 'Umbenennen',
'device.preview_btn': 'Vorschau',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Entfernen',
'device.click_to_confirm': 'Erneut klicken zum Bestätigen',

View file

@ -214,6 +214,7 @@ export default {
'device.back': 'Back to Displays',
'device.owner_label': 'Owner: {owner}',
'device.rename': 'Rename',
'device.preview_btn': 'Preview',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Remove',
'device.click_to_confirm': 'Click again to confirm',

View file

@ -198,6 +198,7 @@ export default {
'device.back': 'Volver a Pantallas',
'device.owner_label': 'Propietario: {owner}',
'device.rename': 'Renombrar',
'device.preview_btn': 'Vista previa',
'device.screenshot_btn': 'Captura',
'device.remove': 'Eliminar',
'device.click_to_confirm': 'Haz clic de nuevo para confirmar',

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Retour aux écrans',
'device.owner_label': 'Propriétaire : {owner}',
'device.rename': 'Renommer',
'device.preview_btn': 'Aperçu',
'device.screenshot_btn': 'Capture',
'device.remove': 'Retirer',
'device.click_to_confirm': 'Cliquez à nouveau pour confirmer',

View file

@ -210,6 +210,7 @@ export default {
'device.back': 'Torna a Schermi',
'device.owner_label': 'Proprietario: {owner}',
'device.rename': 'Rinomina',
'device.preview_btn': 'Anteprima',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Rimuovi',
'device.click_to_confirm': 'Clicca di nuovo per confermare',

View file

@ -199,6 +199,7 @@ export default {
'device.back': 'Voltar para Telas',
'device.owner_label': 'Proprietário: {owner}',
'device.rename': 'Renomear',
'device.preview_btn': 'Pré-visualização',
'device.screenshot_btn': 'Captura',
'device.remove': 'Remover',
'device.click_to_confirm': 'Clique novamente para confirmar',

View file

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

View file

@ -522,8 +522,8 @@
// 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'));
if (_previewQS.get('preview') === '1' && (_previewQS.get('playlist') || _previewQS.get('device'))) {
bootPreview(_previewQS);
} else {
// Auto-detect server URL from origin since player is served from the same server
@ -642,22 +642,40 @@
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) {
// #104: device-free dashboard preview. Renders EITHER a draft playlist
// (?playlist=ID — layout DERIVED from the playlist's zones, orientation togglable)
// OR a device exactly as it shows now (?device=ID — layout/orientation from the
// DEVICE row). Both produce the same payload shape and feed the UNMODIFIED renderer.
function bootPreview(qs) {
const playlistId = qs.get('playlist');
const deviceId = qs.get('device');
let url;
if (playlistId) {
const orientation = qs.get('orientation');
const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
url = '/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q;
} else {
// Device preview: the device's own layout/orientation come from the server; no
// orientation override (we show what the device actually shows).
url = '/api/devices/' + encodeURIComponent(deviceId) + '/preview-payload';
}
return renderPreviewFromUrl(url);
}
// Shared: fetch a preview payload (same shape the device socket sends) and hand it
// straight to the UNMODIFIED renderer. No socket, no pairing.
async function renderPreviewFromUrl(url) {
PREVIEW_MODE = true;
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
const setup = document.getElementById('setupScreen');
if (setup) setup.style.display = 'none';
try {
const token = localStorage.getItem('token'); // same-origin: shares the dashboard's Bearer token
const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
const res = await fetch('/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q, {
headers: token ? { Authorization: 'Bearer ' + token } : {},
});
const res = await fetch(url, { 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.
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
// Device payloads never carry this flag (layout is device-bound, unambiguous).
if (payload.layout && payload.layout._preview_ambiguous) {
const b = document.createElement('div');
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');

View file

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