From 3e71e74c668d449fc0836b7f191dad5d7179a11c Mon Sep 17 00:00:00 2001 From: dpoulter Date: Thu, 8 Jan 2026 17:38:24 +0100 Subject: [PATCH] Add sleep BMP selection feature - Add selectedSleepBmp field to CrossPointSettings to store selected BMP filename - Create SleepBmpSelectionActivity for selecting BMP files from /sleep folder - Add conditional 'Select Sleep BMP' setting that only shows when /sleep folder has BMPs - Modify SleepActivity to use selected BMP instead of random selection when set - Cache sleep BMP check result to avoid mutex conflicts during rendering --- src/CrossPointSettings.cpp | 10 +- src/CrossPointSettings.h | 2 + src/activities/boot_sleep/SleepActivity.cpp | 21 +- src/activities/settings/SettingsActivity.cpp | 92 +++++++-- src/activities/settings/SettingsActivity.h | 1 + .../settings/SleepBmpSelectionActivity.cpp | 191 ++++++++++++++++++ .../settings/SleepBmpSelectionActivity.h | 34 ++++ 7 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 src/activities/settings/SleepBmpSelectionActivity.cpp create mode 100644 src/activities/settings/SleepBmpSelectionActivity.h diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index cd8b56f7..80ced05e 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 17; +constexpr uint8_t SETTINGS_COUNT = 18; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -46,6 +46,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); + serialization::writeString(outputFile, std::string(selectedSleepBmp)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -110,6 +111,13 @@ bool CrossPointSettings::loadFromFile() { } serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; + { + std::string bmpStr; + serialization::readString(inputFile, bmpStr); + strncpy(selectedSleepBmp, bmpStr.c_str(), sizeof(selectedSleepBmp) - 1); + selectedSleepBmp[sizeof(selectedSleepBmp) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 3a2a3503..b79d4e97 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -82,6 +82,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + // Selected sleep BMP filename (empty means random selection) + char selectedSleepBmp[256] = ""; ~CrossPointSettings() = default; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 43e8e60b..f025fa2a 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -45,12 +45,28 @@ void SleepActivity::renderPopup(const char* message) const { } void SleepActivity::renderCustomSleepScreen() const { - // Check if we have a /sleep directory auto dir = SdMan.open("/sleep"); if (dir && dir.isDirectory()) { + if (SETTINGS.selectedSleepBmp[0] != '\0') { + const std::string selectedFile = std::string(SETTINGS.selectedSleepBmp); + const std::string filename = "/sleep/" + selectedFile; + FsFile file; + if (SdMan.openFileForRead("SLP", filename, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + Serial.printf("[%lu] [SLP] Loading selected: /sleep/%s\n", millis(), selectedFile.c_str()); + delay(100); + renderBitmapSleepScreen(bitmap); + dir.close(); + return; + } + file.close(); + } + Serial.printf("[%lu] [SLP] Selected BMP not found or invalid, falling back to random\n", millis()); + } + std::vector files; char name[500]; - // collect all valid BMP files for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { if (file.isDirectory()) { file.close(); @@ -79,7 +95,6 @@ void SleepActivity::renderCustomSleepScreen() const { } const auto numFiles = files.size(); if (numFiles > 0) { - // Generate a random number between 1 and numFiles const auto randomFileIndex = random(numFiles); const auto filename = "/sleep/" + files[randomFileIndex]; FsFile file; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 702db172..7dd491d2 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -5,19 +5,23 @@ #include +#include + #include "CalibreSettingsActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "SleepBmpSelectionActivity.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 18; +constexpr int settingsCount = 19; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), + SettingInfo::Action("Select Sleep BMP"), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), @@ -43,6 +47,40 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Action("Check for updates")}; } // namespace +namespace { +bool checkSleepBmps() { + auto dir = SdMan.open("/sleep"); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return false; + } + + dir.rewindDirectory(); + char name[500]; + bool foundBmp = false; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (file.isDirectory()) { + file.close(); + continue; + } + file.getName(name, sizeof(name)); + auto filename = std::string(name); + if (filename[0] == '.') { + file.close(); + continue; + } + + if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".bmp") { + foundBmp = true; + } + file.close(); + if (foundBmp) break; + } + dir.close(); + return foundBmp; +} +} // namespace + void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -52,10 +90,9 @@ void SettingsActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); - // Reset selection to first item - selectedSettingIndex = 0; + hasSleepBmpsCached = checkSleepBmps(); - // Trigger first update + selectedSettingIndex = 0; updateRequired = true; xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask", @@ -99,26 +136,30 @@ void SettingsActivity::loop() { } // Handle navigation + const int visibleCount = hasSleepBmpsCached ? settingsCount : settingsCount - 1; if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { // Move selection up (with wrap-around) - selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (visibleCount - 1); updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { // Move selection down (with wrap around) - selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; + selectedSettingIndex = (selectedSettingIndex < visibleCount - 1) ? (selectedSettingIndex + 1) : 0; updateRequired = true; } } void SettingsActivity::toggleCurrentSetting() { - // Validate index - if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { + int listIndex = selectedSettingIndex; + if (!hasSleepBmpsCached && selectedSettingIndex >= 2) { + listIndex = selectedSettingIndex + 1; + } + if (listIndex < 0 || listIndex >= settingsCount) { return; } - const auto& setting = settingsList[selectedSettingIndex]; + const auto& setting = settingsList[listIndex]; if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { // Toggle the boolean value using the member pointer @@ -153,6 +194,14 @@ void SettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Select Sleep BMP") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new SleepBmpSelectionActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } } else { // Only toggle if it's a toggle type and has a value pointer @@ -184,15 +233,21 @@ void SettingsActivity::render() const { // Draw header renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD); + const int visibleCount = hasSleepBmpsCached ? settingsCount : settingsCount - 1; + // Draw selection renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); - // Draw all settings + int visibleIndex = 0; for (int i = 0; i < settingsCount; i++) { - const int settingY = 60 + i * 30; // 30 pixels between settings + if (i == 2 && !hasSleepBmpsCached) { + continue; + } + + const int settingY = 60 + visibleIndex * 30; // 30 pixels between settings // Draw setting name - renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, i != selectedSettingIndex); + renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, visibleIndex != selectedSettingIndex); // Draw value based on setting type std::string valueText = ""; @@ -204,9 +259,20 @@ void SettingsActivity::render() const { valueText = settingsList[i].enumValues[value]; } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } else if (settingsList[i].type == SettingType::ACTION && strcmp(settingsList[i].name, "Select Sleep BMP") == 0) { + if (SETTINGS.selectedSleepBmp[0] != '\0') { + valueText = SETTINGS.selectedSleepBmp; + if (valueText.length() > 20) { + valueText = valueText.substr(0, 17) + "..."; + } + } else { + valueText = "Random"; + } } const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), visibleIndex != selectedSettingIndex); + + visibleIndex++; } // Draw version text above button hints diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 157689e3..8a469b6b 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -49,6 +49,7 @@ class SettingsActivity final : public ActivityWithSubactivity { SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; int selectedSettingIndex = 0; // Currently selected setting + bool hasSleepBmpsCached = false; // Cached result of sleep BMP check const std::function onGoHome; static void taskTrampoline(void* param); diff --git a/src/activities/settings/SleepBmpSelectionActivity.cpp b/src/activities/settings/SleepBmpSelectionActivity.cpp new file mode 100644 index 00000000..c6095f28 --- /dev/null +++ b/src/activities/settings/SleepBmpSelectionActivity.cpp @@ -0,0 +1,191 @@ +#include "SleepBmpSelectionActivity.h" + +#include +#include + +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +#include "../../../lib/GfxRenderer/Bitmap.h" + +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +constexpr unsigned long IGNORE_INPUT_MS = 300; // Ignore input for 300ms after entering + +void sortFileList(std::vector& strs) { + std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { + return lexicographical_compare(begin(str1), end(str1), begin(str2), end(str2), + [](const char& char1, const char& char2) { + return tolower(char1) < tolower(char2); + }); + }); +} +} // namespace + +void SleepBmpSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void SleepBmpSelectionActivity::loadFiles() { + files.clear(); + + auto dir = SdMan.open("/sleep"); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + dir.rewindDirectory(); + + char name[500]; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (file.isDirectory()) { + file.close(); + continue; + } + file.getName(name, sizeof(name)); + auto filename = std::string(name); + if (filename[0] == '.') { + file.close(); + continue; + } + + if (filename.substr(filename.length() - 4) != ".bmp") { + file.close(); + continue; + } + + Bitmap bitmap(file); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + file.close(); + continue; + } + file.close(); + + files.emplace_back(filename); + } + dir.close(); + sortFileList(files); +} + +void SleepBmpSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + loadFiles(); + selectorIndex = 0; + enterTime = millis(); + + updateRequired = true; + + xTaskCreate(&SleepBmpSelectionActivity::taskTrampoline, "SleepBmpSelectionActivityTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void SleepBmpSelectionActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + files.clear(); +} + +void SleepBmpSelectionActivity::loop() { + const unsigned long timeSinceEnter = millis() - enterTime; + if (timeSinceEnter < IGNORE_INPUT_MS) { + return; + } + + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (files.empty()) { + return; + } + + const std::string selectedFile = files[selectorIndex]; + strncpy(SETTINGS.selectedSleepBmp, selectedFile.c_str(), sizeof(SETTINGS.selectedSleepBmp) - 1); + SETTINGS.selectedSleepBmp[sizeof(SETTINGS.selectedSleepBmp) - 1] = '\0'; + SETTINGS.saveToFile(); + + onBack(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + } else if (prevReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size(); + } else { + selectorIndex = (selectorIndex + files.size() - 1) % files.size(); + } + updateRequired = true; + } else if (nextReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size(); + } else { + selectorIndex = (selectorIndex + 1) % files.size(); + } + updateRequired = true; + } +} + +void SleepBmpSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void SleepBmpSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Sleep BMP", true, EpdFontFamily::BOLD); + + // Help text + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (files.empty()) { + renderer.drawText(UI_10_FONT_ID, 20, 60, "No BMP files found in /sleep"); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); + for (size_t i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { + auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), renderer.getScreenWidth() - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); + } + + renderer.displayBuffer(); +} + diff --git a/src/activities/settings/SleepBmpSelectionActivity.h b/src/activities/settings/SleepBmpSelectionActivity.h new file mode 100644 index 00000000..f1737d05 --- /dev/null +++ b/src/activities/settings/SleepBmpSelectionActivity.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +class SleepBmpSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + std::vector files; + size_t selectorIndex = 0; + bool updateRequired = false; + unsigned long enterTime = 0; // Time when activity was entered + const std::function onBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void loadFiles(); + + public: + explicit SleepBmpSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack) + : Activity("SleepBmpSelection", renderer, mappedInput), onBack(onBack) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; +