import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendSwipe, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
let playbackHandler = null;
let logHandler = null;
let shellHandler = null;
let screenshotInterval = null;
let remoteActive = false;
// Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session
// is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive).
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => {
if (remoteActive && currentDevice) { remoteActive = false; try { stopRemote(currentDevice.id); } catch (e) {} }
});
}
// #161 device-owner Terminal presets. Commands chosen to work at the APP UID (not root) — getprop,
// /proc + /sys reads, df, pm list, ip. dumpsys/settings are deliberately avoided (OS-denied to apps).
const TERMINAL_PRESETS = [
{ label: 'Device info', cmd: 'getprop ro.product.manufacturer; getprop ro.product.model; echo "Android $(getprop ro.build.version.release) (sdk $(getprop ro.build.version.sdk))"' },
{ label: 'Build', cmd: 'getprop ro.build.fingerprint; echo "serial=$(getprop ro.serialno)"' },
{ label: 'Memory', cmd: 'head -3 /proc/meminfo' },
{ label: 'CPU', cmd: 'grep -iE "hardware|processor" /proc/cpuinfo | head; echo "cores=$(cat /proc/cpuinfo | grep -c ^processor)"' },
{ label: 'Storage', cmd: 'df -h /data 2>/dev/null; df -h /storage/emulated/0 2>/dev/null' },
{ label: 'Uptime', cmd: 'echo "up $(cut -d. -f1 /proc/uptime)s"' },
{ label: 'Date / TZ', cmd: 'date; echo "tz=$(getprop persist.sys.timezone)"' },
{ label: 'Display', cmd: 'getprop | grep -iE "lcd_density|ro.sf.lcd|ro.hwui|ro.surface_flinger" | head' },
{ label: '3rd-party apps', cmd: 'pm list packages -3 2>/dev/null | sed s/package:// | head -40 || echo "pm list denied at app uid"' },
{ label: 'Props', cmd: 'getprop | grep -iE "model|version.release|serialno|wifi.interface|timezone"' },
{ label: 'Whoami', cmd: 'id' },
];
function formatBytes(mb) {
if (mb === null || mb === undefined) return '--';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function formatUptime(seconds) {
if (!seconds) return '--';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// #74/#75: device clock + skew indicator. Compares the device's reported UTC to the
// server's receipt time; a gap > 2 min means the device clock is wrong, so per-item
// schedules will fire at the wrong local time — surface it instead of a support mystery.
function renderDeviceClock(device) {
const tz = device.reported_timezone || device.timezone || '--';
if (!device.reported_utc || !device.reported_at) return tz;
const skewSec = Math.abs(Math.round(device.reported_utc / 1000) - device.reported_at);
let local = '';
try {
local = new Date(device.reported_utc).toLocaleString(undefined,
{ timeZone: device.reported_timezone || undefined, hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
} catch (e) { /* bad tz id -> skip local render */ }
const warn = skewSec > 120
? `
${t('device.clock.skew', { amount: skewSec >= 3600 ? Math.round(skewSec / 3600) + 'h' : Math.round(skewSec / 60) + 'm' })}
`
: '';
return `${tz}${local ? `${t('device.clock.reported', { time: local })}
` : ''}${warn}`;
}
export function render(container, deviceId) {
container.innerHTML = `
`;
loadDevice(deviceId);
// Real-time updates
statusHandler = (data) => {
if (data.device_id !== deviceId) return;
const badge = document.querySelector('.device-status-badge');
if (badge) {
const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status
badge.className = `device-status-badge ${b.state}`;
badge.textContent = b.label;
badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason)
}
if (data.telemetry) updateTelemetryDisplay(data.telemetry);
};
screenshotHandler = (data) => {
if (data.device_id !== deviceId) return;
// Use inline base64 data if available, otherwise fall back to URL
const imgSrc = data.image_data || (() => {
const token = localStorage.getItem('token');
return data.url + (data.url.includes('?') ? '&' : '?') + 'token=' + token;
})();
// Update screenshot in Now Playing tab
const screenshotEl = document.getElementById('currentScreenshot');
if (screenshotEl) {
if (screenshotEl.tagName === 'IMG') {
screenshotEl.src = imgSrc;
} else {
// Replace placeholder div with actual image
const img = document.createElement('img');
img.id = 'currentScreenshot';
img.src = imgSrc;
img.alt = 'Current screen';
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
screenshotEl.replaceWith(img);
}
}
// Update remote canvas
const canvas = document.getElementById('remoteCanvas');
if (canvas && remoteActive) {
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
};
img.src = imgSrc;
}
};
playbackHandler = (data) => {
if (data.device_id !== deviceId) return;
const el = document.getElementById('nowPlayingInfo');
if (el && data.current_content_id) {
el.textContent = t('device.now_playing_id', { id: data.current_content_id });
}
};
// Live debug log lines streamed from the device (when the Debug logging
// checkbox is on). Appended via textContent — no HTML injection.
logHandler = (data) => {
if (data.device_id !== deviceId) return;
const panel = document.getElementById('debugLogPanel');
if (!panel) return;
const line = document.createElement('div');
const time = new Date(data.ts || Date.now()).toLocaleTimeString();
line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`;
panel.appendChild(line);
while (panel.childElementCount > 500) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
};
on('device-status', statusHandler);
on('screenshot-ready', screenshotHandler);
on('playback-state', playbackHandler);
on('device-log', logHandler);
}
async function loadDevice(deviceId, activeTab = null) {
const contentEl = document.getElementById('deviceContent');
try {
const device = await api.getDevice(deviceId);
currentDevice = device;
const latestTelemetry = device.telemetry?.[0] || {};
contentEl.innerHTML = `
${device.tier === 2 ? `
${t('device.tier2.label')}
` : ''}
${t('device.tab.now_playing')} ?
${t('device.tab.playlist')} ?
${t('device.tab.info')} ?
${t('device.tab.remote')} ?
${(device.client_type === 'apk' || device.android_version) ? `
${t('device.tab.controls')} ?
` : ''}
${device.tier === 2 ? `
${t('device.tab.terminal')} ?
` : ''}
${device.screenshot
? `
}&token=${localStorage.getItem('token')})
`
: `
${t('device.no_screenshot')}
`
}
${device.assignments?.length ? tn('device.playlist_count', device.assignments.length) : t('device.no_content_assigned')}
${device.playlist_status === 'draft' ? `
${t('device.draft.banner_title')}
${device.playlist_has_published ? t('device.draft.devices_showing_published') : t('device.draft.never_published')}
${device.playlist_has_published ? `` : ''}
` : ''}
${t('device.layout.label')}
${t('device.playlist.label')}
${renderPlaylist(device.assignments || [])}
${t('device.info.status')}
${device.status}
${t('device.info.ip_address')}
${device.ip_address || '--'}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.battery')}
${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}
${latestTelemetry.battery_level != null ? `
` : ''}
${t('device.info.storage')}
${latestTelemetry.storage_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}
${latestTelemetry.storage_total_mb ? `
` : ''}
` : `
${t('device.info.player_type')}
${t('device.info.web_player')}
`}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.wifi')}
${latestTelemetry.wifi_ssid || '--'}
` : ''}
${t('device.info.uptime')}
${formatUptime(latestTelemetry.uptime_seconds)}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.android_version')}
${device.android_version}
${t('device.info.app_version')}
${device.app_version || '--'}
${t('device.info.settings_pin')}
${device.settings_pin || '--'}
${t('device.info.settings_pin_hint')}
` : ''}
${t('device.info.screen_resolution')}
${device.screen_width && device.screen_height
? device.screen_width + 'x' + device.screen_height +
// #134: show the UI render surface alongside the HDMI output when they differ
// (TV boxes that render at 720p and upscale to a 1080p signal).
(device.render_width && device.render_height &&
(device.render_width !== device.screen_width || device.render_height !== device.screen_height)
? ` (UI ${device.render_width}x${device.render_height})` : '')
: '--'}
${t('device.clock.label')}
${renderDeviceClock(device)}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.ram')}
${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}
${t('device.info.cpu_usage')}
${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}
` : ''}
${t('device.timeline.title')}
${t('device.timeline.h24_ago')}
${t('device.timeline.now')}
${t('device.timeline.online')}
${t('device.timeline.offline')}
${t('device.timeline.no_data')}
${t('device.incidents.title')}
${t('device.remote.start_prompt')}
${device.tier === 2 ? `
${t('device.remote.system_view_owner')}
` : `
${t('device.remote.system_view_hint')}`}
${(device.client_type === 'apk' || device.android_version) ? `
${t('device.sysctl.subtitle')}
` : ''}
${device.tier === 2 ? `
${TERMINAL_PRESETS.map(p => ``).join('')}
${t('device.terminal.welcome')}\n
$
${t('device.terminal.uid_note')}
${t('device.terminal.push_apk')}
${t('device.terminal.push_apk_hint')}
` : ''}
`;
// Hydrate authenticated thumbnail images in the playlist tab
const pc = document.getElementById('playlistContainer');
if (pc) hydrateAuthImages(pc);
// Global key/command handlers for remote
window._sendKey = (keycode) => {
if (currentDevice) sendKey(currentDevice.id, keycode);
};
window._sendCmd = (type) => {
if (currentDevice) sendCommand(currentDevice.id, type, {});
};
window._enableSystemView = () => {
if (!currentDevice) return;
sendCommand(currentDevice.id, 'enable_system_capture', {});
// Unlock the system controls after a short delay (user needs to tap "Start now" on device)
const btn = document.getElementById('enableSystemCaptureBtn');
const hint = document.getElementById('systemViewHint');
if (btn) { btn.textContent = t('device.remote.waiting_for_approval'); btn.disabled = true; }
// Check periodically if the device granted it (we'll know because screenshots keep coming even after Home)
setTimeout(() => {
const controls = document.getElementById('systemViewControls');
if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; }
if (btn) { btn.textContent = t('device.remote.system_view_enabled'); btn.style.background = 'var(--success)'; }
if (hint) hint.textContent = t('device.remote.unlocked_hint');
}, 5000);
};
// #161 device-owner Terminal tab (tier 2 only): a real scrollback shell + preset commands + push-APK.
if (device.tier === 2) {
const termOut = document.getElementById('termOut');
const append = (text) => { if (!termOut) return; termOut.textContent += text; termOut.scrollTop = termOut.scrollHeight; };
const runCmd = (cmd) => { if (!cmd) return; append('\n$ ' + cmd + '\n'); sendCommand(device.id, 'shell', { cmd }); };
const termCmd = document.getElementById('termCmd');
document.getElementById('termRun')?.addEventListener('click', () => { const c = termCmd?.value?.trim(); if (c) { runCmd(c); termCmd.value = ''; } });
termCmd?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const c = e.target.value.trim(); if (c) { runCmd(c); e.target.value = ''; } } });
document.querySelectorAll('.term-preset').forEach(b => b.addEventListener('click', () => runCmd(b.dataset.cmd)));
document.getElementById('termClear')?.addEventListener('click', () => { if (termOut) termOut.textContent = ''; });
document.getElementById('apkInstall')?.addEventListener('click', () => {
const url = document.getElementById('apkUrl')?.value?.trim();
if (!url) return;
if (!/^https?:\/\//.test(url)) { showToast(t('device.owner_tools.bad_url'), 'error'); return; }
sendCommand(device.id, 'install_apk', { url });
append('\n# push apk → ' + url + ' (installs silently on a device owner)\n');
showToast(t('device.owner_tools.apk_sent'), 'success');
});
if (shellHandler) off('shell-result', shellHandler);
shellHandler = (data) => {
if (data.device_id !== device.id) return;
append((data.output || '') + (data.exit != null && data.exit !== 0 ? '\n[exit ' + data.exit + ']\n' : '\n'));
};
on('shell-result', shellHandler);
}
// Render uptime timeline
renderUptimeTimeline(device.uptimeData || [], device.statusLog || []);
// Render the Recent incidents panel (merges typed device_events with
// offline→online transitions derived from the status log).
renderIncidents(device.deviceEvents || [], device.statusLog || []);
setupTabs();
setupActions(device);
setupRemote(device);
setupPlaylistActions(device);
// Restore active tab if specified (e.g. after layout change)
if (activeTab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`);
if (tab) tab.classList.add('active');
const content = document.getElementById(`tab-${activeTab}`);
if (content) content.classList.add('active');
}
// Request a fresh screenshot on page load + poll periodically. #159: the preview used to go stale
// because nothing re-requested it — the device only sends a frame on an explicit request or during
// a live Remote session. Poll every 5s while this page is open so the Now Playing preview stays
// current (cleared on view teardown). A Remote session streams at its own faster rate on top.
if (device.status === 'online') {
requestScreenshot(deviceId);
if (screenshotInterval) clearInterval(screenshotInterval);
screenshotInterval = setInterval(() => {
if (!document.hidden) requestScreenshot(deviceId);
}, 5000);
}
} catch (err) {
contentEl.innerHTML = `${t('device.failed_load')}
${esc(err.message)}
`;
}
}
function renderPlaylist(assignments) {
if (!assignments.length) {
return `${t('device.playlist.empty_title')}
${t('device.playlist.empty_desc')}
`;
}
return assignments.map((a, i) => `
${a.widget_id && !a.content_id
? `
${{clock:'🕓',weather:'⛅',rss:'📰',text:'📝',webpage:'🌐',social:'💬'}[a.widget_type] || '⚙'}
`
: a.thumbnail_path
? `
![]()
`
: `
`
}
${esc(a.filename || a.widget_name || t('common.unknown'))}
${a.widget_id && !a.content_id ? t('device.pl_item.widget_with_type', { type: a.widget_type || 'custom' }) : a.mime_type === 'video/youtube' ? t('device.pl_item.youtube') : a.mime_type?.startsWith('video/') ? t('device.pl_item.video') : t('device.pl_item.image')}
${a.zone_id ? ` · ${t('device.pl_item.zone_label', { id: a.zone_id.slice(0,8) })}` : ''}
${a.content_duration ? ` · ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''}
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''}
${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
${a.orphan ? `
${t('device.pl_item.orphan_zone')}
` : ''}
`).join('');
}
function setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
});
});
}
// #104: device preview — reuse the player in device-free preview mode, iframed
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
// playlist in the device's OWN layout/orientation (server payload). wall members
// preview full-frame (server forces wall_config:null in v1).
function showDevicePreview(device) {
const portrait = (device.orientation || '').includes('portrait');
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
${t('device.preview_btn')} — ${esc(device.name)}
`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('#dpvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc2(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc2); }
});
}
// #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
const ORIENT_LABELS = {
'landscape': 'device.form.orientation.landscape',
'portrait': 'device.form.orientation.portrait',
'landscape-flipped': 'device.form.orientation.landscape_flipped',
'portrait-flipped': 'device.form.orientation.portrait_flipped',
};
const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
// #161: device-owner provisioning helper — QR (scan after factory-reset, tap welcome 6×) + the ADB
// one-liner. Device owner is optional; it unlocks silent updates, reboot, kiosk, time control.
async function showReAdoptModal(device) {
let snapshots, playlists;
try {
[snapshots, playlists] = await Promise.all([
api.getRemovedDevices(),
api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
]);
} catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
const plById = new Map((playlists || []).map(p => [p.id, p.name]));
const playlistLabel = (s) => !s.playlist_id
? t('device.readopt.playlist_none')
: (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
const rowsHtml = (snapshots || []).map((s, i) => {
const blockedBadge = s.blocked
? `${t('device.readopt.blocked')}`
: '';
// Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
const fpShort = (s.fingerprint || '').slice(0, 8);
return `
${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}
${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))}
· ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}
· ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))}
${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))} · ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))}
`;
}).join('');
const emptyHtml = `${t('device.readopt.empty')}
`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
${t('device.readopt.help', { name: esc(device.name || '') })}
${(snapshots && snapshots.length) ? rowsHtml : emptyHtml}
`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('#readoptClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
overlay.querySelectorAll('.readopt-apply').forEach((btn) => {
btn.addEventListener('click', async () => {
const s = snapshots[parseInt(btn.dataset.i, 10)];
let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' });
if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark
if (!confirm(msg)) return;
btn.disabled = true;
try {
await api.reAdoptDevice(device.id, s.fingerprint);
showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success');
close();
loadDevice(device.id); // refresh so restored orientation/name/etc show immediately
} catch (err) {
// Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request.
showToast(err.message || t('device.readopt.error'), 'error');
btn.disabled = false;
}
});
});
}
function setupActions(device) {
// #104 Preview button
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
// Screenshot button
document.getElementById('screenshotBtn')?.addEventListener('click', () => {
requestScreenshot(device.id);
showToast(t('device.toast.screenshot_requested'), 'info');
});
// Rename
document.getElementById('renameBtn')?.addEventListener('click', async () => {
const name = prompt(t('device.prompt_new_name'), device.name);
if (name && name !== device.name) {
try {
await api.updateDevice(device.id, { name });
document.getElementById('deviceName').textContent = name;
currentDevice.name = name;
showToast(t('device.toast.renamed'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
});
// Populate default content dropdown (async, non-blocking — same .then() pattern as the
// playlist picker below). setupActions is a SYNCHRONOUS function; awaiting here made the whole
// file fail to parse ("Unexpected reserved word") AND would have deferred every listener below
// (save, #150 re-adopt, delete) until this fetch resolved. .then() keeps them registering immediately.
api.getContent().then(content => {
const defaultSelect = document.getElementById('deviceDefaultContent');
if (defaultSelect) {
content.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id; opt.textContent = c.filename;
if (device.default_content_id === c.id) opt.selected = true;
defaultSelect.appendChild(opt);
});
}
}).catch(() => {});
// Save settings (notes + orientation + default content)
// Debug logging toggle: sends a transient set_debug command to the device and
// reveals the live log panel. State is per-session (resets on device reconnect).
document.getElementById('debugLogToggle')?.addEventListener('change', (e) => {
const enabled = e.target.checked;
const panel = document.getElementById('debugLogPanel');
if (panel) panel.style.display = enabled ? 'block' : 'none';
sendCommand(device.id, 'set_debug', { enabled });
});
document.getElementById('saveNotesBtn')?.addEventListener('click', async () => {
try {
await api.updateDevice(device.id, {
notes: document.getElementById('deviceNotes').value,
orientation: document.getElementById('deviceOrientation').value,
default_content_id: document.getElementById('deviceDefaultContent').value || null,
ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0,
reboot_schedule: document.getElementById('rebootSchedule')?.value || null,
});
showToast(t('device.toast.settings_saved'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
// #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the
// fallback for when the fingerprint changed and automatic restore couldn't fire).
document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device));
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {
devicePublishBtn.addEventListener('click', async () => {
try {
devicePublishBtn.disabled = true;
devicePublishBtn.textContent = t('device.draft.publishing');
await api.publishPlaylist(device.playlist_id);
showToast(t('device.toast.published'));
loadDevice(device.id, 'playlist');
} catch (err) {
devicePublishBtn.disabled = false;
devicePublishBtn.textContent = t('device.draft.publish');
showToast(err.message, 'error');
}
});
}
const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn');
if (deviceDiscardBtn && device.playlist_id) {
deviceDiscardBtn.addEventListener('click', async () => {
if (!confirm(t('device.confirm_discard_draft'))) return;
try {
await api.discardPlaylistDraft(device.playlist_id);
showToast(t('device.toast.draft_discarded'));
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Populate playlist picker
const playlistPicker = document.getElementById('playlistPicker');
if (playlistPicker) {
api.getPlaylists().then(playlists => {
playlists.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.is_auto_generated
? t('device.playlist_picker.with_auto', { name: p.name, n: p.item_count })
: t('device.playlist_picker.with_count', { name: p.name, n: p.item_count });
if (p.id === device.playlist_id) opt.selected = true;
playlistPicker.appendChild(opt);
});
// If device has no playlist, keep "No playlist" selected
if (!device.playlist_id) playlistPicker.value = '';
}).catch(() => {});
playlistPicker.addEventListener('change', async () => {
const newPlaylistId = playlistPicker.value;
if (!newPlaylistId) return; // Don't allow deselecting for now
try {
await api.assignPlaylistToDevice(newPlaylistId, device.id);
device.playlist_id = newPlaylistId;
const assignments = await api.getAssignments(device.id);
const pc = document.getElementById('playlistContainer');
pc.innerHTML = renderPlaylist(assignments);
hydrateAuthImages(pc);
attachRemoveHandlers(device);
showToast(t('device.toast.playlist_changed'));
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Copy playlist to another device
document.getElementById('copyPlaylistBtn')?.addEventListener('click', async () => {
try {
const devices = await api.getDevices();
const others = devices.filter(d => d.id !== device.id);
if (!others.length) { showToast(t('device.copy.no_other_devices'), 'info'); return; }
const targetId = prompt(t('device.copy.prompt', { list: others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') }));
if (!targetId) return;
const target = others[parseInt(targetId) - 1];
if (!target) { showToast(t('device.copy.invalid_selection'), 'error'); return; }
const token = localStorage.getItem('token');
const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ replace: false })
});
const data = await res.json();
if (res.ok) showToast(t('device.copy.toast', { n: data.copied, device: target.name }), 'success');
else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); }
});
// #146 Item D: operator block/unblock — takes effect on the device's next register,
// no restart. Server enforces even a device_id-less reconnect via the identity chain.
document.getElementById('deviceOwnerBtn')?.addEventListener('click', () => showDeviceOwnerQRModal());
// #161 Tier-2 controls (rendered only for device-owner panels).
const t2 = (type, confirm) => {
if (confirm && !window.confirm(confirm)) return;
sendCommand(device.id, type, {});
showToast(t('device.tier2.sent'), 'success');
};
document.getElementById('t2Reboot')?.addEventListener('click', () => t2('reboot', t('device.tier2.reboot_confirm')));
document.getElementById('t2Lock')?.addEventListener('click', () => t2('lock_now'));
document.getElementById('t2KioskOn')?.addEventListener('click', () => t2('kiosk_lock'));
document.getElementById('t2KioskOff')?.addEventListener('click', () => t2('kiosk_unlock'));
// #160 Track-A system control — send on release ('change', not 'input') so we don't spam the panel.
const bindLevel = (id, cmd) => {
const el = document.getElementById(id);
el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 }));
};
bindLevel('sysVolume', 'set_volume');
bindLevel('sysWinBrightness', 'set_brightness');
bindLevel('sysBrightness', 'set_system_brightness');
document.getElementById('sysTimeout')?.addEventListener('change', (e) =>
sendCommand(device.id, 'set_screen_timeout', { ms: parseInt(e.target.value, 10) }));
const blockBtn = document.getElementById('blockDeviceBtn');
blockBtn?.addEventListener('click', async () => {
blockBtn.disabled = true;
try {
if (device.blocked) { await api.unblockDevice(device.id); device.blocked = 0; showToast('Device unblocked', 'success'); }
else { await api.blockDevice(device.id); device.blocked = 1; showToast('Device blocked — refused on next reconnect', 'success'); }
blockBtn.textContent = device.blocked ? 'Unblock' : 'Block';
} catch (err) { showToast(err.message, 'error'); }
finally { blockBtn.disabled = false; }
});
// Delete (double-click to confirm)
const deleteBtn = document.getElementById('deleteDeviceBtn');
let deleteConfirming = false;
let deleteTimeout = null;
deleteBtn?.addEventListener('click', async () => {
if (deleteConfirming) {
try {
deleteBtn.textContent = t('device.toast.removing');
deleteBtn.disabled = true;
await api.deleteDevice(device.id);
showToast(t('device.toast.removed'), 'success');
window.location.hash = '/';
} catch (err) {
showToast(err.message, 'error');
deleteBtn.textContent = t('device.remove');
deleteBtn.disabled = false;
deleteConfirming = false;
}
return;
}
deleteConfirming = true;
deleteBtn.textContent = t('device.click_to_confirm');
deleteBtn.style.background = 'var(--danger)';
deleteBtn.style.color = 'white';
clearTimeout(deleteTimeout);
deleteTimeout = setTimeout(() => {
deleteConfirming = false;
deleteBtn.textContent = t('device.remove');
deleteBtn.style.background = '';
deleteBtn.style.color = '';
}, 3000);
});
// Send a command and surface the three-state ack as a toast.
// - delivered: device received it (green/success)
// - queued: device is offline, will deliver on reconnect (amber/warning)
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
function sendWithFeedback(type, cmdLabel, successKey) {
sendCommand(device.id, type, {}, (ack) => {
if (ack?.delivered) showToast(t(successKey), 'success');
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
});
}
// Reboot (double-click to confirm)
const rebootBtn = document.getElementById('rebootBtn');
let rebootConfirming = false;
let rebootTimeout = null;
rebootBtn?.addEventListener('click', () => {
if (rebootConfirming) {
sendWithFeedback('reboot', 'Reboot', 'device.toast.reboot_sent');
rebootConfirming = false;
rebootBtn.textContent = t('device.ctl.reboot_device');
return;
}
rebootConfirming = true;
rebootBtn.textContent = t('device.click_to_confirm');
clearTimeout(rebootTimeout);
rebootTimeout = setTimeout(() => {
rebootConfirming = false;
rebootBtn.textContent = t('device.ctl.reboot_device');
}, 3000);
});
// Shutdown (double-click to confirm)
const shutdownBtn = document.getElementById('shutdownBtn');
let shutdownConfirming = false;
let shutdownTimeout = null;
shutdownBtn?.addEventListener('click', () => {
if (shutdownConfirming) {
sendWithFeedback('shutdown', 'Shutdown', 'device.toast.shutdown_sent');
shutdownConfirming = false;
shutdownBtn.textContent = t('device.ctl.shutdown');
return;
}
shutdownConfirming = true;
shutdownBtn.textContent = t('device.click_to_confirm');
shutdownBtn.style.background = 'var(--danger)';
shutdownBtn.style.color = 'white';
clearTimeout(shutdownTimeout);
shutdownTimeout = setTimeout(() => {
shutdownConfirming = false;
shutdownBtn.textContent = t('device.ctl.shutdown');
shutdownBtn.style.background = '';
shutdownBtn.style.color = '';
}, 3000);
});
// Screen Off
document.getElementById('screenOffBtn')?.addEventListener('click', () => {
sendWithFeedback('screen_off', 'Screen off', 'device.toast.screen_off_sent');
});
// Screen On
document.getElementById('screenOnBtn')?.addEventListener('click', () => {
sendWithFeedback('screen_on', 'Screen on', 'device.toast.screen_on_sent');
});
// Launch Player
document.getElementById('launchAppBtn')?.addEventListener('click', () => {
sendWithFeedback('launch', 'Launch', 'device.toast.launch_sent');
});
// Force Update
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
});
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
const uri = (document.getElementById('pipUri')?.value || '').trim();
if (!uri) { showToast('Enter an overlay URL', 'error'); return; }
try {
const res = await api.sendPip(device.id, {
type: document.getElementById('pipType').value,
uri,
position: document.getElementById('pipPosition').value,
duration: Number(document.getElementById('pipDuration').value) || 0,
});
showToast(`Overlay sent (${res.sent} sent, ${res.offline} offline)`, res.sent ? 'success' : 'warning');
} catch (err) { showToast(err.message, 'error'); }
});
document.getElementById('clearPipBtn')?.addEventListener('click', async () => {
try { await api.clearPip(device.id); showToast('Overlay cleared', 'success'); }
catch (err) { showToast(err.message, 'error'); }
});
}
function setupRemote(device) {
const startBtn = document.getElementById('startRemoteBtn');
const stopBtn = document.getElementById('stopRemoteBtn');
const overlay = document.getElementById('remoteOverlay');
const canvas = document.getElementById('remoteCanvas');
startBtn?.addEventListener('click', () => {
console.log('Start Remote clicked for device:', device.id);
remoteActive = true;
startRemote(device.id);
requestScreenshot(device.id);
startBtn.style.display = 'none';
stopBtn.style.display = '';
overlay.style.display = 'none';
showToast(t('device.toast.remote_started'), 'info');
});
stopBtn?.addEventListener('click', () => {
remoteActive = false;
stopRemote(device.id);
stopBtn.style.display = 'none';
startBtn.style.display = '';
overlay.style.display = 'flex';
});
// #159: mouse-as-finger. A click = tap; a drag = swipe (scroll). Pointer events so a press-move-
// release maps to a gesture with the same normalized start/end + duration the device replays.
let drag = null;
const norm = (e) => {
const rect = canvas.getBoundingClientRect();
return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, cx: e.clientX - rect.left, cy: e.clientY - rect.top };
};
const feedback = (cx, cy) => {
const ctx = canvas.getContext('2d');
ctx.beginPath(); ctx.arc(cx, cy, 10, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(59, 130, 246, 0.5)'; ctx.fill();
};
canvas?.addEventListener('pointerdown', (e) => {
if (!remoteActive) return;
const p = norm(e);
drag = { ...p, t: Date.now() };
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
feedback(p.cx, p.cy);
});
canvas?.addEventListener('pointerup', (e) => {
if (!remoteActive || !drag) return;
const p = norm(e);
const dist = Math.hypot(p.x - drag.x, p.y - drag.y);
const dur = Math.min(1200, Math.max(120, Date.now() - drag.t));
if (dist > 0.02) {
sendSwipe(device.id, drag.x, drag.y, p.x, p.y, dur); // drag → scroll
const ctx = canvas.getContext('2d');
ctx.strokeStyle = 'rgba(59,130,246,0.6)'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(drag.cx, drag.cy); ctx.lineTo(p.cx, p.cy); ctx.stroke();
} else {
sendTouch(device.id, drag.x, drag.y, 'tap');
}
drag = null;
});
canvas?.addEventListener('pointercancel', () => { drag = null; });
}
async function setupPlaylistActions(device) {
// Load layouts into selector
try {
const layoutsRes = await fetch('/api/layouts', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }});
const layouts = await layoutsRes.json();
const select = document.getElementById('deviceLayoutSelect');
if (select) {
layouts.filter(l => !l.is_template).forEach(l => {
const opt = document.createElement('option');
opt.value = l.id;
opt.textContent = t('device.layout.zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt);
});
// Add templates too
layouts.filter(l => l.is_template).forEach(l => {
const opt = document.createElement('option');
opt.value = l.id;
opt.textContent = t('device.layout.template_zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt);
});
}
} catch (err) {
console.warn('Failed to load layouts:', err);
}
// Apply layout button
document.getElementById('applyLayoutBtn')?.addEventListener('click', async () => {
const layoutId = document.getElementById('deviceLayoutSelect').value;
try {
await fetch(`/api/layouts/device/${device.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ layout_id: layoutId || null })
});
showToast(layoutId ? t('device.toast.layout_applied') : t('device.toast.switched_to_fullscreen'), 'success');
// Reload the device page to show updated zone selectors, stay on playlist tab
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
});
// Add content button
document.getElementById('addContentBtn')?.addEventListener('click', async () => {
const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
try {
const [content, widgets, kioskPages] = await Promise.all([
api.getContent(),
fetch('/api/widgets', { headers }).then(r => r.json()),
fetch('/api/kiosk', { headers }).then(r => r.json()),
]);
// Get layout zones if device has a layout assigned. We track
// zonesFetchFailed separately so the modal can distinguish "fetch
// broke" from "fetch succeeded, layout genuinely has no zones" -
// both end with zones=[] but the user message differs.
// The !res.ok throw is required because fetch only rejects on network
// errors; an HTTP 403/404 would otherwise json-parse into {error: ...}
// and zones would silently be [].
let zones = [];
let zonesFetchFailed = false;
if (device.layout_id) {
try {
const res = await fetch(`/api/layouts/${device.layout_id}`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const layout = await res.json();
zones = layout.zones || [];
} catch (e) {
console.warn('Failed to load layout for zone picker:', e.message);
zonesFetchFailed = true;
}
}
if (!content.length && !widgets.length && !kioskPages.length) {
showToast(t('device.assign.empty_all'), 'error');
return;
}
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
${t('device.assign.tab.media', { n: content.length })}
${t('device.assign.tab.widgets', { n: widgets.length })}
${t('device.assign.tab.kiosk', { n: kioskPages.length })}
${content.map(c => `
${c.thumbnail_path
? `
![]()
`
: c.remote_url
? `
`
: `
`
}
${esc(c.filename)}
`).join('') || `
${t('device.assign.no_media')}
`}
${widgets.map(w => {
const icons = {clock:'🕓',weather:'⛅',rss:'📰',text:'📝',webpage:'🌐',social:'💬'};
return `
${icons[w.widget_type] || '⚙'}
${w.name}
`;
}).join('') || `
${t('device.assign.no_widgets')} ${t('device.assign.create_one')}
`}
${kioskPages.map(k => `
`).join('') || `
${t('device.assign.no_kiosk')} ${t('device.assign.create_one')}
`}
`;
document.body.appendChild(modal);
hydrateAuthImages(modal, { eager: true });
// Tab switching
modal.querySelectorAll('.assign-tab').forEach(tab => {
tab.onclick = () => {
modal.querySelectorAll('.assign-tab').forEach(t => { t.style.borderBottomColor = 'transparent'; t.style.color = 'var(--text-secondary)'; });
tab.style.borderBottomColor = 'var(--accent)'; tab.style.color = 'var(--accent)';
document.getElementById('assignMedia').style.display = tab.dataset.tab === 'media' ? '' : 'none';
document.getElementById('assignWidgets').style.display = tab.dataset.tab === 'widgets' ? '' : 'none';
document.getElementById('assignKiosk').style.display = tab.dataset.tab === 'kiosk' ? '' : 'none';
};
});
let selectedId = null;
let selectedType = null;
modal.querySelectorAll('.assign-content-item').forEach(item => {
item.addEventListener('click', () => {
modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedId = item.dataset.contentId;
selectedType = item.dataset.type;
});
});
modal.querySelector('#closeAssignModal').onclick = () => modal.remove();
modal.querySelector('#cancelAssign').onclick = () => modal.remove();
modal.querySelector('#confirmAssign').onclick = async () => {
if (!selectedId) {
showToast(t('device.assign.select_first'), 'error');
return;
}
const duration = parseInt(modal.querySelector('#assignDuration').value) || 10;
const zoneId = modal.querySelector('#assignZone')?.value || null;
try {
if (selectedType === 'content') {
await api.addAssignment(device.id, { content_id: selectedId, duration_sec: duration, zone_id: zoneId });
} else if (selectedType === 'widget') {
await api.addAssignment(device.id, { widget_id: selectedId, duration_sec: duration, zone_id: zoneId });
} else if (selectedType === 'kiosk') {
// For kiosk pages, create a webpage widget pointing to the kiosk render URL
const serverUrl = window.location.origin;
const wRes = await fetch('/api/widgets', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: 'webpage', name: t('device.assign.kiosk_widget_name', { name: kioskPages.find(k => k.id === selectedId)?.name || 'Page' }), config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } })
});
const widget = await wRes.json();
await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 });
}
modal.remove();
showToast(t('device.toast.added_to_playlist'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
};
} catch (err) {
showToast(err.message, 'error');
}
});
attachRemoveHandlers(device);
}
function attachRemoveHandlers(device) {
// Populate zone selectors if device has a layout. The current zone_id for
// each assignment is read from data-current-zone-id on the .zone-select
// element (stashed at render time from a.zone_id); no DOM-scraping.
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
// is the default from the render), same end-state as before but no longer
// silent.
// Inline per-item zone reassign dropdowns. A device WITH a layout always gets them;
// visibility is NOT gated on whether the zone list arrived (gating on that silently hid
// the selector when the server payload lacked active_layout_zones — e.g. a stale server
// during a deploy skew). Only a genuinely fullscreen device (no layout_id) has no zones.
if (device.layout_id) {
// Show the selectors immediately; they become usable once zones populate below.
document.querySelectorAll('.zone-select').forEach(s => { s.style.display = ''; });
// Clicking an item's orphan warning badge scrolls to + focuses its zone-select.
document.querySelectorAll('.pl-orphan-warning').forEach(w => {
w.addEventListener('click', () => {
const sel = document.querySelector('.zone-select[data-assignment-id="' + w.dataset.orphanAssignment + '"]');
if (sel) { sel.scrollIntoView({ block: 'center', behavior: 'smooth' }); sel.focus(); }
});
});
const populateZoneSelects = (zones) => {
const activeIds = new Set((zones || []).map(z => z.id));
document.querySelectorAll('.zone-select').forEach(select => {
select.style.display = '';
while (select.options.length > 1) select.remove(1); // keep the "no zone" placeholder, drop stale options
const assignmentId = select.dataset.assignmentId;
const currentZoneId = select.dataset.currentZoneId || '';
(zones || []).forEach(z => {
const opt = document.createElement('option');
opt.value = z.id;
opt.textContent = z.name;
select.appendChild(opt);
});
const orphan = !!currentZoneId && !activeIds.has(currentZoneId);
if (currentZoneId && !orphan) select.value = currentZoneId; // can't select a zone the layout lacks
if (orphan) { select.style.borderColor = 'var(--danger)'; select.style.color = 'var(--danger)'; }
select.onchange = async () => {
try {
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
showToast(t('device.toast.zone_updated'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) { showToast(err.message, 'error'); }
};
});
};
if (device.active_layout_zones && device.active_layout_zones.length) {
// Fast path: zones already in the device payload — no round-trip.
populateZoneSelects(device.active_layout_zones);
} else {
// Fallback: payload field absent/empty (server/frontend version skew, or a payload
// change) — fetch the layout so the dropdowns still populate. A missing field must
// never make the selector silently vanish.
const token = localStorage.getItem('token');
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(layout => populateZoneSelects(layout.zones || []))
.catch(e => console.warn('Zone dropdowns: layout fetch fallback failed:', e.message));
}
}
// Mute toggle buttons
document.querySelectorAll('.mute-toggle').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.muteAssignment;
const currentlyMuted = btn.dataset.muted === '1';
try {
await api.updateAssignment(id, { muted: !currentlyMuted });
showToast(currentlyMuted ? t('device.toast.unmuted') : t('device.toast.muted'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) { showToast(err.message, 'error'); }
});
});
// Remove buttons
document.querySelectorAll('[data-remove-assignment]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.removeAssignment;
try {
await api.deleteAssignment(id);
showToast(t('device.toast.removed_from_playlist'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
});
});
// Drag-and-drop reorder
const container = document.getElementById('playlistContainer');
if (!container) return;
let dragItem = null;
container.querySelectorAll('.playlist-item[draggable]').forEach(item => {
item.addEventListener('dragstart', (e) => {
dragItem = item;
item.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
item.style.opacity = '1';
dragItem = null;
container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = '');
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = '');
if (item !== dragItem) item.style.borderTop = '2px solid var(--accent)';
});
item.addEventListener('drop', async (e) => {
e.preventDefault();
item.style.borderTop = '';
if (!dragItem || dragItem === item) return;
// Get new order
const items = [...container.querySelectorAll('.playlist-item[data-assignment-id]')];
const fromIdx = items.indexOf(dragItem);
const toIdx = items.indexOf(item);
if (fromIdx < 0 || toIdx < 0) return;
// Reorder in DOM
if (fromIdx < toIdx) item.after(dragItem);
else item.before(dragItem);
// Get new order of assignment IDs
const newOrder = [...container.querySelectorAll('.playlist-item[data-assignment-id]')]
.map(el => parseInt(el.dataset.assignmentId));
try {
await api.reorderAssignments(device.id, newOrder);
showToast(t('device.toast.playlist_reordered'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
loadDevice(device.id, 'playlist');
}
});
});
}
function renderUptimeTimeline(uptimeData, statusLog = []) {
const timeline = document.getElementById('uptimeTimeline');
const percentEl = document.getElementById('uptimePercent');
if (!timeline) return;
const now = Math.floor(Date.now() / 1000);
const dayAgo = now - 86400;
const slots = 96; // 15-minute slots over 24 hours
const slotDuration = 86400 / slots; // 900 seconds = 15 min
// Build slot status: 'online', 'offline', or 'unknown'
const slotStatus = new Array(slots).fill('unknown');
// Parallel array: for offline slots, the {reason, detail} of the covering offline event
// (why the device was offline) — surfaced in the slot's hover title.
const slotReason = new Array(slots).fill(null);
// First pass: mark slots that have heartbeat telemetry as online
for (const ts of uptimeData) {
const slotIdx = Math.floor((ts - dayAgo) / slotDuration);
if (slotIdx >= 0 && slotIdx < slots) slotStatus[slotIdx] = 'online';
}
// Second pass: use status log events to paint ranges
// Walk through events and fill slots between online/offline transitions
for (let i = 0; i < statusLog.length; i++) {
const event = statusLog[i];
const nextEvent = statusLog[i + 1];
const startSlot = Math.max(0, Math.floor((event.timestamp - dayAgo) / slotDuration));
const endSlot = nextEvent
? Math.min(slots - 1, Math.floor((nextEvent.timestamp - dayAgo) / slotDuration))
: (event.status === 'online' ? slots - 1 : startSlot);
const isOnline = event.status === 'online';
const reason = isOnline ? null : { reason: event.reason || null, detail: event.detail || null };
for (let s = startSlot; s <= endSlot && s < slots; s++) {
if (s >= 0) {
slotStatus[s] = isOnline ? 'online' : 'offline';
slotReason[s] = reason;
}
}
}
// Mark future slots as unknown
const nowSlot = Math.floor((now - dayAgo) / slotDuration);
for (let i = nowSlot + 1; i < slots; i++) slotStatus[i] = 'unknown';
// Calculate uptime percentage (only over known slots)
const knownSlots = slotStatus.filter(s => s !== 'unknown').length;
const onlineSlots = slotStatus.filter(s => s === 'online').length;
const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0;
if (percentEl) {
percentEl.textContent = knownSlots > 0
? t('device.timeline.uptime_pct_tracked', { pct: uptimePct, n: knownSlots * 15 })
: t('device.timeline.uptime_pct_no_data', { pct: uptimePct });
}
// Color map
const colors = {
online: 'var(--success)',
offline: 'var(--danger)',
unknown: 'var(--bg-secondary)'
};
const opacities = { online: 0.8, offline: 0.6, unknown: 0.3 };
// Render bars
timeline.innerHTML = slotStatus.map((status, i) => {
const time = new Date((dayAgo + i * slotDuration) * 1000);
const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const statusLabel = status === 'unknown' ? t('device.timeline.no_data') : status === 'online' ? t('device.timeline.online') : t('device.timeline.offline');
let title = `${label} - ${statusLabel}`;
if (status === 'offline' && slotReason[i]) {
const r = slotReason[i];
title = `${label} – ${statusLabel} · ${eventLabel(r.reason)}${r.detail ? ` (${r.detail})` : ''}`;
}
return ``;
}).join('');
}
// Map an event/reason token to a friendly label via i18n, falling back to the raw
// token if no translation exists. Null → "Unknown cause".
function eventLabel(key) {
if (!key) return t('device.event.silent');
const full = t('device.event.' + key);
return full === ('device.event.' + key) ? key : full;
}
// Dot color by incident type. Amber (#f59e0b) matches the warning accent used
// elsewhere in this view; the rest use the shared CSS vars.
function incidentColor(type) {
if (type === 'online' || type === 'display_on') return 'var(--success)';
if (type === 'display_off') return 'var(--text-muted)';
if (type === 'reboot') return '#f59e0b';
return 'var(--danger)'; // offline, network, crash, app_error
}
// Compact duration ("4m", "1h 5m", "2d 3h") for an offline period.
function formatDur(seconds) {
seconds = Math.max(0, Math.floor(seconds));
if (seconds < 60) return seconds + 's';
const m = Math.floor(seconds / 60);
if (m < 60) return m + 'm';
const h = Math.floor(m / 60);
const rm = m % 60;
if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
// Compact relative time ("2h ago").
function relTime(tsSec, nowSec = Math.floor(Date.now() / 1000)) {
const diff = Math.max(0, nowSec - tsSec);
if (diff < 60) return diff + 's ago';
const m = Math.floor(diff / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
const d = Math.floor(h / 24);
return d + 'd ago';
}
// "Recent incidents" panel: a newest-first, time-sorted merge of typed device_events
// (display sleep, crash, reboot, network, app_error) with offline→online periods
// derived from the status log (so a device with only server-side offline data still
// shows incidents, and downtime carries a duration).
function renderIncidents(deviceEvents = [], statusLog = []) {
const panel = document.getElementById('incidentsPanel');
if (!panel) return;
const nowSec = Math.floor(Date.now() / 1000);
const incidents = [];
// Offline periods from the status log (server-side ground truth). Start a period
// on each offline transition and close it at the next 'online' row.
const log = (statusLog || []).slice().sort((a, b) => a.timestamp - b.timestamp);
for (let i = 0; i < log.length; i++) {
const ev = log[i];
if (ev.status === 'online') continue;
// Collapse a repeated offline row (e.g. offline followed by offline_timeout).
if (i > 0 && log[i - 1].status !== 'online') continue;
let end = null;
for (let j = i + 1; j < log.length; j++) {
if (log[j].status === 'online') { end = log[j].timestamp; break; }
}
incidents.push({
type: 'offline',
reason: ev.reason || null,
detail: ev.detail || null,
timestamp: ev.timestamp,
durationSec: (end != null ? end : nowSec) - ev.timestamp,
ongoing: end == null,
});
}
// Typed incidents from device_events. offline/online are already represented as
// periods above, so skip them here to avoid double-listing the same event.
for (const ev of (deviceEvents || [])) {
if (!ev || ev.type === 'offline' || ev.type === 'online') continue;
incidents.push({
type: ev.type,
reason: ev.reason || null,
detail: ev.detail || null,
timestamp: ev.timestamp,
});
}
if (!incidents.length) {
panel.innerHTML = `${t('device.incidents.none')}
`;
return;
}
incidents.sort((a, b) => b.timestamp - a.timestamp);
panel.innerHTML = incidents.slice(0, 15).map(inc => {
const label = eventLabel(inc.reason || inc.type);
const detail = inc.detail
? `${esc(inc.detail)}`
: '';
const dur = (inc.durationSec != null)
? `${esc(t('device.incidents.down_for', { dur: formatDur(inc.durationSec) }) + (inc.ongoing ? '…' : ''))}`
: '';
return `
${esc(label)}
${detail}
${dur}
${esc(relTime(inc.timestamp, nowSec))}
`;
}).join('');
}
function updateTelemetryDisplay(telemetry) {
const update = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%');
}
export function cleanup() {
if (statusHandler) off('device-status', statusHandler);
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
if (playbackHandler) off('playback-state', playbackHandler);
if (logHandler) off('device-log', logHandler);
if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener
if (screenshotInterval) clearInterval(screenshotInterval);
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
remoteActive = false;
currentDevice = null;
window._sendKey = null;
}