Files
crosspoint-reader-mod/epub-reader-activity-diff.txt
cottongin bd2cea8b8d mod: Phase 2b - adapt HomeActivity, EpubReaderMenuActivity, EpubReaderActivity
HomeActivity: Add mod features on top of upstream ActivityManager pattern:
- Multi-server OPDS support (OpdsServerStore instead of single URL)
- Long-press recent book for BookManageMenuActivity
- Long-press Browse Files to open archive folder
- Placeholder cover generation for books without covers
- startActivityForResult pattern for manage menu

EpubReaderMenuActivity: Replace upstream menu items with mod menu:
- Add/Remove Bookmark, Lookup Word, Go to Bookmark, Lookup History
- Table of Contents, Toggle Orientation, Toggle Font Size
- Close Book, Delete Dictionary Cache
- Pass isBookmarked and currentFontSize to constructor
- Show current orientation/font size value inline

EpubReaderActivity: Add mod action handlers:
- Bookmark add/remove via BookmarkStore
- Go to Bookmark via EpubReaderBookmarkSelectionActivity
- Dictionary word lookup via DictionaryWordSelectActivity
- Lookup history via LookedUpWordsActivity
- Delete dictionary cache
- Font size toggle with section re-layout
- Close Book action

ActivityResult: Add fontSize field to MenuResult
Made-with: Cursor
2026-03-07 15:38:53 -05:00

1487 lines
64 KiB
Plaintext

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 <Epub/Page.h>
-#include <Epub/blocks/TextBlock.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
+#include <PlaceholderCoverGenerator.h>
#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<int> 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<int>(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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
- startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
- 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<MenuResult>(result.data);
- applyOrientation(menu.orientation);
- toggleAutoPageTurn(menu.pageTurnOption);
- if (!result.isCancelled) {
- onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(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<EpubReaderChapterSelectionActivity>(renderer, mappedInput, epub, path, spineIdx),
- [this](const ActivityResult& result) {
- if (!result.isCancelled && currentSpineIndex != std::get<ChapterResult>(result.data).spineIndex) {
- RenderLock lock(*this);
- currentSpineIndex = std::get<ChapterResult>(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<std::string> allWords;
+ for (const auto& element : p->elements) {
+ const auto* line = static_cast<const PageLine*>(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<EpubReaderFootnotesActivity>(renderer, mappedInput, currentPageFootnotes),
- [this](const ActivityResult& result) {
- if (!result.isCancelled) {
- const auto& footnoteResult = std::get<FootnoteResult>(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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int initialPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
- startActivityForResult(
- std::make_unique<EpubReaderPercentSelectionActivity>(renderer, mappedInput, initialPercent),
- [this](const ActivityResult& result) {
- if (!result.isCancelled) {
- jumpToPercent(std::get<PercentResult>(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<const PageLine&>(*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<Page> 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<const PageLine*>(nextPage->elements[0].get());
+ if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) {
+ nextPageFirstWord = firstLine->getBlock()->getWords().front();
}
}
- if (!fullText.empty()) {
- startActivityForResult(std::make_unique<QrDisplayActivity>(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<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex,
- currentPage, totalPages),
- [this](const ActivityResult& result) {
- if (!result.isCancelled) {
- const auto& sync = std::get<SyncResult>(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<uint8_t>(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<Section>(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> 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> 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<float>(currentPage) / pageCount) : 0;
+ const float sectionChapterProg = static_cast<float>(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<size_t>(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<float>(section->currentPage + 1) / section->pageCount) * 100 : 0;
+ GUI.drawReadingProgressBar(renderer, static_cast<size_t>(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 <Epub.h>
-#include <Epub/FootnoteEntry.h>
#include <Epub/Section.h>
+#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> epub;
std::unique_ptr<Section> 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<FootnoteEntry> 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<void()> onGoBack;
+ const std::function<void()> onGoHome;
void renderContents(std::unique_ptr<Page> 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> epub)
- : Activity("EpubReader", renderer, mappedInput), epub(std::move(epub)) {}
+ explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
+ const std::function<void()>& onGoBack, const std::function<void()>& 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; }
};