Build and push server image / build-and-push (push) Successful in 53s
Both are photos-specific settings (rotation order, how a photo's aspect ratio gets reconciled with the panel), not device-wide configuration -- they belonged on the Photos tab next to the album/queue settings, not Configuration. No backend change: both already save through the shared partial-update /config endpoint regardless of which tab's form sends them.
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
// Configuration tab: frame settings + firmware card + take control.
|
|
// Frame name/mode live in the page header now (frame_header.js), Calendar
|
|
// settings have their own tab (frame_calendar.js), and Order/Display mode
|
|
// live on the Photos tab (frame_photos.js) -- photos-specific settings,
|
|
// not device-wide configuration. 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));
|
|
}
|
|
}
|
|
|
|
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await saveConfig();
|
|
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}/queue`);
|
|
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 ----
|
|
|
|
function loadPreview() {
|
|
const bust = Date.now(); // avoid a stale cached image after settings change
|
|
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
|
|
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
|
|
}
|
|
|
|
document.getElementById('preview-refresh').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);
|