diff --git a/frontend/css/main.css b/frontend/css/main.css index e0c0e0e..bbc6c26 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -237,6 +237,322 @@ body { font-weight: 500; } +.device-card-select { + position: absolute; + top: 8px; + left: 8px; + z-index: 5; + background: rgba(0,0,0,0.6); + border-radius: 4px; + padding: 3px 5px; + display: flex; + align-items: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.device-card:hover .device-card-select, +.device-card.selected .device-card-select { opacity: 1; } +.device-card-select input { cursor: pointer; margin: 0; } +.device-card.selected { outline: 2px solid var(--primary, #3B82F6); outline-offset: -2px; } + +/* Wall editor — free-form pan/zoom canvas */ +.wall-viewport { + position: relative; + overflow: hidden; + cursor: grab; + user-select: none; + background: + linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px, + linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px, + var(--bg-primary); +} +.wall-viewport.panning { cursor: grabbing; } +/* Inner canvas: a 0×0 anchor whose CSS transform supplies pan + zoom. + All rect children are absolutely positioned in canvas-data coordinates + and inherit the parent transform. transform-origin is the canvas's + top-left so pan offsets map cleanly to data → screen pixels. */ +.wall-canvas { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + transform-origin: 0 0; + /* Disable transition so panning doesn't lag behind the cursor */ +} +.wall-zoom-readout { + position: absolute; + bottom: 8px; + right: 12px; + background: rgba(0,0,0,0.65); + color: #fff; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + pointer-events: none; + font-variant-numeric: tabular-nums; +} + +.wall-screen { + position: absolute; + background: rgba(59,130,246,0.08); + border: 2px solid var(--primary, #3B82F6); + border-radius: 4px; + box-sizing: border-box; + cursor: move; + user-select: none; + touch-action: none; + overflow: hidden; +} +.wall-screen-overlap { + position: absolute; + background: rgba(96,165,250,0.35); + pointer-events: none; + display: none; + z-index: 1; +} +.wall-screen-label { + position: absolute; + top: 4px; + left: 6px; + right: 24px; + pointer-events: none; + z-index: 2; +} +.wall-screen-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #fff); + text-shadow: 0 1px 2px rgba(0,0,0,0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.wall-screen-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + text-shadow: 0 1px 2px rgba(0,0,0,0.6); +} +.wall-screen-remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 3; + width: 20px; + height: 20px; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} +.wall-screen-remove:hover { background: var(--danger, #ef4444); } + +.wall-player { + position: absolute; + background: rgba(96,165,250,0.18); + border: 2px dashed #60a5fa; + border-radius: 4px; + box-sizing: border-box; + cursor: move; + user-select: none; + touch-action: none; + z-index: 5; + box-shadow: 0 0 0 9999px transparent; /* keeps stacking explicit */ +} +.wall-player-label { + position: absolute; + top: 4px; + left: 6px; + pointer-events: none; + color: #dbeafe; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); + font-size: 11px; + letter-spacing: 1px; +} + +/* Selected rect highlight (works for both screens and the player) */ +.wall-screen.selected, +.wall-player.selected { + outline: 3px solid #facc15; + outline-offset: 1px; + z-index: 6; +} + +/* Fine-position panel inputs */ +.wall-pos-grid { + display: grid; + grid-template-columns: auto 1fr auto 1fr; + gap: 6px 8px; + align-items: center; + font-size: 12px; +} +.wall-pos-grid label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.wall-pos-grid input { + width: 100%; + padding: 4px 6px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary, #fff); + font: inherit; + font-variant-numeric: tabular-nums; +} +.wall-pos-grid input:focus { outline: 1px solid var(--primary); outline-offset: 0; border-color: var(--primary); } + +/* Eight resize handles, used by both screens and the player */ +.wall-handle { + position: absolute; + width: 10px; + height: 10px; + background: #fff; + border: 1px solid #1d4ed8; + border-radius: 2px; + z-index: 4; +} +.wall-player .wall-handle { border-color: #60a5fa; } +.wall-handle-nw { top: -5px; left: -5px; cursor: nw-resize; } +.wall-handle-n { top: -5px; left: 50%; transform: translateX(-50%); cursor: n-resize; } +.wall-handle-ne { top: -5px; right: -5px; cursor: ne-resize; } +.wall-handle-e { top: 50%; right: -5px; transform: translateY(-50%); cursor: e-resize; } +.wall-handle-se { bottom: -5px; right: -5px; cursor: se-resize; } +.wall-handle-s { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: s-resize; } +.wall-handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; } +.wall-handle-w { top: 50%; left: -5px; transform: translateY(-50%); cursor: w-resize; } + +/* Wall editor — legacy cells (kept for migration; new editor uses wall-canvas) */ +.wall-cell { + position: relative; + background: var(--bg-card); + border: 2px dashed var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text-secondary); + user-select: none; +} +.wall-cell.occupied { + background: rgba(59,130,246,0.15); + border: 2px solid var(--primary, #3B82F6); + cursor: grab; +} +.wall-cell.occupied:active { cursor: grabbing; } +.wall-cell.drag-over { + border-color: var(--success, #10b981); + box-shadow: 0 0 0 2px rgba(16,185,129,0.25) inset; +} +.wall-cell-name { font-weight: 500; padding: 0 6px; text-align: center; } +.wall-cell-pos { + position: absolute; + bottom: 4px; + font-size: 9px; + color: var(--text-muted); + letter-spacing: 0.5px; +} +.wall-cell-remove { + position: absolute; + top: 4px; right: 4px; + background: rgba(0,0,0,0.6); + border: none; + color: #fff; + border-radius: 50%; + width: 20px; height: 20px; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} +.wall-cell-remove:hover { background: var(--danger, #ef4444); } + +.wall-card .wall-card-preview { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(139,92,246,0.15), rgba(59,130,246,0.1)); +} +.wall-card-grid { + display: grid; + gap: 4px; + width: 65%; + aspect-ratio: 16/9; + padding: 8px; +} +.wall-card-cell { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(139,92,246,0.3); + border-radius: 2px; +} +.wall-card-cell.filled { + background: rgba(139,92,246,0.5); + border-color: rgba(139,92,246,0.9); +} + +.device-card-progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 6px 10px 8px; + background: linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0)); + color: #fff; + font-size: 11px; + pointer-events: none; +} +.device-card-progress-label { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); +} +.device-card-progress-label .dcp-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.device-card-progress-label .dcp-time { + font-variant-numeric: tabular-nums; + opacity: 0.85; +} +.device-card-progress-track { + height: 3px; + background: rgba(255,255,255,0.2); + border-radius: 2px; + overflow: hidden; +} +.device-card-progress-fill { + height: 100%; + width: 0%; + background: var(--primary, #3B82F6); + transition: width 0.9s linear; +} +.device-card-progress-fill.indeterminate { + background: linear-gradient(90deg, transparent, var(--primary, #3B82F6), transparent); + background-size: 50% 100%; + animation: dcp-indeterminate 1.4s linear infinite; +} +@keyframes dcp-indeterminate { + 0% { background-position: -50% 0; } + 100% { background-position: 150% 0; } +} + .device-card-body { padding: 14px 16px; } diff --git a/frontend/js/api.js b/frontend/js/api.js index f69cda5..d5409e2 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -128,6 +128,13 @@ export const api = { removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }), sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }), + // Video walls + getWalls: () => request('/walls'), + createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }), + setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }), + updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }), + // Playlists getPlaylists: () => request('/playlists'), createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index da19944..8c7617e 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Raster-Konfiguration', 'wall.columns': 'Spalten', 'wall.rows': 'Zeilen', - 'wall.h_bezel': 'H Rahmen (mm)', - 'wall.v_bezel': 'V Rahmen (mm)', + 'wall.h_bezel': 'H Rahmen (px)', + 'wall.v_bezel': 'V Rahmen (px)', 'wall.update': 'Aktualisieren', 'wall.content': 'Inhalt', 'wall.no_content': 'Kein Inhalt', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index da019fc..f5633f2 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -982,8 +982,8 @@ export default { 'wall.grid_config': 'Grid Configuration', 'wall.columns': 'Columns', 'wall.rows': 'Rows', - 'wall.h_bezel': 'H Bezel (mm)', - 'wall.v_bezel': 'V Bezel (mm)', + 'wall.h_bezel': 'H Bezel (px)', + 'wall.v_bezel': 'V Bezel (px)', 'wall.update': 'Update', 'wall.content': 'Content', 'wall.no_content': 'No content', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 26b4b10..cd13f81 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -945,8 +945,8 @@ export default { 'wall.grid_config': 'Configuración de cuadrícula', 'wall.columns': 'Columnas', 'wall.rows': 'Filas', - 'wall.h_bezel': 'Bisel H (mm)', - 'wall.v_bezel': 'Bisel V (mm)', + 'wall.h_bezel': 'Bisel H (px)', + 'wall.v_bezel': 'Bisel V (px)', 'wall.update': 'Actualizar', 'wall.content': 'Contenido', 'wall.no_content': 'Sin contenido', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 258060b..173518b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Configuration de la grille', 'wall.columns': 'Colonnes', 'wall.rows': 'Lignes', - 'wall.h_bezel': 'Cadre H (mm)', - 'wall.v_bezel': 'Cadre V (mm)', + 'wall.h_bezel': 'Cadre H (px)', + 'wall.v_bezel': 'Cadre V (px)', 'wall.update': 'Mettre à jour', 'wall.content': 'Contenu', 'wall.no_content': 'Aucun contenu', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index f9abd54..64ee1ca 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -946,8 +946,8 @@ export default { 'wall.grid_config': 'Configuração da grade', 'wall.columns': 'Colunas', 'wall.rows': 'Linhas', - 'wall.h_bezel': 'Moldura H (mm)', - 'wall.v_bezel': 'Moldura V (mm)', + 'wall.h_bezel': 'Moldura H (px)', + 'wall.v_bezel': 'Moldura V (px)', 'wall.update': 'Atualizar', 'wall.content': 'Conteúdo', 'wall.no_content': 'Sem conteúdo', diff --git a/frontend/js/socket.js b/frontend/js/socket.js index ff85efd..3ba61bf 100644 --- a/frontend/js/socket.js +++ b/frontend/js/socket.js @@ -48,6 +48,16 @@ export function connectSocket() { emit('playback-state', data); }); + // Playback progress (play_start with duration — drives device-card progress bars) + dashboardSocket.on('dashboard:playback-progress', (data) => { + emit('playback-progress', data); + }); + + // Wall changed — dashboard refreshes wall cards + device-grouping layout + dashboardSocket.on('dashboard:wall-changed', () => { + emit('wall-changed'); + }); + // Content ack dashboardSocket.on('dashboard:content-ack', (data) => { emit('content-ack', data); diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 3d36152..859b31c 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -26,6 +26,14 @@ const CMD_LABEL_KEY = { let statusHandler = null; let screenshotHandler = null; let refreshInterval = null; +let playbackHandler = null; +let progressTickInterval = null; +let wallChangedHandler = null; +// device_id -> { content_name, duration_sec, started_at } +const playbackByDevice = new Map(); +// Multi-select state for the "Create Video Wall" gesture. Holds device_ids +// the user has ticked via checkboxes on the dashboard cards. +const selectedDeviceIds = new Set(); function formatTimeAgo(timestamp) { if (!timestamp) return t('common.never'); @@ -42,14 +50,44 @@ function formatBytes(mb) { return `${mb} MB`; } +function renderProgressFor(deviceId) { + const state = playbackByDevice.get(deviceId); + document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => { + if (!state) { el.style.display = 'none'; return; } + const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000); + const name = state.content_name || ''; + const fill = el.querySelector('.device-card-progress-fill'); + const nameEl = el.querySelector('.dcp-name'); + const timeEl = el.querySelector('.dcp-time'); + if (state.duration_sec && state.duration_sec > 0) { + const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed)); + const pct = Math.min(100, (elapsed / state.duration_sec) * 100); + fill.style.width = pct + '%'; + if (nameEl) nameEl.textContent = name; + if (timeEl) timeEl.textContent = remaining + 's'; + } else { + // Unknown duration (e.g. video plays to end) — show indeterminate state + fill.style.width = '100%'; + fill.classList.add('indeterminate'); + if (nameEl) nameEl.textContent = name; + if (timeEl) timeEl.textContent = ''; + } + el.style.display = 'block'; + }); +} + function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` : null; + const checked = selectedDeviceIds.has(device.id); return ` -
${esc(err.message)}
${t('wall.all_assigned')}
`} +Drag onto the canvas to add. Use the ✕ on a tile to remove.
+ +Click a tile or the player to dial in exact pixel positions.
++ Arrow keys nudge by 1px. Hold Shift for 10px. + Click outside any rect to deselect. +
+${t('wall.all_assigned')}
`; + + sidebar.querySelectorAll('[draggable]').forEach(el => { + el.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', JSON.stringify({ + type: 'sidebar-device', + device_id: el.dataset.deviceId, + device_name: el.dataset.deviceName, + device_status: el.dataset.deviceStatus, + })); + }); + }); + + } + + // Click on canvas background (not on a rect) clears selection + canvas.addEventListener('pointerdown', (ev) => { + if (ev.target === canvas) { + selected = null; + applySelectionClasses(); + renderSelectionPanel(); + } }); - 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 { + // Persist player rect + grid/bezel inputs to the wall, devices to its + // member list. Two PUTs because the existing routes are split that way. + const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1); + const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1); + const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0); + const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0); + // Quantize all coords to integers before persisting. Drag/resize + // produce floats (screen-pixel deltas divided by zoom), and even tiny + // FP drift between two screens with the same nominal Y/H produces + // visibly different `top`/`height` percentages downstream — a known + // source of vertical-misalignment bugs across the wall. await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ - grid_cols: parseInt(document.getElementById('gridCols').value), - grid_rows: parseInt(document.getElementById('gridRows').value), - bezel_h_mm: parseFloat(document.getElementById('bezelH').value), - bezel_v_mm: parseFloat(document.getElementById('bezelV').value), + grid_cols: cols, grid_rows: rows, bezel_h_mm: bH, bezel_v_mm: bV, + player_x: Math.round(player.x), player_y: Math.round(player.y), + player_width: Math.round(player.w), player_height: Math.round(player.h), })}); - wall.grid_cols = parseInt(document.getElementById('gridCols').value); - wall.grid_rows = parseInt(document.getElementById('gridRows').value); - renderGrid(); - showToast(t('wall.toast.grid_updated'), 'success'); + // grid_col/grid_row are kept only to satisfy the legacy + // UNIQUE(wall_id, grid_col, grid_row) constraint — render math now uses + // canvas_* fields. Synthetic (i, 0) guarantees uniqueness. + const payload = screens.map((s, i) => ({ + device_id: s.device_id, + grid_col: i, + grid_row: 0, + rotation: s.rotation || 0, + canvas_x: Math.round(s.x), canvas_y: Math.round(s.y), + canvas_width: Math.round(s.w), canvas_height: Math.round(s.h), + })); + await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: payload }) }); + // Re-fetch master device list so wall_id changes propagate to the sidebar + devices = await api.getDevices(); + dirty = false; + const btn = document.getElementById('saveLayoutBtn'); + btn.disabled = true; + btn.classList.remove('btn-primary'); + showToast('Layout saved', 'success'); } catch (err) { showToast(err.message, 'error'); } - }; + }); - document.getElementById('setContentBtn').onclick = async () => { - const contentId = document.getElementById('wallContent').value; + document.getElementById('renameWallBtn').addEventListener('click', async () => { + const newName = prompt('Wall name:', wall.name); + if (!newName || newName === wall.name) return; try { - await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) }); - showToast(t('wall.toast.content_updated'), 'success'); + await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ name: newName }) }); + wall.name = newName; + document.getElementById('wallTitleText').textContent = newName; } catch (err) { showToast(err.message, 'error'); } - }; + }); - document.getElementById('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 { await API(`/walls/${wallId}`, { method: 'DELETE' }); showToast(t('wall.toast.deleted'), 'success'); window.location.hash = '#/walls'; } 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 => ``) + .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 {} + } +} diff --git a/server/db/database.js b/server/db/database.js index ae6783f..f524cac 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -76,6 +76,20 @@ const migrations = [ "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", "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) { try { db.exec(sql); } catch (e) { /* already exists */ } diff --git a/server/db/schema.sql b/server/db/schema.sql index 38c7a5a..d8e1ea6 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -235,6 +235,12 @@ CREATE TABLE IF NOT EXISTS video_walls ( sync_mode TEXT NOT NULL DEFAULT 'leader', leader_device_id TEXT REFERENCES devices(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')), 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_row INTEGER NOT NULL, 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, grid_col, grid_row) ); @@ -307,6 +318,7 @@ CREATE TABLE IF NOT EXISTS device_groups ( user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, color TEXT DEFAULT '#3B82F6', + playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); diff --git a/server/player/index.html b/server/player/index.html index 6d4ec98..7f22285 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -50,6 +50,22 @@ .zone img { width: 100%; height: 100%; object-fit: cover; } .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 */ #statusOverlay { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; @@ -200,6 +216,21 @@ // playback muted). let userHasInteracted = false; 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 // (a few lines below) may synchronously call into createYoutubeEmbed before the // script reaches the original declaration site, which used to throw a temporal @@ -209,25 +240,89 @@ let activeYtPlayer = null; 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 = '🔇Tap to enable audio'; + ov.addEventListener('click', () => { + unlockAudioContext(); + tryUnmuteLeader(); + }); + document.body.appendChild(ov); + } + function hideEnableAudioPrompt() { + document.getElementById('enableAudioPrompt')?.remove(); + } + // Track user interaction for autoplay policy ['click', 'touchstart', 'keydown'].forEach(evt => { document.addEventListener(evt, () => { + const wasFirst = !userHasInteracted; userHasInteracted = true; - // HTML5 video: setting muted=false on a video that's been muted-autoplaying - // causes the browser to pause it as a side effect. We have a real user - // gesture here, so play() should succeed — but if it doesn't, fall back to - // muted playback rather than leaving a black/paused screen. - const video = document.querySelector('#playerContainer video'); - if (video && video.muted) { - video.muted = false; - video.play() - .then(() => console.log('Unmuted video after user interaction')) - .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)); - }); - } + // First gesture: prime the AudioContext. This signals "site activated" + // to Firefox and unlocks subsequent