Screensavers (#462)

Adds screensavers in addition to the backlight idle off. 
More info in screensavers.md

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added a screensaver system with multiple styles (Bouncing Balls, Mystify, Matrix Rain) and a common screensaver interface; auto-starts after inactivity, dismisses on interaction, and supports auto-off/backlight behavior.
  * New Display setting and UI dropdown to choose and persist the screensaver.

* **Documentation**
  * Added comprehensive screensaver docs covering usage, extension, and configuration.

* **Chores**
  * Registered the display-idle service.

* **Bug Fixes**
  * Updated LVGL API calls to match renamed functions.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Shadowtrance
2026-01-28 02:21:16 +10:00
committed by GitHub
parent e6abd496f9
commit c05d46a28c
17 changed files with 1474 additions and 68 deletions
@@ -0,0 +1,125 @@
#ifdef ESP_PLATFORM
#include "BouncingBallsScreensaver.h"
#include <cstdlib>
namespace tt::service::displayidle {
void BouncingBallsScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
for (int i = 0; i < NUM_BALLS; i++) {
initBall(balls_[i], overlay, screenW, screenH, i);
}
}
void BouncingBallsScreensaver::stop() {
for (auto& ball : balls_) {
ball.obj = nullptr; // Objects deleted by parent overlay
}
}
void BouncingBallsScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
for (auto& ball : balls_) {
if (!ball.obj) continue;
ball.x += ball.dx;
ball.y += ball.dy;
bool bounced = false;
if (ball.x <= 0) {
ball.x = 0;
ball.dx = -ball.dx;
bounced = true;
} else if (ball.x >= screenW - BALL_SIZE) {
ball.x = screenW - BALL_SIZE;
ball.dx = -ball.dx;
bounced = true;
}
if (ball.y <= 0) {
ball.y = 0;
ball.dy = -ball.dy;
bounced = true;
} else if (ball.y >= screenH - BALL_SIZE) {
ball.y = screenH - BALL_SIZE;
ball.dy = -ball.dy;
bounced = true;
}
if (bounced) {
setRandomTargetColor(ball);
}
updateBallColor(ball);
lv_obj_set_pos(ball.obj, ball.x, ball.y);
}
}
void BouncingBallsScreensaver::initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index) {
ball.obj = lv_obj_create(parent);
if (ball.obj == nullptr) {
return;
}
lv_obj_remove_style_all(ball.obj);
lv_obj_set_size(ball.obj, BALL_SIZE, BALL_SIZE);
lv_obj_set_style_bg_opa(ball.obj, LV_OPA_COVER, 0);
lv_obj_set_style_radius(ball.obj, BALL_SIZE / 2, 0);
ball.x = (screenW > BALL_SIZE) ? rand() % (screenW - BALL_SIZE) : 0;
ball.y = (screenH > BALL_SIZE) ? rand() % (screenH - BALL_SIZE) : 0;
// Random speeds between 1-4 with random direction
// Note: adjustment below may extend range to 1-5 to ensure varied paths
ball.dx = (rand() % 4) + 1;
ball.dy = (rand() % 4) + 1;
if (rand() % 2) ball.dx = -ball.dx;
if (rand() % 2) ball.dy = -ball.dy;
// Ensure dx != dy for more varied paths (may adjust dy to 5 or -5)
if (abs(ball.dx) == abs(ball.dy)) {
ball.dy = ball.dy > 0 ? ball.dy + 1 : ball.dy - 1;
}
uint32_t color = COLORS[index % COLORS.size()];
ball.currentR = ball.targetR = (color >> 16) & 0xFF;
ball.currentG = ball.targetG = (color >> 8) & 0xFF;
ball.currentB = ball.targetB = color & 0xFF;
ball.colorStep = 0;
lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0);
lv_obj_set_pos(ball.obj, ball.x, ball.y);
}
void BouncingBallsScreensaver::setRandomTargetColor(Ball& ball) {
uint32_t color = COLORS[rand() % COLORS.size()];
ball.targetR = (color >> 16) & 0xFF;
ball.targetG = (color >> 8) & 0xFF;
ball.targetB = color & 0xFF;
ball.colorStep = COLOR_FADE_STEPS;
}
void BouncingBallsScreensaver::updateBallColor(Ball& ball) {
if (ball.colorStep > 0) {
int stepR = (static_cast<int>(ball.targetR) - ball.currentR) / ball.colorStep;
int stepG = (static_cast<int>(ball.targetG) - ball.currentG) / ball.colorStep;
int stepB = (static_cast<int>(ball.targetB) - ball.currentB) / ball.colorStep;
ball.currentR = static_cast<uint8_t>(ball.currentR + stepR);
ball.currentG = static_cast<uint8_t>(ball.currentG + stepG);
ball.currentB = static_cast<uint8_t>(ball.currentB + stepB);
ball.colorStep--;
if (ball.colorStep == 0) {
ball.currentR = ball.targetR;
ball.currentG = ball.targetG;
ball.currentB = ball.targetB;
}
if (ball.obj) {
lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0);
}
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -0,0 +1,59 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <cstdint>
namespace tt::service::displayidle {
class BouncingBallsScreensaver final : public Screensaver {
public:
BouncingBallsScreensaver() = default;
~BouncingBallsScreensaver() override = default;
BouncingBallsScreensaver(const BouncingBallsScreensaver&) = delete;
BouncingBallsScreensaver& operator=(const BouncingBallsScreensaver&) = delete;
BouncingBallsScreensaver(BouncingBallsScreensaver&&) = delete;
BouncingBallsScreensaver& operator=(BouncingBallsScreensaver&&) = delete;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
static constexpr int BALL_SIZE = 20;
static constexpr int NUM_BALLS = 5;
static constexpr int COLOR_FADE_STEPS = 15;
struct Ball {
lv_obj_t* obj = nullptr;
lv_coord_t x = 0;
lv_coord_t y = 0;
lv_coord_t dx = 0;
lv_coord_t dy = 0;
uint8_t currentR = 0, currentG = 0, currentB = 0;
uint8_t targetR = 0, targetG = 0, targetB = 0;
int colorStep = 0;
};
static constexpr std::array<uint32_t, 8> COLORS = {
0xFF0000, // Red
0x00FF00, // Green
0x0000FF, // Blue
0xFFFF00, // Yellow
0xFF00FF, // Magenta
0x00FFFF, // Cyan
0xFF8000, // Orange
0x80FF00, // Lime
};
std::array<Ball, NUM_BALLS> balls_;
void initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index);
static void setRandomTargetColor(Ball& ball);
static void updateBallColor(Ball& ball);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -1,91 +1,265 @@
#ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h>
#include "Screensaver.h"
#include "BouncingBallsScreensaver.h"
#include "MatrixRainScreensaver.h"
#include "MystifyScreensaver.h"
#include <Tactility/Logger.h>
#include <Tactility/CoreDefines.h>
#include <Tactility/hal/display/DisplayDevice.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/service/ServiceContext.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/Timer.h>
#include <cstdlib>
#include <ctime>
namespace tt::service::displayidle {
class DisplayIdleService final : public Service {
static const auto LOGGER = Logger("DisplayIdle");
std::unique_ptr<Timer> timer;
bool displayDimmed = false;
settings::display::DisplaySettings cachedDisplaySettings;
constexpr uint32_t kWakeActivityThresholdMs = 100;
static std::shared_ptr<hal::display::DisplayDevice> getDisplay() {
return hal::findFirstDevice<hal::display::DisplayDevice>(hal::Device::Type::Display);
static std::shared_ptr<hal::display::DisplayDevice> getDisplay() {
return hal::findFirstDevice<hal::display::DisplayDevice>(hal::Device::Type::Display);
}
void DisplayIdleService::stopScreensaverCb(lv_event_t* e) {
auto* self = static_cast<DisplayIdleService*>(lv_event_get_user_data(e));
lv_event_stop_bubbling(e);
self->stopScreensaverRequested.store(true, std::memory_order_release);
lv_display_trigger_activity(nullptr);
}
void DisplayIdleService::stopScreensaver() {
if (!lvgl::lock(100)) {
// Lock failed - keep flag set to retry on next tick
return;
}
void tick() {
// Settings are now cached and event-driven (no file I/O in timer callback!)
// This prevents watchdog timeout from blocking the Timer Service task
const auto restoreDuty = cachedDisplaySettings.backlightDuty;
const bool wasDimmed = displayDimmed;
if (lv_disp_get_default() == nullptr) {
return;
if (screensaverOverlay) {
if (screensaver) {
screensaver->stop();
screensaver.reset();
}
lv_obj_delete(screensaverOverlay);
screensaverOverlay = nullptr;
}
lvgl::unlock();
stopScreensaverRequested.store(false, std::memory_order_relaxed);
// Query LVGL inactivity once for both checks
uint32_t inactive_ms = 0;
if (lvgl::lock(100)) {
inactive_ms = lv_disp_get_inactive_time(nullptr);
lvgl::unlock();
} else {
return;
}
// Reset auto-off state
screensaverActiveCounter = 0;
backlightOff = false;
// TODO: The following logic only works with the first display. There might be multiple displays.
// Handle display backlight
auto display = getDisplay();
if (display != nullptr && display->supportsBacklightDuty()) {
// If timeout disabled, ensure backlight restored if we had dimmed it
if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) {
if (displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
}
} else {
if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) {
display->setBacklightDuty(0);
displayDimmed = true;
} else if (displayDimmed && inactive_ms < 100) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
// Restore backlight if display was dimmed
auto display = getDisplay();
if (display && wasDimmed) {
display->setBacklightDuty(restoreDuty);
}
displayDimmed = wasDimmed ? false : displayDimmed;
}
void DisplayIdleService::activateScreensaver() {
lv_obj_t* top = lv_layer_top();
if (screensaverOverlay != nullptr) return;
// Reset auto-off counter when starting screensaver
screensaverActiveCounter = 0;
backlightOff = false;
lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
// Black background overlay
screensaverOverlay = lv_obj_create(top);
lv_obj_remove_style_all(screensaverOverlay);
lv_obj_set_size(screensaverOverlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(screensaverOverlay, 0, 0);
lv_obj_set_style_bg_color(screensaverOverlay, lv_color_black(), 0);
lv_obj_set_style_bg_opa(screensaverOverlay, LV_OPA_COVER, 0);
lv_obj_add_flag(screensaverOverlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(screensaverOverlay, stopScreensaverCb, LV_EVENT_CLICKED, this);
// Create and start the screensaver based on settings
switch (cachedDisplaySettings.screensaverType) {
case settings::display::ScreensaverType::Mystify:
screensaver = std::make_unique<MystifyScreensaver>();
break;
case settings::display::ScreensaverType::BouncingBalls:
screensaver = std::make_unique<BouncingBallsScreensaver>();
break;
case settings::display::ScreensaverType::MatrixRain:
screensaver = std::make_unique<MatrixRainScreensaver>();
break;
case settings::display::ScreensaverType::None:
default:
// Just black screen, no animated screensaver
screensaver = nullptr;
break;
}
if (screensaver) {
screensaver->start(screensaverOverlay, screenW, screenH);
}
}
void DisplayIdleService::updateScreensaver() {
if (screensaver) {
lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
screensaver->update(screenW, screenH);
}
}
void DisplayIdleService::tick() {
if (!lvgl::lock(100)) {
return;
}
if (lv_display_get_default() == nullptr) {
lvgl::unlock();
return;
}
// Check for settings reload request (thread-safe)
if (settingsReloadRequested.exchange(false, std::memory_order_acquire)) {
cachedDisplaySettings = settings::display::loadOrGetDefault();
}
uint32_t inactive_ms = 0;
inactive_ms = lv_display_get_inactive_time(nullptr);
// Only update if not stopping (prevents lag on touch)
if (displayDimmed && screensaverOverlay && !stopScreensaverRequested.load(std::memory_order_acquire)) {
// Check if screensaver should auto-off after 5 minutes
if (!backlightOff) {
screensaverActiveCounter++;
if (screensaverActiveCounter >= SCREENSAVER_AUTO_OFF_TICKS) {
// Stop screensaver animation and turn off backlight
if (screensaver) {
screensaver->stop();
screensaver.reset();
}
auto display = getDisplay();
if (display) {
display->setBacklightDuty(0);
}
backlightOff = true;
} else {
updateScreensaver();
}
}
}
lvgl::unlock();
// Check stop request early for faster response
if (stopScreensaverRequested.load(std::memory_order_acquire)) {
stopScreensaver();
return;
}
public:
bool onStart(ServiceContext& service) override {
// Load settings once at startup and cache them
// This eliminates file I/O from timer callback (prevents watchdog timeout)
cachedDisplaySettings = settings::display::loadOrGetDefault();
// Note: Settings changes require service restart to take effect
// TODO: Add DisplaySettingsChanged events for dynamic updates
timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::millisToTicks(250), [this]{ this->tick(); });
timer->setCallbackPriority(Thread::Priority::Lower);
timer->start();
return true;
auto display = getDisplay();
if (display != nullptr && display->supportsBacklightDuty()) {
if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) {
if (displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
}
} else {
if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) {
if (!lvgl::lock(100)) {
return; // Retry on next tick
}
activateScreensaver();
lvgl::unlock();
// Turn off backlight for "None" screensaver (just black screen)
if (cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) {
display->setBacklightDuty(0);
}
displayDimmed = true;
} else if (displayDimmed && (inactive_ms < kWakeActivityThresholdMs)) {
stopScreensaver();
}
}
}
}
bool DisplayIdleService::onStart(ServiceContext& service) {
// Seed random number generator for varied screensaver patterns
srand(static_cast<unsigned int>(time(nullptr)));
cachedDisplaySettings = settings::display::loadOrGetDefault();
timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::millisToTicks(TICK_INTERVAL_MS), [this]{ this->tick(); });
timer->setCallbackPriority(Thread::Priority::Lower);
timer->start();
return true;
}
void DisplayIdleService::onStop(ServiceContext& service) {
if (timer) {
timer->stop();
timer = nullptr;
}
if (screensaverOverlay) {
// Retry screensaver cleanup during shutdown
constexpr int maxRetries = 5;
for (int i = 0; i < maxRetries && screensaverOverlay; ++i) {
stopScreensaver();
if (screensaverOverlay && i < maxRetries - 1) {
kernel::delayMillis(50); // Brief delay before retry
}
}
if (screensaverOverlay) {
LOGGER.warn("Failed to stop screensaver during shutdown - potential resource leak");
}
}
screensaver.reset();
}
void DisplayIdleService::startScreensaver() {
if (!lvgl::lock(100)) {
return;
}
void onStop(ServiceContext& service) override {
if (timer) {
timer->stop();
timer = nullptr;
}
// Ensure display restored on stop
auto display = getDisplay();
if (display && displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
}
// Reload settings to get current screensaver type
// Note: This is safe because we hold the LVGL lock which serializes with tick()
cachedDisplaySettings = settings::display::loadOrGetDefault();
activateScreensaver();
lvgl::unlock();
// Turn off backlight for "None" screensaver
auto display = getDisplay();
if (display && cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) {
display->setBacklightDuty(0);
}
};
displayDimmed = true;
}
bool DisplayIdleService::isScreensaverActive() const {
return screensaverOverlay != nullptr;
}
void DisplayIdleService::reloadSettings() {
// Set flag for thread-safe reload - actual reload happens in tick()
settingsReloadRequested.store(true, std::memory_order_release);
}
std::shared_ptr<DisplayIdleService> findService() {
return std::static_pointer_cast<DisplayIdleService>(
findServiceById("DisplayIdle")
);
}
extern const ServiceManifest manifest = {
.id = "DisplayIdle",
@@ -93,3 +267,5 @@ extern const ServiceManifest manifest = {
};
}
#endif // ESP_PLATFORM
@@ -0,0 +1,320 @@
#ifdef ESP_PLATFORM
#include "MatrixRainScreensaver.h"
#include <cstdlib>
#include <algorithm>
#include <cassert>
namespace tt::service::displayidle {
static_assert(MatrixRainScreensaver::MAX_TRAIL_LENGTH > MatrixRainScreensaver::MIN_TRAIL_LENGTH,
"MAX_TRAIL_LENGTH must be greater than MIN_TRAIL_LENGTH");
static_assert(MatrixRainScreensaver::MAX_TICK_DELAY >= MatrixRainScreensaver::MIN_TICK_DELAY,
"MAX_TICK_DELAY must be >= MIN_TICK_DELAY");
static constexpr int TRAIL_LENGTH_RANGE = MatrixRainScreensaver::MAX_TRAIL_LENGTH - MatrixRainScreensaver::MIN_TRAIL_LENGTH;
static constexpr int TICK_DELAY_RANGE = MatrixRainScreensaver::MAX_TICK_DELAY - MatrixRainScreensaver::MIN_TICK_DELAY + 1;
char MatrixRainScreensaver::randomChar() {
static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*<>?";
return chars[rand() % (sizeof(chars) - 1)];
}
lv_color_t MatrixRainScreensaver::getTrailColor(int index, int trailLength) const {
if (trailLength <= 1) {
return lv_color_hex(COLOR_GRADIENT[0]);
}
float intensity = static_cast<float>(index) / static_cast<float>(trailLength - 1);
int colorIndex = static_cast<int>(intensity * (COLOR_GRADIENT.size() - 1) + 0.5f);
if (colorIndex < 0) colorIndex = 0;
if (colorIndex >= static_cast<int>(COLOR_GRADIENT.size())) {
colorIndex = COLOR_GRADIENT.size() - 1;
}
return lv_color_hex(COLOR_GRADIENT[colorIndex]);
}
int MatrixRainScreensaver::getRandomAvailableColumn() {
if (availableColumns_.empty()) {
return -1;
}
int idx = rand() % availableColumns_.size();
int column = availableColumns_[idx];
// O(1) removal: swap with last element and pop
availableColumns_[idx] = availableColumns_.back();
availableColumns_.pop_back();
return column;
}
void MatrixRainScreensaver::releaseColumn(int column) {
// Add column back to available pool if not already present (duplicate protection)
bool alreadyPresent = std::find(availableColumns_.begin(), availableColumns_.end(), column) != availableColumns_.end();
assert(!alreadyPresent && "Column released twice - indicates bug in column management");
if (!alreadyPresent) {
availableColumns_.push_back(column);
}
}
void MatrixRainScreensaver::createRainCharLabels(RainChar& rc, int trailIndex, int trailLength) {
if (overlay_ == nullptr) {
rc.label = nullptr;
rc.glowLabel = nullptr;
return;
}
rc.ch = randomChar();
// Create glow layer first (behind main text)
rc.glowLabel = lv_label_create(overlay_);
if (rc.glowLabel == nullptr) {
rc.label = nullptr;
return;
}
lv_obj_set_style_text_font(rc.glowLabel, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(rc.glowLabel, lv_color_hex(COLOR_GLOW), 0);
lv_obj_set_style_opa(rc.glowLabel, LV_OPA_70, 0);
// Create main character label
rc.label = lv_label_create(overlay_);
if (rc.label == nullptr) {
lv_obj_delete(rc.glowLabel);
rc.glowLabel = nullptr;
return;
}
lv_obj_set_style_text_font(rc.label, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(rc.label, getTrailColor(trailIndex, trailLength), 0);
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
// Hide initially
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
void MatrixRainScreensaver::initRaindrop(Raindrop& drop, int column) {
drop.column = column;
drop.trailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE;
drop.headRow = -(drop.trailLength); // Start above screen
drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE;
drop.tickCounter = 0;
drop.active = true;
// Create character labels for trail
drop.chars.resize(drop.trailLength);
for (int i = 0; i < drop.trailLength; i++) {
createRainCharLabels(drop.chars[i], i, drop.trailLength);
}
}
void MatrixRainScreensaver::resetRaindrop(Raindrop& drop) {
releaseColumn(drop.column);
int newColumn = getRandomAvailableColumn();
if (newColumn < 0) {
drop.active = false;
return;
}
drop.column = newColumn;
drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE;
drop.tickCounter = 0;
int newTrailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE;
// Resize if needed
if (newTrailLength != drop.trailLength) {
// Delete excess labels
for (size_t i = newTrailLength; i < drop.chars.size(); i++) {
if (drop.chars[i].label) {
lv_obj_delete(drop.chars[i].label);
}
if (drop.chars[i].glowLabel) {
lv_obj_delete(drop.chars[i].glowLabel);
}
}
size_t oldSize = drop.chars.size();
drop.chars.resize(newTrailLength);
// Create new labels if needed (will be refreshed below)
for (size_t i = oldSize; i < drop.chars.size(); i++) {
createRainCharLabels(drop.chars[i], static_cast<int>(i), newTrailLength);
}
drop.trailLength = newTrailLength;
}
// Reset position above screen
drop.headRow = -(drop.trailLength);
// Refresh characters and colors for all labels
for (int i = 0; i < drop.trailLength; i++) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
rc.ch = randomChar();
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
lv_obj_set_style_text_color(rc.label, getTrailColor(i, drop.trailLength), 0);
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
drop.active = true;
}
void MatrixRainScreensaver::updateRaindropDisplay(Raindrop& drop) {
int xPos = drop.column * CHAR_WIDTH;
for (int i = 0; i < drop.trailLength; i++) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
int row = drop.headRow - i;
int yPos = row * CHAR_HEIGHT;
// Only show characters that are on screen (grid-snapped positions)
if (row >= 0 && row < numRows_) {
lv_obj_set_pos(rc.label, xPos, yPos);
lv_obj_set_pos(rc.glowLabel, xPos + GLOW_OFFSET, yPos + GLOW_OFFSET);
} else {
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
}
}
void MatrixRainScreensaver::flickerRandomChars() {
constexpr int ERROR_PERCENT = 3;
for (auto& drop : drops_) {
if (!drop.active) continue;
for (int i = 0; i < drop.trailLength; i++) {
int row = drop.headRow - i;
// Only process visible characters
if (row >= 0 && row < numRows_) {
if (rand() % 100 < ERROR_PERCENT) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
rc.ch = randomChar();
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
}
}
}
}
}
void MatrixRainScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
if (!drops_.empty()) {
stop();
}
overlay_ = overlay;
screenW_ = screenW;
screenH_ = screenH;
globalFlickerCounter_ = 0;
numColumns_ = screenW / CHAR_WIDTH;
numRows_ = screenH / CHAR_HEIGHT;
availableColumns_.clear();
for (int i = 0; i < numColumns_; i++) {
availableColumns_.push_back(i);
}
int numDrops = std::min(MAX_ACTIVE_DROPS, numColumns_);
drops_.resize(numDrops);
for (int i = 0; i < numDrops; i++) {
int col = getRandomAvailableColumn();
if (col >= 0) {
initRaindrop(drops_[i], col);
// Stagger start positions (guard against tiny screens)
int staggerRange = numRows_ / 2;
if (staggerRange > 0) {
drops_[i].headRow = -(rand() % staggerRange);
}
}
}
}
void MatrixRainScreensaver::stop() {
// Explicitly delete labels to prevent resource leaks
for (auto& drop : drops_) {
for (auto& rc : drop.chars) {
if (rc.label) {
lv_obj_delete(rc.label);
rc.label = nullptr;
}
if (rc.glowLabel) {
lv_obj_delete(rc.glowLabel);
rc.glowLabel = nullptr;
}
}
drop.chars.clear();
}
drops_.clear();
availableColumns_.clear();
overlay_ = nullptr;
}
void MatrixRainScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
// Screen dimensions captured at start() - no dynamic resize support during screensaver
LV_UNUSED(screenW);
LV_UNUSED(screenH);
// Global glitch effect
globalFlickerCounter_++;
if (globalFlickerCounter_ >= 5) {
globalFlickerCounter_ = 0;
flickerRandomChars();
}
for (auto& drop : drops_) {
if (!drop.active) continue;
// Tick-based movement (terminal style - advance by row)
drop.tickCounter++;
if (drop.tickCounter >= drop.tickDelay) {
drop.tickCounter = 0;
// Advance head down by one row
drop.headRow++;
// Randomly change the head character when it advances
if (!drop.chars.empty() && drop.chars[0].label != nullptr && drop.chars[0].glowLabel != nullptr) {
drop.chars[0].ch = randomChar();
char text[2] = {drop.chars[0].ch, '\0'};
lv_label_set_text(drop.chars[0].label, text);
lv_label_set_text(drop.chars[0].glowLabel, text);
}
// Check if tail has completely left the screen
int tailRow = drop.headRow - (drop.trailLength - 1);
if (tailRow >= numRows_) {
resetRaindrop(drop);
continue;
}
}
updateRaindropDisplay(drop);
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -0,0 +1,86 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <vector>
#include <cstdint>
namespace tt::service::displayidle {
class MatrixRainScreensaver final : public Screensaver {
public:
MatrixRainScreensaver() = default;
MatrixRainScreensaver(const MatrixRainScreensaver&) = delete;
MatrixRainScreensaver& operator=(const MatrixRainScreensaver&) = delete;
MatrixRainScreensaver(MatrixRainScreensaver&&) = delete;
MatrixRainScreensaver& operator=(MatrixRainScreensaver&&) = delete;
static constexpr int CHAR_WIDTH = 12;
static constexpr int CHAR_HEIGHT = 16;
static constexpr int MIN_TRAIL_LENGTH = 8;
static constexpr int MAX_TRAIL_LENGTH = 20;
static constexpr int MAX_ACTIVE_DROPS = 16;
static constexpr int MIN_TICK_DELAY = 2; // Fastest: advance every 2 frames
static constexpr int MAX_TICK_DELAY = 5; // Slowest: advance every 5 frames
static constexpr int GLOW_OFFSET = 2;
~MatrixRainScreensaver() override = default;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
// A single character cell in the rain
struct RainChar {
lv_obj_t* label = nullptr;
lv_obj_t* glowLabel = nullptr;
char ch = ' ';
};
// A raindrop - now uses integer row positions (terminal-style grid)
struct Raindrop {
int headRow = 0; // Current head row position (grid-based)
int column = 0; // Column index
int trailLength = 10;
int tickDelay = 3; // Frames between row advances
int tickCounter = 0; // Current tick count
std::vector<RainChar> chars;
bool active = false;
};
std::vector<Raindrop> drops_;
std::vector<int> availableColumns_;
lv_obj_t* overlay_ = nullptr;
lv_coord_t screenW_ = 0;
lv_coord_t screenH_ = 0;
int numColumns_ = 0;
int numRows_ = 0;
int globalFlickerCounter_ = 0;
// Six-color gradient palette
static constexpr std::array<uint32_t, 6> COLOR_GRADIENT = {
0xAAFFDD, // 0: Bright cyan-white (head)
0x00FF66, // 1: Bright green
0x00DD44, // 2: Medium-bright green
0x00AA33, // 3: Medium green
0x006622, // 4: Dim green
0x003311, // 5: Dark green (tail end)
};
static constexpr uint32_t COLOR_GLOW = 0x002208;
void initRaindrop(Raindrop& drop, int column);
void resetRaindrop(Raindrop& drop);
void updateRaindropDisplay(Raindrop& drop);
void flickerRandomChars();
void createRainCharLabels(RainChar& rc, int trailIndex, int trailLength);
lv_color_t getTrailColor(int index, int trailLength) const;
static char randomChar();
int getRandomAvailableColumn();
void releaseColumn(int column);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -0,0 +1,209 @@
#ifdef ESP_PLATFORM
#include "MystifyScreensaver.h"
#include <cmath>
#include <cstdlib>
namespace tt::service::displayidle {
// Helper to generate random float in range [min, max]
static float randomFloat(float min, float max) {
if (max <= min) return min;
return min + (max - min) * static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
void MystifyScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
for (auto& polygon : polygons_) {
initPolygon(polygon, overlay, screenW, screenH);
}
}
void MystifyScreensaver::stop() {
for (auto& polygon : polygons_) {
for (auto& trailLines : polygon.lines) {
for (auto& line : trailLines) {
line = nullptr; // Objects deleted by parent overlay
}
}
polygon.historyHead = 0;
polygon.historyFull = false;
}
}
void MystifyScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
constexpr float minSpeed = 0.5f;
constexpr float maxSpeed = 2.5f;
for (auto& polygon : polygons_) {
// Periodic color change
polygon.colorChangeCounter++;
if (polygon.colorChangeCounter >= COLOR_CHANGE_INTERVAL) {
polygon.colorChangeCounter = 0;
uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()];
polygon.color = lv_color_hex(colorHex);
updateLineColors(polygon);
}
// Move vertices
for (auto& vertex : polygon.vertices) {
vertex.x += vertex.dx;
vertex.y += vertex.dy;
// Bounce off edges with slight angle variation for more organic movement
if (vertex.x <= 0) {
vertex.x = 0;
vertex.dx = std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f);
} else if (vertex.x >= screenW - 1) {
vertex.x = static_cast<float>(screenW - 1);
vertex.dx = -std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f);
}
if (vertex.y <= 0) {
vertex.y = 0;
vertex.dy = std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f);
} else if (vertex.y >= screenH - 1) {
vertex.y = static_cast<float>(screenH - 1);
vertex.dy = -std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f);
}
// Clamp speeds to prevent runaway acceleration or stalling
if (std::fabs(vertex.dx) < minSpeed) vertex.dx = (vertex.dx >= 0 ? minSpeed : -minSpeed);
if (std::fabs(vertex.dy) < minSpeed) vertex.dy = (vertex.dy >= 0 ? minSpeed : -minSpeed);
if (std::fabs(vertex.dx) > maxSpeed) vertex.dx = (vertex.dx >= 0 ? maxSpeed : -maxSpeed);
if (std::fabs(vertex.dy) > maxSpeed) vertex.dy = (vertex.dy >= 0 ? maxSpeed : -maxSpeed);
}
// Shift history
int newHead = polygon.historyHead + 1;
if (newHead >= TRAIL_LENGTH) {
newHead = 0;
polygon.historyFull = true;
}
polygon.historyHead = newHead;
// Store current positions at head
for (int v = 0; v < NUM_VERTICES; v++) {
polygon.history[newHead][v].x = polygon.vertices[v].x;
polygon.history[newHead][v].y = polygon.vertices[v].y;
}
// Pre-calculate history indices
int histIndices[TRAIL_LENGTH];
for (int t = 0; t < TRAIL_LENGTH; t++) {
int idx = newHead - t;
histIndices[t] = (idx >= 0) ? idx : idx + TRAIL_LENGTH;
}
// Calculate valid trail count
int validTrailCount = polygon.historyFull ? TRAIL_LENGTH : (newHead + 1);
// Update line positions
for (int t = 0; t < TRAIL_LENGTH; t++) {
auto& trailLines = polygon.lines[t];
// Hide lines beyond valid history
if (t >= validTrailCount) {
for (int e = 0; e < NUM_VERTICES; e++) {
if (trailLines[e]) {
lv_obj_add_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN);
}
}
continue;
}
int histIndex = histIndices[t];
auto& hist = polygon.history[histIndex];
auto& linePointsTrail = polygon.linePoints[t];
for (int e = 0; e < NUM_VERTICES; e++) {
if (!trailLines[e]) continue;
lv_obj_remove_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN);
int v2 = (e + 1) % NUM_VERTICES;
linePointsTrail[e][0].x = hist[e].x;
linePointsTrail[e][0].y = hist[e].y;
linePointsTrail[e][1].x = hist[v2].x;
linePointsTrail[e][1].y = hist[v2].y;
lv_line_set_points(trailLines[e], linePointsTrail[e].data(), 2);
}
}
}
}
void MystifyScreensaver::initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH) {
// Sanity check - avoid undefined behavior from modulo by zero
if (screenW <= 0 || screenH <= 0) return;
// Pick a random color from the pool
uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()];
polygon.color = lv_color_hex(colorHex);
polygon.historyHead = 0;
polygon.historyFull = false;
// Stagger color changes so polygons don't all change at once
polygon.colorChangeCounter = rand() % COLOR_CHANGE_INTERVAL;
// Initialize vertices with random positions and velocities
for (int v = 0; v < NUM_VERTICES; v++) {
auto& vertex = polygon.vertices[v];
vertex.x = static_cast<float>(rand() % screenW);
vertex.y = static_cast<float>(rand() % screenH);
// Slower speeds (0.8-2.0) for smooth movement
vertex.dx = randomFloat(0.8f, 2.0f);
vertex.dy = randomFloat(0.8f, 2.0f);
if (rand() % 2) vertex.dx = -vertex.dx;
if (rand() % 2) vertex.dy = -vertex.dy;
// Ensure dx != dy for more interesting movement patterns
if (std::fabs(std::fabs(vertex.dx) - std::fabs(vertex.dy)) < 0.3f) {
vertex.dy += (vertex.dy > 0 ? 0.5f : -0.5f);
}
}
// Initialize history with current positions
for (int t = 0; t < TRAIL_LENGTH; t++) {
for (int v = 0; v < NUM_VERTICES; v++) {
polygon.history[t][v].x = polygon.vertices[v].x;
polygon.history[t][v].y = polygon.vertices[v].y;
}
}
// Create line objects with pre-computed opacity values
for (int t = 0; t < TRAIL_LENGTH; t++) {
int opacity = LV_OPA_COVER - (t * LV_OPA_COVER / TRAIL_LENGTH);
if (opacity < LV_OPA_10) opacity = LV_OPA_10;
for (int e = 0; e < NUM_VERTICES; e++) {
lv_obj_t* line = lv_line_create(parent);
if (line == nullptr) {
polygon.lines[t][e] = nullptr;
continue;
}
lv_obj_remove_style_all(line);
lv_obj_set_style_line_color(line, polygon.color, 0);
lv_obj_set_style_line_opa(line, opacity, 0);
lv_obj_set_style_line_width(line, 2, 0);
polygon.lines[t][e] = line;
}
}
}
void MystifyScreensaver::updateLineColors(Polygon& polygon) {
// Update all line colors when color changes
for (int t = 0; t < TRAIL_LENGTH; t++) {
for (int e = 0; e < NUM_VERTICES; e++) {
if (polygon.lines[t][e]) {
lv_obj_set_style_line_color(polygon.lines[t][e], polygon.color, 0);
}
}
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -0,0 +1,72 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <cstdint>
namespace tt::service::displayidle {
class MystifyScreensaver final : public Screensaver {
public:
static constexpr int NUM_POLYGONS = 2;
static constexpr int NUM_VERTICES = 4;
static constexpr int TRAIL_LENGTH = 8;
static constexpr int COLOR_CHANGE_INTERVAL = 200; // Frames between color changes (~10s at 20fps)
~MystifyScreensaver() override = default;
MystifyScreensaver() = default;
MystifyScreensaver(const MystifyScreensaver&) = delete;
MystifyScreensaver& operator=(const MystifyScreensaver&) = delete;
MystifyScreensaver(MystifyScreensaver&&) = delete;
MystifyScreensaver& operator=(MystifyScreensaver&&) = delete;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
// Use floats for smooth sub-pixel movement
struct Vertex {
float x = 0;
float y = 0;
float dx = 0;
float dy = 0;
};
struct Polygon {
std::array<Vertex, NUM_VERTICES> vertices;
// History: [trail_index][vertex_index] = {x, y}
std::array<std::array<lv_point_precise_t, NUM_VERTICES>, TRAIL_LENGTH> history;
// Line objects: [trail_index][edge_index]
std::array<std::array<lv_obj_t*, NUM_VERTICES>, TRAIL_LENGTH> lines{};
// Persistent point arrays for each line (required by lv_line_set_points)
std::array<std::array<std::array<lv_point_precise_t, 2>, NUM_VERTICES>, TRAIL_LENGTH> linePoints;
lv_color_t color = {};
int historyHead = 0;
bool historyFull = false;
int colorChangeCounter = 0;
};
// Vibrant color pool for random selection
static constexpr std::array<uint32_t, 8> COLOR_POOL = {
0xFF00FF, // Magenta
0x00FFFF, // Cyan
0xFFFF00, // Yellow
0xFF6600, // Orange
0x00FF66, // Spring green
0x6600FF, // Purple
0xFF0066, // Hot pink
0x66FF00, // Lime
};
std::array<Polygon, NUM_POLYGONS> polygons_;
void initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH);
void updateLineColors(Polygon& polygon);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -0,0 +1,48 @@
#pragma once
#ifdef ESP_PLATFORM
#include <lvgl.h>
namespace tt::service::displayidle {
/**
* Base class for screensaver implementations.
* Screensavers are responsible for creating their visual elements
* on the provided overlay and updating them each tick.
*/
class Screensaver {
public:
Screensaver() = default;
virtual ~Screensaver() = default;
Screensaver(const Screensaver&) = delete;
Screensaver& operator=(const Screensaver&) = delete;
Screensaver(Screensaver&&) = delete;
Screensaver& operator=(Screensaver&&) = delete;
/**
* Start the screensaver, creating visual elements on the overlay.
* @param overlay The parent LVGL object to create elements on
* @param screenW Screen width in pixels
* @param screenH Screen height in pixels
*/
virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0;
/**
* Stop the screensaver, cleaning up any internal state.
* Note: LVGL objects are deleted by the parent overlay, but any
* internal references should be cleared here.
*/
virtual void stop() = 0;
/**
* Update the screensaver animation for one frame.
* Called periodically while the screensaver is active.
* @param screenW Screen width in pixels
* @param screenH Screen height in pixels
*/
virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0;
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM
@@ -32,7 +32,7 @@ class KeyboardIdleService final : public Service {
// Query LVGL inactivity once for both checks
uint32_t inactive_ms = 0;
if (lvgl::lock(100)) {
inactive_ms = lv_disp_get_inactive_time(nullptr);
inactive_ms = lv_display_get_inactive_time(nullptr);
lvgl::unlock();
}