diff --git a/epub-reader-activity-diff.txt b/epub-reader-activity-diff.txt new file mode 100644 index 00000000..62817f62 --- /dev/null +++ b/epub-reader-activity-diff.txt @@ -0,0 +1,1486 @@ +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; } + }; diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 4062fce9..5c72cbef 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -21,6 +21,7 @@ struct MenuResult { int action = -1; uint8_t orientation = 0; uint8_t pageTurnOption = 0; + uint8_t fontSize = 0; }; struct ChapterResult { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 810cb50b..4a39b431 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -6,25 +6,32 @@ #include #include #include +#include #include #include +#include #include #include +#include "BookManageMenuActivity.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" +#include "activities/ActivityResult.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/BookManager.h" +#include "util/StringUtils.h" int HomeActivity::getMenuItemCount() const { int count = 4; // File Browser, Recents, File transfer, Settings if (!recentBooks.empty()) { count += recentBooks.size(); } - if (hasOpdsUrl) { + if (hasOpdsServers) { count++; } return count; @@ -59,45 +66,54 @@ void HomeActivity::loadRecentCovers(int coverHeight) { for (RecentBook& book : recentBooks) { if (!book.coverBmpPath.empty()) { std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); - if (!Storage.exists(coverPath.c_str())) { - // If epub, try to load the metadata for title/author and cover - if (FsHelpers::hasEpubExtension(book.path)) { - Epub epub(book.path, "/.crosspoint"); - // Skip loading css since we only need metadata here - epub.load(false, true); + if (!Epub::isValidThumbnailBmp(coverPath)) { + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, tr(STR_LOADING)); + } + GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); - // Try to generate thumbnail image for Continue Reading card - if (!showingLoading) { - showingLoading = true; - popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + bool success = false; + + if (StringUtils::checkFileExtension(book.path, ".epub")) { + Epub epub(book.path, "/.crosspoint"); + if (!epub.load(false, true)) { + epub.load(true, true); } - GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); - bool success = epub.generateThumbBmp(coverHeight); - if (!success) { - RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); - book.coverBmpPath = ""; + success = epub.generateThumbBmp(coverHeight); + if (success) { + const std::string thumbPath = epub.getThumbBmpPath(coverHeight); + RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath); + book.coverBmpPath = thumbPath; + } else { + const int thumbWidth = static_cast(coverHeight * 0.6); + success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight); + if (!success) { + epub.generateInvalidFormatThumbBmp(coverHeight); + } } - coverRendered = false; - requestUpdate(); - } else if (FsHelpers::hasXtcExtension(book.path)) { - // Handle XTC file + } else if (StringUtils::checkFileExtension(book.path, ".xtch") || + StringUtils::checkFileExtension(book.path, ".xtc")) { Xtc xtc(book.path, "/.crosspoint"); if (xtc.load()) { - // Try to generate thumbnail image for Continue Reading card - if (!showingLoading) { - showingLoading = true; - popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + success = xtc.generateThumbBmp(coverHeight); + if (success) { + const std::string thumbPath = xtc.getThumbBmpPath(coverHeight); + RECENT_BOOKS.updateBook(book.path, book.title, book.author, thumbPath); + book.coverBmpPath = thumbPath; } - GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); - bool success = xtc.generateThumbBmp(coverHeight); - if (!success) { - RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); - book.coverBmpPath = ""; - } - coverRendered = false; - requestUpdate(); } + if (!success) { + const int thumbWidth = static_cast(coverHeight * 0.6); + PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight); + } + } else { + const int thumbWidth = static_cast(coverHeight * 0.6); + PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight); } + + coverRendered = false; + requestUpdate(); } } progress++; @@ -110,8 +126,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { void HomeActivity::onEnter() { Activity::onEnter(); - // Check if OPDS browser URL is configured - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; @@ -184,17 +199,37 @@ void HomeActivity::loop() { requestUpdate(); }); + // Long-press Confirm: manage menu for recent books, or browse archive for Browse Files + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS && + !ignoreNextConfirmRelease) { + if (selectorIndex < static_cast(recentBooks.size())) { + ignoreNextConfirmRelease = true; + openManageMenu(recentBooks[selectorIndex].path); + return; + } + const int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); + if (menuSelectedIndex == 0) { + ignoreNextConfirmRelease = true; + activityManager.goToFileBrowser("/.archive"); + return; + } + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - // Calculate dynamic indices based on which options are available + if (ignoreNextConfirmRelease) { + ignoreNextConfirmRelease = false; + return; + } + int idx = 0; int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); const int fileBrowserIdx = idx++; const int recentsIdx = idx++; - const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; - if (selectorIndex < recentBooks.size()) { + if (selectorIndex < static_cast(recentBooks.size())) { onSelectBook(recentBooks[selectorIndex].path); } else if (menuSelectedIndex == fileBrowserIdx) { onFileBrowserOpen(); @@ -229,7 +264,7 @@ void HomeActivity::render(RenderLock&&) { tr(STR_SETTINGS_TITLE)}; std::vector menuIcons = {Folder, Recent, Transfer, Settings}; - if (hasOpdsUrl) { + if (hasOpdsServers) { // Insert OPDS Browser after File Browser menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); menuIcons.insert(menuIcons.begin() + 2, Library); @@ -269,3 +304,52 @@ void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); } void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); } void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); } + +void HomeActivity::openManageMenu(const std::string& bookPath) { + const bool isArchived = BookManager::isArchived(bookPath); + const std::string capturedPath = bookPath; + startActivityForResult( + std::make_unique(renderer, mappedInput, capturedPath, isArchived, true), + [this, capturedPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + auto action = static_cast(menuResult.action); + bool success = false; + switch (action) { + case BookManageMenuActivity::Action::ARCHIVE: + success = BookManager::archiveBook(capturedPath); + break; + case BookManageMenuActivity::Action::UNARCHIVE: + success = BookManager::unarchiveBook(capturedPath); + break; + case BookManageMenuActivity::Action::DELETE: + success = BookManager::deleteBook(capturedPath); + break; + case BookManageMenuActivity::Action::DELETE_CACHE: + success = BookManager::deleteBookCache(capturedPath); + break; + case BookManageMenuActivity::Action::REINDEX: + success = BookManager::reindexBook(capturedPath, false); + break; + case BookManageMenuActivity::Action::REINDEX_FULL: + success = BookManager::reindexBook(capturedPath, true); + break; + } + { + RenderLock lock(*this); + GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED)); + } + requestUpdateAndWait(); + recentBooks.clear(); + recentsLoaded = false; + recentsLoading = false; + coverRendered = false; + freeCoverBuffer(); + selectorIndex = 0; + firstRenderDone = false; + requestUpdate(); + }); +} diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index e2b24bea..be1c1b67 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -15,22 +15,27 @@ class HomeActivity final : public Activity { bool recentsLoading = false; bool recentsLoaded = false; bool firstRenderDone = false; - bool hasOpdsUrl = false; - bool coverRendered = false; // Track if cover has been rendered once - bool coverBufferStored = false; // Track if cover buffer is stored - uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image + bool hasOpdsServers = false; + bool coverRendered = false; + bool coverBufferStored = false; + uint8_t* coverBuffer = nullptr; std::vector recentBooks; + + bool ignoreNextConfirmRelease = false; + static constexpr unsigned long LONG_PRESS_MS = 700; + void onSelectBook(const std::string& path); void onFileBrowserOpen(); void onRecentsOpen(); void onSettingsOpen(); void onFileTransferOpen(); void onOpdsBrowserOpen(); + void openManageMenu(const std::string& bookPath); int getMenuItemCount() const; - bool storeCoverBuffer(); // Store frame buffer for cover image - bool restoreCoverBuffer(); // Restore frame buffer from stored cover - void freeCoverBuffer(); // Free the stored cover buffer + bool storeCoverBuffer(); + bool restoreCoverBuffer(); + void freeCoverBuffer(); void loadRecentBooks(int maxBooks); void loadRecentCovers(int coverHeight); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d2af6751..26a806d5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -10,16 +10,21 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "DictionaryWordSelectActivity.h" +#include "EpubReaderBookmarkSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" +#include "LookedUpWordsActivity.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/BookmarkStore.h" +#include "util/Dictionary.h" #include "util/ScreenshotUtil.h" namespace { @@ -164,13 +169,16 @@ void EpubReaderActivity::loop() { bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); + const bool isBookmarked = + section ? BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage) : false; startActivityForResult(std::make_unique( renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty()), + SETTINGS.orientation, !currentPageFootnotes.empty(), isBookmarked, SETTINGS.fontSize), [this](const ActivityResult& result) { - // Always apply orientation change even if the menu was cancelled + // Always apply orientation and font size even if the menu was cancelled const auto& menu = std::get(result.data); applyOrientation(menu.orientation); + applyFontSize(menu.fontSize); toggleAutoPageTurn(menu.pageTurnOption); if (!result.isCancelled) { onReaderMenuConfirm(static_cast(menu.action)); @@ -313,6 +321,7 @@ void EpubReaderActivity::jumpToPercent(int percent) { void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { switch (action) { + case EpubReaderMenuActivity::MenuAction::TABLE_OF_CONTENTS: case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { const int spineIdx = currentSpineIndex; const std::string path = epub->getPath(); @@ -432,6 +441,79 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } break; } + case EpubReaderMenuActivity::MenuAction::CLOSE_BOOK: + onGoHome(); + return; + case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: { + if (section && BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage, "")) { + requestUpdate(); + } + break; + } + case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: { + if (section && BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) { + requestUpdate(); + } + break; + } + case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: { + auto bookmarks = BookmarkStore::load(epub->getCachePath()); + startActivityForResult( + std::make_unique(renderer, mappedInput, epub, std::move(bookmarks), + epub->getCachePath()), + [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(); + } + } + }); + break; + } + case EpubReaderMenuActivity::MenuAction::LOOKUP_WORD: { + if (!section || !Dictionary::cacheExists()) { + requestUpdate(); + break; + } + auto p = section->loadPageFromSectionFile(); + if (!p) { + requestUpdate(); + break; + } + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + startActivityForResult( + std::make_unique( + renderer, mappedInput, std::move(p), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, + epub->getCachePath(), SETTINGS.orientation, ""), + [this](const ActivityResult&) { requestUpdate(); }); + break; + } + case EpubReaderMenuActivity::MenuAction::LOOKUP_HISTORY: { + if (!Dictionary::cacheExists()) { + requestUpdate(); + break; + } + startActivityForResult( + std::make_unique(renderer, mappedInput, epub->getCachePath(), + SETTINGS.getReaderFontId(), SETTINGS.orientation), + [this](const ActivityResult&) { requestUpdate(); }); + break; + } + case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: { + if (Dictionary::cacheExists()) { + Dictionary::deleteCache(); + } + requestUpdate(); + break; + } } } @@ -462,6 +544,25 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { } } +void EpubReaderActivity::applyFontSize(const uint8_t fontSize) { + if (fontSize >= CrossPointSettings::FONT_SIZE_COUNT || SETTINGS.fontSize == fontSize) { + return; + } + + { + RenderLock lock(*this); + if (section) { + cachedSpineIndex = currentSpineIndex; + cachedChapterTotalPageCount = section->pageCount; + nextPageNumber = section->currentPage; + } + + SETTINGS.fontSize = fontSize; + SETTINGS.saveToFile(); + section.reset(); + } +} + void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= PAGE_TURN_LABELS.size()) { automaticPageTurnActive = false; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 316677ba..3bdd9372 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -46,6 +46,7 @@ class EpubReaderActivity final : public Activity { void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void applyOrientation(uint8_t orientation); + void applyFontSize(uint8_t fontSize); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); void pageTurn(bool isForwardTurn); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 1d95d9b7..18c0e3fd 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -3,6 +3,7 @@ #include #include +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -10,30 +11,37 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, - const bool hasFootnotes) + const bool hasFootnotes, bool isBookmarked, uint8_t currentFontSize) : Activity("EpubReaderMenu", renderer, mappedInput), - menuItems(buildMenuItems(hasFootnotes)), + menuItems(buildMenuItems(hasFootnotes, isBookmarked)), title(title), pendingOrientation(currentOrientation), + pendingFontSize(currentFontSize < CrossPointSettings::FONT_SIZE_COUNT ? currentFontSize : 0), currentPage(currentPage), totalPages(totalPages), bookProgressPercent(bookProgressPercent) {} -std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { +std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, + bool isBookmarked) { std::vector items; - items.reserve(10); - items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); - if (hasFootnotes) { - items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); + items.reserve(13); + // Mod menu order + if (isBookmarked) { + items.push_back({MenuAction::REMOVE_BOOKMARK, StrId::STR_REMOVE_BOOKMARK}); + } else { + items.push_back({MenuAction::ADD_BOOKMARK, StrId::STR_ADD_BOOKMARK}); } - items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}); - items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN}); + items.push_back({MenuAction::LOOKUP_WORD, StrId::STR_LOOKUP_WORD}); + items.push_back({MenuAction::GO_TO_BOOKMARK, StrId::STR_GO_TO_BOOKMARK}); + items.push_back({MenuAction::LOOKUP_HISTORY, StrId::STR_LOOKUP_HISTORY}); + items.push_back({MenuAction::TABLE_OF_CONTENTS, StrId::STR_TABLE_OF_CONTENTS}); items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); - items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}); - items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR}); - items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}); + items.push_back({MenuAction::TOGGLE_ORIENTATION, StrId::STR_TOGGLE_ORIENTATION}); + items.push_back({MenuAction::TOGGLE_FONT_SIZE, StrId::STR_TOGGLE_FONT_SIZE}); items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); + items.push_back({MenuAction::CLOSE_BOOK, StrId::STR_CLOSE_BOOK}); items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); + items.push_back({MenuAction::DELETE_DICT_CACHE, StrId::STR_DELETE_DICT_CACHE}); return items; } @@ -58,26 +66,28 @@ void EpubReaderMenuActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const auto selectedAction = menuItems[selectedIndex].action; - if (selectedAction == MenuAction::ROTATE_SCREEN) { - // Cycle orientation preview locally; actual rotation happens on menu exit. - pendingOrientation = (pendingOrientation + 1) % orientationLabels.size(); + if (selectedAction == MenuAction::TOGGLE_ORIENTATION) { + // Toggle between preferred portrait and preferred landscape. + const bool isCurrentlyPortrait = + (pendingOrientation == CrossPointSettings::PORTRAIT || pendingOrientation == CrossPointSettings::INVERTED); + pendingOrientation = isCurrentlyPortrait ? SETTINGS.preferredLandscape : SETTINGS.preferredPortrait; requestUpdate(); return; } - if (selectedAction == MenuAction::AUTO_PAGE_TURN) { - selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size(); + if (selectedAction == MenuAction::TOGGLE_FONT_SIZE) { + pendingFontSize = (pendingFontSize + 1) % CrossPointSettings::FONT_SIZE_COUNT; requestUpdate(); return; } - setResult(MenuResult{static_cast(selectedAction), pendingOrientation, selectedPageTurnOption}); + setResult(MenuResult{static_cast(selectedAction), pendingOrientation, selectedPageTurnOption, pendingFontSize}); finish(); return; } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; - result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption}; + result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption, pendingFontSize}; setResult(std::move(result)); finish(); return; @@ -134,16 +144,14 @@ void EpubReaderMenuActivity::render(RenderLock&&) { renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, I18N.get(menuItems[i].labelId), !isSelected); - if (menuItems[i].action == MenuAction::ROTATE_SCREEN) { - // Render current orientation value on the right edge of the content area. + if (menuItems[i].action == MenuAction::TOGGLE_ORIENTATION) { const char* value = I18N.get(orientationLabels[pendingOrientation]); const auto width = renderer.getTextWidth(UI_10_FONT_ID, value); renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected); } - if (menuItems[i].action == MenuAction::AUTO_PAGE_TURN) { - // Render current page turn value on the right edge of the content area. - const auto value = pageTurnLabels[selectedPageTurnOption]; + if (menuItems[i].action == MenuAction::TOGGLE_FONT_SIZE) { + const char* value = I18N.get(fontSizeLabels[pendingFontSize]); const auto width = renderer.getTextWidth(UI_10_FONT_ID, value); renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected); } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 9ddba93d..34a14b17 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -21,12 +21,24 @@ class EpubReaderMenuActivity final : public Activity { DISPLAY_QR, GO_HOME, SYNC, - DELETE_CACHE + DELETE_CACHE, + // Mod-specific actions + ADD_BOOKMARK, + REMOVE_BOOKMARK, + LOOKUP_WORD, + LOOKUP_HISTORY, + GO_TO_BOOKMARK, + TABLE_OF_CONTENTS, + TOGGLE_ORIENTATION, + TOGGLE_FONT_SIZE, + CLOSE_BOOK, + DELETE_DICT_CACHE }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, const int totalPages, const int bookProgressPercent, - const uint8_t currentOrientation, const bool hasFootnotes); + const uint8_t currentOrientation, const bool hasFootnotes, + bool isBookmarked = false, uint8_t currentFontSize = 0); void onEnter() override; void onExit() override; @@ -39,7 +51,7 @@ class EpubReaderMenuActivity final : public Activity { StrId labelId; }; - static std::vector buildMenuItems(bool hasFootnotes); + static std::vector buildMenuItems(bool hasFootnotes, bool isBookmarked); // Fixed menu layout const std::vector menuItems; @@ -49,9 +61,11 @@ class EpubReaderMenuActivity final : public Activity { ButtonNavigator buttonNavigator; std::string title = "Reader Menu"; uint8_t pendingOrientation = 0; + uint8_t pendingFontSize = 0; uint8_t selectedPageTurnOption = 0; const std::vector orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}; + const std::vector fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE}; const std::vector pageTurnLabels = {I18N.get(StrId::STR_STATE_OFF), "1", "3", "6", "12"}; int currentPage = 0; int totalPages = 0;