diff --git a/data/app.js b/data/app.js
index db26a45..6e4704f 100644
--- a/data/app.js
+++ b/data/app.js
@@ -135,16 +135,31 @@ async function renderDisplay(el) {
Metadata Overlay
-
+
+
+ strftime: %Y=year %m=month %d=day %b=Mon %B=Month
+
+
+
+
+ strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM
+
+
+
@@ -155,7 +170,9 @@ async function renderDisplay(el) {
await api('/api/settings', { method: 'POST', body: JSON.stringify({
img_quality: parseInt(document.getElementById('imgQuality').value),
meta_flags: flags,
- meta_pos: parseInt(document.getElementById('metaPos').value)
+ meta_pos: parseInt(document.getElementById('metaPos').value),
+ date_fmt: document.getElementById('dateFmt').value,
+ time_fmt: document.getElementById('timeFmt').value
})});
toast('Display settings saved');
};
diff --git a/src/button_handler.cpp b/src/button_handler.cpp
index edf9759..8ecd600 100644
--- a/src/button_handler.cpp
+++ b/src/button_handler.cpp
@@ -10,7 +10,7 @@ void ButtonHandler::begin(PowerManager& power, SettingsManager& settings) {
pinMode(PIN_BTN_UP, INPUT_PULLUP);
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
- Serial.println("[buttons] Initialized (G1=random, G9=next, G10=pause)");
+ Serial.println("[buttons] Initialized (G1=random, G10=next, G9=pause)");
}
ButtonEvent ButtonHandler::poll() {
@@ -49,7 +49,6 @@ ButtonEvent ButtonHandler::poll() {
if (holdTime >= COMBO_HOLD_MS) {
Serial.println("[buttons] Combo: deep sleep");
_power->flashLED(255, 0, 0, 500);
- _power->enterDeepSleep();
return ButtonEvent::DeepSleep;
}
}
@@ -69,14 +68,31 @@ ButtonEvent ButtonHandler::poll() {
return ButtonEvent::None;
}
- // BTN_TOP released (was short press)
+ // BTN_TOP released (was short press) — double-press detection
if (!topNow && _btnTopPressTime > 0 &&
(now - _btnTopPressTime < COMBO_HOLD_MS) &&
(now - _btnTopPressTime > BUTTON_DEBOUNCE_MS)) {
_btnTopPressTime = 0;
+ _lastDebounce = now;
+
+ if (_btnTopPendingSingle && (now - _btnTopLastRelease) < DOUBLE_PRESS_WINDOW) {
+ // Second press within window -> deep sleep
+ _btnTopPendingSingle = false;
+ Serial.println("[buttons] BTN_TOP: double-press -> deep sleep");
+ _power->flashLED(255, 0, 0, 500);
+ return ButtonEvent::DeepSleep;
+ } else {
+ // First press — mark as pending, wait for possible second
+ _btnTopLastRelease = now;
+ _btnTopPendingSingle = true;
+ }
+ }
+
+ // Emit pending single press after double-press window expires
+ if (_btnTopPendingSingle && (now - _btnTopLastRelease) >= DOUBLE_PRESS_WINDOW) {
+ _btnTopPendingSingle = false;
_power->flashLED(255, 255, 255);
Serial.println("[buttons] BTN_TOP: random photo");
- _lastDebounce = now;
return ButtonEvent::RandomPhoto;
}
diff --git a/src/button_handler.h b/src/button_handler.h
index efbecf6..094da28 100644
--- a/src/button_handler.h
+++ b/src/button_handler.h
@@ -32,6 +32,11 @@ private:
bool _btnUpPressed = false;
bool _btnDownPressed = false;
+ // Double-press detection for BTN_TOP
+ unsigned long _btnTopLastRelease = 0;
+ bool _btnTopPendingSingle = false;
+ static constexpr unsigned long DOUBLE_PRESS_WINDOW = 400;
+
unsigned long _lastDebounce = 0;
bool debounced(unsigned long now);
diff --git a/src/config.h b/src/config.h
index a242516..444193f 100644
--- a/src/config.h
+++ b/src/config.h
@@ -3,8 +3,8 @@
// --- Pin Definitions ---
// Buttons
#define PIN_BTN_TOP 1 // G1 - Random photo
-#define PIN_BTN_UP 9 // G9 - Next photo
-#define PIN_BTN_DOWN 10 // G10 - Play/Pause
+#define PIN_BTN_UP 10 // G10 - Next photo
+#define PIN_BTN_DOWN 9 // G9 - Play/Pause
// RGB LEDs (NeoPixel)
#define PIN_RGB_LED 21
diff --git a/src/display_manager.cpp b/src/display_manager.cpp
index 8ffdc69..e319aa4 100644
--- a/src/display_manager.cpp
+++ b/src/display_manager.cpp
@@ -84,7 +84,6 @@ void DisplayManager::showImage(const ProcessedImage& img) {
_power->enableEPDPower();
Serial.println("[display] Writing framebuffer to e-ink...");
- unsigned long start = millis();
// Write pixel by pixel using palette-mapped colors
_display->startWrite();
@@ -95,13 +94,7 @@ void DisplayManager::showImage(const ProcessedImage& img) {
}
}
_display->endWrite();
-
- triggerRefresh();
-
- unsigned long elapsed = millis() - start;
- Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
-
- _power->disableEPDPower();
+ // Do NOT refresh here — caller should add metadata overlay then call refresh()
}
void DisplayManager::showMessage(const char* title, const char* body) {
@@ -121,14 +114,43 @@ void DisplayManager::showMessage(const char* title, const char* body) {
_power->disableEPDPower();
}
-void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos) {
- if (metaFlags == 0) return; // No metadata to show
+void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
+ const String& dateFmt, const String& timeFmt) {
+ showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt);
+}
+
+void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
+ uint16_t regionX, uint16_t regionY,
+ uint16_t regionW, uint16_t regionH,
+ const String& dateFmt, const String& timeFmt) {
+ if (metaFlags == 0) return;
// Build metadata string
String metaText = "";
- if ((metaFlags & META_DATE) && info.dateTime.length() > 0) {
- // Extract just the date portion (YYYY-MM-DD)
+ // Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime
+ if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) {
+ struct tm t = {};
+ sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d",
+ &t.tm_year, &t.tm_mon, &t.tm_mday,
+ &t.tm_hour, &t.tm_min, &t.tm_sec);
+ t.tm_year -= 1900;
+ t.tm_mon -= 1;
+
+ char buf[64];
+ if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) {
+ String combinedFmt = dateFmt + " " + timeFmt;
+ strftime(buf, sizeof(buf), combinedFmt.c_str(), &t);
+ metaText += buf;
+ } else if (metaFlags & META_DATE) {
+ strftime(buf, sizeof(buf), dateFmt.c_str(), &t);
+ metaText += buf;
+ } else {
+ strftime(buf, sizeof(buf), timeFmt.c_str(), &t);
+ metaText += buf;
+ }
+ } else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) {
+ // Fallback if datetime is too short for full parse
metaText += info.dateTime.substring(0, 10);
}
if ((metaFlags & META_LOCATION) && info.city.length() > 0) {
@@ -143,7 +165,7 @@ void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, Meta
}
}
if ((metaFlags & META_ALBUM)) {
- // Album name would need to be passed separately — skip for now
+ // Album name would need to be passed separately
}
if ((metaFlags & META_CAMERA) && info.camera.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
@@ -152,13 +174,113 @@ void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, Meta
if (metaText.length() == 0) return;
- // Draw semi-transparent bar with text
- uint16_t barY = (pos == MetaPosition::Top) ? 0 : (DISPLAY_HEIGHT - 30);
- _display->fillRect(0, barY, DISPLAY_WIDTH, 30, TFT_BLACK);
+ _display->setTextSize(1.5f);
_display->setTextColor(TFT_WHITE);
- _display->setTextDatum(middle_center);
+
+ switch (pos) {
+ case MetaPosition::CaptionBottom:
+ case MetaPosition::CaptionTop: {
+ // Bar spanning the region width, positioned at region top or bottom
+ uint16_t barH = 36;
+ uint16_t barY = (pos == MetaPosition::CaptionTop)
+ ? regionY
+ : regionY + regionH - barH;
+ _display->fillRect(regionX, barY, regionW, barH, TFT_BLACK);
+ _display->setTextDatum(middle_center);
+ _display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2);
+ break;
+ }
+ case MetaPosition::OverlayTopLeft:
+ case MetaPosition::OverlayTopRight:
+ case MetaPosition::OverlayBottomLeft:
+ case MetaPosition::OverlayBottomRight: {
+ static constexpr uint16_t MARGIN = 8;
+ static constexpr uint16_t PAD_X = 8;
+ static constexpr uint16_t PAD_Y = 5;
+ static constexpr uint16_t TEXT_H = 20;
+
+ uint16_t textW = _display->textWidth(metaText.c_str());
+ uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW);
+ uint16_t badgeH = TEXT_H + PAD_Y * 2;
+
+ uint16_t bx, by;
+ switch (pos) {
+ case MetaPosition::OverlayTopLeft:
+ bx = regionX + MARGIN;
+ by = regionY + MARGIN;
+ break;
+ case MetaPosition::OverlayTopRight:
+ bx = regionX + regionW - badgeW - MARGIN;
+ by = regionY + MARGIN;
+ break;
+ case MetaPosition::OverlayBottomLeft:
+ bx = regionX + MARGIN;
+ by = regionY + regionH - badgeH - MARGIN;
+ break;
+ case MetaPosition::OverlayBottomRight:
+ bx = regionX + regionW - badgeW - MARGIN;
+ by = regionY + regionH - badgeH - MARGIN;
+ break;
+ default:
+ bx = regionX + MARGIN;
+ by = regionY + MARGIN;
+ break;
+ }
+
+ _display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK);
+ _display->setTextDatum(middle_left);
+ _display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2);
+ break;
+ }
+ }
+}
+
+void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
+ _power->enableEPDPower();
+
+ // Pick a corner opposite to metadata position
+ static constexpr uint16_t MARGIN = 6;
+ uint16_t tx, ty;
+ lgfx::textdatum_t datum;
+
+ switch (metaPos) {
+ case MetaPosition::OverlayTopLeft:
+ case MetaPosition::CaptionTop:
+ tx = DISPLAY_WIDTH - MARGIN;
+ ty = DISPLAY_HEIGHT - MARGIN;
+ datum = bottom_right;
+ break;
+ case MetaPosition::OverlayTopRight:
+ tx = MARGIN;
+ ty = DISPLAY_HEIGHT - MARGIN;
+ datum = bottom_left;
+ break;
+ case MetaPosition::OverlayBottomLeft:
+ tx = DISPLAY_WIDTH - MARGIN;
+ ty = MARGIN;
+ datum = top_right;
+ break;
+ case MetaPosition::OverlayBottomRight:
+ case MetaPosition::CaptionBottom:
+ default:
+ tx = MARGIN;
+ ty = MARGIN;
+ datum = top_left;
+ break;
+ }
+
_display->setTextSize(1);
- _display->drawString(metaText.c_str(), DISPLAY_WIDTH / 2, barY + 15);
+ _display->setTextColor(TFT_BLACK);
+ _display->setTextDatum(datum);
+ _display->drawString("SLP", tx, ty);
+}
+
+void DisplayManager::refresh() {
+ unsigned long start = millis();
+ triggerRefresh();
+ unsigned long elapsed = millis() - start;
+ Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
+ _power->disableEPDPower();
}
void DisplayManager::triggerRefresh() {
diff --git a/src/display_manager.h b/src/display_manager.h
index f7ef462..20d60b2 100644
--- a/src/display_manager.h
+++ b/src/display_manager.h
@@ -16,7 +16,13 @@ public:
void showSetupScreen();
void showImage(const ProcessedImage& img);
void showMessage(const char* title, const char* body);
- void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos);
+ void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
+ const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
+ void showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
+ uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH,
+ const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
+ void showSleepIndicator(MetaPosition metaPos);
+ void refresh();
private:
PowerManager* _power = nullptr;
diff --git a/src/image_pipeline.cpp b/src/image_pipeline.cpp
index 6a75f5e..1b1696e 100644
--- a/src/image_pipeline.cpp
+++ b/src/image_pipeline.cpp
@@ -123,55 +123,239 @@ static EdgeColor averageEdge(const uint8_t* rgb, uint16_t w, uint16_t h,
return {(uint8_t)(sumR / count), (uint8_t)(sumG / count), (uint8_t)(sumB / count)};
}
-// Fill letterbox bars in the final buffer, matching each bar to its adjacent photo edge.
-// fitted image is placed at (offsetX, offsetY) with size (fitW x fitH) inside (targetW x targetH).
+// In-place horizontal box blur on a rectangular sub-region of a row-major RGB buffer.
+// Operates on rows from y0..y1-1, columns x0..x1-1, within a buffer of stride `stride` pixels.
+static void boxBlurH(uint8_t* buf, uint16_t stride,
+ uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
+ int width = x1 - x0;
+ if (width <= 0) return;
+
+ // Temp row buffer for one scanline (3 bytes per pixel)
+ uint8_t* tmp = (uint8_t*)malloc(width * 3);
+ if (tmp == nullptr) return;
+
+ int diam = radius * 2 + 1;
+
+ for (uint16_t y = y0; y < y1; y++) {
+ uint8_t* row = buf + ((size_t)y * stride + x0) * 3;
+
+ // Running sum initialization
+ int sumR = 0, sumG = 0, sumB = 0;
+ for (int i = -radius; i <= radius; i++) {
+ int xi = constrain(i, 0, width - 1);
+ sumR += row[xi * 3];
+ sumG += row[xi * 3 + 1];
+ sumB += row[xi * 3 + 2];
+ }
+ tmp[0] = sumR / diam;
+ tmp[1] = sumG / diam;
+ tmp[2] = sumB / diam;
+
+ for (int x = 1; x < width; x++) {
+ int addIdx = constrain(x + radius, 0, width - 1);
+ int remIdx = constrain(x - radius - 1, 0, width - 1);
+ sumR += row[addIdx * 3] - row[remIdx * 3];
+ sumG += row[addIdx * 3 + 1] - row[remIdx * 3 + 1];
+ sumB += row[addIdx * 3 + 2] - row[remIdx * 3 + 2];
+ tmp[x * 3] = sumR / diam;
+ tmp[x * 3 + 1] = sumG / diam;
+ tmp[x * 3 + 2] = sumB / diam;
+ }
+
+ memcpy(row, tmp, width * 3);
+ }
+ free(tmp);
+}
+
+// In-place vertical box blur on a rectangular sub-region.
+static void boxBlurV(uint8_t* buf, uint16_t stride,
+ uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
+ int height = y1 - y0;
+ if (height <= 0) return;
+
+ uint8_t* tmp = (uint8_t*)malloc(height * 3);
+ if (tmp == nullptr) return;
+
+ int diam = radius * 2 + 1;
+
+ for (uint16_t x = x0; x < x1; x++) {
+ // Running sum initialization
+ int sumR = 0, sumG = 0, sumB = 0;
+ for (int i = -radius; i <= radius; i++) {
+ int yi = y0 + constrain(i, 0, height - 1);
+ size_t idx = ((size_t)yi * stride + x) * 3;
+ sumR += buf[idx];
+ sumG += buf[idx + 1];
+ sumB += buf[idx + 2];
+ }
+ tmp[0] = sumR / diam;
+ tmp[1] = sumG / diam;
+ tmp[2] = sumB / diam;
+
+ for (int y = 1; y < height; y++) {
+ int addY = y0 + constrain(y + radius, 0, height - 1);
+ int remY = y0 + constrain(y - radius - 1, 0, height - 1);
+ size_t addIdx = ((size_t)addY * stride + x) * 3;
+ size_t remIdx = ((size_t)remY * stride + x) * 3;
+ sumR += buf[addIdx] - buf[remIdx];
+ sumG += buf[addIdx + 1] - buf[remIdx + 1];
+ sumB += buf[addIdx + 2] - buf[remIdx + 2];
+ tmp[y * 3] = sumR / diam;
+ tmp[y * 3 + 1] = sumG / diam;
+ tmp[y * 3 + 2] = sumB / diam;
+ }
+
+ // Write back
+ for (int y = 0; y < height; y++) {
+ size_t idx = ((size_t)(y0 + y) * stride + x) * 3;
+ buf[idx] = tmp[y * 3];
+ buf[idx + 1] = tmp[y * 3 + 1];
+ buf[idx + 2] = tmp[y * 3 + 2];
+ }
+ }
+ free(tmp);
+}
+
+// 3-pass box blur (approximates Gaussian) on a sub-region
+static void gaussianBlurRegion(uint8_t* buf, uint16_t stride,
+ uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1,
+ int radius) {
+ for (int pass = 0; pass < 3; pass++) {
+ boxBlurH(buf, stride, x0, y0, x1, y1, radius);
+ boxBlurV(buf, stride, x0, y0, x1, y1, radius);
+ }
+}
+
+// Fill letterbox bars with mirrored + blurred + faded content from the photo edge.
+// The fitted image must already be placed in final_buf at (offsetX, offsetY).
static void fillLetterbox(uint8_t* final_buf, uint16_t targetW, uint16_t targetH,
const uint8_t* fitted, uint16_t fitW, uint16_t fitH,
uint16_t offsetX, uint16_t offsetY) {
+ static constexpr int BLUR_RADIUS = 6;
+
if (offsetY > 0) {
- // Horizontal letterbox (top and bottom bars)
- EdgeColor top = averageEdge(fitted, fitW, fitH, 0);
- EdgeColor bot = averageEdge(fitted, fitW, fitH, 1);
+ // Horizontal bars (top and bottom)
+ EdgeColor topEdge = averageEdge(fitted, fitW, fitH, 0);
+ EdgeColor botEdge = averageEdge(fitted, fitW, fitH, 1);
- // Fill top bar
- for (uint16_t y = 0; y < offsetY; y++)
+ // --- Extend top edge into top bar ---
+ // Every row in the bar copies from the photo's topmost row (row 0)
+ for (uint16_t y = 0; y < offsetY; y++) {
for (uint16_t x = 0; x < targetW; x++) {
- size_t idx = ((size_t)y * targetW + x) * 3;
- final_buf[idx] = top.r; final_buf[idx+1] = top.g; final_buf[idx+2] = top.b;
+ int srcX = (int)x - (int)offsetX;
+ srcX = constrain(srcX, 0, (int)fitW - 1);
+ size_t srcIdx = ((size_t)0 * fitW + srcX) * 3; // always row 0
+ size_t dstIdx = ((size_t)y * targetW + x) * 3;
+ final_buf[dstIdx] = fitted[srcIdx];
+ final_buf[dstIdx + 1] = fitted[srcIdx + 1];
+ final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
- // Fill bottom bar
+ }
+
+ // --- Extend bottom edge into bottom bar ---
+ // Every row copies from the photo's bottommost row (fitH - 1)
uint16_t botStart = offsetY + fitH;
- for (uint16_t y = botStart; y < targetH; y++)
+ uint16_t lastRow = fitH - 1;
+ for (uint16_t y = botStart; y < targetH; y++) {
+ for (uint16_t x = 0; x < targetW; x++) {
+ int srcX = (int)x - (int)offsetX;
+ srcX = constrain(srcX, 0, (int)fitW - 1);
+ size_t srcIdx = ((size_t)lastRow * fitW + srcX) * 3;
+ size_t dstIdx = ((size_t)y * targetW + x) * 3;
+ final_buf[dstIdx] = fitted[srcIdx];
+ final_buf[dstIdx + 1] = fitted[srcIdx + 1];
+ final_buf[dstIdx + 2] = fitted[srcIdx + 2];
+ }
+ }
+
+ // --- Blur both bars ---
+ gaussianBlurRegion(final_buf, targetW, 0, 0, targetW, offsetY, BLUR_RADIUS);
+ gaussianBlurRegion(final_buf, targetW, 0, botStart, targetW, targetH, BLUR_RADIUS);
+
+ // --- Fade toward solid edge color ---
+ for (uint16_t y = 0; y < offsetY; y++) {
+ float alpha = (float)(offsetY - 1 - y) / (float)offsetY; // 0 at photo, 1 at screen edge
for (uint16_t x = 0; x < targetW; x++) {
size_t idx = ((size_t)y * targetW + x) * 3;
- final_buf[idx] = bot.r; final_buf[idx+1] = bot.g; final_buf[idx+2] = bot.b;
+ final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + topEdge.r * alpha);
+ final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + topEdge.g * alpha);
+ final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + topEdge.b * alpha);
}
+ }
+ for (uint16_t y = botStart; y < targetH; y++) {
+ float alpha = (float)(y - botStart) / (float)(targetH - botStart);
+ for (uint16_t x = 0; x < targetW; x++) {
+ size_t idx = ((size_t)y * targetW + x) * 3;
+ final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + botEdge.r * alpha);
+ final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + botEdge.g * alpha);
+ final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + botEdge.b * alpha);
+ }
+ }
- Serial.printf("[pipeline] Letterbox TB: top=#%02X%02X%02X bot=#%02X%02X%02X\n",
- top.r, top.g, top.b, bot.r, bot.g, bot.b);
+ Serial.printf("[pipeline] Letterbox TB: bars %dpx, top=#%02X%02X%02X bot=#%02X%02X%02X\n",
+ offsetY, topEdge.r, topEdge.g, topEdge.b, botEdge.r, botEdge.g, botEdge.b);
}
if (offsetX > 0) {
- // Vertical letterbox (left and right bars)
- EdgeColor left = averageEdge(fitted, fitW, fitH, 2);
- EdgeColor right = averageEdge(fitted, fitW, fitH, 3);
+ // Vertical bars (left and right)
+ EdgeColor leftEdge = averageEdge(fitted, fitW, fitH, 2);
+ EdgeColor rightEdge = averageEdge(fitted, fitW, fitH, 3);
- // Fill left bar
- for (uint16_t y = 0; y < targetH; y++)
+ // --- Extend left edge into left bar ---
+ // Every column in the bar copies from the photo's leftmost column (col 0)
+ for (uint16_t y = 0; y < targetH; y++) {
+ int srcY = (int)y - (int)offsetY;
+ srcY = constrain(srcY, 0, (int)fitH - 1);
+ size_t srcIdx = ((size_t)srcY * fitW + 0) * 3; // always column 0
for (uint16_t x = 0; x < offsetX; x++) {
- size_t idx = ((size_t)y * targetW + x) * 3;
- final_buf[idx] = left.r; final_buf[idx+1] = left.g; final_buf[idx+2] = left.b;
- }
- // Fill right bar
- uint16_t rightStart = offsetX + fitW;
- for (uint16_t y = 0; y < targetH; y++)
- for (uint16_t x = rightStart; x < targetW; x++) {
- size_t idx = ((size_t)y * targetW + x) * 3;
- final_buf[idx] = right.r; final_buf[idx+1] = right.g; final_buf[idx+2] = right.b;
+ size_t dstIdx = ((size_t)y * targetW + x) * 3;
+ final_buf[dstIdx] = fitted[srcIdx];
+ final_buf[dstIdx + 1] = fitted[srcIdx + 1];
+ final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
+ }
- Serial.printf("[pipeline] Letterbox LR: left=#%02X%02X%02X right=#%02X%02X%02X\n",
- left.r, left.g, left.b, right.r, right.g, right.b);
+ // --- Extend right edge into right bar ---
+ // Every column copies from the photo's rightmost column (fitW - 1)
+ uint16_t rightStart = offsetX + fitW;
+ uint16_t lastCol = fitW - 1;
+ for (uint16_t y = 0; y < targetH; y++) {
+ int srcY = (int)y - (int)offsetY;
+ srcY = constrain(srcY, 0, (int)fitH - 1);
+ size_t srcIdx = ((size_t)srcY * fitW + lastCol) * 3;
+ for (uint16_t x = rightStart; x < targetW; x++) {
+ size_t dstIdx = ((size_t)y * targetW + x) * 3;
+ final_buf[dstIdx] = fitted[srcIdx];
+ final_buf[dstIdx + 1] = fitted[srcIdx + 1];
+ final_buf[dstIdx + 2] = fitted[srcIdx + 2];
+ }
+ }
+
+ // --- Blur both bars ---
+ gaussianBlurRegion(final_buf, targetW, 0, 0, offsetX, targetH, BLUR_RADIUS);
+ gaussianBlurRegion(final_buf, targetW, rightStart, 0, targetW, targetH, BLUR_RADIUS);
+
+ // --- Fade toward solid edge color ---
+ for (uint16_t y = 0; y < targetH; y++) {
+ for (uint16_t x = 0; x < offsetX; x++) {
+ float alpha = (float)(offsetX - 1 - x) / (float)offsetX;
+ size_t idx = ((size_t)y * targetW + x) * 3;
+ final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + leftEdge.r * alpha);
+ final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + leftEdge.g * alpha);
+ final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + leftEdge.b * alpha);
+ }
+ for (uint16_t x = rightStart; x < targetW; x++) {
+ float alpha = (float)(x - rightStart) / (float)(targetW - rightStart);
+ size_t idx = ((size_t)y * targetW + x) * 3;
+ final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + rightEdge.r * alpha);
+ final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + rightEdge.g * alpha);
+ final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + rightEdge.b * alpha);
+ }
+ }
+
+ Serial.printf("[pipeline] Letterbox LR: bars %dpx, left=#%02X%02X%02X right=#%02X%02X%02X\n",
+ offsetX, leftEdge.r, leftEdge.g, leftEdge.b,
+ rightEdge.r, rightEdge.g, rightEdge.b);
}
}
diff --git a/src/main.cpp b/src/main.cpp
index 28342d9..e366538 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -106,7 +106,7 @@ void displayTask(void* param) {
// Fetch asset info for portrait detection and metadata
AssetInfo info = immichClient.fetchAssetInfo(assetId);
- // Download photo
+ // Download primary photo
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
bool downloaded = immichClient.downloadAsset(
@@ -121,20 +121,77 @@ void displayTask(void* param) {
(unsigned)(jpegSize / 1024),
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
- ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize);
+ ProcessedImage img = {nullptr, 0, 0, false};
+
+ if (info.isPortrait) {
+ // Try to find a second portrait for side-by-side display
+ Serial.println("[display_task] Portrait detected, searching for pair");
+ String pairId = photoQueue.findNextPortrait();
+
+ if (pairId.length() > 0) {
+ // Fetch info for second portrait (for metadata)
+ AssetInfo info2 = immichClient.fetchAssetInfo(pairId);
+
+ // Download the second portrait
+ uint8_t* jpeg2Buf = nullptr;
+ size_t jpeg2Size = 0;
+ bool dl2 = immichClient.downloadAsset(
+ pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
+
+ if (dl2 && jpeg2Buf != nullptr) {
+ Serial.printf("[display_task] Portrait pair: %u KB + %u KB\n",
+ (unsigned)(jpegSize / 1024),
+ (unsigned)(jpeg2Size / 1024));
+ img = imagePipeline.processPortraitPair(
+ jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
+ free(jpeg2Buf);
+
+ if (img.valid) {
+ uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
+ uint16_t rightX = halfW + PORTRAIT_GAP_PX;
+ displayManager.showImage(img);
+ if (s.meta_flags != 0) {
+ displayManager.showMetadataInRegion(
+ info, s.meta_flags, s.meta_pos,
+ 0, 0, halfW, DISPLAY_HEIGHT,
+ s.date_fmt, s.time_fmt);
+ displayManager.showMetadataInRegion(
+ info2, s.meta_flags, s.meta_pos,
+ rightX, 0, halfW, DISPLAY_HEIGHT,
+ s.date_fmt, s.time_fmt);
+ }
+ displayManager.refresh();
+ imagePipeline.freeImage(img);
+ displayed = true;
+ }
+ } else {
+ Serial.println("[display_task] Pair download failed, showing solo");
+ img = imagePipeline.process(jpegBuf, jpegSize);
+ }
+ } else {
+ // No pair found — show solo portrait (will get L/R letterbox)
+ img = imagePipeline.process(jpegBuf, jpegSize);
+ }
+ } else {
+ // Normal landscape processing
+ img = imagePipeline.process(jpegBuf, jpegSize);
+ }
+
free(jpegBuf);
Serial.printf("[display_task] Post-process PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
- if (img.valid) {
+ if (img.valid && !displayed) {
displayManager.showImage(img);
if (s.meta_flags != 0) {
- displayManager.showMetadata(info, s.meta_flags, s.meta_pos);
+ displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
+ s.date_fmt, s.time_fmt);
}
+ displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
- } else {
+ } else if (!img.valid && !displayed) {
Serial.println("[display_task] Processing failed, trying next");
}
}
@@ -206,7 +263,8 @@ void setup() {
if (wifiManager.isAPMode()) {
displayManager.showSetupScreen();
} else {
- displayManager.showMessage("Immich Frame", "Loading photos...");
+ // Skip "Loading photos" screen — the display task will show the first photo
+ // directly, avoiding a redundant 17s e-ink refresh cycle on boot.
// Create display task on Core 1
xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1,
@@ -245,7 +303,14 @@ void loop() {
xSemaphoreGive(stateMutex);
}
break;
- case ButtonEvent::DeepSleep:
+ case ButtonEvent::DeepSleep: {
+ Serial.println("[main] Entering deep sleep...");
+ Settings sleepS = settingsManager.get();
+ displayManager.showSleepIndicator(sleepS.meta_pos);
+ displayManager.refresh();
+ powerManager.enterDeepSleep();
+ break;
+ }
case ButtonEvent::FactoryReset:
break;
case ButtonEvent::None:
diff --git a/src/photo_queue.cpp b/src/photo_queue.cpp
index 0e76f62..ea0dd73 100644
--- a/src/photo_queue.cpp
+++ b/src/photo_queue.cpp
@@ -141,10 +141,21 @@ bool PhotoQueue::needsResync() {
return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL);
}
-String PhotoQueue::findPortraitPair(size_t startIndex) {
- for (size_t i = 1; i <= PORTRAIT_LOOKAHEAD && (startIndex + i) < _queue.size(); i++) {
- return _queue[startIndex + i];
+String PhotoQueue::findNextPortrait() {
+ if (_client == nullptr) return "";
+
+ size_t scanEnd = min(_cursor + PORTRAIT_LOOKAHEAD, _queue.size());
+ for (size_t i = _cursor; i < scanEnd; i++) {
+ AssetInfo info = _client->fetchAssetInfo(_queue[i]);
+ if (info.isPortrait) {
+ String id = _queue[i];
+ _queue.erase(_queue.begin() + i);
+ Serial.printf("[queue] Found portrait pair: %s (scanned %d ahead)\n",
+ id.c_str(), (int)(i - _cursor + 1));
+ return id;
+ }
}
+ Serial.println("[queue] No portrait pair found in lookahead");
return "";
}
diff --git a/src/photo_queue.h b/src/photo_queue.h
index 0a2d208..d5c0201 100644
--- a/src/photo_queue.h
+++ b/src/photo_queue.h
@@ -15,7 +15,7 @@ public:
String current();
size_t size();
bool needsResync();
- String findPortraitPair(size_t startIndex);
+ String findNextPortrait();
private:
ImmichClient* _client = nullptr;
diff --git a/src/settings.cpp b/src/settings.cpp
index 2a432ca..d2b1c6a 100644
--- a/src/settings.cpp
+++ b/src/settings.cpp
@@ -20,6 +20,8 @@ Settings SettingsManager::get() {
s.img_quality = static_cast(readU8("img_quality", DEFAULT_IMG_QUALITY));
s.meta_flags = readU8("meta_flags", DEFAULT_META_FLAGS);
s.meta_pos = static_cast(readU8("meta_pos", DEFAULT_META_POS));
+ s.date_fmt = readString("date_fmt", "%Y-%m-%d");
+ s.time_fmt = readString("time_fmt", "%H:%M:%S");
s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]");
@@ -36,6 +38,8 @@ void SettingsManager::save(const Settings& s) {
writeU8("img_quality", static_cast(s.img_quality));
writeU8("meta_flags", s.meta_flags);
writeU8("meta_pos", static_cast(s.meta_pos));
+ writeString("date_fmt", s.date_fmt);
+ writeString("time_fmt", s.time_fmt);
writeU8("led_bright", s.led_brightness);
writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json);
diff --git a/src/settings.h b/src/settings.h
index 34e9c7e..b134e61 100644
--- a/src/settings.h
+++ b/src/settings.h
@@ -16,12 +16,19 @@ enum class ImageQuality : uint8_t {
};
enum class MetaPosition : uint8_t {
- Bottom = 0,
- Top = 1
+ // Captioned mode (full-width bar)
+ CaptionBottom = 0,
+ CaptionTop = 1,
+ // Overlay mode (corner badge)
+ OverlayTopLeft = 2,
+ OverlayTopRight = 3,
+ OverlayBottomLeft = 4,
+ OverlayBottomRight = 5
};
// Metadata flags bitmask
constexpr uint8_t META_DATE = 0x01;
+constexpr uint8_t META_TIME = 0x20;
constexpr uint8_t META_LOCATION = 0x02;
constexpr uint8_t META_PEOPLE = 0x04;
constexpr uint8_t META_ALBUM = 0x08;
@@ -44,6 +51,8 @@ struct Settings {
// Display
uint8_t meta_flags;
MetaPosition meta_pos;
+ String date_fmt; // strftime-style, default "%Y-%m-%d"
+ String time_fmt; // strftime-style, default "%H:%M:%S"
// Device
uint8_t led_brightness;
diff --git a/src/web_server.cpp b/src/web_server.cpp
index a787e0c..9dbf48d 100644
--- a/src/web_server.cpp
+++ b/src/web_server.cpp
@@ -282,6 +282,8 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
doc["img_quality"] = static_cast(s.img_quality);
doc["meta_flags"] = s.meta_flags;
doc["meta_pos"] = static_cast(s.meta_pos);
+ doc["date_fmt"] = s.date_fmt;
+ doc["time_fmt"] = s.time_fmt;
doc["led_brightness"] = s.led_brightness;
doc["immich_url"] = s.immich_url;
doc["immich_key"] = s.immich_key;
@@ -308,6 +310,8 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
if (!doc["img_quality"].isNull()) s.img_quality = static_cast((uint8_t)doc["img_quality"]);
if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"];
if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast((uint8_t)doc["meta_pos"]);
+ if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as();
+ if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as();
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as();
if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as();