#include "EpubReaderActivity.h" #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderMenuActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; } // namespace void EpubReaderActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void EpubReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); if (!epub) { return; } // Configure screen orientation based on settings switch (SETTINGS.orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: renderer.setOrientation(GfxRenderer::Orientation::Portrait); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); break; case CrossPointSettings::ORIENTATION::INVERTED: renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); break; default: break; } renderingMutex = xSemaphoreCreateMutex(); epub->setupCacheDir(); FsFile f; if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 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(); } // We may want a better condition to detect if we are opening for the first time. // This will trigger if the book is re-opened at Chapter 0. if (currentSpineIndex == 0) { int textSpineIndex = epub->getSpineIndexForTextReference(); if (textSpineIndex != 0) { currentSpineIndex = textSpineIndex; Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(), textSpineIndex); } } // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); RECENT_BOOKS.addBook(epub->getPath()); // Trigger first update updateRequired = true; xTaskCreate(&EpubReaderActivity::taskTrampoline, "EpubReaderActivityTask", 24576, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void EpubReaderActivity::onExit() { ActivityWithSubactivity::onExit(); // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); // 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 (subActivity) { subActivity->loop(); return; } // Enter chapter selection activity or menu if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; // Show menu instead of direct chapter selection, to allow access to footnotes exitActivity(); enterNewActivity(new EpubReaderMenuActivity( this->renderer, this->mappedInput, [this] { // onGoBack from menu updateRequired = true; // Re-enter reader activity logic if needed (handled by stack) // Actually ActivityWithSubactivity handles subActivity exit naturally exitActivity(); }, [this, currentPage, totalPages](EpubReaderMenuActivity::MenuOption option) { // onSelectOption - handle menu choice if (option == EpubReaderMenuActivity::CHAPTERS) { // Show chapter selection exitActivity(); enterNewActivity(new EpubReaderChapterSelectionActivity( this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, [this] { exitActivity(); updateRequired = true; }, [this](int newSpineIndex) { if (currentSpineIndex != newSpineIndex) { currentSpineIndex = newSpineIndex; nextPageNumber = 0; section.reset(); } exitActivity(); updateRequired = true; }, [this](int newSpineIndex, int newPage) { // Handle sync position if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { currentSpineIndex = newSpineIndex; nextPageNumber = newPage; section.reset(); } exitActivity(); updateRequired = true; })); } else if (option == EpubReaderMenuActivity::FOOTNOTES) { // Show footnotes page with current page notes exitActivity(); enterNewActivity(new EpubReaderFootnotesActivity( this->renderer, this->mappedInput, currentPageFootnotes, // Pass collected footnotes (reference) [this] { // onGoBack from footnotes exitActivity(); updateRequired = true; }, [this](const char* href) { // onSelectFootnote - navigate to the footnote location navigateToHref(href, true); // true = save current position 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) { if (isViewingFootnote) { restoreSavedPosition(); updateRequired = true; return; } else { 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; } // 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 = SETTINGS.longPressChapterSkip && mappedInput.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(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } // Apply screen viewable areas and additional padding int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin; orientedMarginBottom += statusBarMargin; if (!section) { const auto filepath = epub->getSpineItem(currentSpineIndex).href; Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); // Progress bar dimensions constexpr int barWidth = 200; constexpr int barHeight = 10; constexpr int boxMargin = 20; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; const int boxWidthNoBar = textWidth + boxMargin * 2; const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2; const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2; constexpr int boxY = 50; const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; // Always show "Indexing..." text first { renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); renderer.displayBuffer(); pagesUntilFullRefresh = 0; } // Setup callback - only called for chapters >= 50KB, redraws with progress bar auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] { renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing..."); renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); renderer.drawRect(barX, barY, barWidth, barHeight); renderer.displayBuffer(); }; // Progress callback to update progress bar auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { const int fillWidth = (barWidth - 2) * progress / 100; renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.displayBuffer(EInkDisplay::FAST_REFRESH); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { 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(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); 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(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); return; } { auto p = section->loadPageFromSectionFile(); if (!p) { Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); section->clearCache(); section.reset(); return renderScreen(); } Serial.printf("[%lu] [ERS] Page loaded: %d elements, %d footnotes\n", millis(), p->elements.size(), p->footnotes.size()); // Copy footnotes from page to currentPageFootnotes currentPageFootnotes.clear(); int maxFootnotes = (p->footnotes.size() < 8) ? p->footnotes.size() : 8; for (int i = 0; i < maxFootnotes; i++) { const FootnoteEntry& footnote = p->footnotes[i]; if (footnote.href[0] != '\0') { currentPageFootnotes.addFootnote(footnote.number, footnote.href); } } Serial.printf("[%lu] [ERS] Loaded %d footnotes for current page\n", millis(), p->footnotes.size()); const auto start = millis(); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", 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, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } 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 if (SETTINGS.textAntiAliasing) { renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderer.copyGrayscaleLsbBuffers(); // 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(); } void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { // determine visible status bar elements const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; // Position status bar near the bottom of the logical screen, regardless of orientation const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 4; if (showProgress) { // Calculate progress in book // TODO: use progress values for UI // cppcheck-suppress unreadVariable const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; // cppcheck-suppress unreadVariable const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; } // Left aligned battery icon and percentage if (showBattery) { ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); } if (showChapterTitle) { // Centered chatper title text // Page width minus existing content with 30px padding on each side const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; int progressTextWidth = 0; const int batterySize = showBattery ? (showBatteryPercentage ? 50 : 20) : 0; const int titleMarginLeft = batterySize + 30; const int titleMarginRight = progressTextWidth + 30; // Attempt to center title on the screen, but if title is too wide then later we will center it within the // available space. int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight); int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted; const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); std::string title; int titleWidth; if (tocIndex == -1) { title = "Unnamed"; titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); } else { const auto tocItem = epub->getTocItem(tocIndex); title = tocItem.title; titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); if (titleWidth > availableTitleSpace) { // Not enough space to center on the screen, center it within the remaining space instead availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; titleMarginLeftAdjusted = titleMarginLeft; } while (titleWidth > availableTitleSpace && title.length() > 11) { title.replace(title.length() - 8, 8, "..."); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } } renderer.drawText(SMALL_FONT_ID, titleMarginLeftAdjusted + orientedMarginLeft + (availableTitleSpace - 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; BookMetadataCache::SpineEntry entry = epub->getSpineItem(i); std::string spineItem = entry.href; 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; updateRequired = true; } }