From c28b8efcb5dae544e1c57555a6d628a26f6871d5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 20:50:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20metadata=20overlay=20tweaks=20=E2=80=94?= =?UTF-8?q?=20location=20badge,=20rounded=20corners,=20translucent=20optio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Location gets its own badge in the lower-left corner (separate from the main date/time/people/camera badge at the configured position) - All overlay badges now use rounded corners (radius 4px) via fillRoundRect instead of fillRect - New setting: 'Translucent background?' checkbox in web UI When enabled, badge backgrounds use a checkerboard pattern (every other pixel black) instead of solid black, creating a 50% opacity effect that lets the underlying image show through - Refactored all badge drawing through a single drawOverlayBadge() helper (metadata, sleep indicator, battery indicator) Co-authored-by: Cursor --- data/app.js | 4 + src/display_manager.cpp | 198 +++++++++++++++++++++++++++------------- src/display_manager.h | 5 +- src/main.cpp | 4 +- src/settings.cpp | 2 + src/settings.h | 1 + src/web_server.cpp | 2 + 7 files changed, 153 insertions(+), 63 deletions(-) diff --git a/data/app.js b/data/app.js index 992edec..b6a78eb 100644 --- a/data/app.js +++ b/data/app.js @@ -173,6 +173,9 @@ async function renderDisplay(el) { +

Battery Indicator

@@ -189,6 +192,7 @@ async function renderDisplay(el) { pipeline_mode: parseInt(document.getElementById('pipelineMode').value), meta_flags: flags, meta_pos: parseInt(document.getElementById('metaPos').value), + meta_translucent: document.getElementById('metaTranslucent').checked ? 1 : 0, date_fmt: document.getElementById('dateFmt').value, time_fmt: document.getElementById('timeFmt').value, show_battery: document.getElementById('showBattery').checked ? 1 : 0 diff --git a/src/display_manager.cpp b/src/display_manager.cpp index 9fa7476..8f71335 100644 --- a/src/display_manager.cpp +++ b/src/display_manager.cpp @@ -130,7 +130,7 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla const String& dateFmt, const String& timeFmt) { if (metaFlags == 0) return; - // Build metadata string + // Build metadata string (excluding location — it gets its own badge) String metaText = ""; // Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime @@ -158,10 +158,6 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla // Fallback if datetime is too short for full parse metaText += info.dateTime.substring(0, 10); } - if ((metaFlags & META_LOCATION) && info.city.length() > 0) { - if (metaText.length() > 0) metaText += " | "; - metaText += info.city; - } if ((metaFlags & META_PEOPLE) && !info.people.empty()) { if (metaText.length() > 0) metaText += " | "; for (size_t i = 0; i < info.people.size(); i++) { @@ -177,71 +173,151 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla metaText += info.camera; } - if (metaText.length() == 0) return; + // Location gets its own badge in lower-left + String locationText = ""; + if ((metaFlags & META_LOCATION) && info.city.length() > 0) { + locationText = info.city; + } + + if (metaText.length() == 0 && locationText.length() == 0) return; + + 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; _display->setTextSize(1.5f); _display->setTextColor(TFT_WHITE); - 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; + // Track where the main badge ends up (for location badge offset) + uint16_t mainBadgeBottomY = 0; - 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; + // Draw main metadata badge (date/time/people/camera) at configured position + if (metaText.length() > 0) { + switch (pos) { + case MetaPosition::CaptionBottom: + case MetaPosition::CaptionTop: { + 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: { + 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; - _display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK); - _display->setTextDatum(middle_left); - _display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2); - break; + 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; + } + + drawOverlayBadge(bx, by, badgeW, badgeH); + _display->setTextDatum(middle_left); + _display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2); + mainBadgeBottomY = by + badgeH; + break; + } } } + + // Draw location badge in lower-left corner (separate from main badge) + if (locationText.length() > 0) { + uint16_t locTextW = _display->textWidth(locationText.c_str()); + uint16_t locBadgeW = min((uint16_t)(locTextW + PAD_X * 2), regionW); + uint16_t locBadgeH = TEXT_H + PAD_Y * 2; + + uint16_t lx = regionX + MARGIN; + uint16_t ly = regionY + regionH - locBadgeH - MARGIN; + + // If main badge is also in bottom-left, stack location above it + if (pos == MetaPosition::OverlayBottomLeft && metaText.length() > 0) { + ly = ly - locBadgeH - 4; + } + + drawOverlayBadge(lx, ly, locBadgeW, locBadgeH); + _display->setTextColor(TFT_WHITE); + _display->setTextDatum(middle_left); + _display->drawString(locationText.c_str(), lx + PAD_X, ly + locBadgeH / 2); + } } -void DisplayManager::drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { - _display->fillRect(x, y, w, h, TFT_BLACK); +void DisplayManager::drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + static constexpr int16_t R = 4; + + if (!_translucent) { + _display->fillRoundRect(x, y, w, h, R, TFT_BLACK); + return; + } + + // Checkerboard pattern within rounded-rect bounds for 50% opacity effect. + // Use fillRoundRect to create a clip mask in black, then punch holes by + // restoring every-other pixel. Simpler: just iterate and test corner radii. + for (uint16_t py = y; py < y + h; py++) { + for (uint16_t px = x; px < x + w; px++) { + if ((px + py) % 2 != 0) continue; + + // Check if pixel is inside the rounded rect (corner exclusion) + int16_t lx = px - x; + int16_t ly = py - y; + bool inside = true; + + // Top-left corner + if (lx < R && ly < R) { + int16_t dx = R - 1 - lx; + int16_t dy = R - 1 - ly; + inside = (dx * dx + dy * dy) <= (R * R); + } + // Top-right corner + else if (lx >= w - R && ly < R) { + int16_t dx = lx - (w - R); + int16_t dy = R - 1 - ly; + inside = (dx * dx + dy * dy) <= (R * R); + } + // Bottom-left corner + else if (lx < R && ly >= h - R) { + int16_t dx = R - 1 - lx; + int16_t dy = ly - (h - R); + inside = (dx * dx + dy * dy) <= (R * R); + } + // Bottom-right corner + else if (lx >= w - R && ly >= h - R) { + int16_t dx = lx - (w - R); + int16_t dy = ly - (h - R); + inside = (dx * dx + dy * dy) <= (R * R); + } + + if (inside) { + _display->writePixel(px, py, TFT_BLACK); + } + } + } } void DisplayManager::showSleepIndicator(MetaPosition metaPos) { @@ -281,7 +357,7 @@ void DisplayManager::showSleepIndicator(MetaPosition metaPos) { break; } - drawBadge(bx, by, badgeW, badgeH); + drawOverlayBadge(bx, by, badgeW, badgeH); _display->setTextColor(TFT_WHITE); _display->setTextDatum(middle_center); _display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2); @@ -324,7 +400,7 @@ void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos) } // Draw badge background - drawBadge(bx, by, badgeW, badgeH); + drawOverlayBadge(bx, by, badgeW, badgeH); // Draw battery outline (white rect with 1px border) uint16_t iconX = bx + PAD_X; diff --git a/src/display_manager.h b/src/display_manager.h index a8f92ff..20fa5e0 100644 --- a/src/display_manager.h +++ b/src/display_manager.h @@ -25,10 +25,13 @@ public: void showBatteryIndicator(uint8_t percent, MetaPosition metaPos); void refresh(); + void setTranslucent(bool enabled) { _translucent = enabled; } + private: PowerManager* _power = nullptr; M5GFX* _display = nullptr; + bool _translucent = false; - void drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h); + void drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h); void triggerRefresh(); }; diff --git a/src/main.cpp b/src/main.cpp index aa8577a..7dc10af 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -63,6 +63,7 @@ void displayTask(void* param) { Settings s = settingsManager.get(); imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); + displayManager.setTranslucent(s.meta_translucent != 0); unsigned long intervalMs = s.interval_min * 60000UL; bool shouldRefresh = false; @@ -246,10 +247,11 @@ void timerWakeCycle() { return; } - // Initialize Immich client and apply pipeline mode + // Initialize Immich client and apply pipeline/display modes immichClient.begin(s.immich_url, s.immich_key); imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); + displayManager.setTranslucent(s.meta_translucent != 0); // Fetch a small batch of random assets static constexpr int TIMER_WAKE_RETRIES = 5; diff --git a/src/settings.cpp b/src/settings.cpp index 4ff8c4e..8ee21ec 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -24,6 +24,7 @@ Settings SettingsManager::get() { s.time_fmt = readString("time_fmt", "%H:%M:%S"); s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE); s.dither_noise = readU8("dith_noise", DEFAULT_DITHER_NOISE); + s.meta_translucent = readU8("meta_trans", 0); s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS); s.show_battery = readU8("show_batt", 1); s.queue_cursor = readU32("queue_cursor", 0); @@ -45,6 +46,7 @@ void SettingsManager::save(const Settings& s) { writeString("time_fmt", s.time_fmt); writeU8("pipe_mode", s.pipeline_mode); writeU8("dith_noise", s.dither_noise); + writeU8("meta_trans", s.meta_translucent); writeU8("led_bright", s.led_brightness); writeU8("show_batt", s.show_battery); writeU32("queue_cursor", s.queue_cursor); diff --git a/src/settings.h b/src/settings.h index eed0163..8cc6162 100644 --- a/src/settings.h +++ b/src/settings.h @@ -59,6 +59,7 @@ struct Settings { // Image pipeline uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none uint8_t dither_noise; // 1=blue noise dither smoothing ON, 0=OFF + uint8_t meta_translucent; // 1=checkerboard 50% opacity badge bg, 0=solid black // Device uint8_t led_brightness; diff --git a/src/web_server.cpp b/src/web_server.cpp index 1bfcc24..3868d04 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -287,6 +287,7 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) { doc["time_fmt"] = s.time_fmt; doc["pipeline_mode"] = s.pipeline_mode; doc["dither_noise"] = s.dither_noise; + doc["meta_translucent"] = s.meta_translucent; doc["led_brightness"] = s.led_brightness; doc["show_battery"] = s.show_battery; doc["immich_url"] = s.immich_url; @@ -318,6 +319,7 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request, if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as(); if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"]; if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"]; + if (!doc["meta_translucent"].isNull()) s.meta_translucent = doc["meta_translucent"]; if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"]; if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"]; if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as();