refactor: Memory optimization and XTC format removal
Memory optimization: - Add LOG_STACK_WATERMARK macro for task stack monitoring - Add freeCoverBufferIfAllocated() and preloadCoverBuffer() for memory management - Improve cover buffer reuse to reduce heap fragmentation - Add grayscale buffer cleanup safety in GfxRenderer - Make grayscale rendering conditional on successful buffer allocation - Add detailed heap fragmentation logging with ESP-IDF API - Add CSS parser memory usage estimation XTC format removal: - Remove entire lib/Xtc library (XTC parser and types) - Remove XtcReaderActivity and XtcReaderChapterSelectionActivity - Remove XTC file handling from HomeActivity, SleepActivity, ReaderActivity - Remove .xtc/.xtch from supported extensions in BookManager - Remove XTC cache prefix from Md5Utils - Update web server and file browser to exclude XTC format - Clear in-memory caches when disk cache is cleared
This commit is contained in:
@@ -190,6 +190,8 @@ void EpubReaderActivity::onExit() {
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
// Log stack high-water mark before deleting task (stack size: 8192 bytes)
|
||||
LOG_STACK_WATERMARK("EpubReaderActivity", displayTaskHandle);
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||
@@ -639,30 +641,33 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
// Save bw buffer to reset buffer state after grayscale data sync
|
||||
renderer.storeBwBuffer();
|
||||
|
||||
// grayscale rendering
|
||||
// grayscale rendering requires storing the BW buffer first
|
||||
// If we can't allocate memory for the backup, skip grayscale to avoid artifacts
|
||||
// TODO: Only do this if font supports it
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
// Try to save BW buffer - if this fails, skip grayscale rendering entirely
|
||||
const bool bwBufferStored = renderer.storeBwBuffer();
|
||||
|
||||
// Render and copy to MSB buffer
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
if (bwBufferStored) {
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
// display grayscale part
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
// Render and copy to MSB buffer
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
// display grayscale part
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
|
||||
// restore the bw data
|
||||
renderer.restoreBwBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// restore the bw data
|
||||
renderer.restoreBwBuffer();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "Txt.h"
|
||||
#include "TxtReaderActivity.h"
|
||||
#include "Xtc.h"
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/home/HomeActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
@@ -17,10 +16,6 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
|
||||
return filePath.substr(0, lastSlash);
|
||||
}
|
||||
|
||||
bool ReaderActivity::isXtcFile(const std::string& path) {
|
||||
return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch");
|
||||
}
|
||||
|
||||
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||
return StringUtils::checkFileExtension(path, ".txt") ||
|
||||
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
|
||||
@@ -41,21 +36,6 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||
if (!SdMan.exists(path.c_str())) {
|
||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
|
||||
if (xtc->load()) {
|
||||
return xtc;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||
if (!SdMan.exists(path.c_str())) {
|
||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||
@@ -85,14 +65,6 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
||||
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
||||
const auto xtcPath = xtc->getPath();
|
||||
currentBookPath = xtcPath;
|
||||
exitActivity();
|
||||
enterNewActivity(new XtcReaderActivity(
|
||||
renderer, mappedInput, std::move(xtc), [this, xtcPath] { goToLibrary(xtcPath); }, [this] { onGoBack(); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||
const auto txtPath = txt->getPath();
|
||||
currentBookPath = txtPath;
|
||||
@@ -104,6 +76,10 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||
void ReaderActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
// Free HomeActivity's cover buffer to reclaim ~48KB for reader use
|
||||
// The cover BMP is cached on disk, so it will be reloaded (not regenerated) on return to Home
|
||||
HomeActivity::freeCoverBufferIfAllocated();
|
||||
|
||||
if (initialBookPath.empty()) {
|
||||
goToLibrary(); // Start from root when entering via Browse
|
||||
return;
|
||||
@@ -111,14 +87,7 @@ void ReaderActivity::onEnter() {
|
||||
|
||||
currentBookPath = initialBookPath;
|
||||
|
||||
if (isXtcFile(initialBookPath)) {
|
||||
auto xtc = loadXtc(initialBookPath);
|
||||
if (!xtc) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else if (isTxtFile(initialBookPath)) {
|
||||
if (isTxtFile(initialBookPath)) {
|
||||
auto txt = loadTxt(initialBookPath);
|
||||
if (!txt) {
|
||||
onGoBack();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "activities/home/MyLibraryActivity.h"
|
||||
|
||||
class Epub;
|
||||
class Xtc;
|
||||
class Txt;
|
||||
|
||||
class ReaderActivity final : public ActivityWithSubactivity {
|
||||
@@ -15,15 +14,12 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary;
|
||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
static bool isTxtFile(const std::string& path);
|
||||
|
||||
static std::string extractFolderPath(const std::string& filePath);
|
||||
void goToLibrary(const std::string& fromBookPath = "");
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||
|
||||
public:
|
||||
|
||||
@@ -131,6 +131,8 @@ void TxtReaderActivity::onExit() {
|
||||
// Wait until not rendering to delete task
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
// Log stack high-water mark before deleting task (stack size: 6144 bytes)
|
||||
LOG_STACK_WATERMARK("TxtReaderActivity", displayTaskHandle);
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
/**
|
||||
* XtcReaderActivity.cpp
|
||||
*
|
||||
* XTC ebook reader activity implementation
|
||||
* Displays pre-rendered XTC pages on e-ink display
|
||||
*/
|
||||
|
||||
#include "XtcReaderActivity.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr unsigned long skipPageMs = 700;
|
||||
constexpr unsigned long goHomeMs = 1000;
|
||||
} // namespace
|
||||
|
||||
void XtcReaderActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<XtcReaderActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
if (!xtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
|
||||
xtc->setupCacheDir();
|
||||
|
||||
// Check if cover generation is needed and do it NOW (blocking)
|
||||
const bool needsCover = !SdMan.exists(xtc->getCoverBmpPath().c_str());
|
||||
const bool needsThumb = !SdMan.exists(xtc->getThumbBmpPath().c_str());
|
||||
const bool needsMicroThumb = !SdMan.exists(xtc->getMicroThumbBmpPath().c_str());
|
||||
|
||||
if (needsCover || needsThumb || needsMicroThumb) {
|
||||
// Show "Preparing book... [X%]" popup, updating every 3 seconds
|
||||
constexpr int boxMargin = 20;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]");
|
||||
const int boxWidth = textWidth + boxMargin * 2;
|
||||
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||
constexpr int boxY = 50;
|
||||
|
||||
unsigned long lastUpdate = 0;
|
||||
|
||||
// Draw initial popup
|
||||
renderer.clearScreen();
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
|
||||
// Generate covers with progress callback
|
||||
xtc->generateAllCovers([&](int percent) {
|
||||
const unsigned long now = millis();
|
||||
if ((now - lastUpdate) >= 3000) {
|
||||
lastUpdate = now;
|
||||
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||
|
||||
char progressStr[32];
|
||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved progress
|
||||
loadProgress();
|
||||
|
||||
// Save current XTC as last opened book and cache title for home screen
|
||||
APP_STATE.openEpubPath = xtc->getPath();
|
||||
APP_STATE.openBookTitle = xtc->getTitle();
|
||||
APP_STATE.openBookAuthor.clear(); // XTC files don't have author metadata
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), "");
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&XtcReaderActivity::taskTrampoline, "XtcReaderActivityTask",
|
||||
4096, // Stack size (smaller than EPUB since no parsing needed)
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
}
|
||||
|
||||
void XtcReaderActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
xtc.reset();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::loop() {
|
||||
// Pass input responsibility to sub activity if exists
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end-of-book prompt
|
||||
if (showingEndOfBookPrompt) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
endOfBookSelection = (endOfBookSelection + 2) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
endOfBookSelection = (endOfBookSelection + 1) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
handleEndOfBookAction();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Go back to last page
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new XtcReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, xtc, currentPage,
|
||||
[this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this](const uint32_t newPage) {
|
||||
currentPage = newPage;
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes directly to home
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes to file selection
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
|
||||
if (!prevReleased && !nextReleased) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If at end of book prompt position, handle differently
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs;
|
||||
const int skipAmount = skipPages ? 10 : 1;
|
||||
|
||||
if (prevReleased) {
|
||||
if (currentPage >= static_cast<uint32_t>(skipAmount)) {
|
||||
currentPage -= skipAmount;
|
||||
} else {
|
||||
currentPage = 0;
|
||||
}
|
||||
updateRequired = true;
|
||||
} else if (nextReleased) {
|
||||
currentPage += skipAmount;
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
currentPage = xtc->getPageCount(); // Will trigger end-of-book prompt
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderScreen() {
|
||||
if (!xtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounds check - show end-of-book prompt
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
showingEndOfBookPrompt = true;
|
||||
renderEndOfBookPrompt();
|
||||
return;
|
||||
}
|
||||
showingEndOfBookPrompt = false;
|
||||
|
||||
renderPage();
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderPage() {
|
||||
const uint16_t pageWidth = xtc->getPageWidth();
|
||||
const uint16_t pageHeight = xtc->getPageHeight();
|
||||
const uint8_t bitDepth = xtc->getBitDepth();
|
||||
|
||||
// Calculate buffer size for one page
|
||||
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
|
||||
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
|
||||
size_t pageBufferSize;
|
||||
if (bitDepth == 2) {
|
||||
pageBufferSize = ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2;
|
||||
} else {
|
||||
pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
|
||||
}
|
||||
|
||||
// Allocate page buffer
|
||||
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
|
||||
if (!pageBuffer) {
|
||||
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load page data
|
||||
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
|
||||
if (bytesRead == 0) {
|
||||
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage);
|
||||
free(pageBuffer);
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear screen first
|
||||
renderer.clearScreen();
|
||||
|
||||
// Copy page bitmap using GfxRenderer's drawPixel
|
||||
// XTC/XTCH pages are pre-rendered with status bar included, so render full page
|
||||
const uint16_t maxSrcY = pageHeight;
|
||||
|
||||
if (bitDepth == 2) {
|
||||
// XTH 2-bit mode: Two bit planes, column-major order
|
||||
// - Columns scanned right to left (x = width-1 down to 0)
|
||||
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
|
||||
// - First plane: Bit1, Second plane: Bit2
|
||||
// - Pixel value = (bit1 << 1) | bit2
|
||||
// - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
|
||||
|
||||
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
|
||||
const uint8_t* plane1 = pageBuffer; // Bit1 plane
|
||||
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
|
||||
const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
|
||||
|
||||
// Lambda to get pixel value at (x, y)
|
||||
auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t {
|
||||
const size_t colIndex = pageWidth - 1 - x;
|
||||
const size_t byteInCol = y / 8;
|
||||
const size_t bitInByte = 7 - (y % 8);
|
||||
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
||||
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
||||
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
||||
return (bit1 << 1) | bit2;
|
||||
};
|
||||
|
||||
// Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory)
|
||||
// Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame
|
||||
|
||||
// Count pixel distribution for debugging
|
||||
uint32_t pixelCounts[4] = {0, 0, 0, 0};
|
||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||
pixelCounts[getPixelValue(x, y)]++;
|
||||
}
|
||||
}
|
||||
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(),
|
||||
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]);
|
||||
|
||||
// Pass 1: BW buffer - draw all non-white pixels as black
|
||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||
if (getPixelValue(x, y) >= 1) {
|
||||
renderer.drawPixel(x, y, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
|
||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||
renderer.clearScreen(0x00);
|
||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||
if (getPixelValue(x, y) == 1) { // Dark grey only
|
||||
renderer.drawPixel(x, y, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
// Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2)
|
||||
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
|
||||
renderer.clearScreen(0x00);
|
||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||
const uint8_t pv = getPixelValue(x, y);
|
||||
if (pv == 1 || pv == 2) { // Dark grey or Light grey
|
||||
renderer.drawPixel(x, y, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
// Display grayscale overlay
|
||||
renderer.displayGrayBuffer();
|
||||
|
||||
// Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer)
|
||||
renderer.clearScreen();
|
||||
for (uint16_t y = 0; y < pageHeight; y++) {
|
||||
for (uint16_t x = 0; x < pageWidth; x++) {
|
||||
if (getPixelValue(x, y) >= 1) {
|
||||
renderer.drawPixel(x, y, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup grayscale buffers with current frame buffer
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
|
||||
free(pageBuffer);
|
||||
|
||||
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1,
|
||||
xtc->getPageCount());
|
||||
return;
|
||||
} else {
|
||||
// 1-bit mode: 8 pixels per byte, MSB first
|
||||
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
|
||||
|
||||
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
|
||||
const size_t srcRowStart = srcY * srcRowBytes;
|
||||
|
||||
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
|
||||
// Read source pixel (MSB first, bit 7 = leftmost pixel)
|
||||
const size_t srcByte = srcRowStart + srcX / 8;
|
||||
const size_t srcBit = 7 - (srcX % 8);
|
||||
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
|
||||
|
||||
if (isBlack) {
|
||||
renderer.drawPixel(srcX, srcY, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// White pixels are already cleared by clearScreen()
|
||||
|
||||
free(pageBuffer);
|
||||
|
||||
// XTC pages already have status bar pre-rendered, no need to add our own
|
||||
|
||||
// Display with appropriate refresh
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(),
|
||||
bitDepth);
|
||||
}
|
||||
|
||||
void XtcReaderActivity::saveProgress() const {
|
||||
FsFile f;
|
||||
if (SdMan.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
data[0] = currentPage & 0xFF;
|
||||
data[1] = (currentPage >> 8) & 0xFF;
|
||||
data[2] = (currentPage >> 16) & 0xFF;
|
||||
data[3] = (currentPage >> 24) & 0xFF;
|
||||
f.write(data, 4);
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::loadProgress() {
|
||||
FsFile f;
|
||||
if (SdMan.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
if (f.read(data, 4) == 4) {
|
||||
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
|
||||
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage);
|
||||
|
||||
// Validate page number
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
currentPage = 0;
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderEndOfBookPrompt() {
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Filename (truncated if needed)
|
||||
std::string filename = xtc->getPath();
|
||||
const size_t lastSlash = filename.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
filename = filename.substr(lastSlash + 1);
|
||||
}
|
||||
if (filename.length() > 30) {
|
||||
filename = filename.substr(0, 27) + "...";
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str());
|
||||
|
||||
// Menu options
|
||||
const int menuStartY = pageHeight / 2 - 30;
|
||||
constexpr int menuLineHeight = 45;
|
||||
constexpr int menuItemWidth = 140;
|
||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||
|
||||
const char* options[] = {"Archive", "Delete", "Keep"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
const int optionY = menuStartY + i * menuLineHeight;
|
||||
if (endOfBookSelection == i) {
|
||||
renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::handleEndOfBookAction() {
|
||||
const std::string bookPath = xtc->getPath();
|
||||
|
||||
switch (endOfBookSelection) {
|
||||
case 0: // Archive
|
||||
BookManager::archiveBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 1: // Delete
|
||||
BookManager::deleteBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 2: // Keep
|
||||
default:
|
||||
onGoHome();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* XtcReaderActivity.h
|
||||
*
|
||||
* XTC ebook reader activity for CrossPoint Reader
|
||||
* Displays pre-rendered XTC pages on e-ink display
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Xtc.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
class XtcReaderActivity final : public ActivityWithSubactivity {
|
||||
std::shared_ptr<Xtc> xtc;
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
bool updateRequired = false;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
// End-of-book prompt state
|
||||
bool showingEndOfBookPrompt = false;
|
||||
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void renderScreen();
|
||||
void renderPage();
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
void renderEndOfBookPrompt();
|
||||
void handleEndOfBookAction();
|
||||
|
||||
public:
|
||||
explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Xtc> xtc,
|
||||
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
||||
: ActivityWithSubactivity("XtcReader", renderer, mappedInput),
|
||||
xtc(std::move(xtc)),
|
||||
onGoBack(onGoBack),
|
||||
onGoHome(onGoHome) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
} // namespace
|
||||
|
||||
int XtcReaderChapterSelectionActivity::getPageItems() const {
|
||||
constexpr int startY = 60;
|
||||
constexpr int lineHeight = 30;
|
||||
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int endY = screenHeight - lineHeight;
|
||||
|
||||
const int availableHeight = endY - startY;
|
||||
int items = availableHeight / lineHeight;
|
||||
if (items < 1) {
|
||||
items = 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const {
|
||||
if (!xtc) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto& chapters = xtc->getChapters();
|
||||
for (size_t i = 0; i < chapters.size(); i++) {
|
||||
if (page >= chapters[i].startPage && page <= chapters[i].endPage) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<XtcReaderChapterSelectionActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
if (!xtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
selectorIndex = findChapterIndexForPage(currentPage);
|
||||
|
||||
updateRequired = true;
|
||||
xTaskCreate(&XtcReaderChapterSelectionActivity::taskTrampoline, "XtcReaderChapterSelectionActivityTask",
|
||||
4096, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::loop() {
|
||||
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;
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto& chapters = xtc->getChapters();
|
||||
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
|
||||
onSelectPage(chapters[selectorIndex].startPage);
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
} else if (prevReleased) {
|
||||
const int total = static_cast<int>(xtc->getChapters().size());
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + total - 1) % total;
|
||||
}
|
||||
updateRequired = true;
|
||||
} else if (nextReleased) {
|
||||
const int total = static_cast<int>(xtc->getChapters().size());
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + 1) % total;
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderChapterSelectionActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int pageItems = getPageItems();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto& chapters = xtc->getChapters();
|
||||
if (chapters.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, "No chapters");
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
||||
for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) {
|
||||
const auto& chapter = chapters[i];
|
||||
const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str();
|
||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
#include <Xtc.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
class XtcReaderChapterSelectionActivity final : public Activity {
|
||||
std::shared_ptr<Xtc> xtc;
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
uint32_t currentPage = 0;
|
||||
int selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(uint32_t newPage)> onSelectPage;
|
||||
|
||||
int getPageItems() const;
|
||||
int findChapterIndexForPage(uint32_t page) const;
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void renderScreen();
|
||||
|
||||
public:
|
||||
explicit XtcReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::shared_ptr<Xtc>& xtc, uint32_t currentPage,
|
||||
const std::function<void()>& onGoBack,
|
||||
const std::function<void(uint32_t newPage)>& onSelectPage)
|
||||
: Activity("XtcReaderChapterSelection", renderer, mappedInput),
|
||||
xtc(xtc),
|
||||
currentPage(currentPage),
|
||||
onGoBack(onGoBack),
|
||||
onSelectPage(onSelectPage) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
};
|
||||
Reference in New Issue
Block a user