// Configuration tab: frame-wide settings (orientation, quiet hours, // palette/color/contrast/dither, firmware, battery alerts) + take // control. Frame name lives in the page header now (frame_header.js); // every per-widget setting (album, calendar view/inclusion, whiteboard // source) lives in its own widget's gear-icon dialog instead (see // static/frame_layout.js) -- this tab never touches those. // window.FRAME_API is set by the template. Checkboxes are always sent // explicitly as "true"/"false" -- the server treats absent fields as // "leave unchanged", so a checkbox must never be simply omitted. async function saveConfig() { const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60; const body = new URLSearchParams({ orientation: document.getElementById('orientation').value, refresh_interval_s: String(minutes * 60), quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked), quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00', quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00', timezone: document.getElementById('timezone').value || 'UTC', }); const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) { throw new Error(await apiError(resp)); } } // Orientation swaps the widget grid's long/short axis (see // grid.grid_dims), so an existing widget layout is usually left with // out-of-bounds coordinates on the new grid -- the server resets it to // one full-panel widget when this actually changes (see // api_frames.py's api_config_save). Warn before that happens rather // than silently losing whatever layout was on the Layout tab. let lastSavedOrientation = document.getElementById('orientation').value; document.getElementById('config-form').addEventListener('submit', async (e) => { e.preventDefault(); const newOrientation = document.getElementById('orientation').value; if (newOrientation !== lastSavedOrientation) { const proceed = confirm( "Changing orientation resets this frame's widget layout to a single " + 'full-panel widget -- any other widgets placed on the Layout tab will ' + 'be removed. Continue?' ); if (!proceed) { document.getElementById('orientation').value = lastSavedOrientation; return; } } try { await saveConfig(); lastSavedOrientation = newOrientation; showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } }); async function takeControl() { try { const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'You have control now.'); loadControl(); } catch (e) { showStatus(false, e.message); } } async function loadControl() { const banner = document.getElementById('control-banner'); try { const resp = await fetch(`${window.FRAME_API}/status`); if (!resp.ok) return; // unconfigured frame: control still works via 409s const data = await resp.json(); if (data.control && !data.control.you) { banner.style.display = 'flex'; document.getElementById('control-holder').textContent = data.control.controller ? `${data.control.controller} currently has control of this frame.` : 'Nobody has control of this frame yet.'; } else { banner.style.display = 'none'; } } catch (e) { /* banner is best-effort */ } } document.getElementById('take-control').addEventListener('click', takeControl); // ---- Advanced configuration: color palette ---- // // Hex field and R/G/B number fields are kept in sync live, both // directions -- editing either updates the other plus the preview // swatch. Hex stays the field actually read at save time (it's what // the server already validates as #rrggbb); the R/G/B fields are purely // an alternate, more precise way to arrive at the same value than // eyeballing a color-picker swatch. function paletteHexInputs() { return Array.from(document.querySelectorAll('.palette-hex')) .sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index)); } function hexFromRgb(r, g, b) { const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0))); return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join(''); } function rgbFromHex(hex) { const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim()); if (!m) return null; const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function paletteFieldsFor(index) { const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`); return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') }; } function syncPaletteFromHex(index) { const f = paletteFieldsFor(index); const rgb = rgbFromHex(f.hex.value); if (!rgb) return; [f.r.value, f.g.value, f.b.value] = rgb; f.swatch.style.background = f.hex.value; } function syncPaletteFromRgb(index) { const f = paletteFieldsFor(index); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); f.hex.value = hex; f.swatch.style.background = hex; } const palettePickerCount = paletteHexInputs().length; for (let i = 0; i < palettePickerCount; i++) { const f = paletteFieldsFor(i); f.hex.addEventListener('input', () => syncPaletteFromHex(i)); [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i))); } // Sliders: live numeric readout next to each, no save until the button // below is clicked. ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { const input = document.getElementById(id); const readout = document.getElementById(`${id}_value`); input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); }); }); async function savePalette(extra) { const body = new URLSearchParams(extra || {}); for (const input of paletteHexInputs()) { body.append('palette', input.value); } try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadPreview(); } catch (e) { showStatus(false, e.message); } } document.getElementById('palette-save').addEventListener('click', () => { savePalette({ color_boost: document.getElementById('color_boost').value, contrast_boost: document.getElementById('contrast_boost').value, dither_strength: document.getElementById('dither_strength').value, }); }); document.getElementById('palette-reset').addEventListener('click', () => { const inputs = paletteHexInputs(); window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; syncPaletteFromHex(i); }); ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { document.getElementById(id).value = '1'; document.getElementById(`${id}_value`).textContent = '1.00'; }); savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); }); // ---- Preview: current photo vs. how it renders with saved settings ---- // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // set by the template) rather than window.FRAME_API -- palette/color/ // contrast/dither are frame-level, but "the current photo" to preview // them against is necessarily one specific photo widget's. Null (no // photo widget on this frame) means the template didn't render the // preview section at all -- nothing to wire up. function loadPreview() { if (!window.PHOTO_WIDGET_PREVIEW_API) return; const bust = Date.now(); // avoid a stale cached image after settings change document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`; document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`; } const previewRefreshBtn = document.getElementById('preview-refresh'); if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview); loadPreview(); // ---- Battery alerts card ---- document.getElementById('battery-alert-save').addEventListener('click', async () => { const raw = document.getElementById('battery_alert_threshold_pct').value.trim(); const body = new URLSearchParams({ battery_alert_threshold_pct: raw === '' ? '-1' : raw, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } }); // ---- Firmware card ---- document.getElementById('firmware-upload').addEventListener('click', async () => { const input = document.getElementById('firmware-file'); if (!input.files.length) { showStatus(false, 'Pick a firmware .bin first.'); return; } const form = new FormData(); form.append('file', input.files[0]); try { const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form }); if (!resp.ok) { throw new Error(await apiError(resp)); } const result = await resp.json(); document.getElementById('firmware-available').textContent = `Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`; showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`); } catch (e) { showStatus(false, e.message); } }); function showRepoDisplayMode(url) { document.getElementById('firmware-repo-text').textContent = url; document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none'; document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block'; } document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => { document.getElementById('firmware-repo-display').style.display = 'none'; document.getElementById('firmware-repo-edit').style.display = 'block'; document.getElementById('firmware_update_repo_url').focus(); }); document.getElementById('firmware-settings-save').addEventListener('click', async () => { try { const body = new URLSearchParams({ firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '', firmware_auto_update: String(document.getElementById('firmware_auto_update').checked), }); const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim()); loadFirmwareCheck(); } catch (e) { showStatus(false, e.message); } }); async function loadFirmwareCheck(force) { const statusEl = document.getElementById('firmware-gitea-status'); const btn = document.getElementById('firmware-update-btn'); const boardEl = document.getElementById('firmware-board'); try { const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' }); if (!resp.ok) { if (force) { showStatus(false, await apiError(resp)); } return; } const data = await resp.json(); if (data.board) { boardEl.textContent = `Detected board: ${data.board}`; } if (!data.enabled) { statusEl.style.display = 'none'; btn.style.display = 'none'; if (force) { showStatus(false, 'No Gitea repo URL configured.'); } return; } statusEl.style.display = 'block'; if (!data.board) { statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.'; btn.style.display = 'none'; } else if (data.update_available) { statusEl.textContent = `Update available: v${data.latest_version}.`; btn.style.display = 'inline-block'; } else if (data.latest_version) { statusEl.textContent = `Up to date (v${data.latest_version}).`; btn.style.display = 'none'; } else { statusEl.textContent = 'No releases found yet.'; btn.style.display = 'none'; } if (force) { showStatus(true, 'Checked.'); } } catch (e) { // A failed passive poll is silent; an explicit "Check now" click // still surfaces the error. if (force) { showStatus(false, e.message); } } } document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true)); document.getElementById('firmware-update-btn').addEventListener('click', async () => { const btn = document.getElementById('firmware-update-btn'); btn.disabled = true; try { const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' }); if (!resp.ok) { throw new Error(await apiError(resp)); } const result = await resp.json(); document.getElementById('firmware-available').textContent = `Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`; showStatus(true, `Firmware v${result.version} staged from Gitea.`); loadFirmwareCheck(); } catch (e) { showStatus(false, e.message); } finally { btn.disabled = false; } }); loadControl(); loadFirmwareCheck(); // The server throttles actual Gitea API calls itself, so this poll is // cheap either way. setInterval(loadFirmwareCheck, 60000); // --- Button assignments ----------------------------------------------- // {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}], // grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's // api_buttons_get. Each button's list is edited client-side (add/ // remove/reorder) then PUT as a whole -- simpler than separate reorder/ // add/remove endpoints for what's normally a handful of entries, and // this file already has the full list in hand after any edit. let buttonsData = null; let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames const BUTTONS = ['next', 'back']; // "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the // frame's grid dims -- the same rough position you'd read off the // Layout canvas by eye, used to tell apart two widgets of the same type // that would otherwise both just say "Photos". function widgetPositionLabel(w, grid) { const cx = w.x + w.w / 2; const cy = w.y + w.h / 2; const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : ''); const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : ''); if (!horiz && !vert) return 'center'; if (!vert) return horiz; if (!horiz) return vert; return `${vert}-${horiz}`; } // A single widget of a given type keeps the plain type name ("Photos") // -- the common case, no need to clutter it. Only widgets sharing a // type with another widget on the same frame get a number + position // suffix, numbered in reading order (top-to-bottom, left-to-right). function buildWidgetNames(widgets, grid) { const byType = {}; widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); }); const names = {}; Object.values(byType).forEach((group) => { if (group.length === 1) { names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type; return; } const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x)); ordered.forEach((w, i) => { const base = WIDGET_LABELS[w.widget_type] || w.widget_type; names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`; }); }); return names; } function widgetActionLabel(widgetId, action) { const w = buttonsData.widgets.find((w) => w.id === widgetId); if (!w) return `(deleted widget): ${action}`; const found = w.actions.find((a) => a.action === action); const actionLabel = found ? found.label : action; return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`; } function renderButtonList(button) { const list = document.getElementById(`button-actions-${button}`); const rows = buttonsData[button]; list.innerHTML = ''; if (!rows.length) { list.innerHTML = '