Frame.theme (7 presets in app/theme_tokens.py) drives font family, corner radius, drop shadow, and an accent hue for every modern-style widget's header/accent region. Rich accent colors (not just the 6 flat panel inks) are approximated via denser Bayer stippling confined to just that region (html_render.ordered_dither_regions), so icon/text content elsewhere stays exactly as crisp as it is today -- verified directly against real Chromium renders, both in unit tests and via run-server. "classic" is a byte-identical no-visual-change default: weather's header keeps its original fixed blue gradient, tasks/calendar keep their flat THEME_* ink. Themes are purely stylistic -- battery's charge-level color, calendar/ tasks' per-owner event chips, and text's own per-widget font choice are never touched.
461 lines
18 KiB
JavaScript
461 lines
18 KiB
JavaScript
// 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);
|
|
}
|
|
});
|
|
|
|
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
|
|
// setting, not per-widget, so it shares api_config_save/the /config
|
|
// endpoint rather than getting its own -- just a separate card/form on
|
|
// this page for a distinct-enough concern.
|
|
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
|
|
const body = new URLSearchParams({
|
|
hold_duration_ms: String(seconds * 1000),
|
|
next_hold_action: document.getElementById('next_hold_action').value,
|
|
back_hold_action: document.getElementById('back_hold_action').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.');
|
|
} catch (err) {
|
|
showStatus(false, err.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(s) ----
|
|
//
|
|
// 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.
|
|
//
|
|
// Parameterized by classPrefix ("palette" for the main one, "photo-
|
|
// palette" for the photos-only one added alongside it) rather than
|
|
// duplicated wholesale -- there are exactly two real instances of this,
|
|
// not a speculative future one, and the two would otherwise be ~90
|
|
// near-identical lines apart.
|
|
|
|
function paletteHexInputs(classPrefix) {
|
|
return Array.from(document.querySelectorAll(`.${classPrefix}-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(classPrefix, index) {
|
|
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
|
return {
|
|
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
|
|
swatch: at(`${classPrefix}-swatch-preview`),
|
|
};
|
|
}
|
|
|
|
function syncPaletteFromHex(classPrefix, index) {
|
|
const f = paletteFieldsFor(classPrefix, 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(classPrefix, index) {
|
|
const f = paletteFieldsFor(classPrefix, index);
|
|
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
|
f.hex.value = hex;
|
|
f.swatch.style.background = hex;
|
|
}
|
|
|
|
function wirePaletteInputs(classPrefix) {
|
|
const count = paletteHexInputs(classPrefix).length;
|
|
for (let i = 0; i < count; i++) {
|
|
const f = paletteFieldsFor(classPrefix, i);
|
|
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
|
|
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
|
|
}
|
|
}
|
|
|
|
wirePaletteInputs('palette');
|
|
wirePaletteInputs('photo-palette');
|
|
|
|
// Sliders: live numeric readout next to each, no save until the button
|
|
// below is clicked.
|
|
['color_boost', 'contrast_boost', 'dither_strength', 'photo_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(classPrefix, paletteFormKey, extra) {
|
|
const body = new URLSearchParams(extra || {});
|
|
for (const input of paletteHexInputs(classPrefix)) {
|
|
body.append(paletteFormKey, 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('palette', 'palette', {
|
|
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('palette');
|
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
|
inputs[i].value = hex;
|
|
syncPaletteFromHex('palette', i);
|
|
});
|
|
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
|
document.getElementById(id).value = '1';
|
|
document.getElementById(`${id}_value`).textContent = '1.00';
|
|
});
|
|
savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
|
});
|
|
|
|
// Fills the table with a community-measured starting point (see the
|
|
// card's own explanatory text) -- doesn't save by itself, same as
|
|
// editing the hex/RGB fields by hand; the user still clicks Save (or
|
|
// Reset) to commit or discard it.
|
|
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
|
|
const inputs = paletteHexInputs('palette');
|
|
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
|
|
inputs[i].value = hex;
|
|
syncPaletteFromHex('palette', i);
|
|
});
|
|
});
|
|
|
|
// ---- Photos configuration: its own separate palette/dithering ----
|
|
|
|
document.getElementById('photo-palette-save').addEventListener('click', () => {
|
|
savePalette('photo-palette', 'photo_palette', {
|
|
photo_dither_strength: document.getElementById('photo_dither_strength').value,
|
|
});
|
|
});
|
|
|
|
document.getElementById('photo-palette-reset').addEventListener('click', () => {
|
|
const inputs = paletteHexInputs('photo-palette');
|
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
|
inputs[i].value = hex;
|
|
syncPaletteFromHex('photo-palette', i);
|
|
});
|
|
document.getElementById('photo_dither_strength').value = '1';
|
|
document.getElementById('photo_dither_strength_value').textContent = '1.00';
|
|
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
|
|
});
|
|
|
|
// ---- Theme ----
|
|
|
|
document.getElementById('theme-save').addEventListener('click', async () => {
|
|
const body = new URLSearchParams({ theme: document.getElementById('theme-select').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);
|
|
}
|
|
});
|
|
|
|
// ---- 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}` +
|
|
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
|
|
btn.style.display = 'inline-block';
|
|
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
|
|
// Already staged (or auto-applied) but the frame hasn't woken up
|
|
// and picked it up yet -- not "up to date" until it actually has.
|
|
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
|
|
`(currently running v${data.running_version}).`;
|
|
btn.style.display = 'none';
|
|
} 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);
|
|
|