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 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`; } export function render(container, deviceId) { container.innerHTML = `
${t('device.back')}

${t('common.loading')}

`; 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 }); } }; on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('playback-state', playbackHandler); } 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.name}

${device.status} ${device.owner_name || device.owner_email ? `${t('device.owner_label', { owner: device.owner_name || device.owner_email })}` : ''}
${t('device.tab.now_playing')} ?
${t('device.tab.playlist')} ?
${t('device.tab.info')} ?
${t('device.tab.remote')} ?
${device.screenshot ? `Current screen` : `
${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 || '--'}
${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}
` : ''}
${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 : '--'}
${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.remote.start_prompt')}






${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}` : ''}
`).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'); }); }); } async function setupActions(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) 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'); }); } 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 let zones = []; if (device.layout_id) { try { const layout = await fetch(`/api/layouts/${device.layout_id}`, { headers }).then(r => r.json()); zones = layout.zones || []; } catch {} } 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 = ` `; 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 if (device.layout_id) { const token = localStorage.getItem('token'); fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }}) .then(r => r.json()) .then(layout => { const zones = layout.zones || []; document.querySelectorAll('.zone-select').forEach(select => { select.style.display = ''; const assignmentId = select.dataset.assignmentId; // Find current zone_id from the playlist item's data const zoneText = select.closest('.playlist-item')?.querySelector('[style*="color:var(--accent)"]')?.textContent || ''; zones.forEach(z => { const opt = document.createElement('option'); opt.value = z.id; opt.textContent = z.name; select.appendChild(opt); }); // Set current value by matching zone_id from the meta text const currentAssignment = document.querySelector(`.playlist-item[data-assignment-id="${assignmentId}"]`); if (currentAssignment) { const meta = currentAssignment.querySelector('.playlist-item-meta')?.innerHTML || ''; const zoneMatch = zones.find(z => meta.includes(z.id.slice(0, 8))); if (zoneMatch) select.value = zoneMatch.id; } 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(() => {}); } // 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 (screenshotInterval) clearInterval(screenshotInterval); if (remoteActive && currentDevice) stopRemote(currentDevice.id); remoteActive = false; currentDevice = null; window._sendKey = null; }