Build and push server image / build-and-push (push) Successful in 32s
Battery (firmware + server, disabled by default): new battery.c reads a 2x200k voltage divider via ADC oneshot with curve-fitting calibration (the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve, and restores the pin to button duty after each read -- the settled XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per wake. Skipped entirely when on mains (a 2x100k VBUS divider into a spare digital pin -- the 5V pin is dead on battery power, so presence = mains, where the charging voltage would read misleadingly full) or when the reading is implausible. The manage overlay gains a battery region (static outline glyph + "NN%", below the manage QR, all menu levels), and the device POSTs to the new /frame/battery endpoint after a successful fetch; the server stores percent + as-of timestamp, exposed via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO / FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board); compile-verified both disabled and enabled, hardware bring-up deferred until the ordered XIAO + batteries arrive. Orientation (server-side only): new config setting + web UI dropdown (landscape / portrait / landscape_flipped / portrait_flipped). Photos are composed/cropped at the logical hanging shape (portrait crops at 480x800, so face-aware crops match how the frame actually hangs), then rotated losslessly into the panel's native 800x480 byte layout after dithering -- the device never knows. Face-label anchors are transformed through the same rotation (logical_to_native()) so they stay attached to faces on rotated frames. Known documented limitation: the on-device manage overlay still renders in native orientation, so it appears sideways on a portrait-hung frame (QRs scan at any rotation; text reads sideways).
453 lines
18 KiB
HTML
453 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ESPresso Frame</title>
|
||
<style>
|
||
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; color: #222; }
|
||
.config-panel { max-width: 480px; }
|
||
h1 { font-size: 20px; }
|
||
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
|
||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
||
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||
.checkbox-row input { width: auto; margin-top: 0; }
|
||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
||
button:hover { background: #1d4ed8; }
|
||
button.secondary { background: #6b7280; margin-right: 8px; }
|
||
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
||
.status.ok { background: #dcfce7; color: #166534; }
|
||
.status.err { background: #fee2e2; color: #991b1b; }
|
||
.info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
|
||
.info-box.warn { background: #fef9c3; color: #854d0e; }
|
||
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
||
h2.section { font-size: 16px; margin-top: 28px; margin-bottom: 8px; }
|
||
.thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; }
|
||
|
||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||
.photo-card {
|
||
position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1;
|
||
background: #f3f4f6; border: 1px solid #e5e7eb;
|
||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||
when you touch a card without meaning to drag it. Dragging on
|
||
touch instead requires a brief hold first (see the JS below),
|
||
which switches this to "none" for the rest of that touch --
|
||
only once we're sure it's a deliberate drag, not a scroll. */
|
||
touch-action: pan-y;
|
||
}
|
||
.photo-card:active { cursor: grabbing; }
|
||
.photo-card.dragging { opacity: 0.35; }
|
||
.photo-card.drag-armed { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5) inset; }
|
||
.photo-card.drag-over { outline: 3px solid #2563eb; outline-offset: -3px; }
|
||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: rgba(0, 0, 0, 0.6); color: white; font-size: 10px; padding: 2px 6px; border-radius: 3px; }
|
||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.55); color: white;
|
||
}
|
||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: rgba(153, 27, 27, 0.85); }
|
||
.photo-card .show-next {
|
||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||
}
|
||
.thumb-wrap { position: relative; display: inline-block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>ESPresso Frame</h1>
|
||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||
|
||
<div class="config-panel">
|
||
{% 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 %}
|
||
|
||
<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>
|
||
<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>
|
||
|
||
<h2 class="section">Now displaying</h2>
|
||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||
</div>
|
||
|
||
<h2 class="section">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>
|
||
|
||
<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,
|
||
});
|
||
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);
|
||
}
|
||
});
|
||
|
||
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 = 350;
|
||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||
|
||
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.classList.remove('dragging', 'drag-armed');
|
||
clearDragOverStyling();
|
||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||
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';
|
||
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;
|
||
}
|
||
|
||
if (!dragState.moved) {
|
||
const dx = e.clientX - dragState.startX;
|
||
const dy = e.clientY - dragState.startY;
|
||
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
|
||
return;
|
||
}
|
||
dragState.moved = true;
|
||
card.classList.remove('drag-armed');
|
||
card.classList.add('dragging');
|
||
}
|
||
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();
|
||
}
|
||
}
|
||
|
||
async function loadQueue() {
|
||
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>';
|
||
}
|
||
if (data.battery) {
|
||
const batteryLine = document.createElement('p');
|
||
batteryLine.className = 'sub';
|
||
const asOf = new Date(data.battery.as_of * 1000).toLocaleString();
|
||
batteryLine.textContent = `Battery: ${data.battery.percent}% (as of ${asOf})`;
|
||
currentEl.appendChild(batteryLine);
|
||
}
|
||
renderUpcoming(data.upcoming);
|
||
} catch (e) {
|
||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||
}
|
||
}
|
||
|
||
loadQueue();
|
||
</script>
|
||
</body>
|
||
</html>
|