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)**_
This commit is contained in:
GenesiaW
2026-02-28 03:42:41 +08:00
committed by GitHub
parent 09cef70709
commit 3b4f2a1129
14 changed files with 181 additions and 46 deletions

View File

@@ -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<int> 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<MenuResult>(result.data);
applyOrientation(menu.orientation);
toggleAutoPageTurn(menu.pageTurnOption);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(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<uint8_t>(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<uint8_t>(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<float>(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) {

View File

@@ -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<FootnoteEntry> 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);

View File

@@ -21,12 +21,13 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
std::vector<MenuItem> 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<int>(selectedAction), pendingOrientation});
if (selectedAction == MenuAction::AUTO_PAGE_TURN) {
selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size();
requestUpdate();
return;
}
setResult(MenuResult{static_cast<int>(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

View File

@@ -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<StrId> orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED,
StrId::STR_LANDSCAPE_CCW};
const std::vector<const char*> pageTurnLabels = {I18N.get(StrId::STR_STATE_OFF), "1", "3", "6", "12"};
int currentPage = 0;
int totalPages = 0;
int bookProgressPercent = 0;

View File

@@ -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);
}