feat: metadata overlay tweaks — location badge, rounded corners, translucent option

- 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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 20:50:01 -04:00
parent 7f51b274c4
commit c28b8efcb5
7 changed files with 153 additions and 63 deletions

View File

@@ -173,6 +173,9 @@ async function renderDisplay(el) {
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
</select>
</div>
<ul class="checkbox-list">
<li><input type="checkbox" id="metaTranslucent" ${settings.meta_translucent?'checked':''}><span>Translucent background?</span></li>
</ul>
</div>
<div class="card">
<h2>Battery Indicator</h2>
@@ -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

View File

@@ -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;

View File

@@ -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();
};

View File

@@ -63,6 +63,7 @@ void displayTask(void* param) {
Settings s = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(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<PipelineMode>(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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<String>();
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<String>();