import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
let playbackHandler = null;
let logHandler = null;
let screenshotInterval = null;
let remoteActive = false;
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) {
badge.className = `device-status-badge ${data.status}`;
badge.textContent = data.status;
}
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 = `
${t('device.tab.now_playing')} ?
${t('device.tab.playlist')} ?
${t('device.tab.info')} ?
${t('device.tab.remote')} ?
${device.screenshot
? `
`
: `
${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.draft.discard')} ` : ''}
${t('device.draft.publish')}
` : ''}
${t('device.layout.label')}
${t('device.layout.fullscreen_default')}
${t('device.layout.apply')}
${t('device.playlist.label')}
${t('device.playlist.no_playlist')}
${t('device.playlist.copy_to_btn')}
${t('device.playlist.add_content_btn')}
${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.screen_resolution')}
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_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.form.orientation_label')}
${t('device.form.orientation.landscape')}
${t('device.form.orientation.portrait')}
${t('device.form.orientation.landscape_flipped')}
${t('device.form.orientation.portrait_flipped')}
${t('device.form.default_content_label')}
${t('device.form.default_content_none')}
${t('device.form.notes_label')}
${t('device.form.save_settings')}
${t('device.ctl.reboot_device')}
${t('device.ctl.screen_off')}
${t('device.ctl.screen_on')}
${t('device.ctl.launch_player')}
${t('device.ctl.force_update')}
${t('device.ctl.shutdown')}
${t('device.remote.start_prompt')}
${t('device.remote.start')}
${t('device.remote.stop')}
${t('device.remote.vol_up')}
${t('device.remote.vol_down')}
${t('device.remote.home')}
${t('device.remote.back')}
${t('device.remote.recents')}
${t('device.remote.power')}
▲
◀
▶
▼
${t('device.remote.ok')}
${t('device.remote.settings')}
${t('device.remote.scrn_off')}
${t('device.remote.scrn_on')}
${t('device.remote.enable_system_view')}
${t('device.remote.system_view_hint')}
`;
// 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);
};
// Render uptime timeline
renderUptimeTimeline(device.uptimeData || [], 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
if (device.status === 'online') {
requestScreenshot(deviceId);
}
} 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}` : ''}
${t('device.pl_item.no_zone')}
${a.muted
? ' '
: ' '
}
`).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)}
${t('widget.close')}
`;
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); }
});
}
async 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
try {
const content = await api.getContent();
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,
});
showToast(t('device.toast.settings_saved'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
// 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);
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
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'); }
});
// 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';
});
// Touch forwarding on canvas
canvas?.addEventListener('click', (e) => {
if (!remoteActive) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
sendTouch(device.id, x, y, 'tap');
// Visual feedback
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(e.clientX - rect.left, e.clientY - rect.top, 10, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(59, 130, 246, 0.5)';
ctx.fill();
setTimeout(() => {
// Redraw will happen on next screenshot
}, 200);
});
}
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.duration_label')}
${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);
// 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.
if (device.layout_id) {
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 => {
const zones = layout.zones || [];
document.querySelectorAll('.zone-select').forEach(select => {
select.style.display = '';
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);
});
if (currentZoneId) select.value = currentZoneId;
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'); }
};
});
})
.catch(e => {
// No toast - fires once per device-detail load, would be annoying for
// a layout misconfig that's already surfaced via the modal info row.
console.warn('Failed to load layout for edit-zone dropdowns:', 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');
// 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';
for (let s = startSlot; s <= endSlot && s < slots; s++) {
if (s >= 0) slotStatus[s] = isOnline ? 'online' : 'offline';
}
}
// 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');
return `
`;
}).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 (screenshotInterval) clearInterval(screenshotInterval);
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
remoteActive = false;
currentDevice = null;
window._sendKey = null;
}