Xteink-X4-crosspoint-reader/src/activities/reader/EpubReaderActivity.cpp
2025-12-17 23:23:35 +01:00

573 lines
19 KiB
C++

#include "EpubReaderActivity.h"
#include <Epub/Page.h>
#include <GfxRenderer.h>
#include <SD.h>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderMenuActivity.h"
// Note: Vous devrez créer ces nouveaux fichiers:
// - EpubReaderMenuActivity.h/cpp (basé sur EpubReaderMenuScreen)
// - EpubReaderFootnotesActivity.h/cpp (basé sur EpubReaderFootnotesScreen)
#include "config.h"
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700;
constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8;
constexpr int marginRight = 10;
constexpr int marginBottom = 22;
constexpr int marginLeft = 10;
} // namespace
void EpubReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderActivity::onEnter() {
if (!epub) {
return;
}
renderingMutex = xSemaphoreCreateMutex();
epub->setupCacheDir();
if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) {
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str());
uint8_t data[4];
f.read(data, 4);
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber);
f.close();
f.close();
}
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderActivity::taskTrampoline, "EpubReaderActivityTask",
24576, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
section.reset();
epub.reset();
}
void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subAcitivity) {
subAcitivity->loop();
return;
}
// Enter menu activity
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
subAcitivity.reset(new EpubReaderMenuActivity(
this->renderer, this->inputManager,
[this] {
// onGoBack from menu
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
},
[this](EpubReaderMenuActivity::MenuOption option) {
// onSelectOption - handle menu choice
if (option == EpubReaderMenuActivity::CHAPTERS) {
// Show chapter selection
subAcitivity->onExit();
subAcitivity.reset(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex,
[this] {
// onGoBack from chapter selection
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
},
[this](const int newSpineIndex) {
// onSelectSpineIndex
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
}));
subAcitivity->onEnter();
} else if (option == EpubReaderMenuActivity::FOOTNOTES) {
// Show footnotes page with current page notes
subAcitivity->onExit();
subAcitivity.reset(new EpubReaderFootnotesActivity(
this->renderer, this->inputManager,
currentPageFootnotes, // Pass collected footnotes (reference)
[this] {
// onGoBack from footnotes
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
},
[this](const char* href) {
// onSelectFootnote - navigate to the footnote location
navigateToHref(href, true); // true = save current position
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
}));
subAcitivity->onEnter();
}
}));
subAcitivity->onEnter();
xSemaphoreGive(renderingMutex);
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (isViewingFootnote) {
restoreSavedPosition();
updateRequired = true;
return;
} else {
onGoBack();
return;
}
}
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
if (!prevReleased && !nextReleased) {
return;
}
// any button press when at end of the book goes back to the last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
updateRequired = true;
return;
}
const bool skipChapter = inputManager.getHeldTime() > skipChapterMs;
if (skipChapter) {
// We don't want to delete the section mid-render, so grab the semaphore
xSemaphoreTake(renderingMutex, portMAX_DELAY);
nextPageNumber = 0;
currentSpineIndex = nextReleased ? currentSpineIndex + 1 : currentSpineIndex - 1;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
// No current section, attempt to rerender the book
if (!section) {
updateRequired = true;
return;
}
if (prevReleased) {
if (section->currentPage > 0) {
section->currentPage--;
} else {
// We don't want to delete the section mid-render, so grab the semaphore
xSemaphoreTake(renderingMutex, portMAX_DELAY);
nextPageNumber = UINT16_MAX;
currentSpineIndex--;
section.reset();
xSemaphoreGive(renderingMutex);
}
updateRequired = true;
} else {
if (section->currentPage < section->pageCount - 1) {
section->currentPage++;
} else {
// We don't want to delete the section mid-render, so grab the semaphore
xSemaphoreTake(renderingMutex, portMAX_DELAY);
nextPageNumber = 0;
currentSpineIndex++;
section.reset();
xSemaphoreGive(renderingMutex);
}
updateRequired = true;
}
}
void EpubReaderActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderActivity::renderScreen() {
if (!epub) {
return;
}
// Edge case handling for sub-zero spine index
if (currentSpineIndex < 0) {
currentSpineIndex = 0;
}
// Based bounds of book, show end of book screen
if (currentSpineIndex > epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount();
}
// Show end of book screen
if (currentSpineIndex == epub->getSpineItemsCount()) {
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 300, "End of book", true, BOLD);
renderer.displayBuffer();
return;
}
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex);
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
SETTINGS.extraParagraphSpacing)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
{
renderer.grayscaleRevert();
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
constexpr int margin = 20;
// Round all coordinates to 8 pixel boundaries
const int x = ((GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2 + 7) / 8 * 8;
constexpr int y = 56;
const int w = (textWidth + margin * 2 + 7) / 8 * 8;
const int h = (renderer.getLineHeight(READER_FONT_ID) + margin * 2 + 7) / 8 * 8;
renderer.fillRect(x, y, w, h, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
// EXPERIMENTAL: Still suffers from ghosting
renderer.displayWindow(x, y, w, h);
pagesUntilFullRefresh = 0;
}
section->setupCacheDir();
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, SETTINGS.extraParagraphSpacing)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset();
return;
}
} else {
Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis());
}
if (nextPageNumber == UINT16_MAX) {
section->currentPage = section->pageCount - 1;
} else {
section->currentPage = nextPageNumber;
}
}
renderer.clearScreen();
if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(READER_FONT_ID, 300, "Empty chapter", true, BOLD);
renderStatusBar();
renderer.displayBuffer();
return;
}
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
renderer.drawCenteredText(READER_FONT_ID, 300, "Out of bounds", true, BOLD);
renderStatusBar();
renderer.displayBuffer();
return;
}
// Load page from SD - use pointer to avoid copying on stack
std::unique_ptr<Page> p = section->loadPageFromSD();
if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache();
section.reset();
return renderScreen();
}
// Copy footnotes from page to currentPageFootnotes
currentPageFootnotes.clear();
for (int i = 0; i < p->footnoteCount && i < 16; i++) {
FootnoteEntry* footnote = p->getFootnote(i);
if (footnote) {
currentPageFootnotes.addFootnote(footnote->number, footnote->href);
}
}
Serial.printf("[%lu] [ERS] Loaded %d footnotes for current page\n", millis(), p->footnoteCount);
const auto start = millis();
renderContents(std::move(p));
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
// Save progress
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE);
if (f) {
uint8_t data[4];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4);
f.close();
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
page->render(renderer, READER_FONT_ID);
renderStatusBar();
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
// Save bw buffer to reset buffer state after grayscale data sync
renderer.storeBwBuffer();
// grayscale rendering
// TODO: Only do this if font supports it
{
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, READER_FONT_ID);
renderer.copyGrayscaleLsbBuffers();
// Render and copy to MSB buffer
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, READER_FONT_ID);
renderer.copyGrayscaleMsbBuffers();
// display grayscale part
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
// restore the bw data
renderer.restoreBwBuffer();
}
void EpubReaderActivity::renderStatusBar() const {
constexpr auto textY = 776;
// Calculate progress in book
float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str());
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
char percentageBuf[8];
snprintf(percentageBuf, sizeof(percentageBuf), "%d%%", percentage);
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageBuf);
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageBuf);
// Battery icon drawing
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int x = marginLeft;
constexpr int y = 783;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// Fill battery based on percentage
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5;
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
// Centered chapter title text
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
const int titleMarginRight = progressTextWidth + 30 + marginRight;
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (tocIndex == -1) {
const char* title = "Unnamed";
const int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title);
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title);
} else {
const auto& tocItem = epub->getTocItem(tocIndex);
std::string title = tocItem.title;
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
// Truncate title if too long
while (titleWidth > availableTextWidth && title.length() > 8) {
title = title.substr(0, title.length() - 8) + "...";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
}
}
void EpubReaderActivity::navigateToHref(const char* href, bool savePosition) {
if (!epub || !href) return;
// Save current position if requested
if (savePosition && section) {
savedSpineIndex = currentSpineIndex;
savedPageNumber = section->currentPage;
isViewingFootnote = true;
Serial.printf("[%lu] [ERS] Saved position: spine %d, page %d\n", millis(), savedSpineIndex, savedPageNumber);
}
// Parse href: "filename.html#anchor"
std::string hrefStr(href);
std::string filename;
std::string anchor;
size_t hashPos = hrefStr.find('#');
if (hashPos != std::string::npos) {
filename = hrefStr.substr(0, hashPos);
anchor = hrefStr.substr(hashPos + 1);
} else {
filename = hrefStr;
}
// Extract just filename without path
size_t lastSlash = filename.find_last_of('/');
if (lastSlash != std::string::npos) {
filename = filename.substr(lastSlash + 1);
}
Serial.printf("[%lu] [ERS] Navigate to: %s (anchor: %s)\n", millis(), filename.c_str(), anchor.c_str());
int targetSpineIndex = -1;
// FIRST: Check if we have an inline footnote or paragraph note for this anchor
if (!anchor.empty()) {
// Try inline footnote first
std::string inlineFilename = "inline_" + anchor + ".html";
Serial.printf("[%lu] [ERS] Looking for inline footnote: %s\n", millis(), inlineFilename.c_str());
targetSpineIndex = epub->findVirtualSpineIndex(inlineFilename);
// If not found, try paragraph note
if (targetSpineIndex == -1) {
std::string pnoteFilename = "pnote_" + anchor + ".html";
Serial.printf("[%lu] [ERS] Looking for paragraph note: %s\n", millis(), pnoteFilename.c_str());
targetSpineIndex = epub->findVirtualSpineIndex(pnoteFilename);
}
if (targetSpineIndex != -1) {
Serial.printf("[%lu] [ERS] Found note at virtual index: %d\n", millis(), targetSpineIndex);
// Navigate to the note
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
} else {
Serial.printf("[%lu] [ERS] No virtual note found, trying normal navigation\n", millis());
}
}
// FALLBACK: Try to find the file in normal spine items
for (int i = 0; i < epub->getSpineItemsCount(); i++) {
if (epub->isVirtualSpineItem(i)) continue;
std::string spineItem = epub->getSpineItem(i);
size_t lastSlash = spineItem.find_last_of('/');
std::string spineFilename = (lastSlash != std::string::npos) ? spineItem.substr(lastSlash + 1) : spineItem;
if (spineFilename == filename) {
targetSpineIndex = i;
break;
}
}
if (targetSpineIndex == -1) {
Serial.printf("[%lu] [ERS] Could not find spine index for: %s\n", millis(), filename.c_str());
return;
}
// Navigate to the target chapter
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
Serial.printf("[%lu] [ERS] Navigated to spine index: %d\n", millis(), targetSpineIndex);
}
void EpubReaderActivity::restoreSavedPosition() {
if (savedSpineIndex >= 0 && savedPageNumber >= 0) {
Serial.printf("[%lu] [ERS] Restoring position: spine %d, page %d\n", millis(), savedSpineIndex, savedPageNumber);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
currentSpineIndex = savedSpineIndex;
nextPageNumber = savedPageNumber;
section.reset();
xSemaphoreGive(renderingMutex);
savedSpineIndex = -1;
savedPageNumber = -1;
isViewingFootnote = false;
}
}