/** * XtcReaderActivity.cpp * * XTC ebook reader activity implementation * Displays pre-rendered XTC pages on e-ink display */ #include "XtcReaderActivity.h" #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" #include "fontIds.h" namespace { constexpr unsigned long skipPageMs = 700; constexpr unsigned long goHomeMs = 1000; } // namespace void XtcReaderActivity::onEnter() { Activity::onEnter(); // See ReaderUtils::InputDrainGuard — prevents wake-up power-button hold from leaking into // the first detectPageTurn() call as a page turn or chapter skip. inputDrainGuard.arm(); if (!xtc) { return; } xtc->setupCacheDir(); // Load saved progress loadProgress(); // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), "", xtc->getThumbBmpPath()); // Trigger first update requestUpdate(); } void XtcReaderActivity::onExit() { Activity::onExit(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); xtc.reset(); } void XtcReaderActivity::loop() { if (inputDrainGuard.shouldDrain(mappedInput)) { return; } // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { ReaderUtils::enforceExitFullRefresh(renderer); startActivityForResult( std::make_unique(renderer, mappedInput, xtc, currentPage), [this](const ActivityResult& result) { if (!result.isCancelled) { currentPage = std::get(result.data).page; } }); } } // Long press BACK (1s+) goes to home screen if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { ReaderUtils::enforceExitFullRefresh(renderer); onGoHome(); return; } // Short press BACK returns to the calling activity if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { ReaderUtils::enforceExitFullRefresh(renderer); finish(); return; } const bool prevTriggered = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextTriggered = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevTriggered && !nextTriggered) { return; } // At end of the book, forward button returns to caller and back button returns to last page if (currentPage >= xtc->getPageCount()) { if (nextTriggered) { finish(); } else { currentPage = xtc->getPageCount() - 1; requestUpdate(); } return; } const bool skipPages = mappedInput.getHeldTime() > skipPageMs; const int skipAmount = skipPages ? 10 : 1; if (prevTriggered) { if (currentPage >= static_cast(skipAmount)) { currentPage -= skipAmount; } else { currentPage = 0; } requestUpdate(); } else if (nextTriggered) { currentPage += skipAmount; if (currentPage >= xtc->getPageCount()) { currentPage = xtc->getPageCount(); // Allow showing "End of book" } requestUpdate(); } } void XtcReaderActivity::render(RenderLock&&) { if (!xtc) { return; } // Bounds check if (currentPage >= xtc->getPageCount()) { // Show end of book screen renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } 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(pageWidth) * pageHeight + 7) / 8) * 2; } else { pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; } // Allocate page buffer uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); if (!pageBuffer) { LOG_ERR("XTR", "Failed to allocate page buffer (%lu bytes)", pageBufferSize); renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_MEMORY_ERROR), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } // Load page data size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); if (bytesRead == 0) { LOG_ERR("XTR", "Failed to load page %lu", currentPage); free(pageBuffer); renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_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(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)]++; } } LOG_DBG("XTR", "Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu", 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(HalDisplay::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); LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", 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(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); pagesUntilFullRefresh--; } LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth); } void XtcReaderActivity::saveProgress() const { FsFile f; if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { uint8_t data[5]; data[0] = currentPage & 0xFF; data[1] = (currentPage >> 8) & 0xFF; data[2] = (currentPage >> 16) & 0xFF; data[3] = (currentPage >> 24) & 0xFF; data[4] = ReaderUtils::pageProgressPercentByte(static_cast(currentPage), static_cast(xtc->getPageCount())); f.write(data, 5); f.close(); } } void XtcReaderActivity::loadProgress() { FsFile f; if (Storage.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); LOG_DBG("XTR", "Loaded progress: page %lu", currentPage); // Validate page number if (currentPage >= xtc->getPageCount()) { currentPage = 0; } } f.close(); } } bool XtcReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) { Xtc xtc(filePath, "/.crosspoint"); if (!xtc.load()) { LOG_DBG("SLP", "XTC: failed to load %s", filePath.c_str()); return false; } // Load saved page number uint32_t savedPage = 0; FsFile f; if (Storage.openFileForRead("SLP", xtc.getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { savedPage = (uint32_t)data[0] | ((uint32_t)data[1] << 8) | ((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24); } f.close(); } if (savedPage >= xtc.getPageCount()) savedPage = 0; const uint16_t pageWidth = xtc.getPageWidth(); const uint16_t pageHeight = xtc.getPageHeight(); const uint8_t bitDepth = xtc.getBitDepth(); // Only use the 1-bit BW path; grayscale is not needed as a background under the overlay const size_t pageBufferSize = (bitDepth == 2) ? ((static_cast(pageWidth) * pageHeight + 7) / 8) * 2 : ((pageWidth + 7) / 8) * pageHeight; uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); if (!pageBuffer) { LOG_ERR("SLP", "XTC: failed to allocate page buffer"); return false; } if (xtc.loadPage(savedPage, pageBuffer, pageBufferSize) == 0) { LOG_ERR("SLP", "XTC: failed to load page %lu", savedPage); free(pageBuffer); return false; } renderer.clearScreen(); if (bitDepth == 2) { // 2-bit XTH: draw all non-white pixels as black (BW pass only) const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; const uint8_t* plane1 = pageBuffer; const uint8_t* plane2 = pageBuffer + planeSize; const size_t colBytes = (pageHeight + 7) / 8; for (uint16_t y = 0; y < pageHeight; y++) { for (uint16_t x = 0; x < pageWidth; x++) { 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; if ((bit1 << 1) | bit2) { renderer.drawPixel(x, y, true); } } } } else { // 1-bit XTG: draw black pixels const size_t srcRowBytes = (pageWidth + 7) / 8; for (uint16_t srcY = 0; srcY < pageHeight; srcY++) { for (uint16_t srcX = 0; srcX < pageWidth; srcX++) { const bool isBlack = !((pageBuffer[srcY * srcRowBytes + srcX / 8] >> (7 - srcX % 8)) & 1); if (isBlack) renderer.drawPixel(srcX, srcY, true); } } } free(pageBuffer); return true; } void XtcReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION action) { using BA = CrossPointSettings::BUTTON_ACTION; if (!xtc) return; const uint32_t pageCount = xtc->getPageCount(); switch (action) { case BA::BTN_PAGE_FORWARD: if (currentPage + 1 < pageCount) { currentPage++; requestUpdate(); } break; case BA::BTN_PAGE_BACK: if (currentPage > 0) { currentPage--; requestUpdate(); } break; case BA::BTN_PAGE_FORWARD_10: currentPage = (currentPage + 10 < pageCount) ? currentPage + 10 : pageCount - 1; requestUpdate(); break; case BA::BTN_PAGE_BACK_10: currentPage = (currentPage >= 10) ? currentPage - 10 : 0; requestUpdate(); break; case BA::BTN_NEXT_SECTION: if (xtc->hasChapters()) { const auto& chapters = xtc->getChapters(); const auto it = std::find_if(chapters.begin(), chapters.end(), [this](const auto& ch) { return ch.startPage > currentPage; }); if (it != chapters.end()) { currentPage = it->startPage; requestUpdate(); } } break; case BA::BTN_PREV_SECTION: if (xtc->hasChapters()) { const auto& chapters = xtc->getChapters(); const auto prevChapter = std::find_if(chapters.rbegin(), chapters.rend(), [this](const auto& ch) { return ch.startPage < currentPage; }); if (prevChapter != chapters.rend()) { currentPage = prevChapter->startPage; requestUpdate(); } } break; case BA::BTN_EXIT_READER: ReaderUtils::enforceExitFullRefresh(renderer); finish(); break; default: break; } }