From 2068bc8833b549a0778cf9a67cd8835a2adfda4f Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 29 Apr 2026 23:11:16 -0500 Subject: [PATCH] Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/css/main.css | 316 +++++++++++++ frontend/js/api.js | 7 + frontend/js/i18n/de.js | 4 +- frontend/js/i18n/en.js | 4 +- frontend/js/i18n/es.js | 4 +- frontend/js/i18n/fr.js | 4 +- frontend/js/i18n/pt.js | 4 +- frontend/js/socket.js | 10 + frontend/js/views/dashboard.js | 222 ++++++++- frontend/js/views/video-wall.js | 789 +++++++++++++++++++++++++++----- server/db/database.js | 14 + server/db/schema.sql | 12 + server/player/index.html | 405 ++++++++++++++-- server/routes/device-groups.js | 45 +- server/routes/video-walls.js | 275 ++++++++--- server/ws/deviceSocket.js | 170 ++++++- 16 files changed, 2038 insertions(+), 247 deletions(-) diff --git a/frontend/css/main.css b/frontend/css/main.css index e0c0e0e..bbc6c26 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -237,6 +237,322 @@ body { font-weight: 500; } +.device-card-select { + position: absolute; + top: 8px; + left: 8px; + z-index: 5; + background: rgba(0,0,0,0.6); + border-radius: 4px; + padding: 3px 5px; + display: flex; + align-items: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.device-card:hover .device-card-select, +.device-card.selected .device-card-select { opacity: 1; } +.device-card-select input { cursor: pointer; margin: 0; } +.device-card.selected { outline: 2px solid var(--primary, #3B82F6); outline-offset: -2px; } + +/* Wall editor — free-form pan/zoom canvas */ +.wall-viewport { + position: relative; + overflow: hidden; + cursor: grab; + user-select: none; + background: + linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px, + linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px, + var(--bg-primary); +} +.wall-viewport.panning { cursor: grabbing; } +/* Inner canvas: a 0×0 anchor whose CSS transform supplies pan + zoom. + All rect children are absolutely positioned in canvas-data coordinates + and inherit the parent transform. transform-origin is the canvas's + top-left so pan offsets map cleanly to data → screen pixels. */ +.wall-canvas { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + transform-origin: 0 0; + /* Disable transition so panning doesn't lag behind the cursor */ +} +.wall-zoom-readout { + position: absolute; + bottom: 8px; + right: 12px; + background: rgba(0,0,0,0.65); + color: #fff; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + pointer-events: none; + font-variant-numeric: tabular-nums; +} + +.wall-screen { + position: absolute; + background: rgba(59,130,246,0.08); + border: 2px solid var(--primary, #3B82F6); + border-radius: 4px; + box-sizing: border-box; + cursor: move; + user-select: none; + touch-action: none; + overflow: hidden; +} +.wall-screen-overlap { + position: absolute; + background: rgba(96,165,250,0.35); + pointer-events: none; + display: none; + z-index: 1; +} +.wall-screen-label { + position: absolute; + top: 4px; + left: 6px; + right: 24px; + pointer-events: none; + z-index: 2; +} +.wall-screen-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #fff); + text-shadow: 0 1px 2px rgba(0,0,0,0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.wall-screen-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + text-shadow: 0 1px 2px rgba(0,0,0,0.6); +} +.wall-screen-remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 3; + width: 20px; + height: 20px; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} +.wall-screen-remove:hover { background: var(--danger, #ef4444); } + +.wall-player { + position: absolute; + background: rgba(96,165,250,0.18); + border: 2px dashed #60a5fa; + border-radius: 4px; + box-sizing: border-box; + cursor: move; + user-select: none; + touch-action: none; + z-index: 5; + box-shadow: 0 0 0 9999px transparent; /* keeps stacking explicit */ +} +.wall-player-label { + position: absolute; + top: 4px; + left: 6px; + pointer-events: none; + color: #dbeafe; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); + font-size: 11px; + letter-spacing: 1px; +} + +/* Selected rect highlight (works for both screens and the player) */ +.wall-screen.selected, +.wall-player.selected { + outline: 3px solid #facc15; + outline-offset: 1px; + z-index: 6; +} + +/* Fine-position panel inputs */ +.wall-pos-grid { + display: grid; + grid-template-columns: auto 1fr auto 1fr; + gap: 6px 8px; + align-items: center; + font-size: 12px; +} +.wall-pos-grid label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.wall-pos-grid input { + width: 100%; + padding: 4px 6px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary, #fff); + font: inherit; + font-variant-numeric: tabular-nums; +} +.wall-pos-grid input:focus { outline: 1px solid var(--primary); outline-offset: 0; border-color: var(--primary); } + +/* Eight resize handles, used by both screens and the player */ +.wall-handle { + position: absolute; + width: 10px; + height: 10px; + background: #fff; + border: 1px solid #1d4ed8; + border-radius: 2px; + z-index: 4; +} +.wall-player .wall-handle { border-color: #60a5fa; } +.wall-handle-nw { top: -5px; left: -5px; cursor: nw-resize; } +.wall-handle-n { top: -5px; left: 50%; transform: translateX(-50%); cursor: n-resize; } +.wall-handle-ne { top: -5px; right: -5px; cursor: ne-resize; } +.wall-handle-e { top: 50%; right: -5px; transform: translateY(-50%); cursor: e-resize; } +.wall-handle-se { bottom: -5px; right: -5px; cursor: se-resize; } +.wall-handle-s { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: s-resize; } +.wall-handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; } +.wall-handle-w { top: 50%; left: -5px; transform: translateY(-50%); cursor: w-resize; } + +/* Wall editor — legacy cells (kept for migration; new editor uses wall-canvas) */ +.wall-cell { + position: relative; + background: var(--bg-card); + border: 2px dashed var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text-secondary); + user-select: none; +} +.wall-cell.occupied { + background: rgba(59,130,246,0.15); + border: 2px solid var(--primary, #3B82F6); + cursor: grab; +} +.wall-cell.occupied:active { cursor: grabbing; } +.wall-cell.drag-over { + border-color: var(--success, #10b981); + box-shadow: 0 0 0 2px rgba(16,185,129,0.25) inset; +} +.wall-cell-name { font-weight: 500; padding: 0 6px; text-align: center; } +.wall-cell-pos { + position: absolute; + bottom: 4px; + font-size: 9px; + color: var(--text-muted); + letter-spacing: 0.5px; +} +.wall-cell-remove { + position: absolute; + top: 4px; right: 4px; + background: rgba(0,0,0,0.6); + border: none; + color: #fff; + border-radius: 50%; + width: 20px; height: 20px; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} +.wall-cell-remove:hover { background: var(--danger, #ef4444); } + +.wall-card .wall-card-preview { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(139,92,246,0.15), rgba(59,130,246,0.1)); +} +.wall-card-grid { + display: grid; + gap: 4px; + width: 65%; + aspect-ratio: 16/9; + padding: 8px; +} +.wall-card-cell { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(139,92,246,0.3); + border-radius: 2px; +} +.wall-card-cell.filled { + background: rgba(139,92,246,0.5); + border-color: rgba(139,92,246,0.9); +} + +.device-card-progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 6px 10px 8px; + background: linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0)); + color: #fff; + font-size: 11px; + pointer-events: none; +} +.device-card-progress-label { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); +} +.device-card-progress-label .dcp-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.device-card-progress-label .dcp-time { + font-variant-numeric: tabular-nums; + opacity: 0.85; +} +.device-card-progress-track { + height: 3px; + background: rgba(255,255,255,0.2); + border-radius: 2px; + overflow: hidden; +} +.device-card-progress-fill { + height: 100%; + width: 0%; + background: var(--primary, #3B82F6); + transition: width 0.9s linear; +} +.device-card-progress-fill.indeterminate { + background: linear-gradient(90deg, transparent, var(--primary, #3B82F6), transparent); + background-size: 50% 100%; + animation: dcp-indeterminate 1.4s linear infinite; +} +@keyframes dcp-indeterminate { + 0% { background-position: -50% 0; } + 100% { background-position: 150% 0; } +} + .device-card-body { padding: 14px 16px; } diff --git a/frontend/js/api.js b/frontend/js/api.js index f69cda5..d5409e2 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -128,6 +128,13 @@ export const api = { removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }), sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }), + // Video walls + getWalls: () => request('/walls'), + createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }), + setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }), + updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }), + // Playlists getPlaylists: () => request('/playlists'), createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index da19944..8c7617e 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Raster-Konfiguration', 'wall.columns': 'Spalten', 'wall.rows': 'Zeilen', - 'wall.h_bezel': 'H Rahmen (mm)', - 'wall.v_bezel': 'V Rahmen (mm)', + 'wall.h_bezel': 'H Rahmen (px)', + 'wall.v_bezel': 'V Rahmen (px)', 'wall.update': 'Aktualisieren', 'wall.content': 'Inhalt', 'wall.no_content': 'Kein Inhalt', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index da019fc..f5633f2 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -982,8 +982,8 @@ export default { 'wall.grid_config': 'Grid Configuration', 'wall.columns': 'Columns', 'wall.rows': 'Rows', - 'wall.h_bezel': 'H Bezel (mm)', - 'wall.v_bezel': 'V Bezel (mm)', + 'wall.h_bezel': 'H Bezel (px)', + 'wall.v_bezel': 'V Bezel (px)', 'wall.update': 'Update', 'wall.content': 'Content', 'wall.no_content': 'No content', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 26b4b10..cd13f81 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -945,8 +945,8 @@ export default { 'wall.grid_config': 'Configuración de cuadrícula', 'wall.columns': 'Columnas', 'wall.rows': 'Filas', - 'wall.h_bezel': 'Bisel H (mm)', - 'wall.v_bezel': 'Bisel V (mm)', + 'wall.h_bezel': 'Bisel H (px)', + 'wall.v_bezel': 'Bisel V (px)', 'wall.update': 'Actualizar', 'wall.content': 'Contenido', 'wall.no_content': 'Sin contenido', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 258060b..173518b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Configuration de la grille', 'wall.columns': 'Colonnes', 'wall.rows': 'Lignes', - 'wall.h_bezel': 'Cadre H (mm)', - 'wall.v_bezel': 'Cadre V (mm)', + 'wall.h_bezel': 'Cadre H (px)', + 'wall.v_bezel': 'Cadre V (px)', 'wall.update': 'Mettre à jour', 'wall.content': 'Contenu', 'wall.no_content': 'Aucun contenu', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index f9abd54..64ee1ca 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Configuração da grade', 'wall.columns': 'Colunas', 'wall.rows': 'Linhas', - 'wall.h_bezel': 'Moldura H (mm)', - 'wall.v_bezel': 'Moldura V (mm)', + 'wall.h_bezel': 'Moldura H (px)', + 'wall.v_bezel': 'Moldura V (px)', 'wall.update': 'Atualizar', 'wall.content': 'Conteúdo', 'wall.no_content': 'Sem conteúdo', diff --git a/frontend/js/socket.js b/frontend/js/socket.js index ff85efd..3ba61bf 100644 --- a/frontend/js/socket.js +++ b/frontend/js/socket.js @@ -48,6 +48,16 @@ export function connectSocket() { emit('playback-state', data); }); + // Playback progress (play_start with duration — drives device-card progress bars) + dashboardSocket.on('dashboard:playback-progress', (data) => { + emit('playback-progress', data); + }); + + // Wall changed — dashboard refreshes wall cards + device-grouping layout + dashboardSocket.on('dashboard:wall-changed', () => { + emit('wall-changed'); + }); + // Content ack dashboardSocket.on('dashboard:content-ack', (data) => { emit('content-ack', data); diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 3d36152..859b31c 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -26,6 +26,14 @@ const CMD_LABEL_KEY = { let statusHandler = null; let screenshotHandler = null; let refreshInterval = null; +let playbackHandler = null; +let progressTickInterval = null; +let wallChangedHandler = null; +// device_id -> { content_name, duration_sec, started_at } +const playbackByDevice = new Map(); +// Multi-select state for the "Create Video Wall" gesture. Holds device_ids +// the user has ticked via checkboxes on the dashboard cards. +const selectedDeviceIds = new Set(); function formatTimeAgo(timestamp) { if (!timestamp) return t('common.never'); @@ -42,14 +50,44 @@ function formatBytes(mb) { return `${mb} MB`; } +function renderProgressFor(deviceId) { + const state = playbackByDevice.get(deviceId); + document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => { + if (!state) { el.style.display = 'none'; return; } + const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000); + const name = state.content_name || ''; + const fill = el.querySelector('.device-card-progress-fill'); + const nameEl = el.querySelector('.dcp-name'); + const timeEl = el.querySelector('.dcp-time'); + if (state.duration_sec && state.duration_sec > 0) { + const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed)); + const pct = Math.min(100, (elapsed / state.duration_sec) * 100); + fill.style.width = pct + '%'; + if (nameEl) nameEl.textContent = name; + if (timeEl) timeEl.textContent = remaining + 's'; + } else { + // Unknown duration (e.g. video plays to end) — show indeterminate state + fill.style.width = '100%'; + fill.classList.add('indeterminate'); + if (nameEl) nameEl.textContent = name; + if (timeEl) timeEl.textContent = ''; + } + el.style.display = 'block'; + }); +} + function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` : null; + const checked = selectedDeviceIds.has(device.id); return ` -
+
+
${screenshotUrl ? `Screenshot` @@ -70,6 +108,10 @@ function renderDeviceCard(device) {
${device.pairing_code}
` : ''} +
${esc(device.name)}
@@ -115,6 +157,37 @@ function renderDeviceCard(device) { `; } +function renderWallCard(wall) { + // Compose a tiny grid preview using the wall's actual cols×rows. Each cell + // is filled (assigned) or hollow (empty slot). + const cells = []; + for (let r = 0; r < wall.grid_rows; r++) { + for (let c = 0; c < wall.grid_cols; c++) { + const dev = (wall.devices || []).find(d => d.grid_col === c && d.grid_row === r); + cells.push(`
`); + } + } + const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length; + return ` +
+
+
${cells.join('')}
+
+ + ${wall.grid_cols}×${wall.grid_rows} wall +
+
+
+
${esc(wall.name)}
+
+
${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}
+
${onlineCount} online
+
+
+
+ `; +} + function getGroupPlaylistLabel(devices, playlists) { const playlistMap = new Map((playlists || []).map(p => [p.id, p])); const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id); @@ -177,6 +250,16 @@ export function render(container) {
+
@@ -243,6 +326,28 @@ export function render(container) { } catch (e) { showToast(e.message, 'error'); } }); + // Multi-select: a checkbox on each device card adds to selectedDeviceIds. + // The selection bar shows when 1+ are selected; "Create Video Wall" is the + // primary action — it creates the wall, removes devices from any group, + // assigns them, and navigates to the editor. + container.addEventListener('change', (ev) => { + const cb = ev.target.closest?.('.device-select-cb'); + if (!cb) return; + const id = cb.dataset.deviceId; + if (cb.checked) selectedDeviceIds.add(id); else selectedDeviceIds.delete(id); + cb.closest('.device-card')?.classList.toggle('selected', cb.checked); + refreshSelectionBar(); + }); + + document.getElementById('clearSelectionBtn').addEventListener('click', () => { + selectedDeviceIds.clear(); + document.querySelectorAll('.device-select-cb').forEach(cb => { cb.checked = false; }); + document.querySelectorAll('.device-card.selected').forEach(c => c.classList.remove('selected')); + refreshSelectionBar(); + }); + + document.getElementById('createWallBtn').addEventListener('click', () => createWallFromSelection()); + // Load everything loadDashboard(); @@ -271,10 +376,28 @@ export function render(container) { const deviceAddedHandler = () => loadDashboard(); const deviceRemovedHandler = () => loadDashboard(); + playbackHandler = (data) => { + if (!data?.device_id) return; + playbackByDevice.set(data.device_id, { + content_name: data.content_name || '', + duration_sec: data.duration_sec || null, + started_at: data.started_at || Date.now(), + }); + renderProgressFor(data.device_id); + }; + + wallChangedHandler = () => loadDashboard(); + on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('device-added', deviceAddedHandler); on('device-removed', deviceRemovedHandler); + on('playback-progress', playbackHandler); + on('wall-changed', wallChangedHandler); + + progressTickInterval = setInterval(() => { + for (const id of playbackByDevice.keys()) renderProgressFor(id); + }, 1000); // Request fresh screenshots on load setTimeout(() => { @@ -290,12 +413,66 @@ export function render(container) { }, 30000); } +function refreshSelectionBar() { + const bar = document.getElementById('selectionBar'); + const count = document.getElementById('selectionCount'); + if (!bar || !count) return; + const n = selectedDeviceIds.size; + if (n === 0) { bar.style.display = 'none'; return; } + bar.style.display = 'flex'; + count.textContent = `${n} display${n === 1 ? '' : 's'} selected`; + // Need at least 2 to make a wall + document.getElementById('createWallBtn').disabled = n < 2; +} + +// Pick a sensible default grid for n devices: prefer near-square layouts, +// breaking ties toward more columns (more common physical wall layout). +function defaultGridForCount(n) { + if (n <= 1) return { cols: 1, rows: 1 }; + if (n === 2) return { cols: 2, rows: 1 }; + if (n === 3) return { cols: 3, rows: 1 }; + if (n === 4) return { cols: 2, rows: 2 }; + if (n === 6) return { cols: 3, rows: 2 }; + if (n === 8) return { cols: 4, rows: 2 }; + if (n === 9) return { cols: 3, rows: 3 }; + // Generic fallback — square-ish, columns >= rows + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + return { cols, rows }; +} + +async function createWallFromSelection() { + const ids = [...selectedDeviceIds]; + if (ids.length < 2) { showToast('Select at least 2 displays', 'error'); return; } + const name = prompt('Name this video wall:', `Wall ${new Date().toLocaleString()}`); + if (!name) return; + const { cols, rows } = defaultGridForCount(ids.length); + try { + const wall = await api.createWall({ name, grid_cols: cols, grid_rows: rows }); + // Pack selected devices into row-major order. The user can reposition in + // the editor; this just gives every selection a sensible starting tile. + const placement = ids.slice(0, cols * rows).map((id, i) => ({ + device_id: id, + grid_col: i % cols, + grid_row: Math.floor(i / cols), + })); + await api.setWallDevices(wall.id, placement); + selectedDeviceIds.clear(); + showToast('Video wall created', 'success'); + window.location.hash = `#/wall/${wall.id}`; + } catch (e) { + showToast(e.message, 'error'); + } +} + async function loadDashboard() { const main = document.getElementById('groupedDevices'); if (!main) return; try { - const [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]); + const [rawDevices, groups, playlists, walls] = await Promise.all([ + api.getDevices(), api.getGroups(), api.getPlaylists(), api.getWalls(), + ]); // Deduplicate devices by id — a stale reconnect race can briefly cause the same // device to appear twice in the list. Last-write-wins keeps the freshest state. @@ -345,12 +522,19 @@ async function loadDashboard() { return; } + // Devices that belong to a wall are owned by that wall — they don't appear + // as their own cards anywhere on the dashboard. The wall's card stands in. + const walledDeviceIds = new Set(); + for (const w of (walls || [])) for (const d of (w.devices || [])) walledDeviceIds.add(d.device_id); + const dashboardDevices = devices.filter(d => !walledDeviceIds.has(d.id)); + // Fetch group memberships const groupsWithDevices = await Promise.all(groups.map(async g => { const members = await api.getGroupDevices(g.id); const memberIds = new Set(members.map(m => m.id)); // Use full device data from the main devices list (has telemetry/screenshots) - const fullDevices = devices.filter(d => memberIds.has(d.id)); + // and exclude any wall members. + const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id)); return { ...g, devices: fullDevices, memberIds }; })); @@ -364,10 +548,24 @@ async function loadDashboard() { return true; }); } - const ungrouped = devices.filter(d => !renderedIds.has(d.id)); + const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id)); let html = ''; + // Walls render before groups: they're a higher-level construct (multiple + // physical screens acting as one logical display). + if ((walls || []).length > 0) { + html += ` +
+
+ Video Walls + ${walls.length} wall${walls.length === 1 ? '' : 's'} +
+
${walls.map(renderWallCard).join('')}
+
+ `; + } + // Render each group with its devices for (const g of groupsWithDevices) { html += renderGroupSection(g, g.devices, playlists); @@ -392,7 +590,14 @@ async function loadDashboard() { } main.innerHTML = html; - attachGroupHandlers(groupsWithDevices, devices); + attachGroupHandlers(groupsWithDevices, dashboardDevices); + + // Drop any selections for devices that have since been absorbed into a + // wall, and update the toolbar. + for (const id of [...selectedDeviceIds]) { + if (walledDeviceIds.has(id)) selectedDeviceIds.delete(id); + } + refreshSelectionBar(); } catch (err) { main.innerHTML = `

${t('dashboard.failed_to_load')}

${esc(err.message)}

`; @@ -626,10 +831,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) { export function cleanup() { if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); + if (playbackHandler) off('playback-progress', playbackHandler); + if (wallChangedHandler) off('wall-changed', wallChangedHandler); off('device-added', () => {}); off('device-removed', () => {}); if (refreshInterval) clearInterval(refreshInterval); + if (progressTickInterval) clearInterval(progressTickInterval); statusHandler = null; screenshotHandler = null; + playbackHandler = null; + wallChangedHandler = null; refreshInterval = null; + progressTickInterval = null; + playbackByDevice.clear(); } diff --git a/frontend/js/views/video-wall.js b/frontend/js/views/video-wall.js index 14ac488..a4e398a 100644 --- a/frontend/js/views/video-wall.js +++ b/frontend/js/views/video-wall.js @@ -3,7 +3,19 @@ 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()); +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; @@ -63,150 +75,723 @@ async function renderList(container) { } catch (err) { showToast(err.message, 'error'); } } +// ============================================================ +// Free-form canvas wall editor +// ============================================================ async function renderWallEditor(container, wallId) { - let wall, devices; + let wall, devices, playlists; try { - [wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]); + [wall, devices, playlists] = await Promise.all([ + API(`/walls/${wallId}`), + api.getDevices(), + api.getPlaylists(), + ]); } catch { container.innerHTML = `

${t('wall.not_found')}

`; return; } - const content = await api.getContent(); - const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id)); + // 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 = ` - + ${t('wall.back')} -