494 lines
16 KiB
C++
494 lines
16 KiB
C++
/**
|
|
* 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 <HalStorage.h>
|
|
#include <I18n.h>
|
|
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "MappedInputManager.h"
|
|
#include "ReaderUtils.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "XtcReaderChapterSelectionActivity.h"
|
|
#include "components/UITheme.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<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
|
[this](const ActivityResult& result) {
|
|
if (!result.isCancelled) {
|
|
currentPage = std::get<PageResult>(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<uint32_t>(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<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) {
|
|
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<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)]++;
|
|
}
|
|
}
|
|
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[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 (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<size_t>(pageWidth) * pageHeight + 7) / 8) * 2
|
|
: ((pageWidth + 7) / 8) * pageHeight;
|
|
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(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<size_t>(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();
|
|
for (const auto& ch : chapters) {
|
|
if (ch.startPage > currentPage) {
|
|
currentPage = ch.startPage;
|
|
requestUpdate();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case BA::BTN_PREV_SECTION:
|
|
if (xtc->hasChapters()) {
|
|
const auto& chapters = xtc->getChapters();
|
|
for (int i = static_cast<int>(chapters.size()) - 1; i >= 0; i--) {
|
|
if (chapters[i].startPage < currentPage) {
|
|
currentPage = chapters[i].startPage;
|
|
requestUpdate();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case BA::BTN_EXIT_READER:
|
|
ReaderUtils::enforceExitFullRefresh(renderer);
|
|
finish();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|