diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 333fa727..a19a1e8d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -59,7 +59,11 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: -- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen +- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: + - "Dark" (default) - The default dark sleep screen + - "Light" - The same default sleep screen, on a white background + - "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information + - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, paragraphs will not have vertical space between them, but will have first word indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. @@ -69,7 +73,12 @@ The Settings screen allows you to configure the device's behavior. There are a f You can customize the sleep screen by placing custom images in specific locations on the SD card: - **Single Image:** Place a file named `sleep.bmp` in the root directory. -- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be randomly selected each time the device sleeps. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images + inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be + randomly selected each time the device sleeps. + +> [!NOTE] +> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images. > [!TIP] > For best results: diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index cc3bc909..d959cb79 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,6 +1,7 @@ #include "Epub.h" #include +#include #include #include @@ -218,7 +219,45 @@ const std::string& Epub::getPath() const { return filepath; } const std::string& Epub::getTitle() const { return title; } -const std::string& Epub::getCoverImageItem() const { return coverImageItem; } +std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Epub::generateCoverBmp() const { + // Already generated, return true + if (SD.exists(getCoverBmpPath().c_str())) { + return true; + } + + if (coverImageItem.empty()) { + Serial.printf("[%lu] [EBP] No known cover image\n", millis()); + return false; + } + + if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || + coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); + File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); + readItemContentsToStream(coverImageItem, coverJpg, 1024); + coverJpg.close(); + + coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); + File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + SD.remove((getCachePath() + "/.cover.jpg").c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); + SD.remove(getCoverBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis()); + } + + return false; +} std::string normalisePath(const std::string& path) { std::vector components; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 31153035..381379c5 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -48,7 +48,8 @@ class Epub { const std::string& getCachePath() const; const std::string& getPath() const; const std::string& getTitle() const; - const std::string& getCoverImageItem() const; + std::string getCoverBmpPath() const; + bool generateCoverBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 0e0b0d61..c9ad6f85 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -128,7 +128,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { int bitShift = 6; // Helper lambda to pack 2bpp color into the output stream - auto packPixel = [&](uint8_t lum) { + auto packPixel = [&](const uint8_t lum) { uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -140,38 +140,49 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { } }; + uint8_t lum; + switch (bpp) { - case 8: { + case 32: { + const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - packPixel(paletteLum[rowBuffer[x]]); + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 4; } break; } case 24: { const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; packPixel(lum); p += 3; } break; } + case 8: { + for (int x = 0; x < width; x++) { + packPixel(paletteLum[rowBuffer[x]]); + } + break; + } + case 2: { + for (int x = 0; x < width; x++) { + lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + packPixel(lum); + } + break; + } case 1: { for (int x = 0; x < width; x++) { - uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; packPixel(lum); } break; } - case 32: { - const uint8_t* p = rowBuffer; - for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; - packPixel(lum); - p += 4; - } - break; - } + default: + return BmpReaderError::UnsupportedBpp; } // Flush remaining bits if width is not a multiple of 4 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 89590721..fe5e2a07 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -23,7 +23,7 @@ bool CrossPointSettings::saveToFile() const { std::ofstream outputFile(SETTINGS_FILE); serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); - serialization::writePod(outputFile, whiteSleepScreen); + serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); outputFile.close(); @@ -54,7 +54,7 @@ bool CrossPointSettings::loadFromFile() { // load settings that exist uint8_t settingsRead = 0; do { - serialization::readPod(inputFile, whiteSleepScreen); + serialization::readPod(inputFile, sleepScreen); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, extraParagraphSpacing); if (++settingsRead >= fileSettingsCount) break; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d6ad7667..14c33322 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -15,8 +15,11 @@ class CrossPointSettings { CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete; + // Should match with SettingsActivity text + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + // Sleep screen settings - uint8_t whiteSleepScreen = 0; + uint8_t sleepScreen = DARK; // Text rendering settings uint8_t extraParagraphSpacing = 1; // Duration of the power button press diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 900a0dbb..4200c4e9 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,16 +1,45 @@ #include "SleepActivity.h" +#include #include #include #include #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "config.h" #include "images/CrossLarge.h" void SleepActivity::onEnter() { renderPopup("Entering Sleep..."); + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { + return renderCustomSleepScreen(); + } + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) { + return renderCoverSleepScreen(); + } + + renderDefaultSleepScreen(); +} + +void SleepActivity::renderPopup(const char* message) const { + const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); + constexpr int margin = 20; + const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; + constexpr int y = 117; + const int w = textWidth + margin * 2; + const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; + // renderer.clearScreen(); + renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); + renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); + renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.displayBuffer(); +} + +void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = SD.open("/sleep"); if (dir && dir.isDirectory()) { @@ -28,31 +57,31 @@ void SleepActivity::onEnter() { } if (filename.substr(filename.length() - 4) != ".bmp") { - Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), file.name()); file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), file.name()); file.close(); continue; } files.emplace_back(filename); file.close(); } - int numFiles = files.size(); + const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - int randomFileIndex = random(numFiles); - auto filename = "/sleep/" + files[randomFileIndex]; + const auto randomFileIndex = random(numFiles); + const auto filename = "/sleep/" + files[randomFileIndex]; auto file = SD.open(filename.c_str()); if (file) { - Serial.printf("[%lu] [Slp] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); + Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - renderCustomSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap); dir.close(); return; } @@ -67,8 +96,8 @@ void SleepActivity::onEnter() { if (file) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis()); - renderCustomSleepScreen(bitmap); + Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); + renderBitmapSleepScreen(bitmap); return; } } @@ -76,41 +105,27 @@ void SleepActivity::onEnter() { renderDefaultSleepScreen(); } -void SleepActivity::renderPopup(const char* message) const { - const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); - constexpr int margin = 20; - const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 117; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; - // renderer.clearScreen(); - renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); - renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); - renderer.displayBuffer(); -} - void SleepActivity::renderDefaultSleepScreen() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); - // Apply white screen if enabled in settings - if (!SETTINGS.whiteSleepScreen) { + // Make sleep screen dark unless light is selected in settings + if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) { renderer.invertScreen(); } renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } -void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { // image will scale, make sure placement is right @@ -153,3 +168,26 @@ void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { renderer.setRenderMode(GfxRenderer::BW); } } + +void SleepActivity::renderCoverSleepScreen() const { + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastEpub.load()) { + Serial.println("[SLP] Failed to load last epub"); + return renderDefaultSleepScreen(); + } + if (!lastEpub.generateCoverBmp()) { + Serial.println("[SLP] Failed to generate cover bmp"); + return renderDefaultSleepScreen(); + } + + auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); + if (file) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + return; + } + } + + renderDefaultSleepScreen(); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index defc1d5e..21121994 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -9,7 +9,9 @@ class SleepActivity final : public Activity { void onEnter() override; private: - void renderDefaultSleepScreen() const; - void renderCustomSleepScreen(const Bitmap& bitmap) const; void renderPopup(const char* message) const; + void renderDefaultSleepScreen() const; + void renderCustomSleepScreen() const; + void renderCoverSleepScreen() const; + void renderBitmapSleepScreen(const Bitmap& bitmap) const; }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b3acf3ff..29f68762 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,11 +6,14 @@ #include "config.h" // Define the static settings list - -const SettingInfo SettingsActivity::settingsList[settingsCount] = { - {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, - {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}}; +namespace { +constexpr int settingsCount = 3; +const SettingInfo settingsList[settingsCount] = { + // Should match with SLEEP_SCREEN_MODE + {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; +} // namespace void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); @@ -81,15 +84,18 @@ void SettingsActivity::toggleCurrentSetting() { const auto& setting = settingsList[selectedSettingIndex]; - // Only toggle if it's a toggle type and has a value pointer - if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) { + if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { + // Toggle the boolean value using the member pointer + const bool currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentValue; + } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { + const uint8_t currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else { + // Only toggle if it's a toggle type and has a value pointer return; } - // Toggle the boolean value using the member pointer - bool currentValue = SETTINGS.*(setting.valuePtr); - SETTINGS.*(setting.valuePtr) = !currentValue; - // Save settings when they change SETTINGS.saveToFile(); } @@ -129,8 +135,13 @@ void SettingsActivity::render() const { // Draw value based on setting type if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - bool value = SETTINGS.*(settingsList[i].valuePtr); + const bool value = SETTINGS.*(settingsList[i].valuePtr); renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); + auto valueText = settingsList[i].enumValues[value]; + const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str()); + renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str()); } } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 7843a5cf..333f467c 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -12,13 +12,14 @@ class CrossPointSettings; -enum class SettingType { TOGGLE }; +enum class SettingType { TOGGLE, ENUM }; // Structure to hold setting information struct SettingInfo { const char* name; // Display name of the setting SettingType type; // Type of setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE) + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM) + std::vector enumValues; }; class SettingsActivity final : public Activity { @@ -28,10 +29,6 @@ class SettingsActivity final : public Activity { int selectedSettingIndex = 0; // Currently selected setting const std::function onGoHome; - // Static settings list - static constexpr int settingsCount = 3; // Number of settings - static const SettingInfo settingsList[settingsCount]; - static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const;