Implements a press-and-hold gesture in the center touch zone (400ms threshold) that opens the reader menu, mirroring the Confirm button behavior. The center third of the screen is now reserved for this menu gesture, while left and right thirds continue to handle page turns. Applied to both EPUB and XTC readers.
433 lines
15 KiB
C++
433 lines
15 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 <algorithm>
|
|
|
|
#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"
|
|
|
|
void XtcReaderActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
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() {
|
|
// Touch reader controls mirror the side page buttons (left third = back, right
|
|
// third = forward, press-and-hold = chapter skip) and the Confirm button
|
|
// (center press-and-hold = chapter selection). No-op on Xteink / when the
|
|
// setting is off.
|
|
const auto touch = ReaderUtils::detectTouchPageTurn(renderer);
|
|
|
|
// Enter chapter selection activity (Confirm release, or a center touch-and-hold).
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(touch)) {
|
|
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
|
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 file selection
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
|
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
|
|
return;
|
|
}
|
|
|
|
// Short press BACK goes directly to home
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
|
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
|
onGoHome();
|
|
return;
|
|
}
|
|
|
|
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
|
prevTriggered = prevTriggered || touch.prev;
|
|
nextTriggered = nextTriggered || touch.next;
|
|
if (!prevTriggered && !nextTriggered) {
|
|
return;
|
|
}
|
|
|
|
// At end of the book, forward button goes home and back button returns to last page
|
|
if (currentPage >= xtc->getPageCount()) {
|
|
if (nextTriggered) {
|
|
onGoHome();
|
|
} else {
|
|
currentPage = xtc->getPageCount() - 1;
|
|
requestUpdate();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
|
|
const bool skipPages =
|
|
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && heldMs > ReaderUtils::SKIP_HOLD_MS;
|
|
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();
|
|
}
|
|
|
|
XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const {
|
|
const int bookPageCount = static_cast<int>(xtc->getPageCount());
|
|
const int bookPage = static_cast<int>(currentPage) + 1;
|
|
std::string title =
|
|
SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : "";
|
|
|
|
if (!xtc->hasChapters()) {
|
|
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
|
|
}
|
|
|
|
const auto& chapters = xtc->getChapters();
|
|
const auto chapterIt = std::find_if(chapters.begin(), chapters.end(), [this](const xtc::ChapterInfo& chapter) {
|
|
return currentPage >= chapter.startPage && currentPage <= chapter.endPage;
|
|
});
|
|
|
|
if (chapterIt == chapters.end() || chapterIt->endPage < chapterIt->startPage) {
|
|
return StatusBarInfo{bookPage, bookPageCount, std::move(title)};
|
|
}
|
|
|
|
if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) {
|
|
title = chapterIt->name.empty() ? tr(STR_UNNAMED) : chapterIt->name;
|
|
}
|
|
|
|
return StatusBarInfo{static_cast<int>(currentPage - chapterIt->startPage) + 1,
|
|
static_cast<int>(chapterIt->endPage - chapterIt->startPage) + 1, std::move(title)};
|
|
}
|
|
|
|
void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position) const {
|
|
const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM &&
|
|
position == StatusBarOverlayPosition::Bottom;
|
|
const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP &&
|
|
position == StatusBarOverlayPosition::Top;
|
|
if (!drawBottom && !drawTop) {
|
|
return;
|
|
}
|
|
|
|
const int statusBarHeight = UITheme::getInstance().getStatusBarHeight();
|
|
if (statusBarHeight <= 0) {
|
|
return;
|
|
}
|
|
|
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
|
&orientedMarginLeft);
|
|
|
|
int clearY;
|
|
int paddingBottom = 0;
|
|
if (position == StatusBarOverlayPosition::Bottom) {
|
|
clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4;
|
|
if (clearY < 0) {
|
|
clearY = 0;
|
|
}
|
|
} else {
|
|
clearY = orientedMarginTop;
|
|
paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4;
|
|
}
|
|
const int clearHeight = position == StatusBarOverlayPosition::Bottom
|
|
? renderer.getScreenHeight() - orientedMarginBottom - clearY
|
|
: statusBarHeight + 4;
|
|
if (clearHeight > 0) {
|
|
renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false);
|
|
}
|
|
|
|
const int pageCount = static_cast<int>(xtc->getPageCount());
|
|
const int displayPage = static_cast<int>(currentPage) + 1;
|
|
const float progress = pageCount > 0 ? (static_cast<float>(displayPage) * 100.0f) / pageCount : 0.0f;
|
|
const auto pageInfo = getStatusBarInfo();
|
|
GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom);
|
|
}
|
|
|
|
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: bufferSize=%lu bitDepth=%u error=%s", currentPage, pageBufferSize,
|
|
bitDepth, xtc::errorToString(xtc->getLastError()));
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
ReaderUtils::displayWithRefreshCycle(renderer, 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);
|
|
|
|
if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) {
|
|
renderStatusBarOverlay(StatusBarOverlayPosition::Top);
|
|
} else {
|
|
renderStatusBarOverlay(StatusBarOverlayPosition::Bottom);
|
|
}
|
|
|
|
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
|
|
|
LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);
|
|
}
|
|
|
|
void XtcReaderActivity::saveProgress() const {
|
|
HalFile 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() {
|
|
HalFile 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();
|
|
}
|
|
}
|
|
|
|
ScreenshotInfo XtcReaderActivity::getScreenshotInfo() const {
|
|
ScreenshotInfo info;
|
|
info.readerType = ScreenshotInfo::ReaderType::Xtc;
|
|
if (xtc) {
|
|
const std::string t = xtc->getTitle();
|
|
snprintf(info.title, sizeof(info.title), "%s", t.c_str());
|
|
const uint32_t pageCount = xtc->getPageCount();
|
|
info.totalPages = pageCount;
|
|
// Clamp to last valid page to avoid sentinel value (currentPage == pageCount)
|
|
uint32_t clampedPage = (pageCount > 0 && currentPage >= pageCount) ? pageCount - 1 : currentPage;
|
|
info.progressPercent = pageCount > 0 ? xtc->calculateProgress(clampedPage) : 0;
|
|
info.currentPage = static_cast<int>(clampedPage) + 1;
|
|
} else {
|
|
info.currentPage = currentPage + 1;
|
|
}
|
|
return info;
|
|
}
|