Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars

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>
This commit is contained in:
ScreenTinker 2026-04-29 23:11:16 -05:00
parent 388e9e6ab8
commit 2068bc8833
16 changed files with 2038 additions and 247 deletions

View file

@ -237,6 +237,322 @@ body {
font-weight: 500; 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 { .device-card-body {
padding: 14px 16px; padding: 14px 16px;
} }

View file

@ -128,6 +128,13 @@ export const api = {
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }), 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 }) }), 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 // Playlists
getPlaylists: () => request('/playlists'), getPlaylists: () => request('/playlists'),
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }), createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),

View file

@ -946,8 +946,8 @@ export default {
'wall.grid_config': 'Raster-Konfiguration', 'wall.grid_config': 'Raster-Konfiguration',
'wall.columns': 'Spalten', 'wall.columns': 'Spalten',
'wall.rows': 'Zeilen', 'wall.rows': 'Zeilen',
'wall.h_bezel': 'H Rahmen (mm)', 'wall.h_bezel': 'H Rahmen (px)',
'wall.v_bezel': 'V Rahmen (mm)', 'wall.v_bezel': 'V Rahmen (px)',
'wall.update': 'Aktualisieren', 'wall.update': 'Aktualisieren',
'wall.content': 'Inhalt', 'wall.content': 'Inhalt',
'wall.no_content': 'Kein Inhalt', 'wall.no_content': 'Kein Inhalt',

View file

@ -982,8 +982,8 @@ export default {
'wall.grid_config': 'Grid Configuration', 'wall.grid_config': 'Grid Configuration',
'wall.columns': 'Columns', 'wall.columns': 'Columns',
'wall.rows': 'Rows', 'wall.rows': 'Rows',
'wall.h_bezel': 'H Bezel (mm)', 'wall.h_bezel': 'H Bezel (px)',
'wall.v_bezel': 'V Bezel (mm)', 'wall.v_bezel': 'V Bezel (px)',
'wall.update': 'Update', 'wall.update': 'Update',
'wall.content': 'Content', 'wall.content': 'Content',
'wall.no_content': 'No content', 'wall.no_content': 'No content',

View file

@ -945,8 +945,8 @@ export default {
'wall.grid_config': 'Configuración de cuadrícula', 'wall.grid_config': 'Configuración de cuadrícula',
'wall.columns': 'Columnas', 'wall.columns': 'Columnas',
'wall.rows': 'Filas', 'wall.rows': 'Filas',
'wall.h_bezel': 'Bisel H (mm)', 'wall.h_bezel': 'Bisel H (px)',
'wall.v_bezel': 'Bisel V (mm)', 'wall.v_bezel': 'Bisel V (px)',
'wall.update': 'Actualizar', 'wall.update': 'Actualizar',
'wall.content': 'Contenido', 'wall.content': 'Contenido',
'wall.no_content': 'Sin contenido', 'wall.no_content': 'Sin contenido',

View file

@ -946,8 +946,8 @@ export default {
'wall.grid_config': 'Configuration de la grille', 'wall.grid_config': 'Configuration de la grille',
'wall.columns': 'Colonnes', 'wall.columns': 'Colonnes',
'wall.rows': 'Lignes', 'wall.rows': 'Lignes',
'wall.h_bezel': 'Cadre H (mm)', 'wall.h_bezel': 'Cadre H (px)',
'wall.v_bezel': 'Cadre V (mm)', 'wall.v_bezel': 'Cadre V (px)',
'wall.update': 'Mettre à jour', 'wall.update': 'Mettre à jour',
'wall.content': 'Contenu', 'wall.content': 'Contenu',
'wall.no_content': 'Aucun contenu', 'wall.no_content': 'Aucun contenu',

View file

@ -946,8 +946,8 @@ export default {
'wall.grid_config': 'Configuração da grade', 'wall.grid_config': 'Configuração da grade',
'wall.columns': 'Colunas', 'wall.columns': 'Colunas',
'wall.rows': 'Linhas', 'wall.rows': 'Linhas',
'wall.h_bezel': 'Moldura H (mm)', 'wall.h_bezel': 'Moldura H (px)',
'wall.v_bezel': 'Moldura V (mm)', 'wall.v_bezel': 'Moldura V (px)',
'wall.update': 'Atualizar', 'wall.update': 'Atualizar',
'wall.content': 'Conteúdo', 'wall.content': 'Conteúdo',
'wall.no_content': 'Sem conteúdo', 'wall.no_content': 'Sem conteúdo',

View file

@ -48,6 +48,16 @@ export function connectSocket() {
emit('playback-state', data); 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 // Content ack
dashboardSocket.on('dashboard:content-ack', (data) => { dashboardSocket.on('dashboard:content-ack', (data) => {
emit('content-ack', data); emit('content-ack', data);

View file

@ -26,6 +26,14 @@ const CMD_LABEL_KEY = {
let statusHandler = null; let statusHandler = null;
let screenshotHandler = null; let screenshotHandler = null;
let refreshInterval = 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) { function formatTimeAgo(timestamp) {
if (!timestamp) return t('common.never'); if (!timestamp) return t('common.never');
@ -42,14 +50,44 @@ function formatBytes(mb) {
return `${mb} 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) { function renderDeviceCard(device) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const screenshotUrl = device.screenshot_path const screenshotUrl = device.screenshot_path
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
: null; : null;
const checked = selectedDeviceIds.has(device.id);
return ` return `
<div class="device-card" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'"> <div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
<label class="device-card-select" title="Select for wall" onclick="event.stopPropagation()">
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
</label>
<div class="device-card-preview" id="preview-${device.id}"> <div class="device-card-preview" id="preview-${device.id}">
${screenshotUrl ${screenshotUrl
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">` ? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
@ -70,6 +108,10 @@ function renderDeviceCard(device) {
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace"> <div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
${device.pairing_code} ${device.pairing_code}
</div>` : ''} </div>` : ''}
<div class="device-card-progress" id="progress-${device.id}" style="display:none">
<div class="device-card-progress-label"><span class="dcp-name"></span><span class="dcp-time"></span></div>
<div class="device-card-progress-track"><div class="device-card-progress-fill"></div></div>
</div>
</div> </div>
<div class="device-card-body"> <div class="device-card-body">
<div class="device-card-name">${esc(device.name)}</div> <div class="device-card-name">${esc(device.name)}</div>
@ -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(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
}
}
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
return `
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
<div class="device-card-preview wall-card-preview">
<div class="wall-card-grid" style="grid-template-columns:repeat(${wall.grid_cols},1fr);grid-template-rows:repeat(${wall.grid_rows},1fr)">${cells.join('')}</div>
<div class="device-card-status">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
<span>${wall.grid_cols}×${wall.grid_rows} wall</span>
</div>
</div>
<div class="device-card-body">
<div class="device-card-name">${esc(wall.name)}</div>
<div class="device-card-meta">
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
</div>
</div>
</div>
`;
}
function getGroupPlaylistLabel(devices, playlists) { function getGroupPlaylistLabel(devices, playlists) {
const playlistMap = new Map((playlists || []).map(p => [p.id, p])); const playlistMap = new Map((playlists || []).map(p => [p.id, p]));
const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id); const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id);
@ -177,6 +250,16 @@ export function render(container) {
</button> </button>
</div> </div>
</div> </div>
<div id="selectionBar" style="display:none;align-items:center;gap:10px;padding:8px 12px;margin-bottom:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px">
<span id="selectionCount" style="font-weight:500;font-size:13px"></span>
<button class="btn btn-primary btn-sm" id="createWallBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/>
</svg>
Create Video Wall
</button>
<button class="btn btn-sm" id="clearSelectionBtn">Clear</button>
</div>
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div> <div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center"> <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px"> <input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
@ -243,6 +326,28 @@ export function render(container) {
} catch (e) { showToast(e.message, 'error'); } } 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 // Load everything
loadDashboard(); loadDashboard();
@ -271,10 +376,28 @@ export function render(container) {
const deviceAddedHandler = () => loadDashboard(); const deviceAddedHandler = () => loadDashboard();
const deviceRemovedHandler = () => 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('device-status', statusHandler);
on('screenshot-ready', screenshotHandler); on('screenshot-ready', screenshotHandler);
on('device-added', deviceAddedHandler); on('device-added', deviceAddedHandler);
on('device-removed', deviceRemovedHandler); 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 // Request fresh screenshots on load
setTimeout(() => { setTimeout(() => {
@ -290,12 +413,66 @@ export function render(container) {
}, 30000); }, 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() { async function loadDashboard() {
const main = document.getElementById('groupedDevices'); const main = document.getElementById('groupedDevices');
if (!main) return; if (!main) return;
try { 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 // 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. // device to appear twice in the list. Last-write-wins keeps the freshest state.
@ -345,12 +522,19 @@ async function loadDashboard() {
return; 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 // Fetch group memberships
const groupsWithDevices = await Promise.all(groups.map(async g => { const groupsWithDevices = await Promise.all(groups.map(async g => {
const members = await api.getGroupDevices(g.id); const members = await api.getGroupDevices(g.id);
const memberIds = new Set(members.map(m => m.id)); const memberIds = new Set(members.map(m => m.id));
// Use full device data from the main devices list (has telemetry/screenshots) // 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 }; return { ...g, devices: fullDevices, memberIds };
})); }));
@ -364,10 +548,24 @@ async function loadDashboard() {
return true; return true;
}); });
} }
const ungrouped = devices.filter(d => !renderedIds.has(d.id)); const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id));
let html = ''; 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 += `
<div class="wall-section" style="margin-bottom:24px">
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid #8b5cf6">
<strong style="font-size:15px">Video Walls</strong>
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${walls.length} wall${walls.length === 1 ? '' : 's'}</span>
</div>
<div class="device-grid">${walls.map(renderWallCard).join('')}</div>
</div>
`;
}
// Render each group with its devices // Render each group with its devices
for (const g of groupsWithDevices) { for (const g of groupsWithDevices) {
html += renderGroupSection(g, g.devices, playlists); html += renderGroupSection(g, g.devices, playlists);
@ -392,7 +590,14 @@ async function loadDashboard() {
} }
main.innerHTML = html; 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) { } catch (err) {
main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`; main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
@ -626,10 +831,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
export function cleanup() { export function cleanup() {
if (statusHandler) off('device-status', statusHandler); if (statusHandler) off('device-status', statusHandler);
if (screenshotHandler) off('screenshot-ready', screenshotHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler);
if (playbackHandler) off('playback-progress', playbackHandler);
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
off('device-added', () => {}); off('device-added', () => {});
off('device-removed', () => {}); off('device-removed', () => {});
if (refreshInterval) clearInterval(refreshInterval); if (refreshInterval) clearInterval(refreshInterval);
if (progressTickInterval) clearInterval(progressTickInterval);
statusHandler = null; statusHandler = null;
screenshotHandler = null; screenshotHandler = null;
playbackHandler = null;
wallChangedHandler = null;
refreshInterval = null; refreshInterval = null;
progressTickInterval = null;
playbackByDevice.clear();
} }

View file

@ -3,7 +3,19 @@ import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t } from '../i18n.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) { export async function render(container) {
const hash = window.location.hash; const hash = window.location.hash;
@ -63,150 +75,723 @@ async function renderList(container) {
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
} }
// ============================================================
// Free-form canvas wall editor
// ============================================================
async function renderWallEditor(container, wallId) { async function renderWallEditor(container, wallId) {
let wall, devices; let wall, devices, playlists;
try { 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 = `<div class="empty-state"><h3>${t('wall.not_found')}</h3></div>`; return; } } catch { container.innerHTML = `<div class="empty-state"><h3>${t('wall.not_found')}</h3></div>`; return; }
const content = await api.getContent(); // Local state — server-roundtripped on Save. Backfill from grid math when
const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id)); // 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 = ` container.innerHTML = `
<a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px"> <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> <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')} ${t('wall.back')}
</a> </a>
<div class="page-header"> <div class="page-header" style="margin-bottom:12px">
<h1>${wall.name}</h1> <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"> <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> <button class="btn btn-danger btn-sm" id="deleteWallBtn">${t('wall.delete_wall')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:24px"> <div style="display:flex;gap:16px;align-items:flex-start">
<div style="flex:1"> <div style="flex:1;min-width:0">
<h3 style="font-size:14px;margin-bottom:12px">${t('wall.grid_config')}</h3> <div id="canvasViewport" class="wall-viewport" style="border:1px solid var(--border);border-radius:var(--radius-lg);height:75vh;min-height:560px">
<div style="display:flex;gap:12px;margin-bottom:16px"> <div id="wallCanvas" class="wall-canvas"></div>
<div class="form-group" style="margin:0"><label>${t('wall.columns')}</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="10" style="width:80px"></div> <div class="wall-zoom-readout" id="zoomReadout">100%</div>
<div class="form-group" style="margin:0"><label>${t('wall.rows')}</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="10" style="width:80px"></div> </div>
<div class="form-group" style="margin:0"><label>${t('wall.h_bezel')}</label><input type="number" id="bezelH" class="input" value="${wall.bezel_h_mm}" min="0" step="0.5" style="width:80px"></div> <div style="display:flex;gap:12px;margin-top:12px;align-items:center;flex-wrap:wrap">
<div class="form-group" style="margin:0"><label>${t('wall.v_bezel')}</label><input type="number" id="bezelV" class="input" value="${wall.bezel_v_mm}" min="0" step="0.5" style="width:80px"></div> <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>
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">${t('wall.update')}</button> <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 id="wallGrid" style="display:inline-grid;gap:4px;background:var(--bg-primary);padding:16px;border:1px solid var(--border);border-radius:var(--radius-lg)"></div>
<h3 style="font-size:14px;margin:24px 0 12px">${t('wall.content')}</h3>
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
<option value="">${t('wall.no_content')}</option>
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${esc(c.filename)}</option>`).join('')}
</select>
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">${t('wall.set_content')}</button>
</div> </div>
<div style="width:250px"> <div style="width:260px;flex-shrink:0">
<h3 style="font-size:14px;margin-bottom:12px">${t('wall.available_displays')}</h3> <div id="selectionPanel" class="wall-selection-panel" style="margin-bottom:14px"></div>
<div id="availableDevices"> <h3 style="font-size:14px;margin-bottom:6px">${t('wall.available_displays')}</h3>
${unassigned.map(d => ` <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 class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true" data-device-id="${d.id}" data-device-name="${d.name}"> <div id="availableDevices" style="min-height:60px;padding:6px;border:1px dashed var(--border);border-radius:8px"></div>
<div class="playlist-item-info"> <div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
<div class="playlist-item-name">${d.name}</div> <strong style="font-size:12px">How it works</strong>
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div> <ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
</div> <li>Each rectangle is a physical screen.</li>
</div> <li>The blue dashed rectangle is the player window content plays inside this rect.</li>
`).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('wall.all_assigned')}</p>`} <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> </div>
</div> </div>
`; `;
function renderGrid() { const canvas = document.getElementById('wallCanvas');
const cols = parseInt(document.getElementById('gridCols').value) || 2;
const rows = parseInt(document.getElementById('gridRows').value) || 2;
const grid = document.getElementById('wallGrid');
grid.style.gridTemplateColumns = `repeat(${cols}, 120px)`;
let html = ''; function renderAll() {
for (let r = 0; r < rows; r++) { canvas.innerHTML = '';
for (let c = 0; c < cols; c++) { canvas.appendChild(renderPlayerEl());
const dev = wall.devices?.find(d => d.grid_col === c && d.grid_row === r); for (const s of screens) canvas.appendChild(renderScreenEl(s));
html += ` updateOverlapsAll();
<div style="width:120px;aspect-ratio:16/9;background:${dev ? 'rgba(59,130,246,0.2)' : 'var(--bg-card)'}; renderSidebar();
border:2px ${dev ? 'solid var(--accent)' : 'dashed var(--border)'};border-radius:var(--radius); applySelectionClasses();
display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;color:var(--text-secondary)" renderSelectionPanel();
data-grid-col="${c}" data-grid-row="${r}"> applyTransform();
${dev ? `<div style="font-weight:500">${dev.device_name}</div><div style="font-size:9px;color:var(--text-muted)">[${c},${r}]</div>` : }
`<div style="color:var(--text-muted)">${t('wall.drop_here')}</div><div style="font-size:9px">[${c},${r}]</div>`}
</div> // 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;
} }
grid.innerHTML = html; 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();
grid.querySelectorAll('[data-grid-col]').forEach(cell => { markDirty();
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; }; // Don't rebuild this panel — keeps the input focused.
cell.ondragleave = () => { cell.style.borderColor = ''; }; });
cell.ondrop = async (e) => {
e.preventDefault();
cell.style.borderColor = '';
const deviceId = e.dataTransfer.getData('device-id');
const deviceName = e.dataTransfer.getData('device-name');
const col = parseInt(cell.dataset.gridCol);
const row = parseInt(cell.dataset.gridRow);
const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || [];
existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row });
try {
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
wall.devices = updated.devices;
renderGrid();
showToast(t('wall.toast.placed', { name: deviceName, col, row }), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
}); });
} }
container.querySelectorAll('[draggable]').forEach(el => { function selectedDomEl() {
el.ondragstart = (e) => { if (!selected) return null;
e.dataTransfer.setData('device-id', el.dataset.deviceId); if (selected.type === 'player') return canvas.querySelector('.wall-player');
e.dataTransfer.setData('device-name', el.dataset.deviceName); 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();
}
}); });
document.getElementById('updateGridBtn').onclick = async () => { // 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 { 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({ await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({
grid_cols: parseInt(document.getElementById('gridCols').value), grid_cols: cols, grid_rows: rows, bezel_h_mm: bH, bezel_v_mm: bV,
grid_rows: parseInt(document.getElementById('gridRows').value), player_x: Math.round(player.x), player_y: Math.round(player.y),
bezel_h_mm: parseFloat(document.getElementById('bezelH').value), player_width: Math.round(player.w), player_height: Math.round(player.h),
bezel_v_mm: parseFloat(document.getElementById('bezelV').value),
})}); })});
wall.grid_cols = parseInt(document.getElementById('gridCols').value); // grid_col/grid_row are kept only to satisfy the legacy
wall.grid_rows = parseInt(document.getElementById('gridRows').value); // UNIQUE(wall_id, grid_col, grid_row) constraint — render math now uses
renderGrid(); // canvas_* fields. Synthetic (i, 0) guarantees uniqueness.
showToast(t('wall.toast.grid_updated'), 'success'); 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'); } } catch (err) { showToast(err.message, 'error'); }
}; });
document.getElementById('setContentBtn').onclick = async () => { document.getElementById('renameWallBtn').addEventListener('click', async () => {
const contentId = document.getElementById('wallContent').value; const newName = prompt('Wall name:', wall.name);
if (!newName || newName === wall.name) return;
try { try {
await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) }); await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ name: newName }) });
showToast(t('wall.toast.content_updated'), 'success'); wall.name = newName;
document.getElementById('wallTitleText').textContent = newName;
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; });
document.getElementById('deleteWallBtn').onclick = async () => { 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 { try {
await API(`/walls/${wallId}`, { method: 'DELETE' }); await API(`/walls/${wallId}`, { method: 'DELETE' });
showToast(t('wall.toast.deleted'), 'success'); showToast(t('wall.toast.deleted'), 'success');
window.location.hash = '#/walls'; window.location.hash = '#/walls';
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; });
renderGrid(); // 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);
});
}
} }
export function cleanup() {} 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 {}
}
}

View file

@ -76,6 +76,20 @@ const migrations = [
"CREATE INDEX IF NOT EXISTS idx_content_folders_user ON content_folders(user_id, parent_id)", "CREATE INDEX IF NOT EXISTS idx_content_folders_user ON content_folders(user_id, parent_id)",
"ALTER TABLE content ADD COLUMN folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL", "ALTER TABLE content ADD COLUMN folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL",
"CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)", "CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)",
// Group-level playlist: when set, devices added to the group inherit it.
"ALTER TABLE device_groups ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Wall-level playlist: video walls now play a playlist (not just one content).
"ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Free-form canvas layout: walls store a player rect; member devices store
// their own rect. Coordinates are in arbitrary canvas units (effectively px).
"ALTER TABLE video_walls ADD COLUMN player_x REAL",
"ALTER TABLE video_walls ADD COLUMN player_y REAL",
"ALTER TABLE video_walls ADD COLUMN player_width REAL",
"ALTER TABLE video_walls ADD COLUMN player_height REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_x REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_y REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_width REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_height REAL",
]; ];
for (const sql of migrations) { for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ } try { db.exec(sql); } catch (e) { /* already exists */ }

View file

@ -235,6 +235,12 @@ CREATE TABLE IF NOT EXISTS video_walls (
sync_mode TEXT NOT NULL DEFAULT 'leader', sync_mode TEXT NOT NULL DEFAULT 'leader',
leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL,
content_id TEXT REFERENCES content(id) ON DELETE SET NULL, content_id TEXT REFERENCES content(id) ON DELETE SET NULL,
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
-- Free-form player rect on the wall canvas (NULL = use bounding box of screens)
player_x REAL,
player_y REAL,
player_width REAL,
player_height REAL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );
@ -246,6 +252,11 @@ CREATE TABLE IF NOT EXISTS video_wall_devices (
grid_col INTEGER NOT NULL, grid_col INTEGER NOT NULL,
grid_row INTEGER NOT NULL, grid_row INTEGER NOT NULL,
rotation INTEGER NOT NULL DEFAULT 0, rotation INTEGER NOT NULL DEFAULT 0,
-- Free-form canvas rect (NULL = derive from grid_col/row + bezel as a fallback)
canvas_x REAL,
canvas_y REAL,
canvas_width REAL,
canvas_height REAL,
UNIQUE(wall_id, device_id), UNIQUE(wall_id, device_id),
UNIQUE(wall_id, grid_col, grid_row) UNIQUE(wall_id, grid_col, grid_row)
); );
@ -307,6 +318,7 @@ CREATE TABLE IF NOT EXISTS device_groups (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT DEFAULT '#3B82F6', color TEXT DEFAULT '#3B82F6',
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );

View file

@ -50,6 +50,22 @@
.zone img { width: 100%; height: 100%; object-fit: cover; } .zone img { width: 100%; height: 100%; object-fit: cover; }
.zone iframe { width: 100%; height: 100%; border: none; } .zone iframe { width: 100%; height: 100%; border: none; }
/* Video wall mode.
wall-stage maps the wall's player_rect into this device's viewport
using vw/vh — so the device fills its full viewport edge-to-edge
(no pillarbox at the seam between adjacent screens).
object-fit:fill is intentional: it stretches the source to the stage,
which keeps vertical position identical between devices that share
a viewport height — without that, cover-cropping on different stage
aspects (different innerWidths) shifts content vertically. */
#playerContainer.wall-mode { overflow: hidden; background: #000; }
.wall-stage { position: absolute; }
.wall-stage > video,
.wall-stage > img { width: 100%; height: 100%; object-fit: fill; display: block; }
.wall-stage > iframe { width: 100%; height: 100%; border: none; display: block; }
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* Status overlay */ /* Status overlay */
#statusOverlay { #statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
@ -200,6 +216,21 @@
// playback muted). // playback muted).
let userHasInteracted = false; let userHasInteracted = false;
let advanceTimer = null; let advanceTimer = null;
// Video wall state. wallConfig is the tile assignment from the server
// (null when this device isn't in a wall). The leader runs the playlist
// normally and broadcasts wall:sync every second; followers don't run
// their own advance timers and instead align their currentIndex and
// video position to whatever the leader is playing.
let wallConfig = null;
let wallSyncTimer = null;
let lastWallSync = null;
let currentVideoEl = null;
let currentItemStartedAt = 0;
// Followers in a video wall must stay silent — N copies of the same audio
// slightly out of sync produce a flanged echo across the wall. Only the
// leader is allowed to make sound. This helper is the single source of
// truth used by every code path that would otherwise unmute audio.
function isWallFollower() { return !!(wallConfig && !wallConfig.is_leader); }
// YouTube player state. Declared up front because the cached-playlist restore // YouTube player state. Declared up front because the cached-playlist restore
// (a few lines below) may synchronously call into createYoutubeEmbed before the // (a few lines below) may synchronously call into createYoutubeEmbed before the
// script reaches the original declaration site, which used to throw a temporal // script reaches the original declaration site, which used to throw a temporal
@ -209,25 +240,89 @@
let activeYtPlayer = null; let activeYtPlayer = null;
let ytGeneration = 0; let ytGeneration = 0;
// AudioContext is created lazily on the first user gesture. Resuming it
// is what convinces stricter browsers (Firefox) that the site is "user-
// activated" for audio. Reused across all later unmute attempts.
let _audioCtx = null;
function unlockAudioContext() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {});
// Play a 1-sample silent buffer to fully promote the context to running.
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { /* harmless */ }
}
// Try to unmute and play the leader video. MUST be called synchronously
// from inside a real user-gesture handler — any preceding await would
// throw away the gesture's user-activation in stricter browsers (Firefox).
// Returns immediately; the play() promise is resolved/rejected async.
function tryUnmuteLeader() {
const video = document.querySelector('#playerContainer video');
if (!video) return false;
if (!video.muted) return true;
// Capture state, unmute, do a fresh pause+play within the same task.
// Firefox is more permissive when play() is treated as a brand-new
// gesture-driven start rather than the unmute of an autoplaying video.
const t = video.currentTime;
video.muted = false;
video.volume = 1.0;
video.pause();
const p = video.play();
if (p && typeof p.then === 'function') {
p.then(() => {
if (isFinite(t)) { try { video.currentTime = t; } catch {} }
console.log('[wall/audio] unmuted play() ok muted=' + video.muted + ' volume=' + video.volume);
hideEnableAudioPrompt();
}).catch((err) => {
console.warn('[wall/audio] unmuted play() rejected: ' + (err?.name || err?.message || err));
// Remute so playback continues; surface the prompt for explicit consent.
video.muted = true;
video.play().catch((e2) => console.error('[wall/audio] muted-fallback play() failed: ' + (e2?.name || e2?.message || e2)));
showEnableAudioPrompt();
});
}
return true;
}
// Visible "tap to enable audio" prompt for leaders whose unmute failed.
// The user clicking this prompt is itself a fresh gesture, which is the
// most reliable path past Firefox's autoplay restriction.
function showEnableAudioPrompt() {
if (isWallFollower()) return;
if (document.getElementById('enableAudioPrompt')) return;
const ov = document.createElement('div');
ov.id = 'enableAudioPrompt';
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
});
document.body.appendChild(ov);
}
function hideEnableAudioPrompt() {
document.getElementById('enableAudioPrompt')?.remove();
}
// Track user interaction for autoplay policy // Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => { ['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => { document.addEventListener(evt, () => {
const wasFirst = !userHasInteracted;
userHasInteracted = true; userHasInteracted = true;
// HTML5 video: setting muted=false on a video that's been muted-autoplaying // First gesture: prime the AudioContext. This signals "site activated"
// causes the browser to pause it as a side effect. We have a real user // to Firefox and unlocks subsequent <video> unmute attempts.
// gesture here, so play() should succeed — but if it doesn't, fall back to if (wasFirst) unlockAudioContext();
// muted playback rather than leaving a black/paused screen. // Followers in a video wall must stay muted forever — even after a
const video = document.querySelector('#playerContainer video'); // user gesture. Otherwise tapping a follower screen would unmute it
if (video && video.muted) { // and cause echo with the leader.
video.muted = false; if (isWallFollower()) return;
video.play() if (wasFirst) console.log('[wall/audio] first user gesture detected — attempting unmute');
.then(() => console.log('Unmuted video after user interaction')) tryUnmuteLeader();
.catch(err => {
console.warn('Unmuted play() rejected, falling back to muted:', err?.message || err);
video.muted = true;
video.play().catch(e => console.warn('Muted fallback play() failed:', e?.message || e));
});
}
// Unmute YouTube player if active // Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') { if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {} try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {}
@ -337,6 +432,8 @@
src.connect(ctx.destination); src.connect(ctx.destination);
src.start(0); src.start(0);
} catch(e) { console.warn('Audio unlock failed:', e); } } catch(e) { console.warn('Audio unlock failed:', e); }
// Wall followers must stay muted — leader is the only audio source.
if (isWallFollower()) return;
// Unmute any playing HTML5 video // Unmute any playing HTML5 video
document.querySelectorAll('video').forEach(v => { v.muted = false; }); document.querySelectorAll('video').forEach(v => { v.muted = false; });
// Unmute the active YouTube embed (iframe — querySelectorAll('video') misses it) // Unmute the active YouTube embed (iframe — querySelectorAll('video') misses it)
@ -447,6 +544,50 @@
handlePlaylistUpdate(data); handlePlaylistUpdate(data);
}); });
// Video wall sync (leader broadcasts; followers align)
socket.on('wall:sync', (data) => {
if (!wallConfig || wallConfig.is_leader) return;
if (data.wall_id !== wallConfig.wall_id) return;
lastWallSync = data;
// If leader switched item, jump to it
if (typeof data.current_index === 'number' && data.current_index !== currentIndex && playlist.length > 0) {
currentIndex = ((data.current_index % playlist.length) + playlist.length) % playlist.length;
playCurrentItem();
}
// Hold the follower close to the leader's clock. Account for relay
// latency: the leader was at position_sec when sent_at was stamped;
// by now a bit more time has elapsed, so target = position + latency.
if (currentVideoEl && typeof data.position_sec === 'number') {
const now = Date.now();
const latency = data.sent_at ? Math.max(0, (now - data.sent_at) / 1000) : 0;
const target = data.position_sec + latency;
const drift = (currentVideoEl.currentTime || 0) - target;
const absDrift = Math.abs(drift);
if (absDrift > 0.3 && isFinite(currentVideoEl.duration) && target < currentVideoEl.duration) {
// Big drift: hard seek and reset rate.
try { currentVideoEl.currentTime = target; } catch (_) {}
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
} else if (absDrift > 0.05) {
// Small drift: nudge playbackRate to converge gently. ±3% is
// imperceptible on most content but pulls in 50ms drift in <2s.
try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (_) {}
} else if (currentVideoEl.playbackRate !== 1.0) {
// In-window: ride at normal rate.
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
}
}
});
// Leader receives a sync-request from a (re)connecting follower and
// immediately broadcasts its position so the requester can align without
// waiting for the next periodic tick.
socket.on('wall:sync-request', (data) => {
if (!wallConfig?.is_leader) return;
if (data?.wall_id && data.wall_id !== wallConfig.wall_id) return;
console.log('[wall] sync-request received from ' + data?.requested_by + ', broadcasting current position');
emitWallSync();
});
socket.on('device:content-delete', (data) => { socket.on('device:content-delete', (data) => {
playlist = playlist.filter(p => p.content_id !== data.content_id); playlist = playlist.filter(p => p.content_id !== data.content_id);
savePlaylistCache(playlist); savePlaylistCache(playlist);
@ -487,14 +628,15 @@
if (video) { video.paused ? video.play() : video.pause(); } if (video) { video.paused ? video.play() : video.pause(); }
break; break;
case 'KEYCODE_VOLUME_UP': case 'KEYCODE_VOLUME_UP':
if (video) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; } // Wall followers ignore volume changes — they stay silent.
if (video && !isWallFollower()) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; }
break; break;
case 'KEYCODE_VOLUME_DOWN': case 'KEYCODE_VOLUME_DOWN':
if (video) { video.volume = Math.max(0, video.volume - 0.1); } if (video) { video.volume = Math.max(0, video.volume - 0.1); }
break; break;
case 'KEYCODE_MENU': case 'KEYCODE_MENU':
// Toggle mute // Toggle mute (followers can't unmute)
if (video) { video.muted = !video.muted; } if (video && !(isWallFollower() && video.muted)) { video.muted = !video.muted; }
break; break;
case 'KEYCODE_HOME': case 'KEYCODE_HOME':
// Go back to first item // Go back to first item
@ -605,6 +747,107 @@
}, 30000); }, 30000);
} }
// ==================== Video Wall ====================
// Convert a wall_config payload into CSS that sizes & positions the wall
// stage so this device's tile is the visible portion. Each tile is
// 100vw × 100vh; the stage is the full grid, translated by this tile's
// grid position (plus bezel offsets in px between tiles).
function applyWallMode(config) {
const container = document.getElementById('playerContainer');
// Tear down previous wall mode (clear sync timer regardless of new state)
if (wallSyncTimer) { clearInterval(wallSyncTimer); wallSyncTimer = null; }
lastWallSync = null;
if (!config) {
wallConfig = null;
container.classList.remove('wall-mode');
console.log('[wall] exited wall mode');
return;
}
wallConfig = config;
container.classList.add('wall-mode');
console.log('[wall] applyWallMode wall=' + config.wall_id + ' is_leader=' + config.is_leader + ' userHasInteracted=' + userHasInteracted);
// Enforce the audio rule on the currently-mounted video right now.
// If the role flipped (e.g., leader was reassigned mid-stream), the
// existing video element keeps its old muted state until we touch it.
if (currentVideoEl) {
if (config.is_leader) {
// Defer to autoplay policy — leader can be unmuted once the user
// has gestured. Don't yank audio if it's already playing.
} else {
if (!currentVideoEl.muted) currentVideoEl.muted = true;
}
}
if (config.is_leader) {
// Leader emits at 4Hz so followers can apply small playbackRate
// corrections instead of jerk-seeking. Higher rates would saturate
// the relay; 4Hz balances tightness against server load.
wallSyncTimer = setInterval(emitWallSync, 250);
// Immediate broadcast so any follower that's already up aligns now,
// without waiting up to 250ms for the first scheduled tick. This
// also covers a leader reclaiming the role after a reconnect.
setTimeout(emitWallSync, 100);
} else {
// Follower: ask the leader for its current position. Without this,
// the screen shows the start of the current item until the leader's
// next periodic tick (up to ~1s of visible drift on a fresh join).
if (socket?.connected) {
console.log('[wall] follower emitting sync-request for wall ' + config.wall_id);
socket.emit('wall:sync-request', { wall_id: config.wall_id });
}
}
}
function emitWallSync() {
if (!wallConfig?.is_leader || !socket?.connected || playlist.length === 0) return;
const item = playlist[currentIndex];
if (!item) return;
const position = currentVideoEl
? (currentVideoEl.currentTime || 0)
: Math.max(0, (Date.now() - currentItemStartedAt) / 1000);
socket.emit('wall:sync', {
wall_id: wallConfig.wall_id,
device_id: config.deviceId,
current_index: currentIndex,
content_id: item.content_id || null,
position_sec: position,
sent_at: Date.now(),
});
}
// Map the player rect into this device's viewport using vw/vh so the
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to
// the stage — which keeps the vertical position of every source pixel
// identical across devices that share a viewport height (1vh maps to
// the same physical pixel on each).
function styleWallStage(stageEl) {
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
const s = wallConfig.screen_rect;
const p = wallConfig.player_rect;
if (!s.w || !s.h) return;
const left = ((p.x - s.x) / s.w) * 100;
const top = ((p.y - s.y) / s.h) * 100;
const width = (p.w / s.w) * 100;
const height = (p.h / s.h) * 100;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
stageEl.style.left = left + 'vw';
stageEl.style.top = top + 'vh';
stageEl.style.width = width + 'vw';
stageEl.style.height = height + 'vh';
stageEl.style.transform = '';
}
// No-op kept for callers that bind a resize listener (kept around in case
// future zoom/orientation tweaks need it). vw/vh stage updates with the
// viewport automatically, so explicit re-style isn't needed today.
function bindWallResizeOnce() {}
// ==================== Playlist ==================== // ==================== Playlist ====================
function handlePlaylistUpdate(data) { function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit) // Check if device is suspended (trial expired / over limit)
@ -642,9 +885,26 @@
} }
} }
// Apply (or clear) wall mode. Force re-render when wall config changes
// even if the playlist itself didn't, so leader/follower role transitions
// and tile reassignments take effect immediately.
function wallKey(c) {
if (!c) return '';
const s = c.screen_rect || {}, p = c.player_rect || {};
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
}
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
if (wallChanged) applyWallMode(data.wall_config || null);
// A fresh playlist-update on a follower (typical after socket reconnect)
// is a good signal to ask the leader for its current position even when
// the wall config itself didn't change. Cheap, debounced server-side.
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
}
layout = data.layout || null; layout = data.layout || null;
if (newFp === oldFp && playlist.length > 0) { if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged'); console.log('Playlist unchanged');
return; return;
} }
@ -676,18 +936,26 @@
hideStatus(); hideStatus();
const item = playlist[currentIndex]; const item = playlist[currentIndex];
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`); console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
currentItemStartedAt = Date.now();
currentVideoEl = null;
// Send play event // Only the leader (or single, non-walled players) records a play_start —
if (socket?.connected) { // followers would just spam duplicate proof-of-play rows for the same item.
if (socket?.connected && (!wallConfig || wallConfig.is_leader)) {
socket.emit('device:play-event', { socket.emit('device:play-event', {
device_id: config.deviceId, device_id: config.deviceId,
event: 'play_start', event: 'play_start',
content_id: item.content_id, content_id: item.content_id,
content_name: item.filename, content_name: item.filename,
duration_sec: item.duration_sec || null,
}); });
} }
renderContent(item); renderContent(item);
// Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync();
} }
function nextItem() { function nextItem() {
@ -847,6 +1115,23 @@
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = ''; container.innerHTML = '';
// In wall mode, mount content into a stage that maps the player_rect
// into this device's viewport. playerContainer's overflow:hidden clips
// the parts of the stage outside this device's viewport, so each
// device shows exactly its slice of the wall.
let mount = container;
if (wallConfig) {
const stage = document.createElement('div');
stage.className = 'wall-stage';
styleWallStage(stage);
container.appendChild(stage);
mount = stage;
}
// Followers don't run their own advance timers — the leader's wall:sync
// dictates index transitions. Single-screen and leader behave normally.
const isFollower = !!wallConfig && !wallConfig.is_leader;
const isYoutube = item.mime_type === 'video/youtube'; const isYoutube = item.mime_type === 'video/youtube';
const isVideo = !isYoutube && item.mime_type?.startsWith('video/'); const isVideo = !isYoutube && item.mime_type?.startsWith('video/');
const isImage = item.mime_type?.startsWith('image/'); const isImage = item.mime_type?.startsWith('image/');
@ -854,47 +1139,85 @@
const serverUrl = config.serverUrl; const serverUrl = config.serverUrl;
const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`; const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`;
if (layout && layout.zones && layout.zones.length > 1) { if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
renderZones(container, item); renderZones(container, item);
} else { } else {
// Fullscreen // Fullscreen / wall-tile
if (isYoutube) { if (isYoutube) {
createYoutubeEmbed(src, item, container); createYoutubeEmbed(src, item, mount);
} else if (isVideo) { } else if (isVideo) {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = src; video.src = src;
video.autoplay = true; video.autoplay = true;
video.muted = !userHasInteracted; // Unmuted if user has interacted // Followers stay muted unconditionally (leader-only audio); leaders
// start muted only if the user hasn't gestured yet (autoplay policy).
video.muted = isFollower ? true : !userHasInteracted;
// Explicit max volume on the leader so audio is at full level when
// unmute happens (default is 1.0 but make it visible in logs).
if (!isFollower) video.volume = 1.0;
video.playsInline = true; video.playsInline = true;
video.crossOrigin = 'anonymous'; video.crossOrigin = 'anonymous';
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000'; // Wall mode uses object-fit:fill so the source stretches to the
// stage exactly. Cover would re-crop based on each device's stage
// aspect (different innerWidths produce different cover scales),
// which is the original vertical-misalignment bug. Fill keeps the
// vertical mapping uniform across devices that share a viewport
// height. Solo (non-wall) keeps contain to preserve aspect.
video.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill;background:#000'
: 'width:100%;height:100%;object-fit:contain;background:#000';
video.loop = (playlist.length === 1); video.loop = (playlist.length === 1);
video.onended = () => { if (!video.loop) nextItem(); }; video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); }; video.onerror = (e) => {
video.onloadeddata = () => { console.error('Video error:', src, e);
console.log('Video loaded:', item.filename, 'muted:', video.muted); if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
}; };
container.appendChild(video); video.onloadeddata = () => {
// Try playing unmuted, fall back to muted console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); };
// If anything (browser, scripts, the user) tries to unmute a
// follower, snap it back. This is the safety net for the audio
// bug — without it, a single stray unmute call causes echo.
if (isFollower) {
video.addEventListener('volumechange', () => {
if (!video.muted) { video.muted = true; }
});
}
mount.appendChild(video);
currentVideoEl = video;
// Try playing as we set muted above. If the browser blocks
// unmuted autoplay (e.g. no user gesture yet), retry muted.
video.play().then(() => {
console.log('[wall/audio] play() ok muted=' + video.muted + ' volume=' + video.volume);
}).catch((err) => {
console.warn('[wall/audio] play() rejected, falling back to muted: ' + (err?.name || err?.message || err));
video.muted = true;
video.play()
.then(() => console.log('[wall/audio] muted-fallback play() ok'))
.catch((e2) => console.error('[wall/audio] muted-fallback play() also failed: ' + (e2?.name || e2?.message || e2)));
});
// Fallback: force play if not started after 2s // Fallback: force play if not started after 2s
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000); setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000);
} else if (isImage) { } else if (isImage) {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = src; img.src = src;
img.style.cssText = 'width:100%;height:100%;object-fit:contain'; img.style.cssText = wallConfig
img.onerror = () => { console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); }; ? 'width:100%;height:100%;object-fit:fill'
container.appendChild(img); : 'width:100%;height:100%;object-fit:contain';
// Auto advance for images img.onerror = () => {
advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000); console.error('Image error');
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
mount.appendChild(img);
// Leader / single screen drives image advance; follower waits for sync
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) { } else if (item.widget_id) {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render`; iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000'; iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen'; iframe.allow = 'autoplay; fullscreen';
container.appendChild(iframe); mount.appendChild(iframe);
// Auto advance for widgets if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
} }
} }
} }

View file

@ -107,11 +107,14 @@ router.get('/:id/devices', requireGroupOwnership, (req, res) => {
res.json(devices); res.json(devices);
}); });
// Add device to group // Add device to group. If the group has a playlist set (via the assign-playlist
// dropdown on the dashboard), the new device inherits it — both for drag-drop
// onto the group section and for the Manage modal's checkboxes, which both
// hit this endpoint. Without this, joining a group never auto-assigned the
// group's playlist, leaving the new device on whatever it had before.
router.post('/:id/devices', requireGroupOwnership, (req, res) => { router.post('/:id/devices', requireGroupOwnership, (req, res) => {
const { device_id } = req.body; const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
// Verify device belongs to the user (admin/superadmin bypass)
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
@ -119,15 +122,43 @@ router.post('/:id/devices', requireGroupOwnership, (req, res) => {
} }
try { try {
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id); db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
res.status(201).json({ success: true });
// Sync device's playlist to the group's: a defined playlist is inherited,
// a group with no playlist clears the device's. The user's mental model
// is "joining a group means using its playlist (or none)" — staying on a
// stale playlist after joining a no-playlist group was the bug we just hit.
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
const newPlaylist = group?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
pushPlaylistToDevice(req, device_id);
res.status(201).json({ success: true, playlist_id: newPlaylist });
} catch (e) { } catch (e) {
res.status(400).json({ error: e.message }); res.status(400).json({ error: e.message });
} }
}); });
// Remove device from group // Remove device from group. Sync the device's playlist to whatever its
// current group membership implies — symmetric with the join sync above.
// - No remaining groups → clear playlist (Ungrouped).
// - Remaining group with a playlist → adopt that playlist.
// - Remaining group(s) but none have a playlist → clear playlist.
// Without this, a device dragged out of a group keeps stale playlist state
// from the group it just left.
router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => { router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => {
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(req.params.deviceId, req.params.id); const deviceId = req.params.deviceId;
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
const remaining = db.prepare(`
SELECT g.playlist_id FROM device_groups g
JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE dgm.device_id = ?
ORDER BY g.playlist_id IS NULL, g.name ASC
LIMIT 1
`).get(deviceId);
const newPlaylist = remaining?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
pushPlaylistToDevice(req, deviceId);
res.json({ success: true }); res.json({ success: true });
}); });
@ -183,7 +214,8 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
res.json({ success: true, devices_updated: members.length }); res.json({ success: true, devices_updated: members.length });
}); });
// Assign an existing playlist to all devices in a group // Assign an existing playlist to all devices in a group, and persist the
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => { router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
const { playlist_id } = req.body; const { playlist_id } = req.body;
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' }); if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
@ -195,6 +227,7 @@ router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
for (const m of members) stmt.run(playlist_id, m.device_id); for (const m of members) stmt.run(playlist_id, m.device_id);
}); });
transaction(); transaction();

View file

@ -3,14 +3,49 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
// List walls // Visibility model (matches widgets/users):
router.get('/', (req, res) => { // superadmin: all walls
const isAdmin = req.user.role === 'superadmin'; // admin: own + walls owned by members of teams this admin owns
const walls = db.prepare( // user: own only
`SELECT * FROM video_walls ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC` function listVisibleWalls(user) {
).all(...(isAdmin ? [] : [req.user.id])); if (user.role === 'superadmin') {
return db.prepare('SELECT * FROM video_walls ORDER BY created_at DESC').all();
}
if (user.role === 'admin') {
return db.prepare(`
SELECT DISTINCT w.* FROM video_walls w
LEFT JOIN team_members tm_target ON w.user_id = tm_target.user_id
LEFT JOIN team_members tm_admin
ON tm_admin.team_id = tm_target.team_id
AND tm_admin.user_id = ?
AND tm_admin.role = 'owner'
WHERE w.user_id = ?
OR tm_admin.team_id IS NOT NULL
ORDER BY w.created_at DESC
`).all(user.id, user.id);
}
return db.prepare('SELECT * FROM video_walls WHERE user_id = ? ORDER BY created_at DESC').all(user.id);
}
function userCanAccessWall(user, wall) {
if (user.role === 'superadmin') return true;
if (wall.user_id === user.id) return true;
if (user.role === 'admin') {
const ownsTeamWithOwner = db.prepare(`
SELECT 1 FROM team_members tm_target
JOIN team_members tm_admin ON tm_admin.team_id = tm_target.team_id
WHERE tm_target.user_id = ? AND tm_admin.user_id = ? AND tm_admin.role = 'owner'
LIMIT 1
`).get(wall.user_id, user.id);
if (ownsTeamWithOwner) return true;
}
return false;
}
// List walls (with attached devices)
router.get('/', (req, res) => {
const walls = listVisibleWalls(req.user);
// Attach devices to each wall
const devStmt = db.prepare(` const devStmt = db.prepare(`
SELECT vwd.*, d.name as device_name, d.status as device_status SELECT vwd.*, d.name as device_name, d.status as device_status
FROM video_wall_devices vwd FROM video_wall_devices vwd
@ -23,59 +58,88 @@ router.get('/', (req, res) => {
res.json(walls); res.json(walls);
}); });
// Helper: check wall ownership
function checkWallAccess(req, res) { function checkWallAccess(req, res) {
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id);
if (!wall) { res.status(404).json({ error: 'Wall not found' }); return null; } if (!wall) { res.status(404).json({ error: 'Wall not found' }); return null; }
if (!['admin','superadmin'].includes(req.user.role) && wall.user_id !== req.user.id) { res.status(403).json({ error: 'Access denied' }); return null; } if (!userCanAccessWall(req.user, wall)) {
res.status(403).json({ error: 'Access denied' }); return null;
}
return wall; return wall;
} }
// Notify dashboard clients to re-fetch walls/devices. Re-fetches re-apply
// per-user visibility filtering, so a broadcast is safe.
function notifyDashboards(req) {
try {
const io = req.app.get('io');
if (!io) return;
io.of('/dashboard').emit('dashboard:wall-changed');
} catch (e) { /* silent */ }
}
function loadWallWithDevices(id) {
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(id);
if (!wall) return null;
wall.devices = db.prepare(`
SELECT vwd.*, d.name as device_name, d.status as device_status
FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id
WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col
`).all(id);
return wall;
}
// Push a fresh wall-aware playlist payload to one device.
function pushWallPayloadToDevice(req, deviceId) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
} catch (e) { /* silent */ }
}
function pushToWallMembers(req, wallId) {
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wallId);
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
}
// Get wall with devices // Get wall with devices
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
res.json(loadWallWithDevices(wall.id));
wall.devices = db.prepare(`
SELECT vwd.*, d.name as device_name, d.status as device_status
FROM video_wall_devices vwd
JOIN devices d ON vwd.device_id = d.id
WHERE vwd.wall_id = ?
ORDER BY vwd.grid_row, vwd.grid_col
`).all(wall.id);
res.json(wall);
}); });
// Create wall // Create wall
router.post('/', (req, res) => { router.post('/', (req, res) => {
const { name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm } = req.body; const { name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id } = req.body;
if (!name) return res.status(400).json({ error: 'name required' }); if (!name) return res.status(400).json({ error: 'name required' });
const id = uuidv4(); const id = uuidv4();
db.prepare(` db.prepare(`
INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm) INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, name, grid_cols || 2, grid_rows || 2, `).run(id, req.user.id, name, grid_cols || 2, grid_rows || 1,
bezel_h_mm || 0, bezel_v_mm || 0, screen_w_mm || 400, screen_h_mm || 225); bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null);
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(id); const wall = loadWallWithDevices(id);
wall.devices = []; notifyDashboards(req);
res.status(201).json(wall); res.status(201).json(wall);
}); });
// Update wall // Update wall (name, grid, bezels, playlist, leader, sync_mode)
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
const fields = ['name', 'grid_cols', 'grid_rows', 'bezel_h_mm', 'bezel_v_mm', const fields = ['name', 'grid_cols', 'grid_rows', 'bezel_h_mm', 'bezel_v_mm',
'screen_w_mm', 'screen_h_mm', 'sync_mode', 'leader_device_id', 'content_id']; 'screen_w_mm', 'screen_h_mm', 'sync_mode', 'leader_device_id', 'content_id', 'playlist_id',
'player_x', 'player_y', 'player_width', 'player_height'];
const updates = []; const updates = [];
const values = []; const values = [];
fields.forEach(f => { for (const f of fields) {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
}); }
if (updates.length > 0) { if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
@ -83,26 +147,43 @@ router.put('/:id', (req, res) => {
db.prepare(`UPDATE video_walls SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE video_walls SET ${updates.join(', ')} WHERE id = ?`).run(...values);
} }
const updated = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); // If playlist changed, propagate to every member device's playlist_id so the
updated.devices = db.prepare(` // existing buildPlaylistPayload picks up the right items.
SELECT vwd.*, d.name as device_name, d.status as device_status if (req.body.playlist_id !== undefined) {
FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
`).all(req.params.id); for (const m of members) stmt.run(req.body.playlist_id || null, m.device_id);
}
res.json(updated); pushToWallMembers(req, req.params.id);
notifyDashboards(req);
res.json(loadWallWithDevices(req.params.id));
}); });
// Delete wall // Delete wall — clear playlists + wall_id on every former member (matches
// group-dissolve semantics: leaving the wall returns devices to ungrouped).
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id);
db.prepare('DELETE FROM video_walls WHERE id = ?').run(req.params.id); const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
const tx = db.transaction(() => {
db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id);
db.prepare('DELETE FROM video_walls WHERE id = ?').run(req.params.id);
});
tx();
// Push fresh (now wall-less, playlist-less) payloads to ex-members so they
// exit wall mode and clear content immediately.
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
notifyDashboards(req);
res.json({ success: true }); res.json({ success: true });
}); });
// Set device grid positions // Set device grid positions. Replaces the entire member set.
// Devices removed lose their playlist (returned to ungrouped); devices added
// inherit the wall's playlist.
router.put('/:id/devices', (req, res) => { router.put('/:id/devices', (req, res) => {
const { devices } = req.body; const { devices } = req.body;
if (!Array.isArray(devices)) return res.status(400).json({ error: 'devices array required' }); if (!Array.isArray(devices)) return res.status(400).json({ error: 'devices array required' });
@ -110,38 +191,89 @@ router.put('/:id/devices', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
// Clear existing // Verify caller owns (or has team access to) every device they're adding.
db.prepare('DELETE FROM video_wall_devices WHERE wall_id = ?').run(req.params.id); // Without this a user could attach another tenant's devices to their own
db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id); // wall and silently take over the playlist + wall_id on those rows.
// Mirrors the per-device check in device-groups.js.
if (!['superadmin'].includes(req.user.role)) {
const isAdmin = req.user.role === 'admin';
for (const d of devices) {
const dev = db.prepare('SELECT user_id, team_id FROM devices WHERE id = ?').get(d.device_id);
if (!dev) return res.status(404).json({ error: `Device ${d.device_id} not found` });
if (dev.user_id === req.user.id) continue;
if (isAdmin && dev.user_id) {
// Admin may attach team members' devices: dev's owner must be in a team this admin owns
const ownsTeamWithOwner = db.prepare(`
SELECT 1 FROM team_members tm_target
JOIN team_members tm_admin ON tm_admin.team_id = tm_target.team_id
WHERE tm_target.user_id = ? AND tm_admin.user_id = ? AND tm_admin.role = 'owner'
LIMIT 1
`).get(dev.user_id, req.user.id);
if (ownsTeamWithOwner) continue;
}
// Non-admin: must own the device directly
return res.status(403).json({ error: `Access denied to device ${d.device_id}` });
}
}
// Add new positions const previous = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
const stmt = db.prepare('INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation) VALUES (?, ?, ?, ?, ?)'); const previousIds = new Set(previous.map(p => p.device_id));
const updateDevice = db.prepare("UPDATE devices SET wall_id = ? WHERE id = ?"); const incomingIds = new Set(devices.map(d => d.device_id));
const removedIds = [...previousIds].filter(id => !incomingIds.has(id));
const transaction = db.transaction(() => { const tx = db.transaction(() => {
devices.forEach(d => { db.prepare('DELETE FROM video_wall_devices WHERE wall_id = ?').run(req.params.id);
stmt.run(req.params.id, d.device_id, d.grid_col, d.grid_row, d.rotation || 0); db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id);
updateDevice.run(req.params.id, d.device_id);
}); // Removed devices: clear playlist (they're returning to ungrouped state).
// Set first device as leader if none set for (const id of removedIds) {
if (!wall.leader_device_id && devices.length > 0) { db.prepare("UPDATE devices SET playlist_id = NULL WHERE id = ?").run(id);
const leader = devices.find(d => d.grid_col === 0 && d.grid_row === 0) || devices[0]; }
const insertPos = db.prepare(`
INSERT INTO video_wall_devices
(wall_id, device_id, grid_col, grid_row, rotation, canvas_x, canvas_y, canvas_width, canvas_height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateDevice = db.prepare("UPDATE devices SET wall_id = ?, playlist_id = ? WHERE id = ?");
for (const d of devices) {
insertPos.run(
req.params.id, d.device_id,
d.grid_col, d.grid_row, d.rotation || 0,
d.canvas_x ?? null, d.canvas_y ?? null,
d.canvas_width ?? null, d.canvas_height ?? null,
);
updateDevice.run(req.params.id, wall.playlist_id || null, d.device_id);
// A device joining a wall leaves all of its groups (walls and groups
// are mutually exclusive concepts in this UX).
db.prepare('DELETE FROM device_group_members WHERE device_id = ?').run(d.device_id);
}
if (devices.length > 0) {
// Prefer the device whose canvas rect is closest to the wall's top-left
// (smallest canvas_x + canvas_y), falling back to grid 0,0, then first.
const leader =
[...devices].sort((a, b) => ((a.canvas_x ?? 0) + (a.canvas_y ?? 0)) - ((b.canvas_x ?? 0) + (b.canvas_y ?? 0)))[0]
|| devices.find(d => d.grid_col === 0 && d.grid_row === 0)
|| devices[0];
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(leader.device_id, req.params.id); db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(leader.device_id, req.params.id);
} else {
db.prepare('UPDATE video_walls SET leader_device_id = NULL WHERE id = ?').run(req.params.id);
} }
}); });
transaction(); tx();
const updated = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); // Push wall-aware payload to current members, and a wall-less payload to
updated.devices = db.prepare(` // ex-members so they exit wall mode.
SELECT vwd.*, d.name as device_name, d.status as device_status for (const id of removedIds) pushWallPayloadToDevice(req, id);
FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id pushToWallMembers(req, req.params.id);
WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col notifyDashboards(req);
`).all(req.params.id);
res.json(updated); res.json(loadWallWithDevices(req.params.id));
}); });
// Set wall content // Set wall content (legacy single-video path — kept for back-compat)
router.put('/:id/content', (req, res) => { router.put('/:id/content', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
@ -151,7 +283,7 @@ router.put('/:id/content', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// Get wall config for a specific device (used by Android app) // Get wall config for a specific device (legacy fetch path)
router.get('/:id/device-config/:deviceId', (req, res) => { router.get('/:id/device-config/:deviceId', (req, res) => {
const wall = checkWallAccess(req, res); const wall = checkWallAccess(req, res);
if (!wall) return; if (!wall) return;
@ -160,15 +292,6 @@ router.get('/:id/device-config/:deviceId', (req, res) => {
.get(req.params.id, req.params.deviceId); .get(req.params.id, req.params.deviceId);
if (!position) return res.status(404).json({ error: 'Device not in this wall' }); if (!position) return res.status(404).json({ error: 'Device not in this wall' });
// Calculate crop region
const totalW = wall.grid_cols * wall.screen_w_mm + (wall.grid_cols - 1) * wall.bezel_h_mm;
const totalH = wall.grid_rows * wall.screen_h_mm + (wall.grid_rows - 1) * wall.bezel_v_mm;
const cropX = (position.grid_col * (wall.screen_w_mm + wall.bezel_h_mm)) / totalW;
const cropY = (position.grid_row * (wall.screen_h_mm + wall.bezel_v_mm)) / totalH;
const cropW = wall.screen_w_mm / totalW;
const cropH = wall.screen_h_mm / totalH;
res.json({ res.json({
wall_id: wall.id, wall_id: wall.id,
grid_cols: wall.grid_cols, grid_cols: wall.grid_cols,
@ -176,8 +299,8 @@ router.get('/:id/device-config/:deviceId', (req, res) => {
grid_col: position.grid_col, grid_col: position.grid_col,
grid_row: position.grid_row, grid_row: position.grid_row,
rotation: position.rotation, rotation: position.rotation,
crop: { x: cropX, y: cropY, width: cropW, height: cropH }, bezel_h_px: wall.bezel_h_mm,
content_id: wall.content_id, bezel_v_px: wall.bezel_v_mm,
sync_mode: wall.sync_mode, sync_mode: wall.sync_mode,
is_leader: wall.leader_device_id === req.params.deviceId, is_leader: wall.leader_device_id === req.params.deviceId,
}); });

View file

@ -46,7 +46,7 @@ function logDeviceStatus(deviceId, status) {
// Build playlist payload with layout and zones // Build playlist payload with layout and zones
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices // Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
function buildPlaylistPayload(deviceId) { function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation FROM devices WHERE id = ?').get(deviceId); const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id FROM devices WHERE id = ?').get(deviceId);
let assignments = []; let assignments = [];
if (device?.playlist_id) { if (device?.playlist_id) {
@ -64,7 +64,68 @@ function buildPlaylistPayload(deviceId) {
} }
} }
return { assignments, layout, orientation: device?.orientation || 'landscape' }; // Wall membership flips the player into wall mode. The renderer needs two
// rectangles in canvas-space: this device's screen rect, and the wall's
// player rect. The intersection is what this screen displays. The leader
// drives playback; followers track via wall:sync.
let wall_config = null;
if (device?.wall_id) {
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(device.wall_id);
const pos = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?').get(device.wall_id, deviceId);
if (wall && pos) {
const baseW = 320, baseH = 180;
const bezelH = wall.bezel_h_mm || 0;
const bezelV = wall.bezel_v_mm || 0;
// Backfill canvas rect from grid math when canvas_* is unset (legacy
// walls that haven't been touched by the new editor yet). Coords are
// rounded to integers so sub-pixel drift can't cause two visually
// identical rects to compute different stage offsets.
const screenRect = {
x: Math.round(pos.canvas_x ?? (pos.grid_col * (baseW + bezelH))),
y: Math.round(pos.canvas_y ?? (pos.grid_row * (baseH + bezelV))),
w: Math.round(pos.canvas_width ?? baseW),
h: Math.round(pos.canvas_height ?? baseH),
};
// Player rect defaults to the bounding box of all screens on the wall.
let playerRect;
if (wall.player_x !== null && wall.player_x !== undefined) {
playerRect = { x: wall.player_x, y: wall.player_y, w: wall.player_width, h: wall.player_height };
} else {
const all = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const p of all) {
const px = p.canvas_x ?? (p.grid_col * (baseW + bezelH));
const py = p.canvas_y ?? (p.grid_row * (baseH + bezelV));
const pw = p.canvas_width ?? baseW;
const ph = p.canvas_height ?? baseH;
if (px < x) x = px;
if (py < y) y = py;
if (px + pw > x2) x2 = px + pw;
if (py + ph > y2) y2 = py + ph;
}
playerRect = isFinite(x)
? { x, y, w: x2 - x, h: y2 - y }
: { x: 0, y: 0, w: baseW, h: baseH };
}
// Round the player rect too — same rationale.
playerRect = {
x: Math.round(playerRect.x), y: Math.round(playerRect.y),
w: Math.round(playerRect.w), h: Math.round(playerRect.h),
};
wall_config = {
wall_id: wall.id,
screen_rect: screenRect,
player_rect: playerRect,
is_leader: wall.leader_device_id === deviceId,
rotation: pos.rotation || 0,
};
}
}
return { assignments, layout, orientation: device?.orientation || 'landscape', wall_config };
} }
// Check if a device should show trial expired screen // Check if a device should show trial expired screen
@ -240,6 +301,40 @@ module.exports = function setupDeviceSocket(io) {
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' }); socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
logDeviceStatus(device_id, 'online'); logDeviceStatus(device_id, 'online');
// If this device is part of a wall, re-evaluate leadership.
// Preferred leader = online member with smallest (canvas_x +
// canvas_y), falling back to grid 0,0. If the original leader
// (top-left tile) is back, they reclaim the role and peers re-sync.
if (device.wall_id) {
try {
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(device.wall_id);
if (wall) {
const candidates = db.prepare(`
SELECT vwd.device_id, vwd.canvas_x, vwd.canvas_y, vwd.grid_col, vwd.grid_row
FROM video_wall_devices vwd
JOIN devices d ON d.id = vwd.device_id
WHERE vwd.wall_id = ? AND d.status = 'online'
`).all(wall.id);
if (candidates.length > 0) {
const score = (c) => (c.canvas_x ?? c.grid_col * 320) + (c.canvas_y ?? c.grid_row * 180);
candidates.sort((a, b) => score(a) - score(b));
const preferredLeader = candidates[0].device_id;
if (wall.leader_device_id !== preferredLeader) {
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(preferredLeader, wall.id);
console.log(`Wall ${wall.id} leader reassigned to ${preferredLeader} on reconnect`);
// Re-push payload to every member so role flags refresh.
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
for (const m of members) {
if (m.device_id !== device_id) {
deviceNs.to(m.device_id).emit('device:playlist-update', buildPlaylistPayload(m.device_id));
}
}
}
}
}
} catch (e) { console.error('Wall leader reclaim failed:', e.message); }
}
// Check subscription/trial status before sending playlist // Check subscription/trial status before sending playlist
const access = checkDeviceAccess(device_id); const access = checkDeviceAccess(device_id);
if (!access.allowed) { if (!access.allowed) {
@ -377,7 +472,7 @@ module.exports = function setupDeviceSocket(io) {
// Play event logging (proof-of-play) // Play event logging (proof-of-play)
socket.on('device:play-event', (data) => { socket.on('device:play-event', (data) => {
if (!requireDeviceAuth()) return; if (!requireDeviceAuth()) return;
const { device_id, event, content_id, content_name, zone_id, completed } = data; const { device_id, event, content_id, content_name, zone_id, completed, duration_sec } = data;
if (device_id !== currentDeviceId) return; if (device_id !== currentDeviceId) return;
try { try {
if (event === 'play_start') { if (event === 'play_start') {
@ -385,6 +480,15 @@ module.exports = function setupDeviceSocket(io) {
INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type) INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type)
VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist') VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist')
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown'); `).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
// Forward to dashboard so it can render a per-device progress bar.
// Server-side timestamp avoids clock-skew between player and dashboard.
dashboardNs.emit('dashboard:playback-progress', {
device_id,
content_id: content_id || null,
content_name: content_name || null,
duration_sec: typeof duration_sec === 'number' && duration_sec > 0 ? duration_sec : null,
started_at: Date.now(),
});
} else if (event === 'play_end') { } else if (event === 'play_end') {
db.prepare(` db.prepare(`
UPDATE play_logs SET ended_at = strftime('%s','now'), UPDATE play_logs SET ended_at = strftime('%s','now'),
@ -401,18 +505,46 @@ module.exports = function setupDeviceSocket(io) {
} }
}); });
// Video wall sync relay // Video wall sync relay. Sender must be a member of the wall it claims —
// otherwise an authenticated device could inject sync packets into a wall
// it doesn't belong to (jitter/DoS that wall's playback). Exclusion uses
// currentDeviceId, never the client-supplied data.device_id.
socket.on('wall:sync', (data) => { socket.on('wall:sync', (data) => {
if (!requireDeviceAuth()) return; if (!requireDeviceAuth()) return;
// Relay to all devices in the same wall if (!data?.wall_id) return;
const isMember = db.prepare(
'SELECT 1 FROM video_wall_devices WHERE wall_id = ? AND device_id = ?'
).get(data.wall_id, currentDeviceId);
if (!isMember) return;
const wallDevices = db.prepare( const wallDevices = db.prepare(
'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?' 'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
).all(data.wall_id, data.device_id); ).all(data.wall_id, currentDeviceId);
// Stamp device_id with the authenticated id so followers can trust it.
const payload = { ...data, device_id: currentDeviceId };
for (const wd of wallDevices) { for (const wd of wallDevices) {
deviceNs.to(wd.device_id).emit('wall:sync', data); deviceNs.to(wd.device_id).emit('wall:sync', payload);
} }
}); });
// A follower asks for an immediate position update from the leader.
// Used on (re)connect so the follower doesn't drift for ~1s waiting on
// the next periodic wall:sync tick. Server forwards only to the leader,
// and only when the requester is actually a member of the named wall.
socket.on('wall:sync-request', (data) => {
if (!requireDeviceAuth()) return;
if (!data?.wall_id) return;
const isMember = db.prepare(
'SELECT 1 FROM video_wall_devices WHERE wall_id = ? AND device_id = ?'
).get(data.wall_id, currentDeviceId);
if (!isMember) return;
const wall = db.prepare('SELECT leader_device_id FROM video_walls WHERE id = ?').get(data.wall_id);
if (!wall?.leader_device_id || wall.leader_device_id === currentDeviceId) return;
deviceNs.to(wall.leader_device_id).emit('wall:sync-request', {
wall_id: data.wall_id,
requested_by: currentDeviceId,
});
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
if (currentDeviceId) { if (currentDeviceId) {
// If a newer socket has already taken over this device_id, this is a stale // If a newer socket has already taken over this device_id, this is a stale
@ -431,6 +563,30 @@ module.exports = function setupDeviceSocket(io) {
logDeviceStatus(currentDeviceId, 'offline'); logDeviceStatus(currentDeviceId, 'offline');
dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' }); dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' });
// If this device was leading a wall, reassign leadership to the next
// online member so playback stays driven. Without this the wall freezes
// when the leader drops.
try {
const wall = db.prepare('SELECT id FROM video_walls WHERE leader_device_id = ?').get(currentDeviceId);
if (wall) {
const candidates = db.prepare(`
SELECT vwd.device_id FROM video_wall_devices vwd
JOIN devices d ON d.id = vwd.device_id
WHERE vwd.wall_id = ? AND d.status = 'online' AND vwd.device_id != ?
ORDER BY vwd.grid_row, vwd.grid_col LIMIT 1
`).all(wall.id, currentDeviceId);
const newLeader = candidates[0]?.device_id || null;
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(newLeader, wall.id);
// Notify the new leader (and refresh peers' is_leader flags).
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
for (const m of members) {
if (m.device_id !== currentDeviceId) {
deviceNs.to(m.device_id).emit('device:playlist-update', buildPlaylistPayload(m.device_id));
}
}
}
} catch (e) { console.error('Wall leader reassign failed:', e.message); }
// Save last screenshot to disk as offline snapshot // Save last screenshot to disk as offline snapshot
const lastB64 = lastScreenshots[currentDeviceId]; const lastB64 = lastScreenshots[currentDeviceId];
if (lastB64) { if (lastB64) {