From 3b4f2a11296aac483e23b7f694d95c3a6db39da2 Mon Sep 17 00:00:00 2001 From: GenesiaW <74142392+GenesiaW@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:42:41 +0800 Subject: [PATCH] feat: Auto Page Turn for Epub Reader (#1219) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) - Implements auto page turn feature for epub reader in the reader submenu * **What changes are included?** - added auto page turn feature in epub reader in the submenu - currently there are 5 settings, `OFF, 1, 3, 6, 12` pages per minute ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). - Replacement PR for #723 - when auto turn is enabled, space reserved for chapter title will be used to indicate auto page turn being active - Back and Confirm button is used to disable it --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**Partially (mainly code reviews)**_ --- lib/I18n/translations/english.yaml | 2 + src/activities/ActivityManager.cpp | 9 + src/activities/ActivityResult.h | 1 + src/activities/RenderLock.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 158 ++++++++++++++---- src/activities/reader/EpubReaderActivity.h | 5 + .../reader/EpubReaderMenuActivity.cpp | 20 ++- .../reader/EpubReaderMenuActivity.h | 3 + src/activities/reader/TxtReaderActivity.cpp | 6 +- .../settings/StatusBarSettingsActivity.cpp | 2 +- src/components/UITheme.cpp | 7 + src/components/UITheme.h | 1 + src/components/themes/BaseTheme.cpp | 9 +- src/components/themes/BaseTheme.h | 3 +- 14 files changed, 181 insertions(+), 46 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 12656a47..8bc6105b 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -334,3 +334,5 @@ STR_FOOTNOTES: "Footnotes" STR_NO_FOOTNOTES: "No footnotes on this page" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" +STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " +STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 0f612dd4..52c506d7 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -250,3 +250,12 @@ void RenderLock::unlock() { isLocked = false; } } + +/** + * + * Checks if renderingMutex is busy. + * + * @return true if renderingMutex is busy, otherwise false. + * + */ +bool RenderLock::peek() { return xQueuePeek(activityManager.renderingMutex, NULL, 0) != pdTRUE; }; diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index dc947004..4062fce9 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -20,6 +20,7 @@ struct KeyboardResult { struct MenuResult { int action = -1; uint8_t orientation = 0; + uint8_t pageTurnOption = 0; }; struct ChapterResult { diff --git a/src/activities/RenderLock.h b/src/activities/RenderLock.h index eeda2cf6..5fb7c3df 100644 --- a/src/activities/RenderLock.h +++ b/src/activities/RenderLock.h @@ -13,4 +13,5 @@ class RenderLock { RenderLock& operator=(const RenderLock&) = delete; ~RenderLock(); void unlock(); + static bool peek(); }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f5c54cda..cc0a100b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -26,6 +26,8 @@ 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}; int clampPercent(int percent) { if (percent < 0) { @@ -126,6 +128,32 @@ void EpubReaderActivity::loop() { 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 + requestUpdate(); + return; + } + + if (!section) { + requestUpdate(); + return; + } + + // Skips page turn if renderingMutex is busy + if (RenderLock::peek()) { + lastPageTurnTime = millis(); + return; + } + + if ((millis() - lastPageTurnTime) >= pageTurnDuration) { + pageTurn(true); + return; + } + } + // Enter reader menu activity. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const int currentPage = section ? section->currentPage + 1 : 0; @@ -143,6 +171,7 @@ void EpubReaderActivity::loop() { // 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)); } @@ -194,6 +223,7 @@ 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); @@ -212,31 +242,9 @@ void EpubReaderActivity::loop() { } if (prevTriggered) { - 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(); + pageTurn(false); } else { - 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(); + pageTurn(true); } } @@ -452,6 +460,61 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { } } +void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { + if (selectedPageTurnOption == 0 || selectedPageTurnOption >= PAGE_TURN_LABELS.size()) { + automaticPageTurnActive = false; + 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. + 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(); + } + } + } + lastPageTurnTime = millis(); + requestUpdate(); +} + // TODO: Failure handling void EpubReaderActivity::render(RenderLock&& lock) { if (!epub) { @@ -472,6 +535,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); renderer.displayBuffer(); + automaticPageTurnActive = false; return; } @@ -482,8 +546,18 @@ void EpubReaderActivity::render(RenderLock&& lock) { orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin; - orientedMarginBottom += - std::max(SETTINGS.screenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); + + 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); + } if (!section) { const auto filepath = epub->getSpineItem(currentSpineIndex).href; @@ -546,6 +620,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD); renderStatusBar(); renderer.displayBuffer(); + automaticPageTurnActive = false; return; } @@ -554,6 +629,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD); renderStatusBar(); renderer.displayBuffer(); + automaticPageTurnActive = false; return; } @@ -564,7 +640,8 @@ void EpubReaderActivity::render(RenderLock&& lock) { section->clearCache(); section.reset(); requestUpdate(); // Try again after clearing cache - // TODO: prevent infinite loop if the page keeps failing to load for some reason + // TODO: prevent infinite loop if the page keeps failing to load for some reason + automaticPageTurnActive = false; return; } @@ -669,23 +746,34 @@ void EpubReaderActivity::renderStatusBar() const { const float sectionChapterProg = (pageCount > 0) ? (static_cast(currentPage) / pageCount) : 0; const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; - const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); std::string title; - if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { - if (tocIndex == -1) { - title = tr(STR_UNNAMED); - } else { + 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(); + + // 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; } + } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { title = epub->getTitle(); - } else { - title = ""; } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title); + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset); } void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 0d1c4567..91c6f049 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -14,6 +14,8 @@ class EpubReaderActivity final : public Activity { 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; @@ -21,6 +23,7 @@ class EpubReaderActivity final : public Activity { 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; @@ -40,6 +43,8 @@ class EpubReaderActivity final : public Activity { void jumpToPercent(int percent); 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); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 5afb77e4..1d95d9b7 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -21,12 +21,13 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { std::vector items; - items.reserve(9); + items.reserve(10); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); if (hasFootnotes) { items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); } 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::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}); @@ -64,13 +65,19 @@ void EpubReaderMenuActivity::loop() { return; } - setResult(MenuResult{static_cast(selectedAction), pendingOrientation}); + if (selectedAction == MenuAction::AUTO_PAGE_TURN) { + selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size(); + requestUpdate(); + return; + } + + setResult(MenuResult{static_cast(selectedAction), pendingOrientation, selectedPageTurnOption}); finish(); return; } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; - result.data = MenuResult{-1, pendingOrientation}; + result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption}; setResult(std::move(result)); finish(); return; @@ -133,6 +140,13 @@ void EpubReaderMenuActivity::render(RenderLock&&) { 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]; + const auto width = renderer.getTextWidth(UI_10_FONT_ID, value); + renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected); + } } // Footer / Hints diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 4748c2f1..9ddba93d 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -15,6 +15,7 @@ class EpubReaderMenuActivity final : public Activity { SELECT_CHAPTER, FOOTNOTES, GO_TO_PERCENT, + AUTO_PAGE_TURN, ROTATE_SCREEN, SCREENSHOT, DISPLAY_QR, @@ -48,8 +49,10 @@ class EpubReaderMenuActivity final : public Activity { ButtonNavigator buttonNavigator; std::string title = "Reader Menu"; uint8_t pendingOrientation = 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 pageTurnLabels = {I18N.get(StrId::STR_STATE_OFF), "1", "3", "6", "12"}; int currentPage = 0; int totalPages = 0; int bookProgressPercent = 0; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index b41f2ba1..994492ee 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -431,8 +431,10 @@ void TxtReaderActivity::renderPage() { void TxtReaderActivity::renderStatusBar() const { const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; - std::string title = txt->getTitle(); - + std::string title; + if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + title = txt->getTitle(); + } GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index e347e7a1..6ff0b34d 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -156,7 +156,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { std::string title; if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { title = tr(STR_EXAMPLE_BOOK); - } else { + } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { title = tr(STR_EXAMPLE_CHAPTER); } diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 8a8a559e..e29db872 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -103,3 +103,10 @@ int UITheme::getStatusBarHeight() { return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) + (showProgressBar ? (((SETTINGS.statusBarProgressBarThickness + 1) * 2) + metrics.progressBarMarginTop) : 0); } + +int UITheme::getProgressBarHeight() { + const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + const bool showProgressBar = + SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; + return (showProgressBar ? (((SETTINGS.statusBarProgressBarThickness + 1) * 2) + metrics.progressBarMarginTop) : 0); +} diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 0478fd3e..5befe279 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -23,6 +23,7 @@ class UITheme { static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); static UIIcon getFileIcon(std::string filename); static int getStatusBarHeight(); + static int getProgressBarHeight(); private: const ThemeMetrics* currentMetrics; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 1020117b..53d82a99 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -629,7 +629,8 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou } void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, - const int pageCount, std::string title, const int paddingBottom) const { + const int pageCount, std::string title, const int paddingBottom, + const int textYOffset) const { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, @@ -637,8 +638,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c // Draw Progress Text const auto screenHeight = renderer.getScreenHeight(); - const auto textY = - screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; + auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { @@ -688,7 +688,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c } // Draw Title - if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { + if (!title.empty()) { + textY -= textYOffset; // Centered chapter title text // Page width minus existing content with 30px padding on each side const int rendererableScreenWidth = diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index f11ce104..212f6e82 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -136,7 +136,8 @@ class BaseTheme { virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, - const int pageCount, std::string title, const int paddingBottom = 0) const; + const int pageCount, std::string title, const int paddingBottom = 0, + const int textYOffset = 0) const; virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const; virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const;