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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user