import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc, livenessBadge } from '../utils.js'; import { t } from '../i18n.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts, }).then(r => r.json()); // Default dimensions for the canvas coordinate space (pixels). Screens added // fresh start at 320x180 (16:9). The editor canvas itself renders at this // natural scale so canvas-pixels == display-pixels. const DEFAULT_SCREEN_W = 320; const DEFAULT_SCREEN_H = 180; const CANVAS_MIN_W = 1200; const CANVAS_MIN_H = 700; const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units // #236: how far a panel's own image has to be turned to come out upright on the wall — i.e. how // far the panel itself is hung the other way. Degrees CLOCKWISE, matching the per-device // orientation setting; the render rule lives in server/lib/wall-geometry.js. // // Before this existed the canvas was secretly FRAMEBUFFER space, so a customer with two portrait // panels side by side had to stack them vertically here and pre-rotate every video. The canvas is // now what it always looked like: the wall as the audience sees it. const WALL_ROTATIONS = [0, 90, 180, 270]; const ROTATION_LABELS = { 0: 'Normal (0°)', 90: 'Turned left (90°)', 180: 'Upside down (180°)', 270: 'Turned right (270°)' }; // A panel already configured portrait is already hung sideways; carry that across so the operator // doesn't have to say the same thing twice (and so the tile lands the right shape first time). const ORIENTATION_TO_ROTATION = { 'landscape': 0, 'portrait': 90, 'landscape-flipped': 180, 'portrait-flipped': 270 }; // Mirrors rotatedFootprint() in server/lib/wall-geometry.js — kept tiny and local because the // dashboard has no import path to the server lib. function footprintFor(renderW, renderH, rotation) { return (rotation === 90 || rotation === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH }; } export async function render(container) { const hash = window.location.hash; if (hash.startsWith('#/wall/')) { const id = hash.split('#/wall/')[1]; return renderWallEditor(container, id); } return renderList(container); } async function renderList(container) { container.innerHTML = `
`; document.getElementById('newWallBtn').onclick = async () => { const name = prompt(t('wall.prompt_name')); if (!name) return; const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) }); window.location.hash = `#/wall/${wall.id}`; }; try { const walls = await API('/walls'); const grid = document.getElementById('wallGrid'); if (!walls.length) { grid.innerHTML = `

${t('wall.empty_title')}

${t('wall.empty_desc')}

`; return; } grid.innerHTML = walls.map(w => `
${Array.from({ length: w.grid_cols * w.grid_rows }, (_, i) => { const row = Math.floor(i / w.grid_cols); const col = i % w.grid_cols; const dev = w.devices?.find(d => d.grid_col === col && d.grid_row === row); return `
${dev?.device_name?.slice(0, 6) || ''}
`; }).join('')}
${esc(w.name)}
${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}
`).join(''); } catch (err) { showToast(err.message, 'error'); } } // ============================================================ // Free-form canvas wall editor // ============================================================ async function renderWallEditor(container, wallId) { let wall, devices, playlists; try { [wall, devices, playlists] = await Promise.all([ API(`/walls/${wallId}`), api.getDevices(), api.getPlaylists(), ]); } catch { container.innerHTML = `

${t('wall.not_found')}

`; return; } // Local state — server-roundtripped on Save. Backfill from grid math when // canvas_* columns aren't populated (fresh walls or pre-canvas walls). const baseW = DEFAULT_SCREEN_W; const baseH = DEFAULT_SCREEN_H; const bezelH = wall.bezel_h_mm || 0; const bezelV = wall.bezel_v_mm || 0; const deviceById = (id) => devices.find(d => d.id === id); // #14: a freshly-added screen's canvas size defaults to the device's RENDER resolution // (what it actually draws) instead of a fixed 320x180 — so a wall of 1080p panels looks // 1080p and a rotated panel that renders 1332x800 lands at 1332x800. Falls back to the // 320x180 default when the device hasn't reported a render size yet. const renderSizeFor = (id) => { const d = deviceById(id); if (d && d.render_width > 0 && d.render_height > 0) return { w: d.render_width, h: d.render_height }; return { w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H }; }; // #236: the tile is the panel's footprint ON THE WALL, so a sideways-hung panel is a tall tile. const footprintOnWall = (id, rotation) => { const r = renderSizeFor(id); return footprintFor(r.w, r.h, rotation); }; // A device already set to portrait is already hung sideways — start it there rather than making // the operator discover the rotation control after the wall comes out wrong. const defaultRotationFor = (id) => ORIENTATION_TO_ROTATION[deviceById(id)?.orientation] || 0; // When the panel's physical resolution differs from what it renders (rotated mount: // the box reports 800x1332 but draws 1332x800), the tile size can look "wrong". Return a // short note making explicit that the tile is sized to the RENDER surface, not the panel. const renderNoteFor = (id) => { const d = deviceById(id); if (!d || !d.render_width || !d.render_height) return ''; const sw = d.screen_width, sh = d.screen_height; if (sw && sh && (sw !== d.render_width || sh !== d.render_height)) { return t('wall.render_note', { render: `${d.render_width}×${d.render_height}`, panel: `${sw}×${sh}` }); } return ''; }; let screens = (wall.devices || []).map(d => ({ device_id: d.device_id, device_name: d.device_name, device_status: d.device_status, grid_col: d.grid_col, grid_row: d.grid_row, rotation: d.rotation || 0, x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)), y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)), // Backfill sizes as the panel's footprint at its saved rotation. Every wall in the field is // rotation 0, where this is exactly the old expression — so nothing existing moves. w: d.canvas_width ?? footprintOnWall(d.device_id, d.rotation || 0).w, h: d.canvas_height ?? footprintOnWall(d.device_id, d.rotation || 0).h, })); // Default player covers the bounding box of all screens; if there are no // screens yet, player stays at 0,0 with default screen size. let player; if (wall.player_x !== null && wall.player_x !== undefined) { player = { x: wall.player_x, y: wall.player_y, w: wall.player_width, h: wall.player_height }; } else if (screens.length > 0) { const b = boundsOf(screens); player = { x: b.x, y: b.y, w: b.w, h: b.h }; } else { player = { x: 0, y: 0, w: baseW, h: baseH }; } let dirty = false; function markDirty() { dirty = true; const btn = document.getElementById('saveLayoutBtn'); if (btn) { btn.disabled = false; btn.classList.add('btn-primary'); } } // Selection state for the fine-position panel + arrow-key nudge. One rect // at a time: either a screen (by device_id) or the player. // null when nothing is selected. let selected = null; function getSelectedRect() { if (!selected) return null; if (selected.type === 'player') return player; return screens.find(s => s.device_id === selected.device_id) || null; } function selectScreen(deviceId) { selected = { type: 'screen', device_id: deviceId }; applySelectionClasses(); renderSelectionPanel(); } function selectPlayer() { selected = { type: 'player' }; applySelectionClasses(); renderSelectionPanel(); } function applySelectionClasses() { canvas.querySelectorAll('.selected').forEach(e => e.classList.remove('selected')); if (!selected) return; if (selected.type === 'player') canvas.querySelector('.wall-player')?.classList.add('selected'); else { const el = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`); if (el) el.classList.add('selected'); } } function getUnassigned() { const inThisWall = new Set(screens.map(s => s.device_id)); return devices.filter(d => !d.wall_id && !inThisWall.has(d.id)); } container.innerHTML = ` ${t('wall.back')}
100%
Cols/rows/bezel are used by Auto-arrange. Drag freely on the canvas to override.

${t('wall.playlist')}

Panels

${t('wall.available_displays')}

Drag onto the canvas to add. Use the ✕ on a tile to remove.

How it works
  • This canvas is the wall as the audience sees it. Arrange the rectangles to match the physical layout.
  • Each rectangle is a physical screen.
  • The blue dashed rectangle is the player window — content plays inside this rect.
  • Each screen shows only the part of the player that overlaps it.
  • Panel hung sideways? Select it and set How this panel is mounted — the tile turns to match and the content is rotated for you.
  • Drag corners to resize, drag the body to move.
`; const canvas = document.getElementById('wallCanvas'); function renderAll() { canvas.innerHTML = ''; canvas.appendChild(renderPlayerEl()); for (const s of screens) canvas.appendChild(renderScreenEl(s)); updateOverlapsAll(); renderSidebar(); renderPanelStatus(); applySelectionClasses(); renderSelectionPanel(); applyTransform(); } // Render the fine-position panel: numeric x/y/w/h inputs for the selected // rect plus the arrow-key hint. Two-way bound — typing into inputs moves // the rect; dragging the rect updates the inputs in place (without // rebuilding the DOM, so focus survives a drag). function renderSelectionPanel() { const panel = document.getElementById('selectionPanel'); if (!panel) return; const rect = getSelectedRect(); if (!rect) { panel.innerHTML = `
Fine position

Click a tile or the player to dial in exact pixel positions.

`; return; } const isPlayer = selected.type === 'player'; const label = isPlayer ? 'Player rect' : (rect.device_name || 'Screen'); panel.innerHTML = `
${esc(label)}
${isPlayer ? '' : `

Lay the canvas out to match the physical wall and set this per panel — content is rotated for you, so portrait walls don't need pre-rotated video. While a panel is in a wall this replaces its own Orientation setting.

`}

Arrow keys nudge by 1px. Hold Shift for 10px. Click outside any rect to deselect.

`; panel.querySelector('#screenRotation')?.addEventListener('change', (ev) => { const r = getSelectedRect(); if (!r) return; const next = parseInt(ev.target.value, 10) || 0; const prev = r.rotation || 0; r.rotation = next; // Turning a panel by a quarter turn changes its footprint on the wall. Swap the tile about // its own CENTRE so it turns in place — resizing from the corner would shove every // neighbouring tile out of alignment and undo the operator's careful placement. const wasQuarter = (prev === 90 || prev === 270); const isQuarter = (next === 90 || next === 270); if (wasQuarter !== isQuarter) { const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const w = r.h, h = r.w; r.w = w; r.h = h; r.x = cx - w / 2; r.y = cy - h / 2; } markDirty(); renderAll(); }); panel.querySelector('#deselectBtn').addEventListener('click', () => { selected = null; applySelectionClasses(); renderSelectionPanel(); }); panel.querySelectorAll('input[data-field]').forEach(input => { input.addEventListener('input', () => { const v = parseFloat(input.value); if (!isFinite(v)) return; const f = input.dataset.field; const r = getSelectedRect(); if (!r) return; if (f === 'w') r.w = Math.max(40, v); else if (f === 'h') r.h = Math.max(24, v); else r[f] = v; // x/y can be negative const el = selectedDomEl(); if (el) setRectStyle(el, r); updateOverlapsAll(); markDirty(); // Don't rebuild this panel — keeps the input focused. }); }); } function selectedDomEl() { if (!selected) return null; if (selected.type === 'player') return canvas.querySelector('.wall-player'); return canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`); } // Sync the panel inputs to the rect's current values without rebuilding // the DOM (so focus survives a drag-resize). Called from drag onChange. function updateSelectionInputsFromRect() { if (!selected) return; const rect = getSelectedRect(); if (!rect) return; const panel = document.getElementById('selectionPanel'); if (!panel) return; for (const f of ['x','y','w','h']) { const input = panel.querySelector(`input[data-field="${f}"]`); if (input && document.activeElement !== input) input.value = Math.round(rect[f]); } } // Pan/zoom state. pan is in viewport screen pixels; zoom is unitless. // The canvas div is a 0×0 anchor; its CSS transform supplies the mapping // from data coords to viewport pixels. All rect children inherit it. let pan = { x: 0, y: 0 }; let zoom = 1; function applyTransform() { canvas.style.transform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`; const r = document.getElementById('zoomReadout'); if (r) r.textContent = Math.round(zoom * 100) + '%'; } // Re-center bounds in the viewport with a small zoom-out so there's slack // around the content for dragging into. Capped at 1× so we never zoom *in* // beyond natural scale on a small layout. function centerView() { const viewport = document.getElementById('canvasViewport'); if (!viewport) return; const all = screens.length > 0 ? [...screens, player] : [player]; const b = boundsOf(all); const vw = viewport.clientWidth, vh = viewport.clientHeight; if (!b.w || !b.h) { pan = { x: vw / 2, y: vh / 2 }; zoom = 1; } else { const fitX = (vw * 0.75) / b.w; const fitY = (vh * 0.75) / b.h; zoom = Math.max(0.1, Math.min(1, fitX, fitY)); pan.x = vw / 2 - (b.x + b.w / 2) * zoom; pan.y = vh / 2 - (b.y + b.h / 2) * zoom; } applyTransform(); } function renderScreenEl(s) { const el = document.createElement('div'); el.className = 'wall-screen'; el.dataset.deviceId = s.device_id; setRectStyle(el, s); el.innerHTML = `
${esc(s.device_name)}
${Math.round(s.w)}×${Math.round(s.h)} ${(s.rotation || 0) !== 0 ? `⟳${s.rotation}°` : ''}
${renderNoteFor(s.device_id) ? `
${esc(renderNoteFor(s.device_id))}
` : ''}
${resizeHandlesHtml()} `; el.querySelector('.wall-screen-remove').addEventListener('click', (ev) => { ev.stopPropagation(); screens = screens.filter(x => x.device_id !== s.device_id); if (selected?.type === 'screen' && selected.device_id === s.device_id) selected = null; markDirty(); renderAll(); }); el.addEventListener('pointerdown', (ev) => { if (ev.target.closest('.wall-screen-remove')) return; selectScreen(s.device_id); }); attachDragResize(el, s, () => { setRectStyle(el, s); const meta = el.querySelector('.wall-screen-meta span:last-child'); if (meta) meta.textContent = `${Math.round(s.w)}×${Math.round(s.h)}`; updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); }); return el; } function renderPlayerEl() { const el = document.createElement('div'); el.className = 'wall-player'; setRectStyle(el, player); el.innerHTML = `
PLAYER ${Math.round(player.w)}×${Math.round(player.h)}
${resizeHandlesHtml()} `; el.addEventListener('pointerdown', () => selectPlayer()); attachDragResize(el, player, () => { setRectStyle(el, player); const meta = el.querySelector('.wall-player-label span:last-child'); if (meta) meta.textContent = `${Math.round(player.w)}×${Math.round(player.h)}`; updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); }); return el; } function updateOverlapsAll() { canvas.querySelectorAll('.wall-screen').forEach(el => { const id = el.dataset.deviceId; const s = screens.find(x => x.device_id === id); if (!s) return; const ov = el.querySelector('.wall-screen-overlap'); const inter = intersect(s, player); if (!inter) { ov.style.display = 'none'; return; } ov.style.display = 'block'; ov.style.left = (inter.x - s.x) + 'px'; ov.style.top = (inter.y - s.y) + 'px'; ov.style.width = inter.w + 'px'; ov.style.height = inter.h + 'px'; }); } // #235: per-panel state for the wall, with a way through to the device itself. // Live socket updates are merged over the fetched rows so a panel that drops off mid-session // turns red here without a reload — "is the wall actually up?" has to be answerable from the // dashboard, because today it means sending someone to look at it. const liveStatus = {}; function panelLiveness(deviceId) { const d = deviceById(deviceId) || {}; const live = liveStatus[deviceId]; return livenessBadge({ ...d, ...(live || {}) }, { short: true }); } function renderPanelStatus() { const host = document.getElementById('wallPanelStatus'); if (!host) return; const summary = document.getElementById('wallPanelSummary'); if (screens.length === 0) { host.innerHTML = `

No panels on this wall yet — drag displays onto the canvas.

`; if (summary) summary.textContent = ''; return; } const badges = screens.map(s => ({ s, b: panelLiveness(s.device_id) })); const down = badges.filter(x => x.b.state !== 'online').length; if (summary) { summary.textContent = down === 0 ? `${screens.length} panel${screens.length === 1 ? '' : 's'}, all online` : `${down} of ${screens.length} not online`; summary.style.color = down === 0 ? 'var(--success)' : 'var(--danger, #e5484d)'; } host.innerHTML = `
${badges.map(({ s, b }) => { const d = deviceById(s.device_id) || {}; const meta = [ d.app_version ? `v${esc(d.app_version)}` : '', (s.rotation || 0) !== 0 ? esc(ROTATION_LABELS[s.rotation]) : '', ].filter(Boolean).join(' · '); return `
${esc(s.device_name || d.name || 'Display')}
${esc(b.label)}${meta ? ` · ${meta}` : ''}
${(!Array.isArray(d.capabilities) || d.capabilities.includes('remote.screenshot')) ? ` ` : ''} Open
`; }).join('')}

Opening a panel doesn't remove it from the wall. Pushing different content to one panel will desync the wall — use the wall playlist above instead.

`; // The button is only rendered for a panel that declares (or baselines to) remote.screenshot — // a BrightSign has no screenshot capability at all, so the old unconditional button popped a // toast promising an image that was never coming. host.querySelectorAll('.wall-panel-shot').forEach(btn => { btn.addEventListener('click', () => { requestScreenshot(btn.dataset.deviceId); showToast('Screenshot requested — it appears on the panel\'s device page', 'info'); }); }); } const panelStatusHandler = (data) => { if (!data?.device_id) return; liveStatus[data.device_id] = data; // Keep the canvas tile dots in step with the list, or the two halves of this screen disagree // about whether a panel is up. const scr = screens.find(s => s.device_id === data.device_id); if (scr && data.status) scr.device_status = data.status; const dot = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(data.device_id)}"] .status-dot`); if (dot && data.status) dot.className = `status-dot ${data.status}`; renderPanelStatus(); }; on('device-status', panelStatusHandler); cleanupHooks.push(() => off('device-status', panelStatusHandler)); function renderSidebar() { const sidebar = document.getElementById('availableDevices'); const unassigned = getUnassigned(); sidebar.innerHTML = unassigned.length ? unassigned.map(d => `
${esc(d.name)}
${d.status}
`).join('') : `

${t('wall.all_assigned')}

`; sidebar.querySelectorAll('[draggable]').forEach(el => { el.addEventListener('dragstart', (e) => { e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'sidebar-device', device_id: el.dataset.deviceId, device_name: el.dataset.deviceName, device_status: el.dataset.deviceStatus, })); }); }); } // Click on canvas background (not on a rect) clears selection canvas.addEventListener('pointerdown', (ev) => { if (ev.target === canvas) { selected = null; applySelectionClasses(); renderSelectionPanel(); } }); // Arrow keys nudge the selected rect by 1px (or 10px with shift). Only // when focus isn't in a text input — typing into the panel's number fields // should still let the browser handle native arrow-key behavior. function onArrowNudge(e) { if (!selected) return; const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; let dx = 0, dy = 0; if (e.key === 'ArrowLeft') dx = -1; else if (e.key === 'ArrowRight') dx = 1; else if (e.key === 'ArrowUp') dy = -1; else if (e.key === 'ArrowDown') dy = 1; else return; e.preventDefault(); const step = e.shiftKey ? 10 : 1; const rect = getSelectedRect(); if (!rect) return; rect.x = rect.x + dx * step; rect.y = rect.y + dy * step; const el = selectedDomEl(); if (el) setRectStyle(el, rect); updateOverlapsAll(); updateSelectionInputsFromRect(); markDirty(); } document.addEventListener('keydown', onArrowNudge); cleanupHooks.push(() => document.removeEventListener('keydown', onArrowNudge)); // Canvas accepts sidebar drops to spawn a new screen rect const viewport = document.getElementById('canvasViewport'); // Pan: pointer-drag on empty viewport space (i.e., not on a rect or its // children). The wall-canvas div itself counts as empty. let panState = null; viewport.addEventListener('pointerdown', (ev) => { // Skip if the pointer landed on a rect — that starts drag/resize instead. if (ev.target.closest('.wall-screen, .wall-player')) return; if (ev.button !== 0 && ev.pointerType === 'mouse') return; // Empty-space click also clears selection if (selected) { selected = null; applySelectionClasses(); renderSelectionPanel(); } panState = { px: ev.clientX, py: ev.clientY, ox: pan.x, oy: pan.y, pid: ev.pointerId }; viewport.classList.add('panning'); viewport.setPointerCapture(ev.pointerId); }); viewport.addEventListener('pointermove', (ev) => { if (!panState || ev.pointerId !== panState.pid) return; pan.x = panState.ox + (ev.clientX - panState.px); pan.y = panState.oy + (ev.clientY - panState.py); applyTransform(); }); function endPan(ev) { if (!panState || ev.pointerId !== panState.pid) return; try { viewport.releasePointerCapture(panState.pid); } catch {} panState = null; viewport.classList.remove('panning'); } viewport.addEventListener('pointerup', endPan); viewport.addEventListener('pointercancel', endPan); // Wheel zoom — pivot at cursor so the world point under the cursor stays // pinned. Clamped to a sane range. viewport.addEventListener('wheel', (ev) => { ev.preventDefault(); const vpRect = viewport.getBoundingClientRect(); const cx = ev.clientX - vpRect.left; const cy = ev.clientY - vpRect.top; const worldX = (cx - pan.x) / zoom; const worldY = (cy - pan.y) / zoom; const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1; const newZoom = Math.max(0.1, Math.min(5, zoom * factor)); pan.x = cx - worldX * newZoom; pan.y = cy - worldY * newZoom; zoom = newZoom; applyTransform(); }, { passive: false }); viewport.addEventListener('dragover', (e) => { e.preventDefault(); }); viewport.addEventListener('drop', (e) => { e.preventDefault(); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain') || '{}'); } catch { return; } if (data.type !== 'sidebar-device' || !data.device_id) return; const vpRect = viewport.getBoundingClientRect(); // #14: size the new tile to the device's render resolution (centered on the drop point). // #236: ...as its FOOTPRINT, so a panel already set to portrait drops in as a tall tile that // matches how it is actually hung, instead of a landscape tile the operator has to reason // backwards from. const rotation = defaultRotationFor(data.device_id); const sz = footprintOnWall(data.device_id, rotation); // Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom. const x = (e.clientX - vpRect.left - pan.x) / zoom - sz.w / 2; const y = (e.clientY - vpRect.top - pan.y) / zoom - sz.h / 2; screens.push({ device_id: data.device_id, device_name: data.device_name || 'Display', device_status: data.device_status || 'offline', grid_col: 0, grid_row: 0, rotation, x, y, w: sz.w, h: sz.h, }); markDirty(); renderAll(); }); // ---------- Toolbar ---------- document.getElementById('centerViewBtn').addEventListener('click', () => centerView()); document.getElementById('autoArrangeBtn').addEventListener('click', () => { const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1); const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1); const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0); const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0); // #14: grid pitch = the largest tile dimension across the wall (tiles keep their own // render size). For a homogeneous wall this is just the panel size; for a mixed wall it // guarantees no overlap. Empty wall falls back to the 320x180 default. const w = Math.max(DEFAULT_SCREEN_W, ...screens.map(s => s.w || 0)); const h = Math.max(DEFAULT_SCREEN_H, ...screens.map(s => s.h || 0)); let i = 0; for (const s of screens) { if (i >= cols * rows) break; const c = i % cols; const r = Math.floor(i / cols); s.x = c * (w + bH); s.y = r * (h + bV); s.grid_col = c; s.grid_row = r; i++; } // Fit player to whole grid bounding box const b = boundsOf(screens); player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h; markDirty(); renderAll(); }); document.getElementById('fitPlayerBtn').addEventListener('click', () => { if (screens.length === 0) return; const b = boundsOf(screens); player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h; markDirty(); renderAll(); }); document.getElementById('saveLayoutBtn').addEventListener('click', async () => { try { // Persist player rect + grid/bezel inputs to the wall, devices to its // member list. Two PUTs because the existing routes are split that way. const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1); const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1); const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0); const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0); // Quantize all coords to integers before persisting. Drag/resize // produce floats (screen-pixel deltas divided by zoom), and even tiny // FP drift between two screens with the same nominal Y/H produces // visibly different `top`/`height` percentages downstream — a known // source of vertical-misalignment bugs across the wall. await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ grid_cols: cols, grid_rows: rows, bezel_h_mm: bH, bezel_v_mm: bV, player_x: Math.round(player.x), player_y: Math.round(player.y), player_width: Math.round(player.w), player_height: Math.round(player.h), })}); // grid_col/grid_row are kept only to satisfy the legacy // UNIQUE(wall_id, grid_col, grid_row) constraint — render math now uses // canvas_* fields. Synthetic (i, 0) guarantees uniqueness. const payload = screens.map((s, i) => ({ device_id: s.device_id, grid_col: i, grid_row: 0, rotation: s.rotation || 0, canvas_x: Math.round(s.x), canvas_y: Math.round(s.y), canvas_width: Math.round(s.w), canvas_height: Math.round(s.h), })); await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: payload }) }); // Re-fetch master device list so wall_id changes propagate to the sidebar devices = await api.getDevices(); dirty = false; const btn = document.getElementById('saveLayoutBtn'); btn.disabled = true; btn.classList.remove('btn-primary'); showToast('Layout saved', 'success'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('renameWallBtn').addEventListener('click', async () => { const newName = prompt('Wall name:', wall.name); if (!newName || newName === wall.name) return; try { await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ name: newName }) }); wall.name = newName; document.getElementById('wallTitleText').textContent = newName; } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('setPlaylistBtn').addEventListener('click', async () => { const playlistId = document.getElementById('wallPlaylist').value || null; try { await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ playlist_id: playlistId }) }); wall.playlist_id = playlistId; showToast(t('wall.toast.playlist_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('deleteWallBtn').addEventListener('click', async () => { if (!confirm(`Delete wall "${wall.name}"? This returns all displays to ungrouped.`)) return; try { await API(`/walls/${wallId}`, { method: 'DELETE' }); showToast(t('wall.toast.deleted'), 'success'); window.location.hash = '#/walls'; } catch (err) { showToast(err.message, 'error'); } }); // Warn before navigating away with unsaved layout changes function beforeUnloadWarn(e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } } window.addEventListener('beforeunload', beforeUnloadWarn); cleanupHooks.push(() => window.removeEventListener('beforeunload', beforeUnloadWarn)); renderAll(); // Center on initial mount once the viewport has measurable dimensions. // requestAnimationFrame defers until layout settles; fits content + padding. requestAnimationFrame(() => centerView()); // ---------- Internal helpers ---------- function setRectStyle(el, r) { el.style.left = r.x + 'px'; el.style.top = r.y + 'px'; el.style.width = r.w + 'px'; el.style.height = r.h + 'px'; } function attachDragResize(el, rect, onChange) { // Drag the body to move; drag a corner/edge handle to resize. el.addEventListener('pointerdown', (ev) => { // Ignore if clicking the remove button or other inner controls if (ev.target.closest('.wall-screen-remove')) return; const handle = ev.target.closest('.wall-handle'); const dir = handle?.dataset.dir; const mode = dir ? `resize:${dir}` : 'move'; ev.preventDefault(); ev.stopPropagation(); el.setPointerCapture(ev.pointerId); const startX = ev.clientX; const startY = ev.clientY; const start = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; function move(e) { // Convert screen-pixel deltas to data-pixel deltas via current zoom // so the rect stays under the cursor regardless of zoom level. const dx = (e.clientX - startX) / zoom; const dy = (e.clientY - startY) / zoom; if (mode === 'move') { // Allow negative coords — physical screen layouts can offset above // or to the left of the canvas's notional origin. rect.x = start.x + dx; rect.y = start.y + dy; } else { applyResize(mode.slice(7), dx, dy, start, rect); } onChange(); } function up(e) { el.releasePointerCapture(ev.pointerId); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); onChange(); } el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); }); } } function applyResize(dir, dx, dy, start, rect) { const minW = 40, minH = 24; let { x, y, w, h } = start; if (dir.includes('e')) w = Math.max(minW, start.w + dx); if (dir.includes('s')) h = Math.max(minH, start.h + dy); if (dir.includes('w')) { const newW = Math.max(minW, start.w - dx); x = start.x + (start.w - newW); w = newW; } if (dir.includes('n')) { const newH = Math.max(minH, start.h - dy); y = start.y + (start.h - newH); h = newH; } // x/y unconstrained — negative coords are allowed rect.x = x; rect.y = y; rect.w = w; rect.h = h; } function resizeHandlesHtml() { return ['nw','n','ne','e','se','s','sw','w'] .map(d => `
`) .join(''); } function boundsOf(rects) { let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity; for (const r of rects) { if (r.x < x) x = r.x; if (r.y < y) y = r.y; if (r.x + r.w > x2) x2 = r.x + r.w; if (r.y + r.h > y2) y2 = r.y + r.h; } if (!isFinite(x)) return { x: 0, y: 0, w: 0, h: 0 }; return { x, y, w: x2 - x, h: y2 - y }; } function intersect(a, b) { const x = Math.max(a.x, b.x); const y = Math.max(a.y, b.y); const x2 = Math.min(a.x + a.w, b.x + b.w); const y2 = Math.min(a.y + a.h, b.y + b.h); if (x2 <= x || y2 <= y) return null; return { x, y, w: x2 - x, h: y2 - y }; } // Cleanup hooks set during render so we can detach them on view unload. const cleanupHooks = []; export function cleanup() { while (cleanupHooks.length) { try { cleanupHooks.pop()(); } catch {} } }