Merge remote-tracking branch 'origin/develop' into feat-bluetooth
# Conflicts: # .gitmodules # freeink-sdk # platformio.ini # src/activities/reader/EpubReaderActivity.cpp
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#include "EndOfBookOptions.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
#include "util/NextBookFinder.h"
|
||||
|
||||
namespace {
|
||||
// Display name without the file extension, mirroring the file browser rows
|
||||
std::string displayName(const std::string& filename) {
|
||||
const auto pos = filename.rfind('.');
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
|
||||
if (isLoaded.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
folder = FsHelpers::extractFolderPath(currentBookPath);
|
||||
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
|
||||
selector = 0;
|
||||
// Release-publish so the main task, which gates all access on isLoaded, never
|
||||
// observes a partially built list
|
||||
isLoaded.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
|
||||
|
||||
std::string EndOfBookOptions::fullPath(const size_t index) const {
|
||||
if (index >= names.size()) {
|
||||
return {};
|
||||
}
|
||||
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
|
||||
}
|
||||
|
||||
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
|
||||
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selector < static_cast<int>(names.size())) {
|
||||
if (openPath) {
|
||||
*openPath = fullPath(selector);
|
||||
}
|
||||
return Action::OpenBook;
|
||||
}
|
||||
return Action::GoHome; // "Home" entry selected
|
||||
}
|
||||
|
||||
// Short-press Back returns to the last page; a long press falls through to the
|
||||
// reader's own handler (file browser). Home is reached through the list's Home entry.
|
||||
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
return Action::LastPage;
|
||||
}
|
||||
|
||||
// Selection movement on the standard list navigation buttons (side Up/Down plus front
|
||||
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
|
||||
// (press-triggered by default, release-triggered when a long-press behavior is
|
||||
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
|
||||
// press-triggered turns, the press that turned the final page already fired in the
|
||||
// reader, and its release must not double-fire into this menu.
|
||||
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
|
||||
const auto triggered = [&](const MappedInputManager::Button button) {
|
||||
return usePress ? input.wasPressed(button) : input.wasReleased(button);
|
||||
};
|
||||
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
|
||||
if (triggered(MappedInputManager::Button::NavPrevious)) {
|
||||
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
|
||||
return Action::Redraw;
|
||||
}
|
||||
if (triggered(MappedInputManager::Button::NavNext)) {
|
||||
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
|
||||
return Action::Redraw;
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
if (!menuActive()) {
|
||||
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
|
||||
// the previous fixed position on the 480x800 panel and scales to other resolutions.
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
|
||||
EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
|
||||
// the physical front buttons, which is a logical side/top edge in the rotated
|
||||
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
|
||||
// positions derive from the safe-area height and font line heights so other panel
|
||||
// resolutions scale (review request on #2532).
|
||||
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int titleY = safe.y + safe.height / 8;
|
||||
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
|
||||
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
|
||||
|
||||
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
|
||||
|
||||
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
|
||||
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
|
||||
[this](const int index) {
|
||||
return index < static_cast<int>(names.size()) ? displayName(names[index])
|
||||
: std::string(tr(STR_EOB_HOME));
|
||||
});
|
||||
|
||||
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
class MappedInputManager;
|
||||
|
||||
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
|
||||
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
|
||||
// draws the end screen. With no suggestions the end screen keeps its historical
|
||||
// plain-title look and behavior.
|
||||
class EndOfBookOptions {
|
||||
public:
|
||||
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
|
||||
|
||||
static constexpr size_t MAX_SUGGESTIONS = 3;
|
||||
|
||||
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
|
||||
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
|
||||
// is the release/acquire publication point that lets the main task read the finished
|
||||
// list safely.
|
||||
void loadOnce(const std::string& currentBookPath);
|
||||
|
||||
// True when the suggestion menu is showing and should own the reader's input.
|
||||
bool menuActive() const;
|
||||
|
||||
// Menu input handling, following the standard list idiom: side Up/Down and front
|
||||
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
|
||||
// Back press returns to the last page of the book. Fills openPath when the result is
|
||||
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
|
||||
// their normal input path (keeping long-press Back to the file browser working).
|
||||
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
|
||||
|
||||
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
|
||||
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
|
||||
|
||||
private:
|
||||
std::string folder;
|
||||
// Written by the render task in loadOnce(), immutable afterwards; the main task only
|
||||
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
|
||||
std::vector<std::string> names;
|
||||
int selector = 0;
|
||||
std::atomic<bool> isLoaded{false};
|
||||
|
||||
std::string fullPath(size_t index) const;
|
||||
};
|
||||
@@ -84,9 +84,10 @@ ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int
|
||||
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
|
||||
}
|
||||
|
||||
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const SavedProgressPosition& progress,
|
||||
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount,
|
||||
const ProgressRange& pageRange) {
|
||||
if (bookmark.xpath == progress.xpath) {
|
||||
if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount &&
|
||||
bookmark.computedChapterProgress == page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -231,6 +232,30 @@ void EpubReaderActivity::onExit() {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openReaderMenu() {
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : 0;
|
||||
float bookProgress = 0.0f;
|
||||
if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) {
|
||||
const float chapterProgress =
|
||||
static_cast<float>(section->currentPage) / static_cast<float>(section->estimatedTotalPages());
|
||||
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(), !cachedBookmarks.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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loop() {
|
||||
if (!epub) {
|
||||
// Should never happen
|
||||
@@ -238,6 +263,33 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive any in-progress incremental section build forward, off the page-turn critical path,
|
||||
// but only within a small window ahead of the reader: an unbounded build monopolized the
|
||||
// RenderLock and locked out page turns. The build follows the reader instead, and instant
|
||||
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
|
||||
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
||||
// isBuilding() under the lock since render() may have just finished it.
|
||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
|
||||
RenderLock lock;
|
||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
||||
// mutation, so it flags this as always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding()) {
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
requestUpdate();
|
||||
} else if (section->isBuildComplete() && applyDeferredReposition()) {
|
||||
// The chapter re-paginated since the saved progress (settings changed): we now know the
|
||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
||||
// finished. Two independent finished-book features key off this same condition.
|
||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||
@@ -298,6 +350,35 @@ void EpubReaderActivity::loop() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
||||
// block. A Confirm release after a long-press function (bookmark/sync) fired is left
|
||||
// to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease.
|
||||
if (atEndOfBook && endOfBookOptions.menuActive() &&
|
||||
!(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) {
|
||||
std::string openPath;
|
||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
||||
case EndOfBookOptions::Action::OpenBook:
|
||||
activityManager.goToReader(openPath);
|
||||
return;
|
||||
case EndOfBookOptions::Action::GoHome:
|
||||
onGoHome();
|
||||
return;
|
||||
case EndOfBookOptions::Action::LastPage:
|
||||
currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0);
|
||||
nextPageNumber = 0;
|
||||
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::Redraw:
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
|
||||
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
||||
// following the hold does not also open the menu.
|
||||
@@ -305,26 +386,7 @@ void EpubReaderActivity::loop() {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
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) {
|
||||
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(), !cachedBookmarks.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));
|
||||
}
|
||||
});
|
||||
openReaderMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,8 +467,14 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// At end of the book, forward button goes home and back button returns to last page
|
||||
// At end of the book with no suggestion menu, forward button goes home and back
|
||||
// button returns to last page
|
||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
if (endOfBookOptions.menuActive()) {
|
||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
||||
return;
|
||||
}
|
||||
if (nextTriggered) {
|
||||
onGoHome();
|
||||
} else {
|
||||
@@ -537,11 +605,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
loadCachedBookmarks();
|
||||
if (!result.isCancelled) {
|
||||
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||
int targetSpineIndex = sync.spineIndex;
|
||||
int targetPage = sync.page;
|
||||
const int activeTotalPages = section ? section->estimatedTotalPages() : 0;
|
||||
const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 &&
|
||||
currentSpineIndex == sync.spineIndex && sync.page >= 0 &&
|
||||
sync.page < sync.totalPages && activeTotalPages == sync.totalPages;
|
||||
|
||||
if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) {
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
CrossPointPosition fallback =
|
||||
ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages);
|
||||
targetSpineIndex = fallback.spineIndex;
|
||||
targetPage = fallback.pageNumber;
|
||||
}
|
||||
|
||||
if (currentSpineIndex != targetSpineIndex) {
|
||||
RenderLock lock(*this);
|
||||
currentSpineIndex = sync.spineIndex;
|
||||
nextPageNumber = sync.page;
|
||||
currentSpineIndex = targetSpineIndex;
|
||||
nextPageNumber = targetPage;
|
||||
section.reset();
|
||||
} else if (section && section->currentPage != targetPage) {
|
||||
RenderLock lock(*this);
|
||||
const int clampedTargetPage = std::max(0, targetPage);
|
||||
section->currentPage = clampedTargetPage;
|
||||
} else if (!section) {
|
||||
nextPageNumber = targetPage;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -661,7 +750,7 @@ bool EpubReaderActivity::launchKOReaderSync() {
|
||||
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
|
||||
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
@@ -759,7 +848,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
|
||||
|
||||
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
if (isForwardTurn) {
|
||||
if (section->currentPage < section->pageCount - 1) {
|
||||
// Advance within the section while there are (or may still be) more pages: either a built
|
||||
// page ahead, or the section is still building (windowed), in which case more pages exist
|
||||
// beyond the current watermark and render()'s ensure-built pump will lay them out. Only when
|
||||
// the section is fully built AND we're on its last page do we move to the next spine -- using
|
||||
// the live pageCount alone would mistake the build watermark for the end of a giant spine.
|
||||
if (section->currentPage < section->pageCount - 1 || section->isBuilding()) {
|
||||
section->currentPage++;
|
||||
} else {
|
||||
// We don't want to delete the section mid-render, so grab the semaphore
|
||||
@@ -811,8 +905,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
|
||||
// Show end of book screen
|
||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||
// Sole load site: runs on the render task (serialized by RenderLock); the main
|
||||
// task only reads the suggestions once the loaded flag is published
|
||||
endOfBookOptions.loadOnce(epub->getPath());
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
endOfBookOptions.render(renderer, mappedInput);
|
||||
renderer.displayBuffer();
|
||||
automaticPageTurnActive = false;
|
||||
showPendingSyncSaveError();
|
||||
@@ -847,33 +944,39 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
// A finalized cache serves every page as-is. A partial cache (suspended build from a
|
||||
// previous session) serves its pages instantly too, but a build must still run to lay
|
||||
// out the rest -- it re-parses from the top in the background (HTML already cached,
|
||||
// pages are deterministic) and finalizes, so the partial machinery retires itself.
|
||||
const bool cacheLoaded = section->loadSectionFile(
|
||||
SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||
if (cacheLoaded) {
|
||||
// Matching render params means identical pagination, so the saved page number is valid
|
||||
// as-is: consume any pending settings-change reposition. Without this, a chapter total
|
||||
// saved while the section was still building (i.e. a watermark, not the real count)
|
||||
// would remap the resume page against the finalized count and teleport the reader.
|
||||
cachedChapterTotalPageCount = 0;
|
||||
}
|
||||
const bool cacheComplete = cacheLoaded && !section->isPartial();
|
||||
if (!cacheComplete) {
|
||||
if (section->isPartial()) {
|
||||
LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount);
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
}
|
||||
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
|
||||
auto buildSection = [&]() {
|
||||
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
|
||||
};
|
||||
|
||||
bool built = buildSection();
|
||||
if (!built && SETTINGS.bluetoothEnabled) {
|
||||
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
|
||||
// Free the BLE stack, build, then restore it. The chapter is cached afterwards, so
|
||||
// this recovery runs at most once per uncached chapter; BT reconnects in a few s.
|
||||
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
|
||||
// On build failure with BT enabled: free the BLE stack, retry the build, then
|
||||
// restore it. The inflated HTML is cached after a successful build, so this
|
||||
// recovery runs at most once per uncached chapter; BT reconnects in a few s.
|
||||
const auto retryWithBleFreed = [&](auto&& buildFn) {
|
||||
LOG_INF("ERS", "Section build failed with Bluetooth on; freeing BLE RAM and retrying");
|
||||
bleinput::setLifecyclePaused(true);
|
||||
bleinput::stop();
|
||||
built = buildSection();
|
||||
const bool built = buildFn();
|
||||
const bool bleOk = bleinput::ensureStarted();
|
||||
bleinput::setLifecyclePaused(false);
|
||||
LOG_INF("ERS", "BLE restart after build: begin=%d", bleOk);
|
||||
@@ -882,36 +985,127 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// without ghosting the grayscale page.
|
||||
bleinput::showConnectingUntilLinked(renderer, mappedInput);
|
||||
requestGhostCleanup();
|
||||
}
|
||||
if (!built) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
return built;
|
||||
};
|
||||
|
||||
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
||||
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
||||
// resolve their landing page until the whole chapter is laid out, so they take the full
|
||||
// (blocking) build with the indexing popup. Everything else -- plain forward reads, resume,
|
||||
// and explicit page jumps -- only needs a specific page, so it builds incrementally to that
|
||||
// page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*)
|
||||
// is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page
|
||||
// count is known, so it never blocks the first page.
|
||||
// Only a percent jump truly needs the whole chapter up front (percent -> page needs the final
|
||||
// page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below --
|
||||
// the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0
|
||||
// without indexing the whole chapter.
|
||||
const bool needsFullBuild = pendingPercentJump;
|
||||
if (needsFullBuild) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
const auto buildSection = [&]() {
|
||||
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
|
||||
};
|
||||
bool built = buildSection();
|
||||
if (!built && SETTINGS.bluetoothEnabled) {
|
||||
built = retryWithBleFreed(buildSection);
|
||||
}
|
||||
if (!built) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
|
||||
// indexing popup up front only when the build will actually be slow: a large spine (its
|
||||
// whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or
|
||||
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
|
||||
// build in a blink and stay popup-free.
|
||||
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
|
||||
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
|
||||
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
|
||||
// Popup only when the build will actually be slow: a big spine whose HTML still needs
|
||||
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
|
||||
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
|
||||
// A partial cache that already covers the target page shows it instantly: never popup.
|
||||
const bool willInflate = !section->hasHtmlCache();
|
||||
const bool anchorJump = !pendingAnchor.empty();
|
||||
bool showPopup;
|
||||
if (anchorJump) {
|
||||
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
|
||||
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
|
||||
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
|
||||
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
|
||||
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
|
||||
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
|
||||
} else {
|
||||
const bool targetAvailable = target < static_cast<int>(section->pageCount);
|
||||
showPopup = !targetAvailable &&
|
||||
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
|
||||
}
|
||||
if (showPopup) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
// startBuild does the zip inflate (the big contiguous allocation), so it gets
|
||||
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
|
||||
// retry safe.
|
||||
const auto beginBuild = [&]() {
|
||||
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||
};
|
||||
bool started = beginBuild();
|
||||
if (!started && SETTINGS.bluetoothEnabled) {
|
||||
started = retryWithBleFreed(beginBuild);
|
||||
}
|
||||
if (!started) {
|
||||
LOG_ERR("ERS", "Failed to start section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
while (!section->isBuildComplete() &&
|
||||
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
|
||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache found, skipping build...");
|
||||
}
|
||||
|
||||
if (pendingPageJump.has_value()) {
|
||||
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
} else {
|
||||
section->currentPage = *pendingPageJump;
|
||||
}
|
||||
section->currentPage = *pendingPageJump;
|
||||
pendingPageJump.reset();
|
||||
} else {
|
||||
section->currentPage = nextPageNumber;
|
||||
if (section->currentPage < 0) {
|
||||
section->currentPage = 0;
|
||||
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
|
||||
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingAnchor.empty()) {
|
||||
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
|
||||
// Resolve from the pages laid out so far and/or the on-disk map (finalized or partial).
|
||||
const auto page = section->findAnchor(pendingAnchor);
|
||||
if (page) {
|
||||
section->currentPage = *page;
|
||||
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
|
||||
} else {
|
||||
@@ -920,17 +1114,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
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
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * section->pageCount);
|
||||
section->currentPage = newPage;
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
|
||||
}
|
||||
|
||||
if (pendingPercentJump && section->pageCount > 0) {
|
||||
// Apply the pending percent jump now that we know the new section's page count.
|
||||
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
|
||||
@@ -942,6 +1125,57 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extend the build to the requested page if needed (for partials and in-progress builds).
|
||||
// This runs every render, so it covers both the first page and any forward turn that gets
|
||||
// ahead of the background builder; pages already built do no work here.
|
||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
// Start a build to extend a partial toward the requested page.
|
||||
if (!section->isBuilding() &&
|
||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||
SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering,
|
||||
SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed to start partial extension build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
// Extend until either the target page exists or the build completes.
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
||||
if (section->isBuilding()) {
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The requested page is now as built as it will get. If it still lands past the end,
|
||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
||||
// Guarded on !isBuilding() because a still-building section's pageCount is only the current
|
||||
// watermark (not the final count) and has already been driven far enough by the loops above.
|
||||
if (!section->isBuilding() && section->pageCount > 0 &&
|
||||
section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
|
||||
// Apply a deferred settings-change reposition now that the real page count is known (a no-op for
|
||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||
applyDeferredReposition();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
if (section->pageCount == 0) {
|
||||
@@ -967,9 +1201,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
updateBookmarkFlag();
|
||||
|
||||
{
|
||||
auto p = section->loadPageFromSectionFile();
|
||||
// Unified page read: the in-progress build's in-RAM table if it has reached the page,
|
||||
// otherwise the on-disk file (finalized section, or a partial from a previous session).
|
||||
auto p = section->loadPage(section->currentPage);
|
||||
if (!p) {
|
||||
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
||||
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
|
||||
// and the destructor's suspend would otherwise commit tables into a deleted handle.
|
||||
section->abandonBuild();
|
||||
section->clearCache();
|
||||
section.reset();
|
||||
requestUpdate(); // Try again after clearing cache
|
||||
@@ -986,8 +1225,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
}
|
||||
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
||||
|
||||
showPendingSyncSaveError();
|
||||
|
||||
@@ -1001,36 +1239,28 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
if (!epub || !section || section->pageCount < 2) {
|
||||
return;
|
||||
bool EpubReaderActivity::applyDeferredReposition() {
|
||||
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the next chapter cache while the penultimate page is on screen.
|
||||
if (section->currentPage != section->pageCount - 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int nextSpineIndex = currentSpineIndex + 1;
|
||||
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Section nextSection(epub, nextSpineIndex, renderer);
|
||||
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||
bool changed = false;
|
||||
// Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain
|
||||
// resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and
|
||||
// nothing moves.
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
const float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * static_cast<float>(section->pageCount));
|
||||
if (newPage < 0) newPage = 0;
|
||||
if (section->pageCount > 0 && newPage >= static_cast<int>(section->pageCount)) {
|
||||
newPage = section->pageCount - 1;
|
||||
}
|
||||
if (newPage != section->currentPage) {
|
||||
section->currentPage = newPage;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||
@@ -1203,9 +1433,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
void EpubReaderActivity::renderStatusBar() const {
|
||||
// Calculate progress in book
|
||||
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
||||
// "page X of Y" and the progress bar don't read off the small build watermark.
|
||||
const int currentPage = section->currentPage + 1;
|
||||
const float pageCount = section->pageCount;
|
||||
const float pageCount = section->estimatedTotalPages();
|
||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||
|
||||
@@ -1236,7 +1467,8 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
title = epub->getTitle();
|
||||
}
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||
section->isBuilding());
|
||||
}
|
||||
|
||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||
@@ -1327,7 +1559,7 @@ void EpubReaderActivity::addBookmark() {
|
||||
int pageCount;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
pageCount = section->pageCount;
|
||||
pageCount = section->estimatedTotalPages();
|
||||
currentPage = section->currentPage;
|
||||
}
|
||||
|
||||
@@ -1335,10 +1567,12 @@ void EpubReaderActivity::addBookmark() {
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
|
||||
|
||||
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
|
||||
cachedBookmarks.erase(
|
||||
std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
|
||||
[&](const BookmarkEntry& b) { return bookmarkMatchesProgress(b, progress, pageRange); }),
|
||||
cachedBookmarks.end());
|
||||
cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
|
||||
[&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount,
|
||||
pageRange);
|
||||
}),
|
||||
cachedBookmarks.end());
|
||||
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
|
||||
bookmarkRemoved = true;
|
||||
currentPageBookmarked = false;
|
||||
@@ -1374,11 +1608,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
|
||||
currentPageBookmarked = false;
|
||||
return;
|
||||
}
|
||||
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||
const ProgressRange pageRange =
|
||||
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
|
||||
const int pageCount = section->estimatedTotalPages();
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount);
|
||||
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, progress, pageRange);
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1391,9 +1624,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
}
|
||||
if (section) {
|
||||
info.currentPage = section->currentPage + 1;
|
||||
info.totalPages = section->pageCount;
|
||||
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||
info.totalPages = section->estimatedTotalPages();
|
||||
if (epub && epub->getBookSize() > 0 && info.totalPages > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(info.totalPages);
|
||||
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
@@ -1405,7 +1638,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
|
||||
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <optional>
|
||||
|
||||
#include "BookmarkEntry.h"
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "activities/Activity.h"
|
||||
@@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity {
|
||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
||||
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||
bool pendingReadFolderMove = false;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
// Footnote support
|
||||
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||
@@ -59,11 +62,37 @@ class EpubReaderActivity final : public Activity {
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
void renderStatusBar() const;
|
||||
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
// Pages laid out per incremental-build pump: on the render path (catching up to the page
|
||||
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
// ahead and resumes as the reader advances; building unbounded instead locked up input by
|
||||
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
|
||||
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
|
||||
// already laid out as a partial file on exit/sleep.
|
||||
static constexpr int BUILD_WINDOW_AHEAD = 5;
|
||||
// Show the indexing popup when an initial build must lay out more than this many pages up front
|
||||
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
|
||||
// of the small look-ahead window so ordinary landings stay popup-free.
|
||||
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
|
||||
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
bool applyDeferredReposition();
|
||||
bool 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);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||
// because no KOReader credentials are stored.
|
||||
bool launchKOReaderSync();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -108,8 +107,17 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
auto bookmark = bookmarks.at(selectorIndex);
|
||||
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
||||
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
|
||||
ProgressChangeResult result{};
|
||||
result.xpath = bookmark.xpath;
|
||||
result.percentage = bookmark.percentage;
|
||||
result.hasSavedProgress = true;
|
||||
if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
||||
bookmark.computedSpineIndex < epub->getSpineItemsCount()) {
|
||||
result.spineIndex = bookmark.computedSpineIndex;
|
||||
result.page = bookmark.computedChapterProgress;
|
||||
result.totalPages = bookmark.computedChapterPageCount;
|
||||
}
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
|
||||
@@ -52,6 +52,8 @@ void EpubReaderMenuActivity::onEnter() {
|
||||
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderMenuActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
@@ -66,14 +68,21 @@ void EpubReaderMenuActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
||||
optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast<int>(orientationLabels.size()),
|
||||
pendingOrientation, [this](int idx) {
|
||||
pendingOrientation = idx;
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAction == MenuAction::AUTO_PAGE_TURN) {
|
||||
selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size();
|
||||
optionPopup.show(I18N.get(StrId::STR_AUTO_TURN_PAGES_PER_MIN), pageTurnLabels.data(),
|
||||
static_cast<int>(pageTurnLabels.size()), selectedPageTurnOption, [this](int idx) {
|
||||
selectedPageTurnOption = idx;
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -102,6 +111,8 @@ void EpubReaderMenuActivity::loop() {
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::render(RenderLock&&) {
|
||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderMenuActivity final : public Activity {
|
||||
@@ -50,6 +51,7 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
int selectedIndex = 0;
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
OptionPopup optionPopup;
|
||||
std::string title = "Reader Menu";
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t selectedPageTurnOption = 0;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -51,8 +54,14 @@ void EpubReaderPercentSelectionActivity::loop() {
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
|
||||
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
|
||||
// On X3 the side buttons sit on the left/right edges of the screen rather than as a vertical up/down
|
||||
// rocker (X4), so BTN_UP is physically the left button and BTN_DOWN the right one. Flip the large-step
|
||||
// direction there so the left button decreases and the right button increases, matching the layout.
|
||||
const int upDelta = gpio.deviceIsX3() ? -kLargeStep : kLargeStep;
|
||||
const int downDelta = gpio.deviceIsX3() ? kLargeStep : -kLargeStep;
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this, upDelta] { adjustPercent(upDelta); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down},
|
||||
[this, downDelta] { adjustPercent(downDelta); });
|
||||
}
|
||||
|
||||
void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
||||
@@ -89,8 +98,13 @@ void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
||||
const int knobX = barX + 2 + fillWidth - 2;
|
||||
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
||||
|
||||
// Hint text for step sizes.
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, tr(STR_PERCENT_STEP_HINT), true);
|
||||
// Two-line step hint built from separate label + value strings (front buttons = fine step, side
|
||||
// buttons = coarse step), so the layout doesn't depend on a separator hidden in translated text.
|
||||
char line[64];
|
||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_FRONT), kSmallStep);
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, line, true);
|
||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_SIDE), kLargeStep);
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 52, line, true);
|
||||
|
||||
// Button hints follow the current front button layout.
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Memory.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -14,6 +15,7 @@
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/util/BmpViewerActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); }
|
||||
|
||||
@@ -35,6 +37,12 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
LOG_ERR("READER", "Failed to allocate EPUB object");
|
||||
return nullptr;
|
||||
}
|
||||
// First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the
|
||||
// indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at
|
||||
// construction, so this check is valid before load(); a cached open loads in a blink -> no popup.
|
||||
if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
}
|
||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||
return epub;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ class Txt;
|
||||
class ReaderActivity final : public Activity {
|
||||
std::string initialBookPath;
|
||||
std::string currentBookPath; // Track current book path for navigation
|
||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
// Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer.
|
||||
std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
|
||||
@@ -53,18 +53,52 @@ void XtcReaderActivity::onExit() {
|
||||
xtc.reset();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::openChapterSelection() {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
startActivityForResult(std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
currentPage = std::get<PageResult>(result.data).page;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::loop() {
|
||||
if (!xtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool atEndOfBook = currentPage >= xtc->getPageCount();
|
||||
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
||||
// block.
|
||||
if (atEndOfBook && endOfBookOptions.menuActive()) {
|
||||
std::string openPath;
|
||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
||||
case EndOfBookOptions::Action::OpenBook:
|
||||
activityManager.goToReader(openPath);
|
||||
return;
|
||||
case EndOfBookOptions::Action::GoHome:
|
||||
onGoHome();
|
||||
return;
|
||||
case EndOfBookOptions::Action::LastPage:
|
||||
currentPage = xtc->getPageCount() > 0 ? xtc->getPageCount() - 1 : 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::Redraw:
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
currentPage = std::get<PageResult>(result.data).page;
|
||||
}
|
||||
});
|
||||
}
|
||||
openChapterSelection();
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
@@ -85,8 +119,14 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// At end of the book, forward button goes home and back button returns to last page
|
||||
// At end of the book with no suggestion menu, forward button goes home and back
|
||||
// button returns to last page
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
if (endOfBookOptions.menuActive()) {
|
||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
||||
return;
|
||||
}
|
||||
if (nextTriggered) {
|
||||
onGoHome();
|
||||
} else {
|
||||
@@ -123,9 +163,11 @@ void XtcReaderActivity::render(RenderLock&&) {
|
||||
|
||||
// Bounds check
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
// Show end of book screen
|
||||
// Show end of book screen. Sole load site: runs on the render task (serialized by
|
||||
// RenderLock); the main task only reads the suggestions once the flag is published.
|
||||
endOfBookOptions.loadOnce(xtc->getPath());
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
endOfBookOptions.render(renderer, mappedInput);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class XtcReaderActivity final : public Activity {
|
||||
@@ -19,6 +20,8 @@ class XtcReaderActivity final : public Activity {
|
||||
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
enum class StatusBarOverlayPosition { Bottom, Top };
|
||||
struct StatusBarInfo {
|
||||
@@ -28,6 +31,8 @@ class XtcReaderActivity final : public Activity {
|
||||
};
|
||||
|
||||
void renderPage();
|
||||
// Opens chapter selection when the book has chapters (short-press Confirm); no-op otherwise
|
||||
void openChapterSelection();
|
||||
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
|
||||
StatusBarInfo getStatusBarInfo() const;
|
||||
void saveProgress() const;
|
||||
|
||||
Reference in New Issue
Block a user