Build and push server image / build-and-push (push) Successful in 35s
Dropped the paragraph explaining the release workflow builds the binaries -- not something the web UI needs to narrate. The repo URL field now shows as plain text with an Edit button once a value is saved, instead of always being an open input.
813 lines
31 KiB
HTML
813 lines
31 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block subtitle %}
|
||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
{% if cfg.immich_url %}
|
||
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
|
||
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
|
||
<code>docker-compose.yml.example</code>.</div>
|
||
{% else %}
|
||
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
|
||
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
|
||
<code>docker-compose.yml.example</code>) and restart the server.</div>
|
||
{% endif %}
|
||
|
||
<div class="layout">
|
||
<div class="main-col">
|
||
<section class="card config-panel">
|
||
<h2 class="card-title">Settings</h2>
|
||
<form id="config-form">
|
||
<label>Album
|
||
<select id="album_id">
|
||
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
|
||
</select>
|
||
</label>
|
||
<label>Order
|
||
<select id="order">
|
||
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||
</select>
|
||
</label>
|
||
<label>Orientation
|
||
<select id="orientation">
|
||
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
||
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
||
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
||
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
||
</select>
|
||
</label>
|
||
<label>Refresh interval (minutes)
|
||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
|
||
</label>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
|
||
<label for="smart_crop_faces">Center faces in crop</label>
|
||
</div>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
|
||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||
</div>
|
||
<label>Quiet hours start
|
||
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
|
||
</label>
|
||
<label>Quiet hours end
|
||
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
|
||
</label>
|
||
<label>Timezone
|
||
<select id="timezone">
|
||
{% for tz in timezones %}
|
||
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<p class="sub" style="margin-top: 8px;">Quiet hours times above are
|
||
interpreted in this timezone. The device may still wake once right
|
||
at the start of quiet hours -- it can't know ahead of time -- but
|
||
goes right back to sleep until they end.</p>
|
||
<label>Upcoming photos to show
|
||
<select id="queue_target_len">
|
||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||
<button type="submit">Save</button>
|
||
</form>
|
||
<div id="result"></div>
|
||
</section>
|
||
|
||
<details class="card">
|
||
<summary class="card-title">Stats</summary>
|
||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="side-col">
|
||
<section class="card">
|
||
<h2 class="card-title">Now displaying</h2>
|
||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card-title">Device</h2>
|
||
<div id="device-status"><p class="sub">Loading...</p></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card-title">Battery history</h2>
|
||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card-title">Firmware update</h2>
|
||
<p class="sub" id="firmware-board">
|
||
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
|
||
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
||
</p>
|
||
<p class="sub" id="firmware-available">
|
||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||
</p>
|
||
<input type="file" id="firmware-file" accept=".bin">
|
||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||
|
||
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
|
||
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
|
||
<button type="button" class="secondary" id="firmware-repo-edit-btn">Edit</button>
|
||
</p>
|
||
</div>
|
||
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||
value="{{ cfg.firmware_update_repo_url }}">
|
||
</label>
|
||
<div class="checkbox-row">
|
||
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
|
||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||
</div>
|
||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="card" style="margin-top: 20px;">
|
||
<h2 class="card-title">Upcoming</h2>
|
||
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
|
||
normal scroll still works), "Show next" to jump it to the front, or
|
||
the × to remove it from rotation entirely.</p>
|
||
<div id="upcoming-grid" class="photo-grid"></div>
|
||
</section>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
const resultEl = document.getElementById('result');
|
||
|
||
function showStatus(ok, message) {
|
||
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||
}
|
||
|
||
async function saveConfig() {
|
||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||
const body = new URLSearchParams({
|
||
album_id: document.getElementById('album_id').value || '',
|
||
order: document.getElementById('order').value,
|
||
orientation: document.getElementById('orientation').value,
|
||
refresh_interval_s: String(minutes * 60),
|
||
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
||
queue_target_len: document.getElementById('queue_target_len').value,
|
||
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',
|
||
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('/api/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
}
|
||
|
||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||
try {
|
||
await saveConfig();
|
||
|
||
const resp = await fetch('/api/albums');
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
const albums = await resp.json();
|
||
|
||
const select = document.getElementById('album_id');
|
||
select.innerHTML = '';
|
||
for (const a of albums) {
|
||
const opt = document.createElement('option');
|
||
opt.value = a.id;
|
||
opt.textContent = `${a.name} (${a.count})`;
|
||
select.appendChild(opt);
|
||
}
|
||
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await saveConfig();
|
||
showStatus(true, 'Saved.');
|
||
loadQueue();
|
||
} 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 {
|
||
await saveConfig();
|
||
showStatus(true, 'Saved.');
|
||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||
loadFirmwareCheck();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
let upcomingItems = [];
|
||
|
||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||
// mouse-only by spec and never fires at all on phones/tablets.
|
||
//
|
||
// On touch specifically, a card only "arms" for dragging after a
|
||
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
|
||
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
|
||
// the whole time up to that point, so a normal touch-and-swipe to
|
||
// scroll the page still works even though it starts on a card. Once
|
||
// armed, touch-action switches to "none" for the rest of that touch
|
||
// so drag tracking gets every pointermove reliably. Mouse skips the
|
||
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
|
||
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
|
||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
|
||
const DRAG_HOLD_MS = 250;
|
||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||
|
||
function vibrate(ms) {
|
||
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
|
||
// it's a harmless no-op everywhere else rather than needing a
|
||
// feature check at every call site.
|
||
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function clearDragOverStyling() {
|
||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||
}
|
||
|
||
function endDrag(card) {
|
||
if (dragState && dragState.holdTimer) {
|
||
clearTimeout(dragState.holdTimer);
|
||
}
|
||
card.style.touchAction = '';
|
||
card.style.transform = '';
|
||
card.classList.remove('dragging', 'drag-armed');
|
||
clearDragOverStyling();
|
||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||
vibrate(15);
|
||
moveItem(dragState.fromIndex, dragState.toIndex);
|
||
}
|
||
dragState = null;
|
||
}
|
||
|
||
function renderUpcoming(items) {
|
||
upcomingItems = items;
|
||
const grid = document.getElementById('upcoming-grid');
|
||
grid.innerHTML = '';
|
||
|
||
items.forEach((item, i) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'photo-card';
|
||
card.dataset.index = String(i);
|
||
|
||
const img = document.createElement('img');
|
||
img.src = item.thumbnail_url;
|
||
img.alt = '';
|
||
card.appendChild(img);
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge';
|
||
badge.textContent = String(i + 1);
|
||
card.appendChild(badge);
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'remove-btn';
|
||
removeBtn.title = 'Remove from rotation';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
removeAsset(item.id);
|
||
});
|
||
card.appendChild(removeBtn);
|
||
|
||
const nextBtn = document.createElement('button');
|
||
nextBtn.type = 'button';
|
||
nextBtn.className = 'show-next';
|
||
nextBtn.textContent = 'Show next';
|
||
nextBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
showNext(i);
|
||
});
|
||
card.appendChild(nextBtn);
|
||
|
||
card.addEventListener('pointerdown', (e) => {
|
||
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
|
||
return; // let the button's own click handler run, don't start a drag
|
||
}
|
||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||
return; // left button only
|
||
}
|
||
|
||
dragState = {
|
||
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
|
||
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
|
||
};
|
||
|
||
if (e.pointerType === 'touch') {
|
||
dragState.holdTimer = setTimeout(() => {
|
||
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
|
||
dragState.armed = true;
|
||
card.classList.add('drag-armed');
|
||
card.style.touchAction = 'none';
|
||
vibrate(10);
|
||
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
|
||
}
|
||
}, DRAG_HOLD_MS);
|
||
} else {
|
||
card.setPointerCapture(e.pointerId);
|
||
}
|
||
});
|
||
|
||
card.addEventListener('pointermove', (e) => {
|
||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||
return;
|
||
}
|
||
|
||
if (!dragState.armed) {
|
||
// Still deciding whether this is a hold-to-drag or a scroll --
|
||
// moving this much before the hold timer fires means scroll;
|
||
// bail out and let the browser's native pan-y handle it.
|
||
const dx0 = e.clientX - dragState.startX;
|
||
const dy0 = e.clientY - dragState.startY;
|
||
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
|
||
clearTimeout(dragState.holdTimer);
|
||
dragState = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const dx = e.clientX - dragState.startX;
|
||
const dy = e.clientY - dragState.startY;
|
||
|
||
if (!dragState.moved) {
|
||
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
|
||
return;
|
||
}
|
||
dragState.moved = true;
|
||
card.classList.remove('drag-armed');
|
||
card.classList.add('dragging');
|
||
}
|
||
|
||
// Follows the finger 1:1 -- the actual "pick it up and carry it"
|
||
// feedback that was missing before (the card used to just fade
|
||
// in place while a static outline highlighted the drop target).
|
||
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
|
||
|
||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
||
clearDragOverStyling();
|
||
if (overCard && overCard !== card) {
|
||
overCard.classList.add('drag-over');
|
||
dragState.toIndex = Number(overCard.dataset.index);
|
||
} else {
|
||
dragState.toIndex = undefined;
|
||
}
|
||
});
|
||
|
||
card.addEventListener('pointerup', (e) => {
|
||
if (dragState && dragState.pointerId === e.pointerId) {
|
||
endDrag(card);
|
||
}
|
||
});
|
||
card.addEventListener('pointercancel', (e) => {
|
||
if (dragState && dragState.pointerId === e.pointerId) {
|
||
endDrag(card);
|
||
}
|
||
});
|
||
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function moveItem(fromIndex, toIndex) {
|
||
const items = upcomingItems.slice();
|
||
const [moved] = items.splice(fromIndex, 1);
|
||
items.splice(toIndex, 0, moved);
|
||
renderUpcoming(items);
|
||
persistOrder(items);
|
||
}
|
||
|
||
async function showNext(index) {
|
||
const assetId = upcomingItems[index].id;
|
||
try {
|
||
const resp = await fetch('/api/queue/promote', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ asset_id: assetId }),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
loadQueue(); // always refetch the authoritative order rather than guessing locally
|
||
}
|
||
|
||
async function removeAsset(assetId) {
|
||
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch('/api/queue/remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ asset_id: assetId }),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
loadQueue();
|
||
}
|
||
|
||
async function persistOrder(items) {
|
||
try {
|
||
const resp = await fetch('/api/queue/reorder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ queue: items.map((item) => item.id) }),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
loadQueue();
|
||
}
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
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`;
|
||
if (h > 0) return `${h}h ${m}m`;
|
||
return `${m}m`;
|
||
}
|
||
|
||
function renderDeviceStatus(device) {
|
||
const el = document.getElementById('device-status');
|
||
el.innerHTML = '';
|
||
if (!device || !device.last_seen) {
|
||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||
return;
|
||
}
|
||
const now = Date.now() / 1000;
|
||
const rows = [];
|
||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
|
||
if (device.firmware_version) {
|
||
let fw = `v${device.firmware_version}`;
|
||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||
fw += ` (v${device.firmware_available} waiting)`;
|
||
}
|
||
rows.push(['Firmware', fw, false]);
|
||
}
|
||
if (device.battery) {
|
||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||
}
|
||
if (device.on_battery_since) {
|
||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
||
}
|
||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
||
}
|
||
for (const [label, value, alert] of rows) {
|
||
const p = document.createElement('p');
|
||
p.className = 'sub';
|
||
if (alert) {
|
||
p.style.color = 'var(--danger-text)';
|
||
p.style.fontWeight = '600';
|
||
}
|
||
p.textContent = `${label}: ${value}`;
|
||
el.appendChild(p);
|
||
}
|
||
}
|
||
|
||
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('/api/firmware', { method: 'POST', body: form });
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
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);
|
||
}
|
||
});
|
||
|
||
async function loadFirmwareCheck() {
|
||
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('/api/firmware/check');
|
||
if (!resp.ok) {
|
||
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';
|
||
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';
|
||
}
|
||
} catch (e) {
|
||
// A failed check is silent -- the manual upload path still works
|
||
// regardless, and this just retries on the next poll.
|
||
}
|
||
}
|
||
|
||
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('firmware-update-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
|
||
if (!resp.ok) {
|
||
throw new Error(await resp.text());
|
||
}
|
||
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;
|
||
}
|
||
});
|
||
|
||
let lastDevice = null;
|
||
|
||
async function loadQueue() {
|
||
if (dragState) {
|
||
return; // don't yank the grid out from under an in-progress drag
|
||
}
|
||
const currentEl = document.getElementById('current-thumb');
|
||
try {
|
||
const resp = await fetch('/api/queue');
|
||
if (!resp.ok) {
|
||
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
|
||
renderUpcoming([]);
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
currentEl.innerHTML = '';
|
||
if (data.current) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'thumb-wrap';
|
||
|
||
const img = document.createElement('img');
|
||
img.className = 'thumb';
|
||
img.src = data.current.thumbnail_url;
|
||
img.alt = '';
|
||
wrap.appendChild(img);
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'remove-btn';
|
||
removeBtn.title = 'Remove from rotation';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
|
||
wrap.appendChild(removeBtn);
|
||
|
||
currentEl.appendChild(wrap);
|
||
} else {
|
||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||
}
|
||
lastDevice = data.device;
|
||
renderDeviceStatus(data.device);
|
||
renderUpcoming(data.upcoming);
|
||
} catch (e) {
|
||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||
}
|
||
}
|
||
|
||
// Reads resolved colors from CSS custom properties rather than
|
||
// hardcoding hex values, so the chart matches the current theme
|
||
// (light/dark) without needing its own separate palette.
|
||
function themeColor(name) {
|
||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
}
|
||
|
||
let lastBatteryLog = null;
|
||
|
||
function drawBatteryChart(log) {
|
||
lastBatteryLog = log;
|
||
const wrap = document.getElementById('battery-chart-wrap');
|
||
if (!log || log.length < 2) {
|
||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||
return;
|
||
}
|
||
wrap.innerHTML = '';
|
||
const width = wrap.clientWidth || 440;
|
||
const height = 180;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
canvas.style.display = 'block';
|
||
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||
canvas.style.borderRadius = '8px';
|
||
wrap.appendChild(canvas);
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const gridColor = themeColor('--border');
|
||
const mutedColor = themeColor('--text-muted');
|
||
const accentColor = themeColor('--accent');
|
||
|
||
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||
const plotW = width - pad.left - pad.right;
|
||
const plotH = height - pad.top - pad.bottom;
|
||
|
||
const times = log.map((p) => p[0]);
|
||
const minT = Math.min(...times);
|
||
const maxT = Math.max(...times);
|
||
const spanT = Math.max(1, maxT - minT);
|
||
|
||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||
|
||
ctx.strokeStyle = gridColor;
|
||
ctx.fillStyle = mutedColor;
|
||
ctx.font = '10px system-ui, sans-serif';
|
||
ctx.lineWidth = 1;
|
||
ctx.textAlign = 'left';
|
||
[0, 25, 50, 75, 100].forEach((pct) => {
|
||
const yy = y(pct);
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad.left, yy);
|
||
ctx.lineTo(width - pad.right, yy);
|
||
ctx.stroke();
|
||
ctx.fillText(String(pct), 2, yy + 3);
|
||
});
|
||
|
||
ctx.strokeStyle = accentColor;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
log.forEach((p, i) => {
|
||
const px = x(p[0]);
|
||
const py = y(p[1]);
|
||
if (i === 0) ctx.moveTo(px, py);
|
||
else ctx.lineTo(px, py);
|
||
});
|
||
ctx.stroke();
|
||
|
||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
ctx.fillStyle = mutedColor;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
|
||
}
|
||
|
||
async function loadBatteryLog() {
|
||
const wrap = document.getElementById('battery-chart-wrap');
|
||
try {
|
||
const resp = await fetch('/api/battery-log');
|
||
if (!resp.ok) {
|
||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
drawBatteryChart(data.log);
|
||
} catch (e) {
|
||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||
}
|
||
}
|
||
|
||
function renderStats(stats) {
|
||
const el = document.getElementById('stats-box');
|
||
el.innerHTML = '';
|
||
const now = Date.now() / 1000;
|
||
const rows = [
|
||
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
|
||
['Wake cycles', stats.device_wakes],
|
||
['Photos displayed', stats.photos_displayed],
|
||
['Photos removed from rotation', stats.photos_removed],
|
||
['Battery reports received', stats.battery_reports],
|
||
['Battery recharge cycles', stats.recharge_cycles],
|
||
['OTA updates applied', stats.ota_updates_applied],
|
||
['Settings saved', stats.config_saves],
|
||
];
|
||
for (const [label, value] of rows) {
|
||
const p = document.createElement('p');
|
||
p.className = 'sub';
|
||
p.textContent = `${label}: ${value}`;
|
||
el.appendChild(p);
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
const el = document.getElementById('stats-box');
|
||
try {
|
||
const resp = await fetch('/api/stats');
|
||
if (!resp.ok) {
|
||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||
return;
|
||
}
|
||
renderStats(await resp.json());
|
||
} catch (e) {
|
||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||
}
|
||
}
|
||
|
||
loadQueue();
|
||
loadBatteryLog();
|
||
loadStats();
|
||
loadFirmwareCheck();
|
||
|
||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||
// new theme's colors as soon as the toggle in the header is used --
|
||
// canvas pixels don't repaint themselves the way CSS does.
|
||
document.addEventListener('themechange', () => {
|
||
if (lastBatteryLog) {
|
||
drawBatteryChart(lastBatteryLog);
|
||
}
|
||
if (lastDevice) {
|
||
renderDeviceStatus(lastDevice);
|
||
}
|
||
});
|
||
|
||
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
||
// already-fetched device data every second, so they count up smoothly
|
||
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
|
||
setInterval(() => {
|
||
if (lastDevice) {
|
||
renderDeviceStatus(lastDevice);
|
||
}
|
||
}, 1000);
|
||
|
||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||
// from elsewhere, battery report, firmware version) without a manual
|
||
// refresh. Skipped mid-drag (see loadQueue above).
|
||
setInterval(loadQueue, 10000);
|
||
|
||
// Separate, slower poll for the Gitea release check -- cheap either
|
||
// way since the server itself throttles actual Gitea API calls to
|
||
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
|
||
setInterval(loadFirmwareCheck, 60000);
|
||
</script>
|
||
{% endblock %}
|