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>
|
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
<li><input type="checkbox" id="metaTranslucent" ${settings.meta_translucent?'checked':''}><span>Translucent background?</span></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Battery Indicator</h2>
|
<h2>Battery Indicator</h2>
|
||||||
@@ -189,6 +192,7 @@ async function renderDisplay(el) {
|
|||||||
pipeline_mode: parseInt(document.getElementById('pipelineMode').value),
|
pipeline_mode: parseInt(document.getElementById('pipelineMode').value),
|
||||||
meta_flags: flags,
|
meta_flags: flags,
|
||||||
meta_pos: parseInt(document.getElementById('metaPos').value),
|
meta_pos: parseInt(document.getElementById('metaPos').value),
|
||||||
|
meta_translucent: document.getElementById('metaTranslucent').checked ? 1 : 0,
|
||||||
date_fmt: document.getElementById('dateFmt').value,
|
date_fmt: document.getElementById('dateFmt').value,
|
||||||
time_fmt: document.getElementById('timeFmt').value,
|
time_fmt: document.getElementById('timeFmt').value,
|
||||||
show_battery: document.getElementById('showBattery').checked ? 1 : 0
|
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) {
|
const String& dateFmt, const String& timeFmt) {
|
||||||
if (metaFlags == 0) return;
|
if (metaFlags == 0) return;
|
||||||
|
|
||||||
// Build metadata string
|
// Build metadata string (excluding location — it gets its own badge)
|
||||||
String metaText = "";
|
String metaText = "";
|
||||||
|
|
||||||
// Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime
|
// 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
|
// Fallback if datetime is too short for full parse
|
||||||
metaText += info.dateTime.substring(0, 10);
|
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 ((metaFlags & META_PEOPLE) && !info.people.empty()) {
|
||||||
if (metaText.length() > 0) metaText += " | ";
|
if (metaText.length() > 0) metaText += " | ";
|
||||||
for (size_t i = 0; i < info.people.size(); i++) {
|
for (size_t i = 0; i < info.people.size(); i++) {
|
||||||
@@ -177,15 +173,30 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla
|
|||||||
metaText += info.camera;
|
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->setTextSize(1.5f);
|
||||||
_display->setTextColor(TFT_WHITE);
|
_display->setTextColor(TFT_WHITE);
|
||||||
|
|
||||||
|
// Track where the main badge ends up (for location badge offset)
|
||||||
|
uint16_t mainBadgeBottomY = 0;
|
||||||
|
|
||||||
|
// Draw main metadata badge (date/time/people/camera) at configured position
|
||||||
|
if (metaText.length() > 0) {
|
||||||
switch (pos) {
|
switch (pos) {
|
||||||
case MetaPosition::CaptionBottom:
|
case MetaPosition::CaptionBottom:
|
||||||
case MetaPosition::CaptionTop: {
|
case MetaPosition::CaptionTop: {
|
||||||
// Bar spanning the region width, positioned at region top or bottom
|
|
||||||
uint16_t barH = 36;
|
uint16_t barH = 36;
|
||||||
uint16_t barY = (pos == MetaPosition::CaptionTop)
|
uint16_t barY = (pos == MetaPosition::CaptionTop)
|
||||||
? regionY
|
? regionY
|
||||||
@@ -199,11 +210,6 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla
|
|||||||
case MetaPosition::OverlayTopRight:
|
case MetaPosition::OverlayTopRight:
|
||||||
case MetaPosition::OverlayBottomLeft:
|
case MetaPosition::OverlayBottomLeft:
|
||||||
case MetaPosition::OverlayBottomRight: {
|
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 textW = _display->textWidth(metaText.c_str());
|
||||||
uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW);
|
uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW);
|
||||||
uint16_t badgeH = TEXT_H + PAD_Y * 2;
|
uint16_t badgeH = TEXT_H + PAD_Y * 2;
|
||||||
@@ -232,16 +238,86 @@ void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFla
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK);
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
_display->setTextDatum(middle_left);
|
_display->setTextDatum(middle_left);
|
||||||
_display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2);
|
_display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2);
|
||||||
|
mainBadgeBottomY = by + badgeH;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplayManager::drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
|
// Draw location badge in lower-left corner (separate from main badge)
|
||||||
_display->fillRect(x, y, w, h, TFT_BLACK);
|
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::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) {
|
void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
|
||||||
@@ -281,7 +357,7 @@ void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawBadge(bx, by, badgeW, badgeH);
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
_display->setTextColor(TFT_WHITE);
|
_display->setTextColor(TFT_WHITE);
|
||||||
_display->setTextDatum(middle_center);
|
_display->setTextDatum(middle_center);
|
||||||
_display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2);
|
_display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2);
|
||||||
@@ -324,7 +400,7 @@ void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw badge background
|
// Draw badge background
|
||||||
drawBadge(bx, by, badgeW, badgeH);
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
|
|
||||||
// Draw battery outline (white rect with 1px border)
|
// Draw battery outline (white rect with 1px border)
|
||||||
uint16_t iconX = bx + PAD_X;
|
uint16_t iconX = bx + PAD_X;
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ public:
|
|||||||
void showBatteryIndicator(uint8_t percent, MetaPosition metaPos);
|
void showBatteryIndicator(uint8_t percent, MetaPosition metaPos);
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
|
void setTranslucent(bool enabled) { _translucent = enabled; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
PowerManager* _power = nullptr;
|
PowerManager* _power = nullptr;
|
||||||
M5GFX* _display = 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();
|
void triggerRefresh();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ void displayTask(void* param) {
|
|||||||
Settings s = settingsManager.get();
|
Settings s = settingsManager.get();
|
||||||
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
unsigned long intervalMs = s.interval_min * 60000UL;
|
unsigned long intervalMs = s.interval_min * 60000UL;
|
||||||
|
|
||||||
bool shouldRefresh = false;
|
bool shouldRefresh = false;
|
||||||
@@ -246,10 +247,11 @@ void timerWakeCycle() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Immich client and apply pipeline mode
|
// Initialize Immich client and apply pipeline/display modes
|
||||||
immichClient.begin(s.immich_url, s.immich_key);
|
immichClient.begin(s.immich_url, s.immich_key);
|
||||||
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
|
|
||||||
// Fetch a small batch of random assets
|
// Fetch a small batch of random assets
|
||||||
static constexpr int TIMER_WAKE_RETRIES = 5;
|
static constexpr int TIMER_WAKE_RETRIES = 5;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Settings SettingsManager::get() {
|
|||||||
s.time_fmt = readString("time_fmt", "%H:%M:%S");
|
s.time_fmt = readString("time_fmt", "%H:%M:%S");
|
||||||
s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE);
|
s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE);
|
||||||
s.dither_noise = readU8("dith_noise", DEFAULT_DITHER_NOISE);
|
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.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
|
||||||
s.show_battery = readU8("show_batt", 1);
|
s.show_battery = readU8("show_batt", 1);
|
||||||
s.queue_cursor = readU32("queue_cursor", 0);
|
s.queue_cursor = readU32("queue_cursor", 0);
|
||||||
@@ -45,6 +46,7 @@ void SettingsManager::save(const Settings& s) {
|
|||||||
writeString("time_fmt", s.time_fmt);
|
writeString("time_fmt", s.time_fmt);
|
||||||
writeU8("pipe_mode", s.pipeline_mode);
|
writeU8("pipe_mode", s.pipeline_mode);
|
||||||
writeU8("dith_noise", s.dither_noise);
|
writeU8("dith_noise", s.dither_noise);
|
||||||
|
writeU8("meta_trans", s.meta_translucent);
|
||||||
writeU8("led_bright", s.led_brightness);
|
writeU8("led_bright", s.led_brightness);
|
||||||
writeU8("show_batt", s.show_battery);
|
writeU8("show_batt", s.show_battery);
|
||||||
writeU32("queue_cursor", s.queue_cursor);
|
writeU32("queue_cursor", s.queue_cursor);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ struct Settings {
|
|||||||
// Image pipeline
|
// Image pipeline
|
||||||
uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none
|
uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none
|
||||||
uint8_t dither_noise; // 1=blue noise dither smoothing ON, 0=OFF
|
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
|
// Device
|
||||||
uint8_t led_brightness;
|
uint8_t led_brightness;
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
|
|||||||
doc["time_fmt"] = s.time_fmt;
|
doc["time_fmt"] = s.time_fmt;
|
||||||
doc["pipeline_mode"] = s.pipeline_mode;
|
doc["pipeline_mode"] = s.pipeline_mode;
|
||||||
doc["dither_noise"] = s.dither_noise;
|
doc["dither_noise"] = s.dither_noise;
|
||||||
|
doc["meta_translucent"] = s.meta_translucent;
|
||||||
doc["led_brightness"] = s.led_brightness;
|
doc["led_brightness"] = s.led_brightness;
|
||||||
doc["show_battery"] = s.show_battery;
|
doc["show_battery"] = s.show_battery;
|
||||||
doc["immich_url"] = s.immich_url;
|
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["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as<String>();
|
||||||
if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"];
|
if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"];
|
||||||
if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"];
|
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["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
|
||||||
if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"];
|
if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"];
|
||||||
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
|
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
|
||||||
|
|||||||
Reference in New Issue
Block a user