#include "EpubReaderActivity.h" #include #include #include #include #include #include #include "BookManager.h" #include "BookmarkStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" #include "activities/dictionary/DictionaryMenuActivity.h" #include "activities/dictionary/DictionarySearchActivity.h" #include "activities/dictionary/EpubWordSelectionActivity.h" #include "activities/util/QuickMenuActivity.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; constexpr int progressBarMarginTop = 1; // Progress file version for content offset tracking // Version 1: Added content offset for position restoration after re-indexing constexpr uint8_t EPUB_PROGRESS_VERSION = 1; } // 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(); // Check if cover generation is needed and do it NOW (blocking) const bool needsThumb = !SdMan.exists(epub->getThumbBmpPath().c_str()); const bool needsMicroThumb = !SdMan.exists(epub->getMicroThumbBmpPath().c_str()); const bool needsCoverFit = !SdMan.exists(epub->getCoverBmpPath(false).c_str()); const bool needsCoverCrop = !SdMan.exists(epub->getCoverBmpPath(true).c_str()); if (needsThumb || needsMicroThumb || needsCoverFit || needsCoverCrop) { // Show "Preparing book... [X%]" popup, updating every 3 seconds constexpr int boxMargin = 20; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]"); const int boxWidth = textWidth + boxMargin * 2; const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; constexpr int boxY = 50; unsigned long lastUpdate = 0; // Draw initial popup renderer.clearScreen(); renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); renderer.displayBuffer(EInkDisplay::FAST_REFRESH); // Generate covers with progress callback epub->generateAllCovers([&](int percent) { const unsigned long now = millis(); if ((now - lastUpdate) >= 3000) { lastUpdate = now; renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); char progressStr[32]; snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); renderer.displayBuffer(EInkDisplay::FAST_REFRESH); } }); } FsFile f; if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { const size_t fileSize = f.size(); if (fileSize >= 9) { // New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes uint8_t version; serialization::readPod(f, version); if (version == EPUB_PROGRESS_VERSION) { uint16_t spineIndex, pageNumber; serialization::readPod(f, spineIndex); serialization::readPod(f, pageNumber); serialization::readPod(f, savedContentOffset); currentSpineIndex = spineIndex; nextPageNumber = pageNumber; hasContentOffset = true; Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", millis(), currentSpineIndex, nextPageNumber, savedContentOffset); } else { // Unknown version, try legacy format f.seek(0); uint8_t data[4]; if (f.read(data, 4) == 4) { currentSpineIndex = data[0] + (data[1] << 8); nextPageNumber = data[2] + (data[3] << 8); hasContentOffset = false; Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(), version, currentSpineIndex, nextPageNumber); } } } else if (fileSize >= 4) { // Legacy format: just spineIndex (2) + pageNumber (2) = 4 bytes uint8_t data[4]; if (f.read(data, 4) == 4) { currentSpineIndex = data[0] + (data[1] << 8); nextPageNumber = data[2] + (data[3] << 8); hasContentOffset = false; Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %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 cache title/author for home screen APP_STATE.openEpubPath = epub->getPath(); APP_STATE.openBookTitle = epub->getTitle(); APP_STATE.openBookAuthor = epub->getAuthor(); APP_STATE.saveToFile(); RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); // Trigger first update updateRequired = true; xTaskCreate(&EpubReaderActivity::taskTrampoline, "EpubReaderActivityTask", 8192, // 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) { // Log stack high-water mark before deleting task (stack size: 8192 bytes) LOG_STACK_WATERMARK("EpubReaderActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; section.reset(); epub.reset(); } void EpubReaderActivity::loop() { // Pass input responsibility to sub activity if exists if (subActivity) { subActivity->loop(); return; } // Handle end-of-book prompt if (showingEndOfBookPrompt) { if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { endOfBookSelection = (endOfBookSelection + 2) % 3; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { endOfBookSelection = (endOfBookSelection + 1) % 3; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { handleEndOfBookAction(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { // Go back to last page instead currentSpineIndex = epub->getSpineItemsCount() - 1; nextPageNumber = UINT16_MAX; showingEndOfBookPrompt = false; updateRequired = true; return; } return; } // Enter chapter selection activity 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; exitActivity(); enterNewActivity(new EpubReaderChapterSelectionActivity( this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, [this] { exitActivity(); updateRequired = true; }, [this](const int newSpineIndex) { if (currentSpineIndex != newSpineIndex) { currentSpineIndex = newSpineIndex; nextPageNumber = 0; section.reset(); } exitActivity(); updateRequired = true; }, [this](const int newSpineIndex, const int newPage) { // Handle sync position if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { currentSpineIndex = newSpineIndex; nextPageNumber = newPage; section.reset(); } 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) { onGoBack(); return; } // Dictionary power button press if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::DICTIONARY && mappedInput.wasReleased(MappedInputManager::Button::Power)) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new DictionaryMenuActivity( renderer, mappedInput, [this](DictionaryMode mode) { // CRITICAL: Cache all needed values BEFORE exitActivity() destroys the lambda's owner // The lambda is stored in DictionaryMenuActivity, so exitActivity() destroys it GfxRenderer& cachedRenderer = renderer; MappedInputManager& cachedMappedInput = mappedInput; Section* cachedSection = section.get(); SemaphoreHandle_t cachedMutex = renderingMutex; EpubReaderActivity* self = this; // Handle dictionary mode selection - exitActivity deletes DictionaryMenuActivity exitActivity(); if (mode == DictionaryMode::ENTER_WORD) { // Enter word mode - show keyboard and search self->enterNewActivity(new DictionarySearchActivity(cachedRenderer, cachedMappedInput, [self]() { // On back from dictionary self->exitActivity(); self->updateRequired = true; }, "")); // Empty string = show keyboard } else { // Select from screen mode - show word selection on current page if (cachedSection) { xSemaphoreTake(cachedMutex, portMAX_DELAY); auto page = cachedSection->loadPageFromSectionFile(); if (page) { // Get margins for word selection positioning int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; cachedRenderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; // Cache the font ID before creating activity const int cachedFontId = SETTINGS.getReaderFontId(); self->enterNewActivity(new EpubWordSelectionActivity( cachedRenderer, cachedMappedInput, std::move(page), cachedFontId, orientedMarginLeft, orientedMarginTop, [self](const std::string& selectedWord) { // Word selected - look it up self->exitActivity(); self->enterNewActivity(new DictionarySearchActivity(self->renderer, self->mappedInput, [self]() { self->exitActivity(); self->updateRequired = true; }, selectedWord)); }, [self]() { // Cancelled word selection self->exitActivity(); self->updateRequired = true; })); xSemaphoreGive(cachedMutex); } else { xSemaphoreGive(cachedMutex); self->updateRequired = true; } } else { self->updateRequired = true; } } }, [this]() { // Cancelled dictionary menu - cache self before exitActivity destroys the lambda EpubReaderActivity* self = this; exitActivity(); self->updateRequired = true; }, section != nullptr)); // Word selection only available if section is loaded xSemaphoreGive(renderingMutex); return; } // Quick Menu power button press if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU && mappedInput.wasReleased(MappedInputManager::Button::Power)) { xSemaphoreTake(renderingMutex, portMAX_DELAY); // Check if current page is bookmarked bool isBookmarked = false; if (section) { const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset); } exitActivity(); enterNewActivity(new QuickMenuActivity( renderer, mappedInput, [this](QuickMenuAction action) { // Cache values before exitActivity EpubReaderActivity* self = this; GfxRenderer& cachedRenderer = renderer; MappedInputManager& cachedMappedInput = mappedInput; Section* cachedSection = section.get(); SemaphoreHandle_t cachedMutex = renderingMutex; exitActivity(); if (action == QuickMenuAction::DICTIONARY) { // Open dictionary menu self->enterNewActivity(new DictionaryMenuActivity( cachedRenderer, cachedMappedInput, [self](DictionaryMode mode) { GfxRenderer& r = self->renderer; MappedInputManager& m = self->mappedInput; Section* s = self->section.get(); SemaphoreHandle_t mtx = self->renderingMutex; self->exitActivity(); if (mode == DictionaryMode::ENTER_WORD) { self->enterNewActivity(new DictionarySearchActivity(r, m, [self]() { self->exitActivity(); self->updateRequired = true; }, "")); } else if (s) { xSemaphoreTake(mtx, portMAX_DELAY); auto page = s->loadPageFromSectionFile(); if (page) { int mt, mr, mb, ml; r.getOrientedViewableTRBL(&mt, &mr, &mb, &ml); mt += SETTINGS.screenMargin; ml += SETTINGS.screenMargin; const int fontId = SETTINGS.getReaderFontId(); self->enterNewActivity(new EpubWordSelectionActivity( r, m, std::move(page), fontId, ml, mt, [self](const std::string& word) { self->exitActivity(); self->enterNewActivity(new DictionarySearchActivity( self->renderer, self->mappedInput, [self]() { self->exitActivity(); self->updateRequired = true; }, word)); }, [self]() { self->exitActivity(); self->updateRequired = true; })); xSemaphoreGive(mtx); } else { xSemaphoreGive(mtx); self->updateRequired = true; } } else { self->updateRequired = true; } }, [self]() { self->exitActivity(); self->updateRequired = true; }, self->section != nullptr)); } else if (action == QuickMenuAction::ADD_BOOKMARK) { // Toggle bookmark on current page if (self->section) { const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage); const std::string& bookPath = self->epub->getPath(); if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) { // Remove bookmark BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset); } else { // Add bookmark with auto-generated name Bookmark bm; bm.spineIndex = self->currentSpineIndex; bm.contentOffset = contentOffset; bm.pageNumber = self->section->currentPage; bm.timestamp = millis() / 1000; // Approximate timestamp // Generate name: "Chapter - Page X" or fallback std::string chapterTitle; const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex); if (tocIndex >= 0) { chapterTitle = self->epub->getTocItem(tocIndex).title; } if (!chapterTitle.empty()) { bm.name = chapterTitle + " - Page " + std::to_string(self->section->currentPage + 1); } else { bm.name = "Page " + std::to_string(self->section->currentPage + 1); } BookmarkStore::addBookmark(bookPath, bm); } } self->updateRequired = true; } else if (action == QuickMenuAction::CLEAR_CACHE) { // Navigate to Clear Cache activity if (self->onGoToClearCache) { xSemaphoreGive(cachedMutex); self->onGoToClearCache(); return; } self->updateRequired = true; } else if (action == QuickMenuAction::GO_TO_SETTINGS) { // Navigate to Settings activity if (self->onGoToSettings) { xSemaphoreGive(cachedMutex); self->onGoToSettings(); return; } self->updateRequired = true; } }, [this]() { EpubReaderActivity* self = this; exitActivity(); self->updateRequired = true; }, isBookmarked)); xSemaphoreGive(renderingMutex); 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 - this is now handled by the prompt // Just ensure we don't go past the end if (currentSpineIndex >= epub->getSpineItemsCount()) { 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); } } // TODO: Failure handling 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 prompt if (currentSpineIndex == epub->getSpineItemsCount()) { showingEndOfBookPrompt = true; renderEndOfBookPrompt(); return; } showingEndOfBookPrompt = false; // 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 += SETTINGS.screenMargin; // Add status bar margin if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { // Add additional margin for status bar if progress bar is shown const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); } 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; bool sectionWasReIndexed = false; 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()); sectionWasReIndexed = true; // 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()); } // Determine the correct page to display if (nextPageNumber == UINT16_MAX) { // Special case: go to last page section->currentPage = section->pageCount - 1; } else if (sectionWasReIndexed && hasContentOffset) { // Section was re-indexed (settings changed) and we have a content offset // Use the offset to find the correct page const int restoredPage = section->findPageForContentOffset(savedContentOffset); section->currentPage = restoredPage; Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", millis(), savedContentOffset, restoredPage, nextPageNumber); // Clear the offset flag since we've used it hasContentOffset = false; } else { // Normal case: use the saved page number 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(); } // Handle empty pages (e.g., from malformed chapters that couldn't be parsed) if (p->elements.empty()) { Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis()); renderer.drawCenteredText(UI_12_FONT_ID, 280, "Chapter content unavailable", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 320, "(File may be malformed)"); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); return; } const auto start = millis(); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } // Save progress with content offset for position restoration after re-indexing FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { // Get content offset for current page const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); // New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes serialization::writePod(f, EPUB_PROGRESS_VERSION); serialization::writePod(f, static_cast(currentSpineIndex)); serialization::writePod(f, static_cast(section->currentPage)); serialization::writePod(f, contentOffset); f.close(); Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", millis(), currentSpineIndex, section->currentPage, contentOffset); } } 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); // Draw bookmark indicator (folded corner) if this page is bookmarked if (section) { const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); if (BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset)) { // Draw folded corner in top-right const int screenWidth = renderer.getScreenWidth(); constexpr int cornerSize = 20; const int cornerX = screenWidth - orientedMarginRight - cornerSize; const int cornerY = orientedMarginTop; // Draw triangle (folded corner effect) const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize}; const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize}; renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle } } renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); pagesUntilFullRefresh--; } // grayscale rendering requires storing the BW buffer first // If we can't allocate memory for the backup, skip grayscale to avoid artifacts // TODO: Only do this if font supports it if (SETTINGS.textAntiAliasing) { // Try to save BW buffer - if this fails, skip grayscale rendering entirely const bool bwBufferStored = renderer.storeBwBuffer(); if (bwBufferStored) { 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 showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; 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; int progressTextWidth = 0; // Calculate progress in book const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; if (showProgressText || showProgressPercentage) { // Right aligned text for progress counter char progressStr[32]; // Hide percentage when progress bar is shown to reduce clutter if (showProgressPercentage) { snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount, bookProgress); } else { snprintf(progressStr, sizeof(progressStr), "%d/%d", section->currentPage + 1, section->pageCount); } progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, progressStr); } if (showProgressBar) { // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area ScreenComponents::drawBookProgressBar(renderer, static_cast(bookProgress)); } 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; 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::renderEndOfBookPrompt() { const int pageWidth = renderer.getScreenWidth(); const int pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); // Title renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD); // Book title (truncated if needed) std::string bookTitle = epub->getTitle(); if (bookTitle.length() > 30) { bookTitle = bookTitle.substr(0, 27) + "..."; } renderer.drawCenteredText(UI_10_FONT_ID, 120, bookTitle.c_str()); // Menu options const int menuStartY = pageHeight / 2 - 30; constexpr int menuLineHeight = 45; constexpr int menuItemWidth = 140; const int menuX = (pageWidth - menuItemWidth) / 2; const char* options[] = {"Archive", "Delete", "Keep"}; for (int i = 0; i < 3; i++) { const int optionY = menuStartY + i * menuLineHeight; if (endOfBookSelection == i) { renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5); } renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i); } // Button hints const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } void EpubReaderActivity::handleEndOfBookAction() { const std::string bookPath = epub->getPath(); switch (endOfBookSelection) { case 0: // Archive BookManager::archiveBook(bookPath); onGoHome(); break; case 1: // Delete BookManager::deleteBook(bookPath); onGoHome(); break; case 2: // Keep default: onGoHome(); break; } }