mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each display is a rectangle that can be dragged/resized to match its physical arrangement; a separate semi-transparent player rect overlays the screens and defines what content plays where. Drag empty space to pan, wheel to zoom, "Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys nudge by 1px (10px with shift). Negative coordinates supported for screens offset above/left of the origin. Coords rounded to integers on save. Wall rendering: each device receives screen_rect + player_rect, maps the player into its viewport with vw/vh and object-fit:fill so vertical position of every source pixel is identical across devices that share viewport height. Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply latency-adjusted target and use playbackRate ±3% for sub-300ms drift, hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with AudioContext priming and pause+play retry to bypass Firefox autoplay. "Tap to enable audio" overlay as a final fallback. Reconnect handling: server re-evaluates leader on device:register so the top-left tile reclaims leadership when it returns. Followers emit wall:sync-request on entering wall mode (incl. reconnect) so they snap to position immediately instead of drifting until the next periodic tick. Group dissolve: removing a device from its last group clears its playlist to mirror wall-leave semantics. Leaving a group with playlists on remaining groups inherits the next group's playlist. Dashboard: walls render as their own card section (hidden the device cards they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar action that creates the wall, removes devices from groups, and opens the editor. dashboard:wall-changed broadcast triggers live re-render. Per-card playback progress bar driven by play_start events forwarded from devices. Security: PUT /walls/:id/devices verifies caller owns each device (or has team-owner access via the widgets pattern), preventing cross-tenant device takeover. wall:sync and wall:sync-request validate that the sending device is a member of the named wall; relay re-stamps device_id with currentDeviceId so clients can't spoof or shadow-exclude peers. Schema: video_walls += player_x/y/width/height, playlist_id; video_wall_devices += canvas_x/y/width/height. All idempotent migrations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
798 lines
34 KiB
JavaScript
798 lines
34 KiB
JavaScript
import { api } from '../api.js';
|
||
import { showToast } from '../components/toast.js';
|
||
import { esc } 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
|
||
|
||
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 = `
|
||
<div class="page-header">
|
||
<div><h1>${t('wall.title')} <span class="help-tip" data-tip="${t('wall.help_tip')}">?</span></h1><div class="subtitle">${t('wall.subtitle')}</div></div>
|
||
<button class="btn btn-primary" id="newWallBtn">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
${t('wall.new_wall')}
|
||
</button>
|
||
</div>
|
||
<div class="content-grid" id="wallGrid"></div>
|
||
`;
|
||
|
||
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 = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('wall.empty_title')}</h3><p>${t('wall.empty_desc')}</p></div>`;
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = walls.map(w => `
|
||
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/wall/${w.id}'">
|
||
<div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;background:var(--bg-primary)">
|
||
<div style="display:grid;grid-template-columns:repeat(${w.grid_cols},1fr);gap:3px;width:60%;aspect-ratio:${w.grid_cols}/${w.grid_rows}">
|
||
${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 `<div style="background:${dev ? 'rgba(59,130,246,0.3)' : 'var(--bg-card)'};border:1px solid ${dev ? 'var(--accent)' : 'var(--border)'};border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--text-muted);aspect-ratio:16/9">${dev?.device_name?.slice(0, 6) || ''}</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="content-item-body">
|
||
<div class="content-item-name">${w.name}</div>
|
||
<div class="content-item-size">${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}</div>
|
||
</div>
|
||
</div>
|
||
`).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 = `<div class="empty-state"><h3>${t('wall.not_found')}</h3></div>`; 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;
|
||
|
||
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)),
|
||
w: d.canvas_width ?? baseW,
|
||
h: d.canvas_height ?? baseH,
|
||
}));
|
||
|
||
// 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 = `
|
||
<a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:12px;font-size:13px">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||
${t('wall.back')}
|
||
</a>
|
||
<div class="page-header" style="margin-bottom:12px">
|
||
<h1 style="display:flex;align-items:center;gap:10px">
|
||
<span id="wallTitleText">${esc(wall.name)}</span>
|
||
<button class="btn btn-sm" id="renameWallBtn" title="Rename wall" style="padding:2px 8px;font-size:12px">✎</button>
|
||
</h1>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-sm" id="centerViewBtn" title="Re-center and fit content to the viewport">Center</button>
|
||
<button class="btn btn-sm" id="autoArrangeBtn" title="Lay out screens in a grid using the columns/rows/bezel below">Auto-arrange</button>
|
||
<button class="btn btn-sm" id="fitPlayerBtn" title="Snap the player rect to the bounding box of all screens">Fit player to screens</button>
|
||
<button class="btn btn-sm" id="saveLayoutBtn" disabled>Save layout</button>
|
||
<button class="btn btn-danger btn-sm" id="deleteWallBtn">${t('wall.delete_wall')}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:16px;align-items:flex-start">
|
||
<div style="flex:1;min-width:0">
|
||
<div id="canvasViewport" class="wall-viewport" style="border:1px solid var(--border);border-radius:var(--radius-lg);height:75vh;min-height:560px">
|
||
<div id="wallCanvas" class="wall-canvas"></div>
|
||
<div class="wall-zoom-readout" id="zoomReadout">100%</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;margin-top:12px;align-items:center;flex-wrap:wrap">
|
||
<div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.columns')}</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="20" style="width:70px"></div>
|
||
<div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.rows')}</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="20" style="width:70px"></div>
|
||
<div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.h_bezel')}</label><input type="number" id="bezelH" class="input" value="${Math.round(wall.bezel_h_mm)}" min="0" step="1" style="width:80px"></div>
|
||
<div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.v_bezel')}</label><input type="number" id="bezelV" class="input" value="${Math.round(wall.bezel_v_mm)}" min="0" step="1" style="width:80px"></div>
|
||
<span style="font-size:11px;color:var(--text-muted);max-width:340px">Cols/rows/bezel are used by Auto-arrange. Drag freely on the canvas to override.</span>
|
||
</div>
|
||
<div style="margin-top:16px">
|
||
<h3 style="font-size:14px;margin:0 0 8px">${t('wall.playlist') || 'Playlist'}</h3>
|
||
<select id="wallPlaylist" class="input" style="width:300px;background:var(--bg-input)">
|
||
<option value="">${t('wall.no_playlist') || 'No playlist'}</option>
|
||
${(playlists || []).map(p => `<option value="${esc(p.id)}" ${p.id === wall.playlist_id ? 'selected' : ''}>${esc(p.name)}${p.status === 'draft' ? ' (draft)' : ''}</option>`).join('')}
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" id="setPlaylistBtn" style="margin-left:8px">${t('wall.set_playlist') || 'Set Playlist'}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="width:260px;flex-shrink:0">
|
||
<div id="selectionPanel" class="wall-selection-panel" style="margin-bottom:14px"></div>
|
||
<h3 style="font-size:14px;margin-bottom:6px">${t('wall.available_displays')}</h3>
|
||
<p style="color:var(--text-muted);font-size:11px;margin:0 0 8px">Drag onto the canvas to add. Use the ✕ on a tile to remove.</p>
|
||
<div id="availableDevices" style="min-height:60px;padding:6px;border:1px dashed var(--border);border-radius:8px"></div>
|
||
<div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
|
||
<strong style="font-size:12px">How it works</strong>
|
||
<ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
|
||
<li>Each rectangle is a physical screen.</li>
|
||
<li>The blue dashed rectangle is the player window — content plays inside this rect.</li>
|
||
<li>Each screen shows only the part of the player that overlaps it.</li>
|
||
<li>Drag corners to resize, drag the body to move.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const canvas = document.getElementById('wallCanvas');
|
||
|
||
function renderAll() {
|
||
canvas.innerHTML = '';
|
||
canvas.appendChild(renderPlayerEl());
|
||
for (const s of screens) canvas.appendChild(renderScreenEl(s));
|
||
updateOverlapsAll();
|
||
renderSidebar();
|
||
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 = `
|
||
<div class="info-card" style="padding:10px;font-size:12px">
|
||
<strong style="font-size:12px">Fine position</strong>
|
||
<p style="margin:4px 0 0;color:var(--text-muted);font-size:11px">Click a tile or the player to dial in exact pixel positions.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const isPlayer = selected.type === 'player';
|
||
const label = isPlayer ? 'Player rect' : (rect.device_name || 'Screen');
|
||
panel.innerHTML = `
|
||
<div class="info-card" style="padding:10px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||
<strong style="font-size:12px">${esc(label)}</strong>
|
||
<button class="btn btn-sm" id="deselectBtn" style="padding:2px 8px;font-size:11px">Deselect</button>
|
||
</div>
|
||
<div class="wall-pos-grid">
|
||
<label>X</label><input type="number" data-field="x" value="${Math.round(rect.x)}" step="1">
|
||
<label>Y</label><input type="number" data-field="y" value="${Math.round(rect.y)}" step="1">
|
||
<label>W</label><input type="number" data-field="w" value="${Math.round(rect.w)}" step="1" min="40">
|
||
<label>H</label><input type="number" data-field="h" value="${Math.round(rect.h)}" step="1" min="24">
|
||
</div>
|
||
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||
Arrow keys nudge by 1px. Hold <kbd>Shift</kbd> for 10px.
|
||
Click outside any rect to deselect.
|
||
</p>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="wall-screen-overlap"></div>
|
||
<div class="wall-screen-label">
|
||
<div class="wall-screen-name" title="${esc(s.device_name)}">${esc(s.device_name)}</div>
|
||
<div class="wall-screen-meta">
|
||
<span class="status-dot ${s.device_status}" style="display:inline-block"></span>
|
||
<span style="font-size:10px;color:var(--text-muted)">${Math.round(s.w)}×${Math.round(s.h)}</span>
|
||
</div>
|
||
</div>
|
||
<button class="wall-screen-remove" title="Remove from wall">×</button>
|
||
${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 = `
|
||
<div class="wall-player-label">
|
||
<span style="font-weight:600">PLAYER</span>
|
||
<span style="font-size:10px;color:rgba(255,255,255,0.7);margin-left:8px">${Math.round(player.w)}×${Math.round(player.h)}</span>
|
||
</div>
|
||
${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';
|
||
});
|
||
}
|
||
|
||
function renderSidebar() {
|
||
const sidebar = document.getElementById('availableDevices');
|
||
const unassigned = getUnassigned();
|
||
sidebar.innerHTML = unassigned.length
|
||
? unassigned.map(d => `
|
||
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true"
|
||
data-device-id="${esc(d.id)}" data-device-name="${esc(d.name)}" data-device-status="${esc(d.status)}">
|
||
<div class="playlist-item-info">
|
||
<div class="playlist-item-name">${esc(d.name)}</div>
|
||
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div>
|
||
</div>
|
||
</div>
|
||
`).join('')
|
||
: `<p style="color:var(--text-muted);font-size:12px;text-align:center;padding:12px">${t('wall.all_assigned')}</p>`;
|
||
|
||
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();
|
||
// Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom.
|
||
const x = (e.clientX - vpRect.left - pan.x) / zoom - DEFAULT_SCREEN_W / 2;
|
||
const y = (e.clientY - vpRect.top - pan.y) / zoom - DEFAULT_SCREEN_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: 0,
|
||
x, y, w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_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);
|
||
const w = DEFAULT_SCREEN_W;
|
||
const h = DEFAULT_SCREEN_H;
|
||
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.w = w;
|
||
s.h = h;
|
||
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') || '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 => `<div class="wall-handle wall-handle-${d}" data-dir="${d}"></div>`)
|
||
.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 {}
|
||
}
|
||
}
|