import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc } from '../utils.js'; import { t, tn } from '../i18n.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; // Command types only — labels resolved through t('dashboard.cmd.') const GROUP_COMMANDS = [ { type: 'screen_on' }, { type: 'screen_off' }, { type: 'launch' }, { type: 'update' }, { type: 'reboot', destructive: true }, { type: 'shutdown', destructive: true }, ]; const CMD_LABEL_KEY = { screen_on: 'dashboard.cmd.screen_on', screen_off: 'dashboard.cmd.screen_off', launch: 'dashboard.cmd.restart_app', update: 'dashboard.cmd.check_update', reboot: 'dashboard.cmd.reboot', shutdown: 'dashboard.cmd.shutdown', }; 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'); const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return t('common.just_now'); if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) }); if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) }); return t('common.days_ago', { n: Math.floor(seconds / 86400) }); } function formatBytes(mb) { if (mb === null || mb === undefined) return '--'; if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb} MB`; } function renderProgressFor(deviceId) { const state = playbackByDevice.get(deviceId); document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => { if (!state) { el.style.display = 'none'; return; } const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000); const name = state.content_name || ''; const fill = el.querySelector('.device-card-progress-fill'); const nameEl = el.querySelector('.dcp-name'); const timeEl = el.querySelector('.dcp-time'); if (state.duration_sec && state.duration_sec > 0) { const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed)); const pct = Math.min(100, (elapsed / state.duration_sec) * 100); fill.style.width = pct + '%'; if (nameEl) nameEl.textContent = name; if (timeEl) timeEl.textContent = remaining + 's'; } else { // Unknown duration (e.g. video plays to end) — show indeterminate state fill.style.width = '100%'; fill.classList.add('indeterminate'); if (nameEl) nameEl.textContent = name; if (timeEl) timeEl.textContent = ''; } el.style.display = 'block'; }); } function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` : null; const checked = selectedDeviceIds.has(device.id); return `
${screenshotUrl ? `Screenshot` : `
${t('dashboard.no_preview')}
` }
${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}
${device.status === 'provisioning' && device.pairing_code ? `
${device.pairing_code}
` : ''}
${esc(device.name)}
${device.owner_name || device.owner_email ? `
${esc(device.owner_name || device.owner_email)}
` : ''}
${formatTimeAgo(device.last_heartbeat)}
${device.battery_level !== null && device.battery_level !== undefined ? `
${device.battery_level}%
` : ''} ${device.wifi_rssi ? `
${device.wifi_rssi} dBm
` : ''} ${device.storage_free_mb ? `
${formatBytes(device.storage_free_mb)} free
` : ''}
`; } function renderWallCard(wall) { // Compose a tiny grid preview using the wall's actual cols×rows. Each cell // is filled (assigned) or hollow (empty slot). const cells = []; for (let r = 0; r < wall.grid_rows; r++) { for (let c = 0; c < wall.grid_cols; c++) { const dev = (wall.devices || []).find(d => d.grid_col === c && d.grid_row === r); cells.push(`
`); } } const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length; return `
${cells.join('')}
${wall.grid_cols}×${wall.grid_rows} wall
${esc(wall.name)}
${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}
${onlineCount} online
`; } function getGroupPlaylistLabel(devices, playlists) { const playlistMap = new Map((playlists || []).map(p => [p.id, p])); const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id); if (assigned.length === 0) return ''; const unique = [...new Set(assigned)]; if (unique.length === 1) { const pl = playlistMap.get(unique[0]); return pl ? esc(pl.name) : t('dashboard.unknown_playlist'); } return t('dashboard.mixed_playlists'); } function renderGroupSection(group, devices, playlists) { const onlineCount = devices.filter(d => d.status === 'online').length; const playlistLabel = getGroupPlaylistLabel(devices, playlists); return `
${esc(group.name)} ${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })} ${playlistLabel ? `${t('dashboard.playlist_label', { name: playlistLabel })}` : ''}
${devices.length > 0 ? ` ` : ''}
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `
${t('dashboard.no_devices_in_group')}
`}
`; } export function render(container) { container.innerHTML = `
`; const addBtn = container.querySelector('#addDeviceBtn'); addBtn.addEventListener('click', () => { document.getElementById('addDeviceModal').style.display = 'flex'; document.getElementById('pairingCodeInput').value = ''; document.getElementById('deviceNameInput').value = ''; document.getElementById('pairingCodeInput').focus(); }); // Search and filter document.getElementById('deviceSearch').oninput = () => filterDevices(); document.getElementById('deviceFilter').onchange = () => filterDevices(); function filterDevices() { const search = document.getElementById('deviceSearch').value.toLowerCase(); const status = document.getElementById('deviceFilter').value; document.querySelectorAll('.device-card').forEach(card => { const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || ''; const matchSearch = !search || name.includes(search); const matchStatus = !status || deviceStatus === status; card.style.display = (matchSearch && matchStatus) ? '' : 'none'; }); } // Setup pairing const pairBtn = document.getElementById('pairDeviceBtn'); pairBtn.onclick = async () => { const code = document.getElementById('pairingCodeInput').value.trim(); const name = document.getElementById('deviceNameInput').value.trim(); if (!code || code.length !== 6) { showToast(t('dashboard.error_pairing_code'), 'error'); return; } try { await api.pairDevice(code, name || undefined); document.getElementById('addDeviceModal').style.display = 'none'; showToast(t('dashboard.toast.display_paired'), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }; // Create group container.querySelector('#createGroupBtn').addEventListener('click', async () => { const name = prompt(t('dashboard.prompt_group_name')); if (!name) return; try { await api.createGroup(name); showToast(t('dashboard.toast.group_created'), 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); // Multi-select: a checkbox on each device card adds to selectedDeviceIds. // The selection bar shows when 1+ are selected; "Create Video Wall" is the // primary action — it creates the wall, removes devices from any group, // assigns them, and navigates to the editor. container.addEventListener('change', (ev) => { const cb = ev.target.closest?.('.device-select-cb'); if (!cb) return; const id = cb.dataset.deviceId; if (cb.checked) selectedDeviceIds.add(id); else selectedDeviceIds.delete(id); cb.closest('.device-card')?.classList.toggle('selected', cb.checked); refreshSelectionBar(); }); document.getElementById('clearSelectionBtn').addEventListener('click', () => { selectedDeviceIds.clear(); document.querySelectorAll('.device-select-cb').forEach(cb => { cb.checked = false; }); document.querySelectorAll('.device-card.selected').forEach(c => c.classList.remove('selected')); refreshSelectionBar(); }); document.getElementById('createWallBtn').addEventListener('click', () => createWallFromSelection()); // Load everything loadDashboard(); // Real-time updates statusHandler = (data) => { const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); if (statusEl) statusEl.innerHTML = `${data.status}`; }); }; screenshotHandler = (data) => { document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => { const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); const img = preview.querySelector('img'); if (img) { img.src = imgSrc; } else { const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || ''; preview.innerHTML = `Screenshot${statusHtml}`; } }); }; const deviceAddedHandler = () => loadDashboard(); const deviceRemovedHandler = () => loadDashboard(); playbackHandler = (data) => { if (!data?.device_id) return; playbackByDevice.set(data.device_id, { content_name: data.content_name || '', duration_sec: data.duration_sec || null, started_at: data.started_at || Date.now(), }); renderProgressFor(data.device_id); }; wallChangedHandler = () => loadDashboard(); on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('device-added', deviceAddedHandler); on('device-removed', deviceRemovedHandler); on('playback-progress', playbackHandler); on('wall-changed', wallChangedHandler); progressTickInterval = setInterval(() => { for (const id of playbackByDevice.keys()) renderProgressFor(id); }, 1000); // Request fresh screenshots on load setTimeout(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 2000); refreshInterval = setInterval(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 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'; // Need at least 2 to make a wall - surface the constraint inline so the // greyed-out button isn't just silently unresponsive. count.textContent = n < 2 ? `${n} display selected - pick 1 more to create a wall` : `${n} displays selected`; const btn = document.getElementById('createWallBtn'); btn.disabled = n < 2; btn.title = n < 2 ? 'Select at least 2 displays to create a video wall' : ''; } // Pick a sensible default grid for n devices: prefer near-square layouts, // breaking ties toward more columns (more common physical wall layout). function defaultGridForCount(n) { if (n <= 1) return { cols: 1, rows: 1 }; if (n === 2) return { cols: 2, rows: 1 }; if (n === 3) return { cols: 3, rows: 1 }; if (n === 4) return { cols: 2, rows: 2 }; if (n === 6) return { cols: 3, rows: 2 }; if (n === 8) return { cols: 4, rows: 2 }; if (n === 9) return { cols: 3, rows: 3 }; // Generic fallback — square-ish, columns >= rows const cols = Math.ceil(Math.sqrt(n)); const rows = Math.ceil(n / cols); return { cols, rows }; } async function createWallFromSelection() { const ids = [...selectedDeviceIds]; if (ids.length < 2) { showToast('Select at least 2 displays', 'error'); return; } const name = prompt('Name this video wall:', `Wall ${new Date().toLocaleString()}`); if (!name) return; const { cols, rows } = defaultGridForCount(ids.length); try { const wall = await api.createWall({ name, grid_cols: cols, grid_rows: rows }); // Pack selected devices into row-major order. The user can reposition in // the editor; this just gives every selection a sensible starting tile. const placement = ids.slice(0, cols * rows).map((id, i) => ({ device_id: id, grid_col: i % cols, grid_row: Math.floor(i / cols), })); await api.setWallDevices(wall.id, placement); selectedDeviceIds.clear(); showToast('Video wall created', 'success'); window.location.hash = `#/wall/${wall.id}`; } catch (e) { showToast(e.message, 'error'); } } async function loadDashboard() { const main = document.getElementById('groupedDevices'); if (!main) return; try { const [rawDevices, groups, playlists, walls] = await Promise.all([ api.getDevices(), api.getGroups(), api.getPlaylists(), api.getWalls(), ]); // Deduplicate devices by id — a stale reconnect race can briefly cause the same // device to appear twice in the list. Last-write-wins keeps the freshest state. const seen = new Map(); for (const d of rawDevices) seen.set(d.id, d); const devices = Array.from(seen.values()); // Stats const online = devices.filter(d => d.status === 'online').length; const offline = devices.filter(d => d.status === 'offline').length; const provisioning = devices.filter(d => d.status === 'provisioning').length; const statsEl = document.getElementById('dashStats'); if (statsEl) { statsEl.innerHTML = `
${t('dashboard.total_displays')}
${devices.length}
${t('dashboard.online')}
${online}
${t('dashboard.offline')}
${offline}
${provisioning > 0 ? `
${t('dashboard.awaiting_pairing')}
${provisioning}
` : ''} `; } if (devices.length === 0 && groups.length === 0) { main.innerHTML = `

${t('dashboard.no_displays')}

${t('dashboard.no_displays_desc')}

`; return; } // Devices that belong to a wall are owned by that wall — they don't appear // as their own cards anywhere on the dashboard. The wall's card stands in. const walledDeviceIds = new Set(); for (const w of (walls || [])) for (const d of (w.devices || [])) walledDeviceIds.add(d.device_id); const dashboardDevices = devices.filter(d => !walledDeviceIds.has(d.id)); // Fetch group memberships const groupsWithDevices = await Promise.all(groups.map(async g => { const members = await api.getGroupDevices(g.id); const memberIds = new Set(members.map(m => m.id)); // Use full device data from the main devices list (has telemetry/screenshots) // and exclude any wall members. const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id)); return { ...g, devices: fullDevices, memberIds }; })); // Render each device exactly once: the first group it belongs to wins. // memberIds is preserved for the Manage modal so multi-group membership info stays accurate. const renderedIds = new Set(); for (const g of groupsWithDevices) { g.devices = g.devices.filter(d => { if (renderedIds.has(d.id)) return false; renderedIds.add(d.id); return true; }); } const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id)); let html = ''; // Walls render before groups: they're a higher-level construct (multiple // physical screens acting as one logical display). if ((walls || []).length > 0) { html += `
Video Walls ${walls.length} wall${walls.length === 1 ? '' : 's'}
${walls.map(renderWallCard).join('')}
`; } // Render each group with its devices for (const g of groupsWithDevices) { html += renderGroupSection(g, g.devices, playlists); } // Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so // attachGroupHandlers can wire it as a drop target — dropping a device here // removes it from every group it currently belongs to. if (ungrouped.length > 0) { html += `
${groups.length > 0 ? `
${t('dashboard.ungrouped')} ${tn('dashboard.devices_count', ungrouped.length)}
` : ''}
${ungrouped.map(renderDeviceCard).join('')}
`; } main.innerHTML = html; attachGroupHandlers(groupsWithDevices, dashboardDevices); // Drop any selections for devices that have since been absorbed into a // wall, and update the toolbar. for (const id of [...selectedDeviceIds]) { if (walledDeviceIds.has(id)) selectedDeviceIds.delete(id); } refreshSelectionBar(); } catch (err) { main.innerHTML = `

${t('dashboard.failed_to_load')}

${esc(err.message)}

`; } } function attachGroupHandlers(groupsWithDevices, allDevices) { // Drag-and-drop: device cards are draggable; group sections + the Ungrouped // wrapper are drop targets. Drop on a group adds membership (mirrors the // Manage modal). Drop on Ungrouped removes the device from every group it's // currently a member of. const groupsByDeviceId = new Map(); for (const g of groupsWithDevices) { g.memberIds.forEach(id => { if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []); groupsByDeviceId.get(id).push({ id: g.id, name: g.name }); }); } document.querySelectorAll('.device-card').forEach(card => { card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/device-id', card.dataset.deviceId); e.dataTransfer.setData('text/device-name', card.dataset.deviceName || ''); e.dataTransfer.effectAllowed = 'move'; }); }); function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; } function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; } document.querySelectorAll('.group-section').forEach(section => { section.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('text/device-id')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; highlightOn(section); }); section.addEventListener('dragleave', (e) => { // Avoid flicker when moving across child elements if (e.target === section) highlightOff(section); }); section.addEventListener('drop', async (e) => { e.preventDefault(); highlightOff(section); const deviceId = e.dataTransfer.getData('text/device-id'); const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; if (!deviceId) return; const groupId = section.dataset.groupId; const targetGroup = groupsWithDevices.find(g => g.id === groupId); if (!targetGroup) return; // Already in this group — no-op. if (targetGroup.memberIds.has(deviceId)) { showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info'); return; } // If the device is in another group, mirror the Manage modal's confirm. const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name); if (others.length > 0) { if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return; } try { await api.addDeviceToGroup(groupId, deviceId); showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }); }); // Ungrouped wrapper: remove device from every group it's in. document.querySelectorAll('[data-ungrouped="1"]').forEach(section => { section.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('text/device-id')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; highlightOn(section); }); section.addEventListener('dragleave', (e) => { if (e.target === section) highlightOff(section); }); section.addEventListener('drop', async (e) => { e.preventDefault(); highlightOff(section); const deviceId = e.dataTransfer.getData('text/device-id'); const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; if (!deviceId) return; const memberships = groupsByDeviceId.get(deviceId) || []; if (memberships.length === 0) return; // already ungrouped try { await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId))); showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }); }); // Playlist assignment handlers document.querySelectorAll('.group-playlist-select').forEach(select => { select.addEventListener('change', async (e) => { const playlistId = e.target.value; if (!playlistId) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const playlistName = e.target.options[e.target.selectedIndex].textContent; if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) { e.target.value = ''; return; } try { const result = await api.groupAssignPlaylist(groupId, playlistId); showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // Command select handlers document.querySelectorAll('.group-cmd-select').forEach(select => { select.addEventListener('change', async (e) => { const type = e.target.value; if (!type) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const count = e.target.dataset.deviceCount; const cmdLabel = t(CMD_LABEL_KEY[type] || type); if (DESTRUCTIVE_COMMANDS.includes(type)) { if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) { e.target.value = ''; return; } } try { const result = await api.sendGroupCommand(groupId, type); const msg = result.offline > 0 ? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline }) : t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total }); showToast(msg, result.offline > 0 ? 'warning' : 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // Delete group document.querySelectorAll('[data-group-delete]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.groupDelete; if (!confirm(t('dashboard.confirm_delete_group'))) return; try { await api.deleteGroup(id); showToast(t('dashboard.toast.group_deleted'), 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); }); // Manage group (add/remove devices) document.querySelectorAll('[data-group-manage]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const groupId = btn.dataset.groupManage; const group = groupsWithDevices.find(g => g.id === groupId); const memberIds = new Set(group.devices.map(d => d.id)); // Get all groups for multi-group warning const otherGroups = groupsWithDevices.filter(g => g.id !== groupId); const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.innerHTML = `

${esc(group.name)}

${t('dashboard.manage_group_subtitle')}

${allDevices.filter(d => d.status !== 'provisioning').map(d => { const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name); return ` `; }).join('')}
`; document.body.appendChild(modal); modal.querySelector('#manageGroupClose').onclick = () => { modal.remove(); loadDashboard(); }; modal.addEventListener('click', (ev) => { if (ev.target === modal) { modal.remove(); loadDashboard(); } }); modal.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', async () => { const deviceId = cb.dataset.deviceId; const existingGroups = cb.dataset.inGroups; const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || ''; try { if (cb.checked && existingGroups) { if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) { cb.checked = false; return; } } if (cb.checked) { await api.addDeviceToGroup(groupId, deviceId); } else { await api.removeDeviceFromGroup(groupId, deviceId); } } catch (err) { showToast(err.message, 'error'); cb.checked = !cb.checked; } }); }); }); }); } export function cleanup() { if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); if (playbackHandler) off('playback-progress', playbackHandler); if (wallChangedHandler) off('wall-changed', wallChangedHandler); off('device-added', () => {}); off('device-removed', () => {}); if (refreshInterval) clearInterval(refreshInterval); if (progressTickInterval) clearInterval(progressTickInterval); statusHandler = null; screenshotHandler = null; playbackHandler = null; wallChangedHandler = null; refreshInterval = null; progressTickInterval = null; playbackByDevice.clear(); }