diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 478a738b..f40fed36 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -38,6 +38,11 @@ The device utilises the standard buttons on the Xteink X4 (in the same layout as Button layout can be customized in **[Settings](#35-settings)**. +### Taking a Screenshot +When the Power Button and Volume Down button are pressed at the same time, it will take a screenshot and save it in the folder `screenshots/`. + +Alternatively, while reading a book, press the **Confirm** button to open the reader menu and select **Take screenshot**. + --- ## 2. Power & Startup diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index df6aef29..bba4189f 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -6,6 +6,38 @@ #include "BitmapHelpers.h" +#pragma pack(push, 1) +struct BmpHeader { + struct { + uint16_t bfType; + uint32_t bfSize; + uint16_t bfReserved1; + uint16_t bfReserved2; + uint32_t bfOffBits; + } fileHeader; + struct { + uint32_t biSize; + int32_t biWidth; + int32_t biHeight; + uint16_t biPlanes; + uint16_t biBitCount; + uint32_t biCompression; + uint32_t biSizeImage; + int32_t biXPelsPerMeter; + int32_t biYPelsPerMeter; + uint32_t biClrUsed; + uint32_t biClrImportant; + } infoHeader; + struct RgbQuad { + uint8_t rgbBlue; + uint8_t rgbGreen; + uint8_t rgbRed; + uint8_t rgbReserved; + }; + RgbQuad colors[2]; +}; +#pragma pack(pop) + enum class BmpReaderError : uint8_t { Ok = 0, FileInvalid, diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index 465593e8..e9dbb64d 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -1,6 +1,9 @@ #include "BitmapHelpers.h" #include +#include // Added for memset + +#include "Bitmap.h" // Brightness/Contrast adjustments: constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments @@ -104,3 +107,44 @@ uint8_t quantize1bit(int gray, int x, int y) { const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 return (gray >= adjustedThreshold) ? 1 : 0; } + +void createBmpHeader(BmpHeader* bmpHeader, int width, int height) { + if (!bmpHeader) return; + + // Zero out the memory to ensure no garbage data if called on uninitialized stack memory + std::memset(bmpHeader, 0, sizeof(BmpHeader)); + + uint32_t rowSize = (width + 31) / 32 * 4; + uint32_t imageSize = rowSize * height; + uint32_t fileSize = sizeof(BmpHeader) + imageSize; + + bmpHeader->fileHeader.bfType = 0x4D42; + bmpHeader->fileHeader.bfSize = fileSize; + bmpHeader->fileHeader.bfReserved1 = 0; + bmpHeader->fileHeader.bfReserved2 = 0; + bmpHeader->fileHeader.bfOffBits = sizeof(BmpHeader); + + bmpHeader->infoHeader.biSize = sizeof(bmpHeader->infoHeader); + bmpHeader->infoHeader.biWidth = width; + bmpHeader->infoHeader.biHeight = height; + bmpHeader->infoHeader.biPlanes = 1; + bmpHeader->infoHeader.biBitCount = 1; + bmpHeader->infoHeader.biCompression = 0; + bmpHeader->infoHeader.biSizeImage = imageSize; + bmpHeader->infoHeader.biXPelsPerMeter = 0; + bmpHeader->infoHeader.biYPelsPerMeter = 0; + bmpHeader->infoHeader.biClrUsed = 0; + bmpHeader->infoHeader.biClrImportant = 0; + + // Color 0 (black) + bmpHeader->colors[0].rgbBlue = 0; + bmpHeader->colors[0].rgbGreen = 0; + bmpHeader->colors[0].rgbRed = 0; + bmpHeader->colors[0].rgbReserved = 0; + + // Color 1 (white) + bmpHeader->colors[1].rgbBlue = 255; + bmpHeader->colors[1].rgbGreen = 255; + bmpHeader->colors[1].rgbRed = 255; + bmpHeader->colors[1].rgbReserved = 0; +} \ No newline at end of file diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index 791e70b9..8f49124c 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -1,13 +1,19 @@ #pragma once +#include #include +struct BmpHeader; + // Helper functions uint8_t quantize(int gray, int x, int y); uint8_t quantizeSimple(int gray); uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +// Populates a 1-bit BMP header in the provided memory. +void createBmpHeader(BmpHeader* bmpHeader, int width, int height); + // 1-bit Atkinson dithering - better quality than noise dithering for thumbnails // Error distribution pattern (same as 2-bit but quantizes to 2 levels): // X 1/8 1/8 diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index 61284f95..006a5719 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -355,6 +355,7 @@ enum class StrId : uint16_t { STR_BOOK_S_STYLE, STR_EMBEDDED_STYLE, STR_OPDS_SERVER_URL, + STR_SCREENSHOT_BUTTON, // Sentinel - must be last _COUNT }; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 3c4f1a88..c212f8cd 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Nahrát" STR_BOOK_S_STYLE: "Styl knihy" STR_EMBEDDED_STYLE: "Vložený styl" STR_OPDS_SERVER_URL: "URL serveru OPDS" +STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky" \ No newline at end of file diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a47c8ab7..b3e0c08c 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Upload" STR_BOOK_S_STYLE: "Book's Style" STR_EMBEDDED_STYLE: "Embedded Style" STR_OPDS_SERVER_URL: "OPDS Server URL" +STR_SCREENSHOT_BUTTON: "Take screenshot" \ No newline at end of file diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 1796c2f3..63c3622e 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Envoi" STR_BOOK_S_STYLE: "Style du livre" STR_EMBEDDED_STYLE: "Style intégré" STR_OPDS_SERVER_URL: "URL du serveur OPDS" +STR_SCREENSHOT_BUTTON: "Prendre une capture d'écran" \ No newline at end of file diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index eddc5ff9..ca74329e 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Hochladen" STR_BOOK_S_STYLE: "Buch-Stil" STR_EMBEDDED_STYLE: "Eingebetteter Stil" STR_OPDS_SERVER_URL: "OPDS-Server-URL" +STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" \ No newline at end of file diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 69ba4a91..8ba7defa 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Enviar" STR_BOOK_S_STYLE: "Estilo do livro" STR_EMBEDDED_STYLE: "Estilo embutido" STR_OPDS_SERVER_URL: "URL do servidor OPDS" +STR_SCREENSHOT_BUTTON: "Capturar tela" \ No newline at end of file diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 2771e14c..dc5a1e40 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Отправить" STR_BOOK_S_STYLE: "Стиль книги" STR_EMBEDDED_STYLE: "Встроенный стиль" STR_OPDS_SERVER_URL: "URL OPDS сервера" +STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" \ No newline at end of file diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index c624242d..5a76e3be 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Subir" STR_BOOK_S_STYLE: "Estilo del libro" STR_EMBEDDED_STYLE: "Estilo integrado" STR_OPDS_SERVER_URL: "URL del servidor OPDS" +STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla" \ No newline at end of file diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index a4b812e4..95cb5764 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -317,3 +317,4 @@ STR_UPLOAD: "Uppladdning" STR_BOOK_S_STYLE: "Bokstil" STR_EMBEDDED_STYLE: "Inbäddad stil" STR_OPDS_SERVER_URL: "OPDS-serveradress" +STR_SCREENSHOT_BUTTON: "Ta en skärmdump" \ No newline at end of file diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7f66d3bf..3e910081 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -17,6 +17,7 @@ #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/ScreenshotUtil.h" namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() @@ -431,6 +432,15 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction pendingGoHome = true; break; } + case EpubReaderMenuActivity::MenuAction::SCREENSHOT: { + { + RenderLock lock(*this); + pendingScreenshot = true; + } + exitActivity(); + requestUpdate(); + break; + } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { const int currentPage = section ? section->currentPage : 0; @@ -616,6 +626,11 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) { renderer.clearFontCache(); } saveProgress(currentSpineIndex, section->currentPage, section->pageCount); + + if (pendingScreenshot) { + pendingScreenshot = false; + ScreenshotUtil::takeScreenshot(renderer); + } } void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 2c70af70..861e590a 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -20,7 +20,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { float pendingSpineProgress = 0.0f; bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free bool pendingGoHome = false; // Defer go home to avoid race condition with display task - bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit + bool pendingScreenshot = false; + bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit const std::function onGoBack; const std::function onGoHome; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index d2da2b3c..787a2302 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -12,7 +12,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { public: // Menu actions available from the reader menu. - enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE }; + enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, SCREENSHOT, GO_HOME, SYNC, DELETE_CACHE }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, const int totalPages, const int bookProgressPercent, @@ -39,13 +39,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { }; // Fixed menu layout (order matters for up/down navigation). - const std::vector menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}, - {MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}, - {MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}, - {MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}, - {MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}, - {MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}}; - + const std::vector menuItems = { + {MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}, {MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}, + {MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}, {MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}, + {MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}, {MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}, + {MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}}; int selectedIndex = 0; ButtonNavigator buttonNavigator; diff --git a/src/main.cpp b/src/main.cpp index 6b227065..f1f6188a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,7 @@ #include "components/UITheme.h" #include "fontIds.h" #include "util/ButtonNavigator.h" +#include "util/ScreenshotUtil.h" HalDisplay display; HalGPIO gpio; @@ -404,6 +405,20 @@ void loop() { powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity } + static bool screenshotButtonsReleased = true; + if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) { + if (screenshotButtonsReleased) { + screenshotButtonsReleased = false; + if (currentActivity) { + Activity::RenderLock lock(*currentActivity); + ScreenshotUtil::takeScreenshot(renderer); + } + } + return; + } else { + screenshotButtonsReleased = true; + } + const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); if (millis() - lastActivityTime >= sleepTimeoutMs) { LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", sleepTimeoutMs); @@ -413,6 +428,10 @@ void loop() { } if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) { + // If the screenshot combination is potentially being pressed, don't sleep + if (gpio.isPressed(HalGPIO::BTN_DOWN)) { + return; + } enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return; @@ -448,4 +467,4 @@ void loop() { delay(10); } } -} +} \ No newline at end of file diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp new file mode 100644 index 00000000..6713bd30 --- /dev/null +++ b/src/util/ScreenshotUtil.cpp @@ -0,0 +1,117 @@ +#include "ScreenshotUtil.h" + +#include +#include +#include +#include +#include + +#include + +#include "Bitmap.h" // Required for BmpHeader struct definition + +void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { + const uint8_t* fb = renderer.getFrameBuffer(); + if (fb) { + String filename_str = "/screenshots/screenshot-" + String(millis()) + ".bmp"; + if (ScreenshotUtil::saveFramebufferAsBmp(filename_str.c_str(), fb, HalDisplay::DISPLAY_WIDTH, + HalDisplay::DISPLAY_HEIGHT)) { + LOG_DBG("SCR", "Screenshot saved to %s", filename_str.c_str()); + } else { + LOG_ERR("SCR", "Failed to save screenshot"); + } + } else { + LOG_ERR("SCR", "Framebuffer not available"); + } + + // Display a border around the screen to indicate a screenshot was taken + if (renderer.storeBwBuffer()) { + renderer.drawRect(6, 6, HalDisplay::DISPLAY_HEIGHT - 12, HalDisplay::DISPLAY_WIDTH - 12, 2, true); + renderer.displayBuffer(); + delay(1000); + renderer.restoreBwBuffer(); + renderer.displayBuffer(HalDisplay::RefreshMode::HALF_REFRESH); + } +} + +bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height) { + if (!framebuffer) { + return false; + } + + // Note: the width and height, we rotate the image 90d counter-clockwise to match the default display orientation + int phyWidth = height; + int phyHeight = width; + + std::string path(filename); + size_t last_slash = path.find_last_of('/'); + if (last_slash != std::string::npos) { + std::string dir = path.substr(0, last_slash); + if (!Storage.exists(dir.c_str())) { + if (!Storage.mkdir(dir.c_str())) { + return false; + } + } + } + + FsFile file; + if (!Storage.openFileForWrite("SCR", filename, file)) { + LOG_ERR("SCR", "Failed to save screenshot"); + return false; + } + + BmpHeader header; + + createBmpHeader(&header, phyWidth, phyHeight); + + bool write_error = false; + if (file.write(reinterpret_cast(&header), sizeof(header)) != sizeof(header)) { + write_error = true; + } + + if (write_error) { + file.close(); + Storage.remove(filename); + return false; + } + + const uint32_t rowSizePadded = (phyWidth + 31) / 32 * 4; + // Max row size for 480px width = 60 bytes; use fixed buffer to avoid VLA + constexpr size_t kMaxRowSize = 64; + if (rowSizePadded > kMaxRowSize) { + LOG_ERR("SCR", "Row size %u exceeds buffer capacity", rowSizePadded); + file.close(); + Storage.remove(filename); + return false; + } + + // rotate the image 90d counter-clockwise on-the-fly while writing to save memory + uint8_t rowBuffer[kMaxRowSize]; + memset(rowBuffer, 0, rowSizePadded); + + for (int outY = 0; outY < phyHeight; outY++) { + for (int outX = 0; outX < phyWidth; outX++) { + // 90d counter-clockwise: source (srcX, srcY) + // BMP rows are bottom-to-top, so outY=0 is the bottom of the displayed image + int srcX = width - 1 - outY; // phyHeight == width + int srcY = phyWidth - 1 - outX; // phyWidth == height + int fbIndex = srcY * (width / 8) + (srcX / 8); + uint8_t pixel = (framebuffer[fbIndex] >> (7 - (srcX % 8))) & 0x01; + rowBuffer[outX / 8] |= pixel << (7 - (outX % 8)); + } + if (file.write(rowBuffer, rowSizePadded) != rowSizePadded) { + write_error = true; + break; + } + memset(rowBuffer, 0, rowSizePadded); // Clear the buffer for the next row + } + + file.close(); + + if (write_error) { + Storage.remove(filename); + return false; + } + + return true; +} diff --git a/src/util/ScreenshotUtil.h b/src/util/ScreenshotUtil.h new file mode 100644 index 00000000..96d459e6 --- /dev/null +++ b/src/util/ScreenshotUtil.h @@ -0,0 +1,8 @@ +#pragma once +#include + +class ScreenshotUtil { + public: + static void takeScreenshot(GfxRenderer& renderer); + static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height); +};