feat: Take screenshots (#759)
## Summary * **What is the goal of this PR?** Implements a take-screenshot feature * **What changes are included?** - Quick press Power button and Down button at the same time to take a screenshot - Screenshots are saved in `screenshots` folder ## Additional Context - Currently it does not use the device orientation. --- Example screenshots:  [screenshot-6771.bmp](https://github.com/user-attachments/files/25157071/screenshot-6771.bmp)  [screenshot-14158.bmp](https://github.com/user-attachments/files/25157073/screenshot-14158.bmp) ### AI Usage Did you use AI tools to help write this code? _** YES --------- Co-authored-by: Eliz Kilic <elizk@google.com> Co-authored-by: Xuan Son Nguyen <son@huggingface.co> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Arthur Tazhitdinov <lisnake@gmail.com>
This commit is contained in:
@@ -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)**.
|
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
|
## 2. Power & Startup
|
||||||
|
|||||||
@@ -6,6 +6,38 @@
|
|||||||
|
|
||||||
#include "BitmapHelpers.h"
|
#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 {
|
enum class BmpReaderError : uint8_t {
|
||||||
Ok = 0,
|
Ok = 0,
|
||||||
FileInvalid,
|
FileInvalid,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#include "BitmapHelpers.h"
|
#include "BitmapHelpers.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring> // Added for memset
|
||||||
|
|
||||||
|
#include "Bitmap.h"
|
||||||
|
|
||||||
// Brightness/Contrast adjustments:
|
// Brightness/Contrast adjustments:
|
||||||
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma 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
|
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
||||||
return (gray >= adjustedThreshold) ? 1 : 0;
|
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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
struct BmpHeader;
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
uint8_t quantize(int gray, int x, int y);
|
uint8_t quantize(int gray, int x, int y);
|
||||||
uint8_t quantizeSimple(int gray);
|
uint8_t quantizeSimple(int gray);
|
||||||
uint8_t quantize1bit(int gray, int x, int y);
|
uint8_t quantize1bit(int gray, int x, int y);
|
||||||
int adjustPixel(int gray);
|
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
|
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
|
||||||
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
||||||
// X 1/8 1/8
|
// X 1/8 1/8
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ enum class StrId : uint16_t {
|
|||||||
STR_BOOK_S_STYLE,
|
STR_BOOK_S_STYLE,
|
||||||
STR_EMBEDDED_STYLE,
|
STR_EMBEDDED_STYLE,
|
||||||
STR_OPDS_SERVER_URL,
|
STR_OPDS_SERVER_URL,
|
||||||
|
STR_SCREENSHOT_BUTTON,
|
||||||
// Sentinel - must be last
|
// Sentinel - must be last
|
||||||
_COUNT
|
_COUNT
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Nahrát"
|
|||||||
STR_BOOK_S_STYLE: "Styl knihy"
|
STR_BOOK_S_STYLE: "Styl knihy"
|
||||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||||
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Upload"
|
|||||||
STR_BOOK_S_STYLE: "Book's Style"
|
STR_BOOK_S_STYLE: "Book's Style"
|
||||||
STR_EMBEDDED_STYLE: "Embedded Style"
|
STR_EMBEDDED_STYLE: "Embedded Style"
|
||||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Envoi"
|
|||||||
STR_BOOK_S_STYLE: "Style du livre"
|
STR_BOOK_S_STYLE: "Style du livre"
|
||||||
STR_EMBEDDED_STYLE: "Style intégré"
|
STR_EMBEDDED_STYLE: "Style intégré"
|
||||||
STR_OPDS_SERVER_URL: "URL du serveur OPDS"
|
STR_OPDS_SERVER_URL: "URL du serveur OPDS"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Prendre une capture d'écran"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Hochladen"
|
|||||||
STR_BOOK_S_STYLE: "Buch-Stil"
|
STR_BOOK_S_STYLE: "Buch-Stil"
|
||||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Enviar"
|
|||||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Capturar tela"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Отправить"
|
|||||||
STR_BOOK_S_STYLE: "Стиль книги"
|
STR_BOOK_S_STYLE: "Стиль книги"
|
||||||
STR_EMBEDDED_STYLE: "Встроенный стиль"
|
STR_EMBEDDED_STYLE: "Встроенный стиль"
|
||||||
STR_OPDS_SERVER_URL: "URL OPDS сервера"
|
STR_OPDS_SERVER_URL: "URL OPDS сервера"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Subir"
|
|||||||
STR_BOOK_S_STYLE: "Estilo del libro"
|
STR_BOOK_S_STYLE: "Estilo del libro"
|
||||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||||
@@ -317,3 +317,4 @@ STR_UPLOAD: "Uppladdning"
|
|||||||
STR_BOOK_S_STYLE: "Bokstil"
|
STR_BOOK_S_STYLE: "Bokstil"
|
||||||
STR_EMBEDDED_STYLE: "Inbäddad stil"
|
STR_EMBEDDED_STYLE: "Inbäddad stil"
|
||||||
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/ScreenshotUtil.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
@@ -431,6 +432,15 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
pendingGoHome = true;
|
pendingGoHome = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::SCREENSHOT: {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
pendingScreenshot = true;
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
requestUpdate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
||||||
if (KOREADER_STORE.hasCredentials()) {
|
if (KOREADER_STORE.hasCredentials()) {
|
||||||
const int currentPage = section ? section->currentPage : 0;
|
const int currentPage = section ? section->currentPage : 0;
|
||||||
@@ -616,6 +626,11 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
|||||||
renderer.clearFontCache();
|
renderer.clearFontCache();
|
||||||
}
|
}
|
||||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||||
|
|
||||||
|
if (pendingScreenshot) {
|
||||||
|
pendingScreenshot = false;
|
||||||
|
ScreenshotUtil::takeScreenshot(renderer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
|||||||
float pendingSpineProgress = 0.0f;
|
float pendingSpineProgress = 0.0f;
|
||||||
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
|
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 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<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
const std::function<void()> onGoHome;
|
const std::function<void()> onGoHome;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||||
public:
|
public:
|
||||||
// Menu actions available from the reader menu.
|
// 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,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
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).
|
// Fixed menu layout (order matters for up/down navigation).
|
||||||
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER},
|
const std::vector<MenuItem> menuItems = {
|
||||||
{MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION},
|
{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}, {MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION},
|
||||||
{MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT},
|
{MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}, {MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON},
|
||||||
{MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON},
|
{MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}, {MenuAction::SYNC, StrId::STR_SYNC_PROGRESS},
|
||||||
{MenuAction::SYNC, StrId::STR_SYNC_PROGRESS},
|
{MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}};
|
||||||
{MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}};
|
|
||||||
|
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
|
||||||
ButtonNavigator buttonNavigator;
|
ButtonNavigator buttonNavigator;
|
||||||
|
|||||||
21
src/main.cpp
21
src/main.cpp
@@ -31,6 +31,7 @@
|
|||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
#include "util/ScreenshotUtil.h"
|
||||||
|
|
||||||
HalDisplay display;
|
HalDisplay display;
|
||||||
HalGPIO gpio;
|
HalGPIO gpio;
|
||||||
@@ -404,6 +405,20 @@ void loop() {
|
|||||||
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
|
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();
|
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||||
if (millis() - lastActivityTime >= sleepTimeoutMs) {
|
if (millis() - lastActivityTime >= sleepTimeoutMs) {
|
||||||
LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", 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 (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();
|
enterDeepSleep();
|
||||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||||
return;
|
return;
|
||||||
@@ -448,4 +467,4 @@ void loop() {
|
|||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
117
src/util/ScreenshotUtil.cpp
Normal file
117
src/util/ScreenshotUtil.cpp
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#include "ScreenshotUtil.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <BitmapHelpers.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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<uint8_t*>(&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;
|
||||||
|
}
|
||||||
8
src/util/ScreenshotUtil.h
Normal file
8
src/util/ScreenshotUtil.h
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
class ScreenshotUtil {
|
||||||
|
public:
|
||||||
|
static void takeScreenshot(GfxRenderer& renderer);
|
||||||
|
static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user