diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d2af675..3868c4f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,33 +1,43 @@ #include "EpubReaderActivity.h" #include -#include #include #include #include #include #include +#include #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "EpubReaderBookmarkSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h" -#include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" +#include "EndOfBookMenuActivity.h" #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "MappedInputManager.h" -#include "QrDisplayActivity.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" -#include "util/ScreenshotUtil.h" +#include "util/BookManager.h" +#include "util/BookmarkStore.h" +#include "util/Dictionary.h" + +extern void enterDeepSleep(); namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; -// pages per minute, first item is 1 to prevent division by zero if accessed -const std::vector PAGE_TURN_LABELS = {1, 1, 3, 6, 12}; +constexpr unsigned long longPressConfirmMs = 700; +constexpr int statusBarMargin = 19; +constexpr int progressBarMarginTop = 1; + +// 8x8 1-bit hourglass icon for the indexing status bar indicator. +// Format: MSB-first, 0 = black pixel, 1 = white pixel (e-ink convention). +constexpr uint8_t kIndexingIcon[] = {0x00, 0x81, 0xC3, 0xE7, 0xE7, 0xC3, 0x81, 0x00}; +constexpr int kIndexingIconSize = 8; int clampPercent(int percent) { if (percent < 0) { @@ -63,7 +73,7 @@ void applyReaderOrientation(GfxRenderer& renderer, const uint8_t orientation) { } // namespace void EpubReaderActivity::onEnter() { - Activity::onEnter(); + ActivityWithSubactivity::onEnter(); if (!epub) { return; @@ -100,6 +110,67 @@ void EpubReaderActivity::onEnter() { } } + // Prerender covers and thumbnails on first open so Home and Sleep screens are instant. + // Each generate* call is a no-op if the file already exists, so this only does work once. + { + int totalSteps = 0; + if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) totalSteps++; + if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++; + for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { + if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++; + } + + if (totalSteps > 0) { + Rect popupRect = GUI.drawPopup(renderer, "Preparing book..."); + int completedSteps = 0; + + auto updateProgress = [&]() { + completedSteps++; + GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps); + }; + + if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) { + epub->generateCoverBmp(false); + // Fallback: generate placeholder if real cover extraction failed + if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(false))) { + if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), + 480, 800)) { + // Last resort: X-pattern marker + epub->generateInvalidFormatCoverBmp(false); + } + } + updateProgress(); + } + if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) { + epub->generateCoverBmp(true); + if (!Epub::isValidThumbnailBmp(epub->getCoverBmpPath(true))) { + if (!PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), + 480, 800)) { + // Last resort: X-pattern marker + epub->generateInvalidFormatCoverBmp(true); + } + } + updateProgress(); + } + for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) { + if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) { + epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]); + // Fallback: generate placeholder thumbnail + if (!Epub::isValidThumbnailBmp(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]))) { + const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i]; + const int thumbWidth = static_cast(thumbHeight * 0.6); + if (!PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(), + epub->getAuthor(), thumbWidth, thumbHeight)) { + // Last resort: X-pattern marker + epub->generateInvalidFormatThumbBmp(thumbHeight); + } + } + updateProgress(); + } + } + } + } + // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); @@ -110,7 +181,7 @@ void EpubReaderActivity::onEnter() { } void EpubReaderActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -122,74 +193,152 @@ void EpubReaderActivity::onExit() { } void EpubReaderActivity::loop() { - if (!epub) { - // Should never happen - finish(); - return; - } - - if (automaticPageTurnActive) { - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || - mappedInput.wasReleased(MappedInputManager::Button::Back)) { - automaticPageTurnActive = false; - // updates chapter title space to indicate page turn disabled + // Pass input responsibility to sub activity if exists + if (subActivity) { + subActivity->loop(); + // Deferred exit: process after subActivity->loop() returns to avoid use-after-free + if (pendingSubactivityExit) { + pendingSubactivityExit = false; + exitActivity(); requestUpdate(); + skipNextButtonCheck = true; // Skip button processing to ignore stale events + } + // Deferred go home: process after subActivity->loop() returns to avoid race condition + if (pendingGoHome) { + pendingGoHome = false; + exitActivity(); + if (onGoHome) { + onGoHome(); + } + return; // Don't access 'this' after callback + } + if (pendingSleep) { + pendingSleep = false; + exitActivity(); + enterDeepSleep(); return; } + return; + } - if (!section) { - requestUpdate(); - return; + if (pendingSleep) { + pendingSleep = false; + enterDeepSleep(); + return; + } + + // Handle pending go home when no subactivity (e.g., from long press back) + if (pendingGoHome) { + pendingGoHome = false; + if (onGoHome) { + onGoHome(); } + return; // Don't access 'this' after callback + } - // Skips page turn if renderingMutex is busy - if (RenderLock::peek()) { - lastPageTurnTime = millis(); - return; + // Deferred end-of-book menu (set in render() to avoid deadlock) + if (pendingEndOfBookMenu) { + pendingEndOfBookMenu = false; + endOfBookMenuOpened = true; + const std::string path = epub->getPath(); + enterNewActivity(new EndOfBookMenuActivity( + renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) { + exitActivity(); + switch (action) { + case EndOfBookMenuActivity::Action::ARCHIVE: + if (epub) BookManager::archiveBook(epub->getPath()); + pendingGoHome = true; + break; + case EndOfBookMenuActivity::Action::DELETE: + if (epub) BookManager::deleteBook(epub->getPath()); + pendingGoHome = true; + break; + case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS: + endOfBookMenuOpened = false; + currentSpineIndex = epub->getSpineItemsCount() - 1; + nextPageNumber = UINT16_MAX; + section.reset(); + openChapterSelection(); + break; + case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING: + currentSpineIndex = 0; + nextPageNumber = 0; + section.reset(); + endOfBookMenuOpened = false; + requestUpdate(); + break; + case EndOfBookMenuActivity::Action::CLOSE_BOOK: + pendingGoHome = true; + break; + case EndOfBookMenuActivity::Action::CLOSE_MENU: + currentSpineIndex = epub->getSpineItemsCount() - 1; + nextPageNumber = UINT16_MAX; + section.reset(); + endOfBookMenuOpened = false; + requestUpdate(); + break; + } + })); + return; + } + + // Skip button processing after returning from subactivity + // This prevents stale button release events from triggering actions + // We wait until: (1) all relevant buttons are released, AND (2) wasReleased events have been cleared + if (skipNextButtonCheck) { + const bool confirmCleared = !mappedInput.isPressed(MappedInputManager::Button::Confirm) && + !mappedInput.wasReleased(MappedInputManager::Button::Confirm); + const bool backCleared = !mappedInput.isPressed(MappedInputManager::Button::Back) && + !mappedInput.wasReleased(MappedInputManager::Button::Back); + if (confirmCleared && backCleared) { + skipNextButtonCheck = false; + ignoreNextConfirmRelease = false; } + return; + } - if ((millis() - lastPageTurnTime) >= pageTurnDuration) { - pageTurn(true); - return; + // Long press CONFIRM opens Table of Contents directly (skip menu) + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= longPressConfirmMs) { + ignoreNextConfirmRelease = true; + if (epub && epub->getTocItemsCount() > 0) { + openChapterSelection(true); // skip the stale release from this long-press } + return; } - // Enter reader menu activity. + // Short press CONFIRM opens reader menu if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (ignoreNextConfirmRelease) { + ignoreNextConfirmRelease = false; + return; + } const int currentPage = section ? section->currentPage + 1 : 0; const int totalPages = section ? section->pageCount : 0; float bookProgress = 0.0f; - if (epub->getBookSize() > 0 && section && section->pageCount > 0) { + if (epub && epub->getBookSize() > 0 && section && section->pageCount > 0) { const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty()), - [this](const ActivityResult& result) { - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + const bool hasDictionary = Dictionary::exists(); + const bool isBookmarked = + BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0); + exitActivity(); + enterNewActivity(new EpubReaderMenuActivity( + this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, + SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(), epub->getPath(), + [this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); }, + [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); } // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { - activityManager.goToFileBrowser(epub ? epub->getPath() : ""); + onGoBack(); return; } - // Short press BACK goes directly to home (or restores position if viewing footnote) + // Short press BACK goes directly to home if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { - if (footnoteDepth > 0) { - restoreSavedPosition(); - return; - } onGoHome(); return; } @@ -212,10 +361,11 @@ void EpubReaderActivity::loop() { return; } - // any botton press when at end of the book goes back to the last page + // 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; + endOfBookMenuOpened = false; requestUpdate(); return; } @@ -223,7 +373,6 @@ void EpubReaderActivity::loop() { const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; if (skipChapter) { - lastPageTurnTime = millis(); // We don't want to delete the section mid-render, so grab the semaphore { RenderLock lock(*this); @@ -242,12 +391,46 @@ void EpubReaderActivity::loop() { } if (prevTriggered) { - pageTurn(false); + if (section->currentPage > 0) { + section->currentPage--; + } else if (currentSpineIndex > 0) { + // We don't want to delete the section mid-render, so grab the semaphore + { + RenderLock lock(*this); + nextPageNumber = UINT16_MAX; + currentSpineIndex--; + section.reset(); + } + } + requestUpdate(); } else { - pageTurn(true); + if (section->currentPage < section->pageCount - 1) { + section->currentPage++; + } else { + // We don't want to delete the section mid-render, so grab the semaphore + { + RenderLock lock(*this); + nextPageNumber = 0; + currentSpineIndex++; + section.reset(); + } + } + requestUpdate(); } } +void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation, const uint8_t fontSize) { + exitActivity(); + // Apply the user-selected orientation when the menu is dismissed. + // This ensures the menu can be navigated without immediately rotating the screen. + applyOrientation(orientation); + // Apply font size change (no-op if unchanged). + applyFontSize(fontSize); + // Force a half refresh on the next render to clear menu/popup artifacts + pagesUntilFullRefresh = 1; + requestUpdate(); +} + // Translate an absolute percent into a spine index plus a normalized position // within that spine so we can jump after the section is loaded. void EpubReaderActivity::jumpToPercent(int percent) { @@ -311,127 +494,367 @@ void EpubReaderActivity::jumpToPercent(int percent) { } } +void EpubReaderActivity::openChapterSelection(bool initialSkipRelease) { + const int currentP = section ? section->currentPage : 0; + const int totalP = section ? section->pageCount : 0; + const int spineIdx = currentSpineIndex; + const std::string path = epub->getPath(); + + enterNewActivity(new EpubReaderChapterSelectionActivity( + this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP, + [this] { + exitActivity(); + requestUpdate(); + }, + [this](const int newSpineIndex) { + if (currentSpineIndex != newSpineIndex) { + currentSpineIndex = newSpineIndex; + nextPageNumber = 0; + section.reset(); + } + exitActivity(); + requestUpdate(); + }, + [this](const int newSpineIndex, const int newPage) { + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); + } + exitActivity(); + requestUpdate(); + }, + initialSkipRelease)); +} + void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { switch (action) { - case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { - const int spineIdx = currentSpineIndex; - const std::string path = epub->getPath(); - startActivityForResult( - std::make_unique(renderer, mappedInput, epub, path, spineIdx), - [this](const ActivityResult& result) { - if (!result.isCancelled && currentSpineIndex != std::get(result.data).spineIndex) { - RenderLock lock(*this); - currentSpineIndex = std::get(result.data).spineIndex; - nextPageNumber = 0; + case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: { + const int page = section ? section->currentPage : 0; + + // Extract first full sentence from the current page for the bookmark snippet. + // If the first word is lowercase, the page starts mid-sentence — skip to the + // next sentence boundary and start collecting from there. + std::string snippet; + if (section) { + auto p = section->loadPageFromSectionFile(); + if (p) { + // Gather all words on the page into a flat list for easier traversal + std::vector allWords; + for (const auto& element : p->elements) { + const auto* line = static_cast(element.get()); + if (!line) continue; + const auto& block = line->getBlock(); + if (!block) continue; + for (const auto& word : block->getWords()) { + allWords.push_back(word); + } + } + + if (!allWords.empty()) { + size_t startIdx = 0; + + // Check if the first word starts with a lowercase letter (mid-sentence) + const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0]; + if (firstChar >= 'a' && firstChar <= 'z') { + // Skip past the end of this partial sentence + for (size_t i = 0; i < allWords.size(); i++) { + if (!allWords[i].empty()) { + char last = allWords[i].back(); + if (last == '.' || last == '!' || last == '?' || last == ':') { + startIdx = i + 1; + break; + } + } + } + // If no sentence boundary found, fall back to using everything from the start + if (startIdx >= allWords.size()) { + startIdx = 0; + } + } + + // Collect words from startIdx until the next sentence boundary + for (size_t i = startIdx; i < allWords.size(); i++) { + if (!snippet.empty()) snippet += " "; + snippet += allWords[i]; + if (!allWords[i].empty()) { + char last = allWords[i].back(); + if (last == '.' || last == '!' || last == '?' || last == ':') { + break; + } + } + } + } + } + } + + BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet); + { + RenderLock lock(*this); + GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED)); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + } + vTaskDelay(750 / portTICK_PERIOD_MS); + // Exit the menu and return to reading — the bookmark indicator will show on re-render, + // and next menu open will reflect the updated state. + exitActivity(); + pagesUntilFullRefresh = 1; + requestUpdate(); + break; + } + case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: { + const int page = section ? section->currentPage : 0; + BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page); + { + RenderLock lock(*this); + GUI.drawPopup(renderer, tr(STR_BOOKMARK_REMOVED)); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + } + vTaskDelay(750 / portTICK_PERIOD_MS); + exitActivity(); + pagesUntilFullRefresh = 1; + requestUpdate(); + break; + } + case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: { + auto bookmarks = BookmarkStore::load(epub->getCachePath()); + + if (bookmarks.empty()) { + // No bookmarks: fall back to Table of Contents if available, otherwise go back + if (epub->getTocItemsCount() > 0) { + exitActivity(); + openChapterSelection(); + } + // If no TOC either, just return to reader (menu already closed by callback) + break; + } + + exitActivity(); + enterNewActivity(new EpubReaderBookmarkSelectionActivity( + this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(), + [this] { + exitActivity(); + requestUpdate(); + }, + [this](const int newSpineIndex, const int newPage) { + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; section.reset(); } - }); + exitActivity(); + requestUpdate(); + })); break; } - case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } - requestUpdate(); - }); + case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { + exitActivity(); + openChapterSelection(); break; } case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: { + // Launch the slider-based percent selector and return here on confirm/cancel. float bookProgress = 0.0f; if (epub && epub->getBookSize() > 0 && section && section->pageCount > 0) { const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; } const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); - startActivityForResult( - std::make_unique(renderer, mappedInput, initialPercent), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - jumpToPercent(std::get(result.data).percent); - } - }); + exitActivity(); + enterNewActivity(new EpubReaderPercentSelectionActivity( + renderer, mappedInput, initialPercent, + [this](const int percent) { + // Apply the new position and exit back to the reader. + jumpToPercent(percent); + exitActivity(); + requestUpdate(); + }, + [this]() { + // Cancel selection and return to the reader. + exitActivity(); + requestUpdate(); + })); break; } - case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: { - if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { - auto p = section->loadPageFromSectionFile(); - if (p) { - std::string fullText; - for (const auto& el : p->elements) { - if (el->getTag() == TAG_PageLine) { - const auto& line = static_cast(*el); - if (line.getBlock()) { - const auto& words = line.getBlock()->getWords(); - for (const auto& w : words) { - if (!fullText.empty()) fullText += " "; - fullText += w; - } - } + case EpubReaderMenuActivity::MenuAction::LOOKUP: { + // Gather data we need while holding the render lock + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + std::unique_ptr pageForLookup; + int readerFontId; + std::string bookCachePath; + uint8_t currentOrientation; + std::string nextPageFirstWord; + { + RenderLock lock(*this); + + // Compute margins (same logic as render) + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + orientedMarginRight += SETTINGS.screenMargin; + orientedMarginBottom += SETTINGS.screenMargin; + + if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { + auto metrics = UITheme::getInstance().getMetrics(); + const bool showProgressBar = + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR; + orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); + } + + // Load the current page + pageForLookup = section ? section->loadPageFromSectionFile() : nullptr; + readerFontId = SETTINGS.getReaderFontId(); + bookCachePath = epub->getCachePath(); + currentOrientation = SETTINGS.orientation; + + // Get first word of next page for cross-page hyphenation + if (section && section->currentPage < section->pageCount - 1) { + int savedPage = section->currentPage; + section->currentPage = savedPage + 1; + auto nextPage = section->loadPageFromSectionFile(); + section->currentPage = savedPage; + if (nextPage && !nextPage->elements.empty()) { + const auto* firstLine = static_cast(nextPage->elements[0].get()); + if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) { + nextPageFirstWord = firstLine->getBlock()->getWords().front(); } } - if (!fullText.empty()) { - startActivityForResult(std::make_unique(renderer, mappedInput, fullText), - [this](const ActivityResult& result) {}); - break; - } } } - // If no text or page loading failed, just close menu - requestUpdate(); + // Lock released — safe to call enterNewActivity which takes its own lock + exitActivity(); + + if (pageForLookup) { + enterNewActivity(new DictionaryWordSelectActivity( + renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop, + bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord)); + } + break; + } + case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: { + exitActivity(); + enterNewActivity(new LookedUpWordsActivity( + renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation, + [this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }, + true)); // initialSkipRelease: consumed the long-press that triggered this break; } case EpubReaderMenuActivity::MenuAction::GO_HOME: { - onGoHome(); - return; + // Defer go home to avoid race condition with display task + pendingGoHome = true; + break; } case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: { { RenderLock lock(*this); - if (epub && section) { + if (epub) { + // 2. BACKUP: Read current progress + // We use the current variables that track our position uint16_t backupSpine = currentSpineIndex; uint16_t backupPage = section->currentPage; uint16_t backupPageCount = section->pageCount; + section.reset(); + // 3. WIPE: Clear the cache directory epub->clearCache(); + + // 4. RESTORE: Re-setup the directory and rewrite the progress file epub->setupCacheDir(); + saveProgress(backupSpine, backupPage, backupPageCount); + + // 5. Remove from recent books so the home screen doesn't show a stale/placeholder cover + RECENT_BOOKS.removeBook(epub->getPath()); } } - onGoHome(); - return; + // Defer go home to avoid race condition with display task + pendingGoHome = true; + break; } - case EpubReaderMenuActivity::MenuAction::SCREENSHOT: { - { - RenderLock lock(*this); - pendingScreenshot = true; + case EpubReaderMenuActivity::MenuAction::ARCHIVE_BOOK: { + if (epub) { + BookManager::archiveBook(epub->getPath()); } - requestUpdate(); + pendingGoHome = true; + break; + } + case EpubReaderMenuActivity::MenuAction::DELETE_BOOK: { + if (epub) { + BookManager::deleteBook(epub->getPath()); + } + pendingGoHome = true; + break; + } + case EpubReaderMenuActivity::MenuAction::MANAGE_BOOK: + break; + case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK: { + if (epub) { + BookManager::reindexBook(epub->getPath(), false); + } + pendingGoHome = true; + break; + } + case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK_FULL: { + if (epub) { + BookManager::reindexBook(epub->getPath(), true); + } + pendingGoHome = true; break; } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; - startActivityForResult( - std::make_unique(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, - currentPage, totalPages), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& sync = std::get(result.data); - if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { - RenderLock lock(*this); - currentSpineIndex = sync.spineIndex; - nextPageNumber = sync.page; - section.reset(); - } + exitActivity(); + enterNewActivity(new KOReaderSyncActivity( + renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, + [this]() { + // On cancel - defer exit to avoid use-after-free + pendingSubactivityExit = true; + }, + [this](int newSpineIndex, int newPage) { + // On sync complete - update position and defer exit + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); } - }); + pendingSubactivityExit = true; + })); } break; } + case EpubReaderMenuActivity::MenuAction::PUSH_AND_SLEEP: { + if (KOREADER_STORE.hasCredentials()) { + const int cp = section ? section->currentPage : 0; + const int tp = section ? section->pageCount : 0; + exitActivity(); + enterNewActivity(new KOReaderSyncActivity( + renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, cp, tp, + [this]() { + // Push failed -- sleep anyway (silent failure) + pendingSleep = true; + }, + [this](int, int) { + // Push succeeded -- sleep + pendingSleep = true; + }, + KOReaderSyncActivity::SyncMode::PUSH_ONLY)); + } else { + // No credentials -- just sleep + pendingSleep = true; + } + break; + } + // Handled locally in the menu activity (cycle on Confirm, never dispatched here) + case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN: + case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE: + case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL: + break; } } @@ -462,63 +885,30 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { } } -void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { - if (selectedPageTurnOption == 0 || selectedPageTurnOption >= PAGE_TURN_LABELS.size()) { - automaticPageTurnActive = false; +void EpubReaderActivity::applyFontSize(const uint8_t fontSize) { + if (SETTINGS.fontSize == fontSize) { return; } - lastPageTurnTime = millis(); - // calculates page turn duration by dividing by number of pages - pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_LABELS[selectedPageTurnOption]; - automaticPageTurnActive = true; - - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only - if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { - // Preserve current reading position so we can restore after reflow. + // Preserve current reading position so we can restore after reflow. + { RenderLock lock(*this); if (section) { cachedSpineIndex = currentSpineIndex; cachedChapterTotalPageCount = section->pageCount; nextPageNumber = section->currentPage; } - section.reset(); - } -} -void EpubReaderActivity::pageTurn(bool isForwardTurn) { - if (isForwardTurn) { - if (section->currentPage < section->pageCount - 1) { - section->currentPage++; - } else { - // We don't want to delete the section mid-render, so grab the semaphore - { - RenderLock lock(*this); - nextPageNumber = 0; - currentSpineIndex++; - section.reset(); - } - } - } else { - if (section->currentPage > 0) { - section->currentPage--; - } else if (currentSpineIndex > 0) { - // We don't want to delete the section mid-render, so grab the semaphore - { - RenderLock lock(*this); - nextPageNumber = UINT16_MAX; - currentSpineIndex--; - section.reset(); - } - } + SETTINGS.fontSize = fontSize; + SETTINGS.saveToFile(); + + // Reset section to force re-layout with the new font size. + section.reset(); } - lastPageTurnTime = millis(); - requestUpdate(); } // TODO: Failure handling -void EpubReaderActivity::render(RenderLock&& lock) { +void EpubReaderActivity::render(Activity::RenderLock&& lock) { if (!epub) { return; } @@ -532,12 +922,11 @@ void EpubReaderActivity::render(RenderLock&& lock) { currentSpineIndex = epub->getSpineItemsCount(); } - // Show end of book screen + // End of book — defer menu creation to loop() to avoid deadlock (render holds the lock) if (currentSpineIndex == epub->getSpineItemsCount()) { - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - automaticPageTurnActive = false; + if (!endOfBookMenuOpened) { + pendingEndOfBookMenu = true; + } return; } @@ -548,41 +937,44 @@ void EpubReaderActivity::render(RenderLock&& lock) { orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin; - - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - - // reserves space for automatic page turn indicator when no status bar or progress bar only - if (automaticPageTurnActive && - (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { - orientedMarginBottom += - std::max(SETTINGS.screenMargin, - static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin)); - } else { - orientedMarginBottom += std::max(SETTINGS.screenMargin, statusBarHeight); + orientedMarginBottom += SETTINGS.screenMargin; + + auto metrics = UITheme::getInstance().getMetrics(); + + // 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::BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR; + orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); } + const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + if (!section) { + loadingSection = true; + preIndexedNextSpine = -1; + const auto filepath = epub->getSpineItem(currentSpineIndex).href; LOG_DBG("ERS", "Loading file: %s, index: %d", 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, SETTINGS.embeddedStyle, - SETTINGS.imageRendering)) { + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { LOG_DBG("ERS", "Cache not found, building..."); const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, - SETTINGS.imageRendering, popupFn)) { + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) { LOG_ERR("ERS", "Failed to persist page data to SD"); section.reset(); + loadingSection = false; return; } } else { @@ -595,16 +987,6 @@ void EpubReaderActivity::render(RenderLock&& lock) { section->currentPage = nextPageNumber; } - if (!pendingAnchor.empty()) { - if (const auto page = section->getPageForAnchor(pendingAnchor)) { - section->currentPage = *page; - LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page); - } else { - LOG_DBG("ERS", "Anchor '%s' not found in section %d", pendingAnchor.c_str(), currentSpineIndex); - } - pendingAnchor.clear(); - } - // handles changes in reader settings and reset to approximate position based on cached progress if (cachedChapterTotalPageCount > 0) { // only goes to relative position if spine index matches cached value @@ -625,6 +1007,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { section->currentPage = newPage; pendingPercentJump = false; } + + loadingSection = false; } renderer.clearScreen(); @@ -632,18 +1016,16 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (section->pageCount == 0) { LOG_DBG("ERS", "No pages to render"); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); - renderStatusBar(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); - automaticPageTurnActive = false; return; } if (section->currentPage < 0 || section->currentPage >= section->pageCount) { LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD); - renderStatusBar(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(); - automaticPageTurnActive = false; return; } @@ -653,26 +1035,81 @@ void EpubReaderActivity::render(RenderLock&& lock) { LOG_ERR("ERS", "Failed to load page from SD - clearing section cache"); section->clearCache(); section.reset(); + silentIndexingActive = false; requestUpdate(); // Try again after clearing cache - // TODO: prevent infinite loop if the page keeps failing to load for some reason - automaticPageTurnActive = false; + // TODO: prevent infinite loop if the page keeps failing to load for some reason return; } - // Collect footnotes from the loaded page - currentPageFootnotes = std::move(p->footnotes); + silentIndexingActive = false; + const bool textOnlyPage = !p->hasImages(); + if (textOnlyPage && SETTINGS.indexingDisplay != CrossPointSettings::INDEXING_DISPLAY::INDEXING_POPUP && + section->pageCount >= 1 && + ((section->pageCount == 1 && section->currentPage == 0) || + (section->pageCount >= 2 && section->currentPage == section->pageCount - 2)) && + currentSpineIndex + 1 < epub->getSpineItemsCount() && preIndexedNextSpine != currentSpineIndex + 1) { + Section probe(epub, currentSpineIndex + 1, renderer); + if (probe.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { + preIndexedNextSpine = currentSpineIndex + 1; + } else { + silentIndexingActive = true; + } + } const auto start = millis(); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); renderer.clearFontCache(); + + if (silentIndexingActive) { + silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight); + requestUpdate(); + } } saveProgress(currentSpineIndex, section->currentPage, section->pageCount); +} + +bool EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { + if (preIndexedNextSpine == currentSpineIndex + 1) { + silentIndexingActive = false; + return false; + } + + const bool shouldPreIndex = (section->pageCount == 1 && section->currentPage == 0) || + (section->pageCount >= 2 && section->currentPage == section->pageCount - 2); + if (!epub || !section || !shouldPreIndex) { + silentIndexingActive = false; + return false; + } - if (pendingScreenshot) { - pendingScreenshot = false; - ScreenshotUtil::takeScreenshot(renderer); + const int nextSpineIndex = currentSpineIndex + 1; + if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) { + silentIndexingActive = false; + return false; } + + Section nextSection(epub, nextSpineIndex, renderer); + if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { + preIndexedNextSpine = nextSpineIndex; + silentIndexingActive = false; + return false; + } + + LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex); + if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { + LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); + silentIndexingActive = false; + return false; + } + preIndexedNextSpine = nextSpineIndex; + silentIndexingActive = false; + return true; } void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { @@ -698,22 +1135,43 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // Force special handling for pages with images when anti-aliasing is on bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing; + if (page->countUncachedImages() > 0) { + page->renderTextOnly(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + page->renderImagePlaceholders(renderer, orientedMarginLeft, orientedMarginTop); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + renderer.displayBuffer(); + renderer.clearScreen(); + } + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderStatusBar(); + + // Draw bookmark ribbon indicator in top-right corner if current page is bookmarked + if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) { + const int screenWidth = renderer.getScreenWidth(); + const int bkWidth = 12; + const int bkHeight = 22; + const int bkX = screenWidth - orientedMarginRight - bkWidth + 2; + const int bkY = 0; + const int notchDepth = bkHeight / 3; + const int centerX = bkX + bkWidth / 2; + + const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX}; + const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight}; + renderer.fillPolygon(xPoints, yPoints, 5, true); + } + + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (imagePageWithAA) { // Double FAST_REFRESH with selective image blanking (pablohc's technique): // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust. // Instead, blank only the image area and do two fast refreshes. - // Step 1: Display page with image area blanked (text appears, image area white) - // Step 2: Re-render with images and display again (images appear clean) - int16_t imgX, imgY, imgW, imgH; + int imgX, imgY, imgW, imgH; if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false); renderer.displayBuffer(HalDisplay::FAST_REFRESH); - // Re-render page content to restore images into the blanked area page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderStatusBar(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { renderer.displayBuffer(HalDisplay::HALF_REFRESH); @@ -753,98 +1211,121 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or renderer.restoreBwBuffer(); } -void EpubReaderActivity::renderStatusBar() const { +void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) const { + auto metrics = UITheme::getInstance().getMetrics(); + + // determine visible status bar elements + const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBookProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR; + const bool showChapterProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR; + const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR; + const bool showBookPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_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::BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_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::BOOK_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_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 int currentPage = section->currentPage + 1; - const float pageCount = section->pageCount; - const float sectionChapterProg = (pageCount > 0) ? (static_cast(currentPage) / pageCount) : 0; + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; - std::string title; - - int textYOffset = 0; - - if (automaticPageTurnActive) { - title = tr(STR_AUTO_TURN_ENABLED) + std::to_string(60 * 1000 / pageTurnDuration); - - // calculates textYOffset when rendering title in status bar - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + if (showProgressText || showProgressPercentage || showBookPercentage) { + // Right aligned text for progress counter + char progressStr[32]; - // offsets text if no status bar or progress bar only - if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { - textYOffset += UITheme::getInstance().getMetrics().statusBarVerticalMargin; - } - - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { - title = tr(STR_UNNAMED); - const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); - if (tocIndex != -1) { - const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; + // 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 if (showBookPercentage) { + snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); + } else { + snprintf(progressStr, sizeof(progressStr), "%d/%d", section->currentPage + 1, section->pageCount); } - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { - title = epub->getTitle(); + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progressStr); } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset); -} - -void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { - if (!epub) return; + if (showBookProgressBar) { + // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area + GUI.drawReadingProgressBar(renderer, static_cast(bookProgress)); + } - // Push current position onto saved stack - if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) { - savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage}; - footnoteDepth++; - LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage); + if (showChapterProgressBar) { + // Draw chapter progress bar at the very bottom of the screen, from edge to edge of viewable area + const float chapterProgress = + (section->pageCount > 0) ? (static_cast(section->currentPage + 1) / section->pageCount) * 100 : 0; + GUI.drawReadingProgressBar(renderer, static_cast(chapterProgress)); } - // Extract fragment anchor (e.g. "#note1" or "chapter2.xhtml#note1") - std::string anchor; - const auto hashPos = hrefStr.find('#'); - if (hashPos != std::string::npos && hashPos + 1 < hrefStr.size()) { - anchor = hrefStr.substr(hashPos + 1); + if (showBattery) { + GUI.drawBatteryLeft(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight}, + showBatteryPercentage); } - // Check for same-file anchor reference (#anchor only) - bool sameFile = !hrefStr.empty() && hrefStr[0] == '#'; + 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 targetSpineIndex; - if (sameFile) { - targetSpineIndex = currentSpineIndex; - } else { - targetSpineIndex = epub->resolveHrefToSpineIndex(hrefStr); - } + const int batterySize = showBattery ? (showBatteryPercentage ? 50 : 20) : 0; + const int titleMarginLeft = batterySize + 30; + const int titleMarginRight = progressTextWidth + 30; - if (targetSpineIndex < 0) { - LOG_DBG("ERS", "Could not resolve href: %s", hrefStr.c_str()); - if (savePosition && footnoteDepth > 0) footnoteDepth--; // undo push - return; - } + // 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); - { - RenderLock lock(*this); - pendingAnchor = std::move(anchor); - currentSpineIndex = targetSpineIndex; - nextPageNumber = 0; - section.reset(); - } - requestUpdate(); - LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, hrefStr.c_str()); -} + std::string title; + int titleWidth; + if (tocIndex == -1) { + title = tr(STR_UNNAMED); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } 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; + } + if (titleWidth > availableTitleSpace) { + title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + } -void EpubReaderActivity::restoreSavedPosition() { - if (footnoteDepth <= 0) return; - footnoteDepth--; - const auto& pos = savedPositions[footnoteDepth]; - LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber); + renderer.drawText(SMALL_FONT_ID, + titleMarginLeftAdjusted + orientedMarginLeft + (availableTitleSpace - titleWidth) / 2, textY, + title.c_str()); + } - { - RenderLock lock(*this); - currentSpineIndex = pos.spineIndex; - nextPageNumber = pos.pageNumber; - section.reset(); + if (silentIndexingActive && SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { + const int batteryWidth = showBattery ? (showBatteryPercentage ? 50 : 20) : 0; + const int indicatorX = orientedMarginLeft + batteryWidth + 8; + if (SETTINGS.indexingDisplay == CrossPointSettings::INDEXING_DISPLAY::INDEXING_STATUS_TEXT) { + renderer.drawText(SMALL_FONT_ID, indicatorX, textY, tr(STR_INDEXING)); + } else if (SETTINGS.indexingDisplay == CrossPointSettings::INDEXING_DISPLAY::INDEXING_STATUS_ICON) { + renderer.drawIcon(kIndexingIcon, indicatorX, textY - kIndexingIconSize + 2, kIndexingIconSize, kIndexingIconSize); + } } - requestUpdate(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 316677b..a8232ac 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -1,64 +1,68 @@ #pragma once #include -#include #include +#include "DictionaryWordSelectActivity.h" #include "EpubReaderMenuActivity.h" -#include "activities/Activity.h" +#include "LookedUpWordsActivity.h" +#include "activities/ActivityWithSubactivity.h" -class EpubReaderActivity final : public Activity { +class EpubReaderActivity final : public ActivityWithSubactivity { std::shared_ptr epub; std::unique_ptr
section = nullptr; int currentSpineIndex = 0; int nextPageNumber = 0; - // Set when navigating to a footnote href with a fragment (e.g. #note1). - // Cleared on the next render after the new section loads and resolves it to a page. - std::string pendingAnchor; int pagesUntilFullRefresh = 0; int cachedSpineIndex = 0; int cachedChapterTotalPageCount = 0; - unsigned long lastPageTurnTime = 0UL; - unsigned long pageTurnDuration = 0UL; // Signals that the next render should reposition within the newly loaded section // based on a cross-book percentage jump. bool pendingPercentJump = false; // Normalized 0.0-1.0 progress within the target spine item, computed from book percentage. float pendingSpineProgress = 0.0f; - bool pendingScreenshot = false; - bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit - bool automaticPageTurnActive = false; - - // Footnote support - std::vector currentPageFootnotes; - struct SavedPosition { - int spineIndex; - int pageNumber; - }; - static constexpr int MAX_FOOTNOTE_DEPTH = 3; - SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; - int footnoteDepth = 0; + bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free + bool pendingGoHome = false; // Defer go home to avoid race condition with display task + bool pendingSleep = false; // Defer deep sleep until after push-and-sleep completes + bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit + bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm + volatile bool loadingSection = false; // True during the entire !section block (read from main loop) + bool silentIndexingActive = false; // True while silently pre-indexing the next chapter + int preIndexedNextSpine = -1; // Spine index already pre-indexed (prevents re-render loop) + bool endOfBookMenuOpened = false; // Guard to prevent repeated opening of EndOfBookMenuActivity + bool pendingEndOfBookMenu = false; // Deferred: open EndOfBookMenuActivity from loop(), not render() + const std::function onGoBack; + const std::function onGoHome; void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); - void renderStatusBar() const; + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + bool silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight); void saveProgress(int spineIndex, int currentPage, int pageCount); // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); + // Open the Table of Contents (chapter selection) as a subactivity. + // Pass initialSkipRelease=true when triggered by long-press to consume the stale release. + void openChapterSelection(bool initialSkipRelease = false); + void onReaderMenuBack(uint8_t orientation, uint8_t fontSize); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void applyOrientation(uint8_t orientation); - void toggleAutoPageTurn(uint8_t selectedPageTurnOption); - void pageTurn(bool isForwardTurn); - - // Footnote navigation - void navigateToHref(const std::string& href, bool savePosition = false); - void restoreSavedPosition(); + void applyFontSize(uint8_t fontSize); public: - explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub) - : Activity("EpubReader", renderer, mappedInput), epub(std::move(epub)) {} + explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub, + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("EpubReader", renderer, mappedInput), + epub(std::move(epub)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; - void render(RenderLock&& lock) override; - bool isReaderActivity() const override { return true; } + void render(Activity::RenderLock&& lock) override; + // Defer low-power mode and auto-sleep while a section is loading/building. + // !section covers the period before the Section object is created (including + // cover prerendering in onEnter). loadingSection covers the full !section block + // in render (including createSectionFile), during which section is non-null + // but the section file is still being built. + bool preventAutoSleep() override { return !section || loadingSection; } };