From 29355653ea0d1009ba44cf8f91bd6467cccaa578 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:33:46 -0700 Subject: [PATCH 01/20] feat(reader): add chapter time-remaining status bar estimate - Add Customise Status Bar toggle 'Chapter Time Remaining Estimate' (under Chapter Page Count), including web settings - Format as '8/32 (15m)' with page count, or '15m' alone (h/d/y supported) - Estimate from remaining chapter words / word reading rate, not page-turn rate, so image/sparse pages do not inflate time - Bump section cache to v41 to store per-page word counts; extrapolate unbuilt content from HTML byte density - Track totalWordsRead plus current-session words in reading stats; blend historical and live rates (needs ~80 words and ~1 min before showing) --- lib/Epub/Epub/Page.cpp | 30 ++++++ lib/Epub/Epub/Page.h | 4 + lib/Epub/Epub/Section.cpp | 98 ++++++++++++++++++- lib/Epub/Epub/Section.h | 9 ++ lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 1 + src/JsonSettingsIO.cpp | 10 ++ src/ReadingStatsStore.cpp | 40 ++++++++ src/ReadingStatsStore.h | 7 ++ src/SettingsList.cpp | 3 + src/activities/reader/EpubReaderActivity.cpp | 23 ++++- .../settings/StatusBarSettingsActivity.cpp | 10 +- src/components/UITheme.cpp | 3 +- src/components/themes/BaseTheme.cpp | 27 +++-- src/components/themes/BaseTheme.h | 3 +- src/network/CrossPointWebServer.cpp | 2 + src/util/ChapterTimeEstimate.cpp | 58 +++++++++++ src/util/ChapterTimeEstimate.h | 16 +++ 18 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 src/util/ChapterTimeEstimate.cpp create mode 100644 src/util/ChapterTimeEstimate.h diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 080b0cbaea5..39301453061 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -221,6 +221,20 @@ uint16_t PageTableFragment::getHeight() const { return total; } +uint32_t PageTableFragment::countWords() const { + uint32_t words = 0; + for (const auto& row : rows) { + for (const auto& cell : row.cells) { + for (const auto& line : cell.lines) { + if (line) { + words += line->wordCount(); + } + } + } + } + return words; +} + void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, const uint8_t bionicReadingMode) { if (columnCount == 0 || columnCount > MAX_TABLE_CELLS_PER_ROW || rows.empty() || width < 2) { @@ -342,6 +356,22 @@ void PageTableFragment::recordFontUsage(FontCacheManager& fontCacheManager, cons } } +uint32_t Page::countWords() const { + uint32_t words = 0; + for (const auto& element : elements) { + if (!element) continue; + if (element->getTag() == TAG_PageLine) { + const auto& line = static_cast(*element); + if (line.getBlock()) { + words += line.getBlock()->wordCount(); + } + } else if (element->getTag() == TAG_PageTableFragment) { + words += static_cast(*element).countWords(); + } + } + return words; +} + void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, const uint8_t bionicReadingMode) const { for (auto& element : elements) { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index f099b10eb99..20194e7ecb5 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -120,6 +120,7 @@ class PageTableFragment final : public PageElement { PageElementTag getTag() const override { return TAG_PageTableFragment; } static std::unique_ptr deserialize(FsFile& file); uint16_t getHeight() const; + uint32_t countWords() const; void recordFontUsage(FontCacheManager& fontCacheManager, int fontId, uint8_t bionicReadingMode = 0) const; }; @@ -148,6 +149,9 @@ class Page { bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); + // Count laid-out text words (PageLine + table cells). Image-only / sparse pages return 0. + uint32_t countWords() const; + // Check if page contains any images (used to force full refresh) bool hasImages() const { return std::any_of(elements.begin(), elements.end(), [](const std::shared_ptr& el) { diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 4186028af5b..de61f2086d7 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -22,7 +22,8 @@ namespace { // first render). // v40: progressive/partial cache, with vCodex ruby blocks, paragraph/list // mapping and XHTML byte offsets retained. -constexpr uint8_t SECTION_FILE_VERSION = 40; +// v41: per-page word counts appended after the li LUT (chapter time-remaining estimates). +constexpr uint8_t SECTION_FILE_VERSION = 41; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -173,11 +174,13 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { if (filePartial) { // A partial's pageCount is the watermark of a suspended build. Read the watermark - // trailer (appended after the li LUT) so estimatedTotalPages can extrapolate. + // trailer (appended after the li LUT + word-count table) so estimatedTotalPages can + // extrapolate. uint32_t liLutOffset = 0; file.seek(HEADER_SIZE - sizeof(uint32_t)); serialization::readPod(file, liLutOffset); - const uint32_t trailerOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); + const uint32_t trailerOffset = + liLutOffset + static_cast(pageCount) * sizeof(uint16_t) * 2; // li + words const bool trailerValid = pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size(); if (!trailerValid) { @@ -194,6 +197,23 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { partialPageCount_ = pageCount; } + // Load per-page word counts (v41+) from immediately after the li LUT. + pageWordCounts_.clear(); + if (pageCount > 0) { + uint32_t liLutOffset = 0; + file.seek(HEADER_SIZE - sizeof(uint32_t)); + serialization::readPod(file, liLutOffset); + const uint32_t wordLutOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); + const uint32_t wordLutEnd = wordLutOffset + static_cast(pageCount) * sizeof(uint16_t); + if (liLutOffset >= HEADER_SIZE && wordLutEnd <= file.size()) { + pageWordCounts_.resize(pageCount); + file.seek(wordLutOffset); + for (uint16_t i = 0; i < pageCount; ++i) { + serialization::readPod(file, pageWordCounts_[i]); + } + } + } + // Explicit close() required: member variable persists beyond function scope file.close(); LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : ""); @@ -241,6 +261,11 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::function partialPageCount_) { + pageWordCounts_.resize(partialPageCount_); + } // Remove a stale tmp .bin from a crash-interrupted build; this build recreates it. { @@ -381,8 +406,14 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::function page, const ChapterHtmlSlimParser::ParagraphLutEntry syncEntry) { + const uint32_t words = page ? page->countWords() : 0; + const uint16_t wordCount = words > UINT16_MAX ? UINT16_MAX : static_cast(words); ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), syncEntry.xhtmlByteOffset, - syncEntry.paragraphIndex, syncEntry.listItemIndex}); + syncEntry.paragraphIndex, syncEntry.listItemIndex, wordCount}); + if (pageWordCounts_.size() < ctxPtr->lut.size()) { + pageWordCounts_.resize(ctxPtr->lut.size()); + } + pageWordCounts_[ctxPtr->lut.size() - 1] = wordCount; }, spec.embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, spec.imageRendering, std::move(tocAnchors), popupFn, ctxPtr->cssParser); @@ -550,8 +581,13 @@ bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsume serialization::writePod(file, entry.listItemIndex); } + // Per-page word counts (v41+), immediately after the li LUT. + for (const auto& entry : build_->lut) { + serialization::writePod(file, entry.wordCount); + } + if (asPartial) { - // Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t). + // Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t) * 2. serialization::writePod(file, bytesConsumed); serialization::writePod(file, totalBytes); } @@ -655,6 +691,11 @@ void Section::suspendBuild() { buildComplete_ = false; pageCount = partial_ ? partialPageCount_ : 0; builtPageCount_ = 0; + if (partial_ && pageWordCounts_.size() > pageCount) { + pageWordCounts_.resize(pageCount); + } else if (!partial_) { + pageWordCounts_.clear(); + } } void Section::abandonBuild() { @@ -680,6 +721,7 @@ void Section::abandonBuild() { partialPageCount_ = 0; pageCount = 0; builtPageCount_ = 0; + pageWordCounts_.clear(); } std::unique_ptr Section::loadPageDuringBuild(const int page) { @@ -973,3 +1015,49 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) return resultPage; } + +uint16_t Section::getPageWordCount(const uint16_t page) const { + if (page < pageWordCounts_.size()) { + return pageWordCounts_[page]; + } + if (build_ && page < build_->lut.size()) { + return build_->lut[page].wordCount; + } + return 0; +} + +uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { + const uint16_t availablePages = pageCount; + uint32_t remaining = 0; + for (uint16_t page = fromPage; page < availablePages; ++page) { + remaining += getPageWordCount(page); + } + + // Still-building / partial chapters: extrapolate unbuilt content from HTML density. + uint32_t bytesConsumed = 0; + uint32_t totalBytes = 0; + if (build_) { + bytesConsumed = build_->bytesConsumed; + totalBytes = build_->totalBytes; + } else if (partial_) { + bytesConsumed = partialBytesConsumed_; + totalBytes = partialTotalBytes_; + } + + if (totalBytes > bytesConsumed && bytesConsumed > 0 && availablePages > 0) { + uint32_t knownWords = 0; + for (uint16_t page = 0; page < availablePages; ++page) { + knownWords += getPageWordCount(page); + } + if (knownWords > 0) { + const uint64_t unbuiltBytes = static_cast(totalBytes - bytesConsumed); + const uint64_t unbuiltWords = + (static_cast(knownWords) * unbuiltBytes) / static_cast(bytesConsumed); + if (unbuiltWords > 0 && unbuiltWords < static_cast(UINT32_MAX)) { + remaining += static_cast(unbuiltWords); + } + } + } + + return remaining; +} diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index eb2c25fd630..8a471a9f5ac 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -30,6 +30,7 @@ class Section { uint32_t xhtmlByteOffset; uint16_t paragraphIndex; uint16_t listItemIndex; + uint16_t wordCount; }; // Held only while an incremental build is in progress (see startBuild). Carries the // live parser plus the strings it references (the parser stores them by reference) @@ -62,6 +63,8 @@ class Section { // Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them. bool partial_ = false; uint16_t partialPageCount_ = 0; + // Per-page word counts from the section cache / in-progress build. Empty until loaded. + std::vector pageWordCounts_; // Parse watermark from the partial's trailer, for estimating the total page count. uint32_t partialBytesConsumed_ = 0; uint32_t partialTotalBytes_ = 0; @@ -151,4 +154,10 @@ class Section { // XHTML byte boundary retained for KOReader's position mapper. std::optional getXhtmlByteOffsetForPage(uint16_t page) const; + + // Word count for a built/available page (0 if unknown / out of range). + uint16_t getPageWordCount(uint16_t page) const; + // Remaining chapter words from `fromPage` inclusive, including an estimate for + // still-unbuilt pages based on HTML byte density (not page-turn rate). + uint32_t estimateRemainingWords(uint16_t fromPage) const; }; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 77a3dbdc3bd..5d553363c6f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -290,6 +290,7 @@ STR_SET_SLEEP_COVER: "Set Cover" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" +STR_CHAPTER_TIME_REMAINING_ESTIMATE: "Chapter Time Remaining Estimate" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" STR_PROGRESS_BAR: "Progress Bar" STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 56791c91cfa..81da33c6fcb 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -262,6 +262,7 @@ class CrossPointSettings { // Status bar settings (statusBar retained for migration only) uint8_t statusBar = FULL; uint8_t statusBarChapterPageCount = 1; + uint8_t statusBarChapterTimeRemaining = 0; uint8_t statusBarBookProgressPercentage = 1; uint8_t statusBarProgressBar = HIDE_PROGRESS; uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 44326d994e8..55f863ff433 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -310,6 +310,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { switch (static_cast(settings.statusBar)) { case CrossPointSettings::NONE: settings.statusBarChapterPageCount = 0; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; @@ -317,6 +318,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::NO_PROGRESS: settings.statusBarChapterPageCount = 0; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -324,6 +326,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::BOOK_PROGRESS_BAR: settings.statusBarChapterPageCount = 1; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -331,6 +334,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR: settings.statusBarChapterPageCount = 1; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; @@ -338,6 +342,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::CHAPTER_PROGRESS_BAR: settings.statusBarChapterPageCount = 0; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -346,6 +351,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { case CrossPointSettings::FULL: default: settings.statusBarChapterPageCount = 1; + settings.statusBarChapterTimeRemaining = 0; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -523,6 +529,7 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne } loadToggle("statusBarChapterPageCount", s.statusBarChapterPageCount); + loadToggle("statusBarChapterTimeRemaining", s.statusBarChapterTimeRemaining); loadToggle("statusBarBookProgressPercentage", s.statusBarBookProgressPercentage); loadEnum("statusBarProgressBar", s.statusBarProgressBar, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_COUNT); loadEnum("statusBarProgressBarThickness", s.statusBarProgressBarThickness, @@ -893,6 +900,7 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) doc["koSyncAutoPushOnClose"] = s.koSyncAutoPushOnClose; doc["statusBarChapterPageCount"] = s.statusBarChapterPageCount; + doc["statusBarChapterTimeRemaining"] = s.statusBarChapterTimeRemaining; doc["statusBarBookProgressPercentage"] = s.statusBarBookProgressPercentage; doc["statusBarProgressBar"] = s.statusBarProgressBar; doc["statusBarProgressBarThickness"] = s.statusBarProgressBarThickness; @@ -1547,6 +1555,7 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["coverBmpPath"] = book.coverBmpPath; obj["chapterTitle"] = book.chapterTitle; obj["totalReadingMs"] = book.totalReadingMs; + obj["totalWordsRead"] = book.totalWordsRead; obj["sessions"] = book.sessions; obj["lastSessionMs"] = book.lastSessionMs; obj["firstReadAt"] = book.firstReadAt; @@ -1695,6 +1704,7 @@ bool JsonSettingsIO::loadReadingStatsDocument(ReadingStatsStore& store, const Js book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.chapterTitle = obj["chapterTitle"] | std::string(""); book.totalReadingMs = obj["totalReadingMs"] | static_cast(0); + book.totalWordsRead = obj["totalWordsRead"] | static_cast(0); book.sessions = obj["sessions"] | static_cast(0); book.lastSessionMs = obj["lastSessionMs"] | static_cast(0); book.firstReadAt = obj["firstReadAt"] | static_cast(0); diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 6bd70f9477c..a4797366577 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -522,6 +522,7 @@ void ReadingStatsStore::mergeBookInto(ReadingBookStats& primary, const ReadingBo } primary.totalReadingMs += duplicate.totalReadingMs; + primary.totalWordsRead += duplicate.totalWordsRead; primary.sessions += duplicate.sessions; primary.lastSessionMs = std::max(primary.lastSessionMs, duplicate.lastSessionMs); if (primary.firstReadAt == 0 || (duplicate.firstReadAt != 0 && duplicate.firstReadAt < primary.firstReadAt)) { @@ -1218,6 +1219,7 @@ void ReadingStatsStore::beginSession(const std::string& path, const std::string& activeSession.bookIndex = 0; activeSession.lastInteractionMs = millis(); activeSession.accumulatedMs = 0; + activeSession.sessionWordsRead = 0; markDirty(); } @@ -1247,6 +1249,13 @@ void ReadingStatsStore::noteActivity() { } } +void ReadingStatsStore::noteWordsRead(const uint32_t words) { + if (!activeSession.active || words == 0) { + return; + } + activeSession.sessionWordsRead += words; +} + void ReadingStatsStore::tickActiveSession() { if (!activeSession.active || activeSession.bookIndex >= books.size()) { return; @@ -1445,6 +1454,7 @@ void ReadingStatsStore::endSession() { if (countedSession) { book.sessions++; book.lastSessionMs = sessionMs; + book.totalWordsRead += activeSession.sessionWordsRead; const uint32_t sessionTimestamp = getReferenceTimestamp(TimeUtils::getAuthoritativeTimestamp(), book.lastReadAt); if (isClockValid(sessionTimestamp)) { appendSessionLogEntry(TimeUtils::getLocalDayOrdinal(sessionTimestamp), sessionMs, book); @@ -1466,6 +1476,36 @@ void ReadingStatsStore::endSession() { saveToFile(); } +uint64_t ReadingStatsStore::getActiveSessionWordsRead() const { + return activeSession.active ? activeSession.sessionWordsRead : 0; +} + +double ReadingStatsStore::getEffectiveWordsPerMs() const { + constexpr uint64_t MIN_RATE_WORDS = 80; + constexpr uint64_t MIN_RATE_MS = 60ULL * 1000ULL; + + uint64_t words = 0; + uint64_t ms = 0; + + if (activeSession.active && activeSession.bookIndex < books.size()) { + const auto& book = books[activeSession.bookIndex]; + // totalReadingMs already includes credited session time; totalWordsRead does not yet. + words = book.totalWordsRead + activeSession.sessionWordsRead; + ms = book.totalReadingMs; + } else { + // Global fallback across all books when no session is active. + for (const auto& book : books) { + words += book.totalWordsRead; + ms += book.totalReadingMs; + } + } + + if (words < MIN_RATE_WORDS || ms < MIN_RATE_MS) { + return 0.0; + } + return static_cast(words) / static_cast(ms); +} + bool ReadingStatsStore::adjustBookReadingTime(const std::string& path, const uint32_t dayOrdinal, const int32_t deltaMs) { if (dayOrdinal == 0 || deltaMs == 0) { diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index febeb62e5d0..795621d58c9 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -25,6 +25,7 @@ struct ReadingBookStats { std::string chapterTitle; std::vector readingDays; uint64_t totalReadingMs = 0; + uint64_t totalWordsRead = 0; uint32_t sessions = 0; uint32_t lastSessionMs = 0; uint32_t firstReadAt = 0; @@ -83,6 +84,7 @@ class ReadingStatsStore { size_t bookIndex = 0; unsigned long lastInteractionMs = 0; uint64_t accumulatedMs = 0; + uint64_t sessionWordsRead = 0; uint8_t startProgressPercent = 0; bool startCompleted = false; }; @@ -150,11 +152,16 @@ class ReadingStatsStore { const std::string& coverBmpPath, uint8_t progressPercent = 0, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void noteActivity(); + void noteWordsRead(uint32_t words); void tickActiveSession(); void resumeSession(); void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); + // Blended historical + current-session word reading rate (words per millisecond). + // Returns 0 when there is not enough sample data yet. + double getEffectiveWordsPerMs() const; + uint64_t getActiveSessionWordsRead() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); bool updateBookMetadata(const std::string& path, const std::string& title, const std::string& author, diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index c405b35f7d4..b875e591302 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -229,6 +229,9 @@ const std::vector& getSettingsList() { // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Toggle(StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, + &CrossPointSettings::statusBarChapterTimeRemaining, "statusBarChapterTimeRemaining", + StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cb4cb681a98..26134949b92 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -45,6 +45,7 @@ #include "fontIds.h" #include "util/AchievementPopupUtils.h" #include "util/BookIdentity.h" +#include "util/ChapterTimeEstimate.h" #include "util/CompletedBookMover.h" #include "util/ScreenshotUtil.h" @@ -1317,6 +1318,14 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { const int oldPage = section ? section->currentPage : nextPageNumber; if (isForwardTurn) { + // Credit words on the page being finished so chapter time estimates use a + // word rate rather than page-turn rate (image/sparse pages count as 0). + if (oldPage >= 0) { + const uint16_t words = section->getPageWordCount(static_cast(oldPage)); + if (words > 0) { + READING_STATS.noteWordsRead(words); + } + } if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { section->currentPage++; } else { @@ -1980,7 +1989,19 @@ void EpubReaderActivity::renderStatusBar() const { title = epub->getTitle(); } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset); + char chapterTimeBuf[12] = {}; + const char* chapterTimeEstimate = nullptr; + if (SETTINGS.statusBarChapterTimeRemaining && section->currentPage >= 0) { + const uint32_t remainingWords = + section->estimateRemainingWords(static_cast(section->currentPage)); + const uint64_t remainingMs = + ChapterTimeEstimate::estimateRemainingMs(remainingWords, READING_STATS.getEffectiveWordsPerMs()); + if (ChapterTimeEstimate::formatCompactDuration(remainingMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; + } + } + + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); } void EpubReaderActivity::renderSectionLoadFailure() { diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index a2b06d9d084..d346823b3ba 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -18,6 +18,7 @@ namespace { // DS3231 RTC is present so X4 devices don't see them at all. enum MenuItem { ITEM_CHAPTER_PAGE_COUNT = 0, + ITEM_CHAPTER_TIME_REMAINING, ITEM_BOOK_PROGRESS_PERCENTAGE, ITEM_PROGRESS_BAR, ITEM_PROGRESS_BAR_THICKNESS, @@ -35,6 +36,7 @@ constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is availabl const StrId menuNames[FULL_MENU_ITEMS] = { StrId::STR_CHAPTER_PAGE_COUNT, + StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, @@ -156,6 +158,9 @@ void StatusBarSettingsActivity::handleSelection() { case ITEM_CHAPTER_PAGE_COUNT: SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; break; + case ITEM_CHAPTER_TIME_REMAINING: + SETTINGS.statusBarChapterTimeRemaining = (SETTINGS.statusBarChapterTimeRemaining + 1) % 2; + break; case ITEM_BOOK_PROGRESS_PERCENTAGE: SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; break; @@ -216,6 +221,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { switch (index) { case ITEM_CHAPTER_PAGE_COUNT: return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_CHAPTER_TIME_REMAINING: + return SETTINGS.statusBarChapterTimeRemaining ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_BOOK_PROGRESS_PERCENTAGE: return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_PROGRESS_BAR: @@ -253,7 +260,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { title = tr(STR_EXAMPLE_CHAPTER); } - GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false); + GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, + SETTINGS.statusBarChapterTimeRemaining ? "15m" : nullptr); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, renderer.getScreenHeight() - UITheme::getInstance().getStatusBarHeight() - verticalPreviewPadding - diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 8c427228a5b..1a8e2e29787 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -116,7 +116,8 @@ int UITheme::getStatusBarHeight() { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); // Add status bar margin - const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || + const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarChapterTimeRemaining || + SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery; const bool showProgressBar = diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 92a1223abdf..adb2bb259c4 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -835,7 +835,7 @@ 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 int textYOffset, - const bool fillMargin) const { + const bool fillMargin, const char* chapterTimeEstimate) const { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, @@ -846,16 +846,29 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { - // Right aligned text for progress counter - char progressStr[32]; + const bool showChapterPages = SETTINGS.statusBarChapterPageCount; + const bool showBookPercent = SETTINGS.statusBarBookProgressPercentage; + const bool showChapterTime = chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; - if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { + if (showBookPercent || showChapterPages || showChapterTime) { + // Right aligned text for progress counter + char progressStr[48]; + + if (showChapterPages && showChapterTime && showBookPercent) { + snprintf(progressStr, sizeof(progressStr), "%d/%d (%s) %.0f%%", currentPage, pageCount, chapterTimeEstimate, + bookProgress); + } else if (showChapterPages && showChapterTime) { + snprintf(progressStr, sizeof(progressStr), "%d/%d (%s)", currentPage, pageCount, chapterTimeEstimate); + } else if (showChapterPages && showBookPercent) { snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress); - } else if (SETTINGS.statusBarBookProgressPercentage) { + } else if (showChapterTime && showBookPercent) { + snprintf(progressStr, sizeof(progressStr), "%s %.0f%%", chapterTimeEstimate, bookProgress); + } else if (showBookPercent) { snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); - } else { + } else if (showChapterPages) { snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount); + } else { + snprintf(progressStr, sizeof(progressStr), "%s", chapterTimeEstimate); } progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 18853d5f94c..659a178639c 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -167,7 +167,8 @@ class BaseTheme { 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 int textYOffset = 0, const bool fillMargin = true) const; + const int textYOffset = 0, const bool fillMargin = true, + const char* chapterTimeEstimate = nullptr) const; virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 24288cf1db9..a4bb032082d 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -438,6 +438,8 @@ constexpr WebSettingDef WEB_SETTINGS[] = { WEB_TOGGLE(StrId::STR_CHAPTER_PAGE_COUNT, statusBarChapterPageCount, "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), + WEB_TOGGLE(StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, statusBarChapterTimeRemaining, + "statusBarChapterTimeRemaining", StrId::STR_CUSTOMISE_STATUS_BAR), WEB_TOGGLE(StrId::STR_BOOK_PROGRESS_PERCENTAGE, statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), WEB_ENUM(StrId::STR_PROGRESS_BAR, statusBarProgressBar, OPT_BOOK_CHAPTER_HIDE, "statusBarProgressBar", diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp new file mode 100644 index 00000000000..4b434f2afc3 --- /dev/null +++ b/src/util/ChapterTimeEstimate.cpp @@ -0,0 +1,58 @@ +#include "util/ChapterTimeEstimate.h" + +#include + +namespace ChapterTimeEstimate { +namespace { +constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; +constexpr uint64_t MS_PER_HOUR = 60ULL * MS_PER_MINUTE; +constexpr uint64_t MS_PER_DAY = 24ULL * MS_PER_HOUR; +constexpr uint64_t MS_PER_YEAR = 365ULL * MS_PER_DAY; +} // namespace + +bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { + if (!buf || bufSize < 3 || totalMs == 0) { + return false; + } + + if (totalMs < MS_PER_HOUR) { + uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; + if (minutes == 0) { + minutes = 1; + } + return snprintf(buf, bufSize, "%llum", static_cast(minutes)) > 0; + } + if (totalMs < MS_PER_DAY) { + uint64_t hours = (totalMs + MS_PER_HOUR / 2) / MS_PER_HOUR; + if (hours == 0) { + hours = 1; + } + return snprintf(buf, bufSize, "%lluh", static_cast(hours)) > 0; + } + if (totalMs < MS_PER_YEAR) { + uint64_t days = (totalMs + MS_PER_DAY / 2) / MS_PER_DAY; + if (days == 0) { + days = 1; + } + return snprintf(buf, bufSize, "%llud", static_cast(days)) > 0; + } + + uint64_t years = (totalMs + MS_PER_YEAR / 2) / MS_PER_YEAR; + if (years == 0) { + years = 1; + } + return snprintf(buf, bufSize, "%lluy", static_cast(years)) > 0; +} + +uint64_t estimateRemainingMs(const uint32_t remainingWords, const double wordsPerMs) { + if (remainingWords == 0 || wordsPerMs <= 0.0) { + return 0; + } + const double ms = static_cast(remainingWords) / wordsPerMs; + if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { + return 0; + } + return static_cast(ms); +} + +} // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h new file mode 100644 index 00000000000..dfccc708364 --- /dev/null +++ b/src/util/ChapterTimeEstimate.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace ChapterTimeEstimate { + +// Compact single-unit duration for the status bar: 15m / 2h / 3d / 1y. +// Returns false when buf is too small or ms is zero (nothing to show). +bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); + +// Estimate chapter remaining time from remaining words and an effective words/ms rate. +// Returns 0 when inputs are insufficient. +uint64_t estimateRemainingMs(uint32_t remainingWords, double wordsPerMs); + +} // namespace ChapterTimeEstimate From c9da62ea3f28e1507f9e02c8c574853c5c512e8f Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:56:55 -0700 Subject: [PATCH 02/20] feat(reader): combine chapter page and time into Chapter Progress - Replace the dual Chapter Page Count / Chapter Time Remaining toggles with one Chapter Progress enum: Pages, Pages+Time, Time, Hide (default Pages) - Migrate legacy dual-toggle and older status-bar settings into the combined enum - Keep status bar formats 8/32, 8/32 (15m), and 15m, including web settings --- lib/I18n/translations/belarusian.yaml | 1 - lib/I18n/translations/catalan.yaml | 1 - lib/I18n/translations/czech.yaml | 1 - lib/I18n/translations/danish.yaml | 1 - lib/I18n/translations/dutch.yaml | 1 - lib/I18n/translations/english.yaml | 5 +- lib/I18n/translations/finnish.yaml | 1 - lib/I18n/translations/french.yaml | 1 - lib/I18n/translations/german.yaml | 1 - lib/I18n/translations/hungarian.yaml | 1 - lib/I18n/translations/italian.yaml | 1 - lib/I18n/translations/kazakh.yaml | 1 - lib/I18n/translations/lithuanian.yaml | 1 - lib/I18n/translations/polish.yaml | 1 - lib/I18n/translations/portuguese.yaml | 1 - lib/I18n/translations/romanian.yaml | 1 - lib/I18n/translations/russian.yaml | 1 - lib/I18n/translations/slovenian.yaml | 1 - lib/I18n/translations/spanish.yaml | 1 - lib/I18n/translations/swedish.yaml | 1 - lib/I18n/translations/turkish.yaml | 1 - lib/I18n/translations/ukrainian.yaml | 1 - lib/I18n/translations/vietnamese.yaml | 1 - src/CrossPointSettings.h | 10 +++- src/JsonSettingsIO.cpp | 57 ++++++++++++------- src/SettingsList.cpp | 8 +-- src/activities/reader/EpubReaderActivity.cpp | 4 +- .../settings/StatusBarSettingsActivity.cpp | 40 ++++++++----- src/components/UITheme.cpp | 8 +-- src/components/themes/BaseTheme.cpp | 8 ++- src/network/CrossPointWebServer.cpp | 8 +-- 31 files changed, 93 insertions(+), 77 deletions(-) diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index a0d8028a3be..99a547ccbf8 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Фільтр экрана сну" STR_SET_SLEEP_COVER: "Усталяваць вокладку" STR_FILTER_CONTRAST: "Кантраст" STR_CUSTOMISE_STATUS_BAR: "Наладзіць радок стану" -STR_CHAPTER_PAGE_COUNT: "Колькасць старонак раздзела" STR_BOOK_PROGRESS_PERCENTAGE: "Працэнт прагрэсу кнігі" STR_PROGRESS_BAR: "Паласа прагрэсу" STR_PROGRESS_BAR_THICKNESS: "Таўшчыня паласы прагрэсу" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 22227308117..25cf6f9c12c 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtre de pantalla de repòs" STR_SET_SLEEP_COVER: "Estableix portada" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Personalitza la barra d'estat" -STR_CHAPTER_PAGE_COUNT: "Comptador de pàgines del capítol" STR_BOOK_PROGRESS_PERCENTAGE: "Percentatge de progrés del llibre" STR_PROGRESS_BAR: "Barra de progrés" STR_PROGRESS_BAR_THICKNESS: "Gruix de la barra de progrés" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index ba6626371c4..d74459a8dc2 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtr obrazovky spánku" STR_SET_SLEEP_COVER: "Nastavit obálku" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Přizpůsobit stavový řádek" -STR_CHAPTER_PAGE_COUNT: "Počet stránek kapitoly" STR_BOOK_PROGRESS_PERCENTAGE: "Procento průběhu knihy" STR_PROGRESS_BAR: "Ukazatel průběhu" STR_PROGRESS_BAR_THICKNESS: "Tloušťka ukazatele průběhu" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 08fadc1b443..7d6925c8510 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Hvile-skærm omslag-filter" STR_SET_SLEEP_COVER: "Angiv omslag" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Tilpas statuslinje" -STR_CHAPTER_PAGE_COUNT: "Kapitelsidetæller" STR_BOOK_PROGRESS_PERCENTAGE: "Bogfremskridtsprocent" STR_PROGRESS_BAR: "Fremskridtslinje" STR_PROGRESS_BAR_THICKNESS: "Fremskridtslinjens tykkelse" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index c2a7ca8a05e..b48d629bb76 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Slaapscherm omslag-filter" STR_SET_SLEEP_COVER: "Omslag instellen" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Statusbalk aanpassen" -STR_CHAPTER_PAGE_COUNT: "Paginanummering hoofdstuk" STR_BOOK_PROGRESS_PERCENTAGE: "Percentage voortgang boek" STR_PROGRESS_BAR: "Voortgangsbalk" STR_PROGRESS_BAR_THICKNESS: "Dikte voortgangsbalk" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 5d553363c6f..e4281ca33bc 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -289,8 +289,9 @@ STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_SET_SLEEP_COVER: "Set Cover" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" -STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" -STR_CHAPTER_TIME_REMAINING_ESTIMATE: "Chapter Time Remaining Estimate" +STR_PAGES: "Pages" +STR_PAGES_PLUS_TIME: "Pages+Time" +STR_TIME: "Time" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" STR_PROGRESS_BAR: "Progress Bar" STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 95ed5757b70..e17820e8c51 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Lepotilanäytön kansisuodatin" STR_SET_SLEEP_COVER: "Aseta kansi" STR_FILTER_CONTRAST: "Kontrasti" STR_CUSTOMISE_STATUS_BAR: "Mukauta tilapalkkia" -STR_CHAPTER_PAGE_COUNT: "Luvun sivumäärä" STR_BOOK_PROGRESS_PERCENTAGE: "Kirjan edistymisprosentti" STR_PROGRESS_BAR: "Edistymispalkki" STR_PROGRESS_BAR_THICKNESS: "Edistymispalkin paksuus" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 73724edfd27..81ee7fe865f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtre écran de veille" STR_SET_SLEEP_COVER: "Définir la couverture" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personnaliser la barre d'état" -STR_CHAPTER_PAGE_COUNT: "Nombre de pages du chapitre" STR_BOOK_PROGRESS_PERCENTAGE: "Pourcentage de progression" STR_PROGRESS_BAR: "Barre de progression" STR_PROGRESS_BAR_THICKNESS: "Épaisseur de la barre" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b24b0e18463..ebeea66a8f9 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" STR_SET_SLEEP_COVER: "Cover festlegen" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" -STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" STR_PROGRESS_BAR: "Fortschrittsbalken" STR_PROGRESS_BAR_THICKNESS: "Balkenstärke" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 7660ba7b899..02839a1680d 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Alvásképernyő borítószűrő" STR_SET_SLEEP_COVER: "Borító beállítása" STR_FILTER_CONTRAST: "Kontraszt" STR_CUSTOMISE_STATUS_BAR: "Állapotsáv testreszabása" -STR_CHAPTER_PAGE_COUNT: "Fejezet oldalszám" STR_BOOK_PROGRESS_PERCENTAGE: "Könyv haladás százaléka" STR_PROGRESS_BAR: "Haladássáv" STR_PROGRESS_BAR_THICKNESS: "Haladássáv vastagsága" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index c6d61c85e2c..250475f6445 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -270,7 +270,6 @@ STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro copertina" STR_FILTER_CONTRAST: "Contrasto" STR_CUSTOMISE_STATUS_BAR: "Personalizza la barra di stato" -STR_CHAPTER_PAGE_COUNT: "Conteggio pagine capitolo" STR_BOOK_PROGRESS_PERCENTAGE: "Percentuale di avanzamento del libro" STR_PROGRESS_BAR: "Barra di avanzamento" STR_PROGRESS_BAR_THICKNESS: "Spessore della barra di avanzamento" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index a60063a5738..7ad5cc6c7a4 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Ұйқы экраны мұқаба сүзгісі" STR_SET_SLEEP_COVER: "Мұқабаны орнату" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Күй жолағын баптау" -STR_CHAPTER_PAGE_COUNT: "Тараудың бет саны" STR_BOOK_PROGRESS_PERCENTAGE: "Кітап үлгерімі пайызы" STR_PROGRESS_BAR: "Үлгерім жолағы" STR_PROGRESS_BAR_THICKNESS: "Үлгерім жолағының қалыңдығы" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index de4328a9321..f4f567c50f3 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Viršelio filtras" STR_SET_SLEEP_COVER: "Nustatyti viršelį" STR_FILTER_CONTRAST: "Kontrastas" STR_CUSTOMISE_STATUS_BAR: "Būsenos juosta" -STR_CHAPTER_PAGE_COUNT: "Skyriaus psl." STR_BOOK_PROGRESS_PERCENTAGE: "Progresas %" STR_PROGRESS_BAR: "Progreso juosta" STR_PROGRESS_BAR_THICKNESS: "Juostos storis" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 9419dd387bc..c465fa653cc 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtr okładek wygaszacza" STR_SET_SLEEP_COVER: "Ustaw okładkę" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Dostosowanie paska statusu" -STR_CHAPTER_PAGE_COUNT: "Strona rozdziału" STR_BOOK_PROGRESS_PERCENTAGE: "Postęp książki" STR_PROGRESS_BAR: "Pasek postępu" STR_PROGRESS_BAR_THICKNESS: "Grubość paska postępu" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 45929b8e991..52380fb8e28 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso" STR_SET_SLEEP_COVER: "Definir capa" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de status" -STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de progresso do livro" STR_PROGRESS_BAR: "Barra de progresso" STR_PROGRESS_BAR_THICKNESS: "Espessura da barra de progresso" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 21039eb6291..fbd8ca3b850 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus" STR_SET_SLEEP_COVER: "Setează coperta" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customizaţi bara de stare" -STR_CHAPTER_PAGE_COUNT: "Număr de pagini în capitol" STR_BOOK_PROGRESS_PERCENTAGE: "Progres carte procentual" STR_PROGRESS_BAR: "Bară de progres" STR_PROGRESS_BAR_THICKNESS: "Grosime bară de progres" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 88c62a719a4..6f67c2b23fc 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Фильтр экрана сна" STR_SET_SLEEP_COVER: "Установить обложку" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Настройка строки состояния" -STR_CHAPTER_PAGE_COUNT: "Количество страниц главы" STR_BOOK_PROGRESS_PERCENTAGE: "% прочтения книги" STR_PROGRESS_BAR: "Полоса прогресса" STR_PROGRESS_BAR_THICKNESS: "Толщина индикатора прогресса" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 6231c59a84d..dff7033a5cf 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju" STR_SET_SLEEP_COVER: "Nastavi naslovnico" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja" -STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju" STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi" STR_PROGRESS_BAR: "Vrstica napredka" STR_PROGRESS_BAR_THICKNESS: "Debelina vrstice napredka" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 2688a7b7a18..5d7386e83ea 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -281,7 +281,6 @@ STR_SLEEP_COVER_FILTER: "Filtro de pantalla de suspensión" STR_SET_SLEEP_COVER: "Establecer portada" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" -STR_CHAPTER_PAGE_COUNT: "Contador de pág. por cap." STR_BOOK_PROGRESS_PERCENTAGE: "Porcentaje progreso libro" STR_PROGRESS_BAR: "Barra de progreso" STR_PROGRESS_BAR_THICKNESS: "Grosor de barra de progreso" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 3cb77ad2f23..2bc07e7d28b 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Viloskärmens omslagsfilter" STR_SET_SLEEP_COVER: "Ange omslag" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Anpassa statusfält" -STR_CHAPTER_PAGE_COUNT: "Antal sidor i kapitel" STR_BOOK_PROGRESS_PERCENTAGE: "Procentuellt bokframsteg" STR_PROGRESS_BAR: "Framstegsindikator" STR_PROGRESS_BAR_THICKNESS: "Tjocklek på framstegsindikator" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 117e845dd75..6cd89ca6368 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Uyku Ekranı Kapak Filtresi" STR_SET_SLEEP_COVER: "Kapağı ayarla" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Durum Çubuğunu Özelleştir" -STR_CHAPTER_PAGE_COUNT: "Bölüm Sayfa Sayısı" STR_BOOK_PROGRESS_PERCENTAGE: "Kitap İlerleme Yüzdesi" STR_PROGRESS_BAR: "İlerleme Çubuğu" STR_PROGRESS_BAR_THICKNESS: "İlerleme Çubuğu Kalınlığı" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 2a328337ec2..a57d0c94ff7 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "????? ????????" STR_SET_SLEEP_COVER: "Встановити обкладинку" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Налаштувати рядок стану" -STR_CHAPTER_PAGE_COUNT: "????????? ???????? ? ???????" STR_BOOK_PROGRESS_PERCENTAGE: "Відсоток прочитаного" STR_PROGRESS_BAR: "Рядок прогресу" STR_PROGRESS_BAR_THICKNESS: "Товщина рядку прогресу" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 7aa1fca7b66..940965b3d04 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Bộ lọc bìa khi ngủ" STR_SET_SLEEP_COVER: "Đặt bìa" STR_FILTER_CONTRAST: "Tương phản" STR_CUSTOMISE_STATUS_BAR: "Tùy chỉnh thanh trạng thái" -STR_CHAPTER_PAGE_COUNT: "Số trang chương" STR_BOOK_PROGRESS_PERCENTAGE: "Phần trăm tiến trình sách" STR_PROGRESS_BAR: "Thanh tiến trình" STR_PROGRESS_BAR_THICKNESS: "Độ dày thanh tiến trình" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 81da33c6fcb..7ba8fa269c9 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -56,6 +56,13 @@ class CrossPointSettings { HIDE_PROGRESS = 2, STATUS_BAR_PROGRESS_BAR_COUNT }; + enum STATUS_BAR_CHAPTER_PROGRESS { + CHAPTER_PROGRESS_PAGES = 0, + CHAPTER_PROGRESS_PAGES_TIME = 1, + CHAPTER_PROGRESS_TIME = 2, + CHAPTER_PROGRESS_HIDE = 3, + STATUS_BAR_CHAPTER_PROGRESS_COUNT + }; enum STATUS_BAR_PROGRESS_BAR_THICKNESS { PROGRESS_BAR_THIN = 0, PROGRESS_BAR_NORMAL = 1, @@ -261,8 +268,7 @@ class CrossPointSettings { uint8_t cleanSleepRefresh = 1; // Status bar settings (statusBar retained for migration only) uint8_t statusBar = FULL; - uint8_t statusBarChapterPageCount = 1; - uint8_t statusBarChapterTimeRemaining = 0; + uint8_t statusBarChapterProgress = CHAPTER_PROGRESS_PAGES; uint8_t statusBarBookProgressPercentage = 1; uint8_t statusBarProgressBar = HIDE_PROGRESS; uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 55f863ff433..f56db0f5810 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -309,40 +309,35 @@ void migrateLegacyStatsShortcut(CrossPointSettings& settings, const JsonDocument void applyLegacyStatusBarSettings(CrossPointSettings& settings) { switch (static_cast(settings.statusBar)) { case CrossPointSettings::NONE: - settings.statusBarChapterPageCount = 0; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarBattery = 0; break; case CrossPointSettings::NO_PROGRESS: - settings.statusBarChapterPageCount = 0; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarBattery = 1; break; case CrossPointSettings::BOOK_PROGRESS_BAR: - settings.statusBarChapterPageCount = 1; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarBattery = 1; break; case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR: - settings.statusBarChapterPageCount = 1; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarBattery = 0; break; case CrossPointSettings::CHAPTER_PROGRESS_BAR: - settings.statusBarChapterPageCount = 0; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -350,8 +345,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::FULL: default: - settings.statusBarChapterPageCount = 1; - settings.statusBarChapterTimeRemaining = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -360,6 +354,19 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { } } +uint8_t migrateChapterProgressFromLegacyToggles(const uint8_t showPages, const uint8_t showTime) { + if (showPages && showTime) { + return CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; + } + if (showTime) { + return CrossPointSettings::CHAPTER_PROGRESS_TIME; + } + if (showPages) { + return CrossPointSettings::CHAPTER_PROGRESS_PAGES; + } + return CrossPointSettings::CHAPTER_PROGRESS_HIDE; +} + namespace { void migrateDisplayHeaderSettings(CrossPointSettings& s, const JsonDocument& doc, bool* needsResave) { if (doc["displayHeaderTime"].isNull()) { @@ -408,8 +415,14 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne dest[maxLen - 1] = '\0'; }; - if (doc["statusBarChapterPageCount"].isNull()) { + if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { applyLegacyStatusBarSettings(s); + } else if (doc["statusBarChapterProgress"].isNull()) { + // Migrate the short-lived dual-toggle settings into the combined enum. + const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); + const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); + s.statusBarChapterProgress = migrateChapterProgressFromLegacyToggles(showPages, showTime); + if (needsResave) *needsResave = true; } loadEnum("sleepScreen", s.sleepScreen, CrossPointSettings::SLEEP_SCREEN_MODE_COUNT); @@ -528,8 +541,8 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne s.opdsPassword[sizeof(s.opdsPassword) - 1] = '\0'; } - loadToggle("statusBarChapterPageCount", s.statusBarChapterPageCount); - loadToggle("statusBarChapterTimeRemaining", s.statusBarChapterTimeRemaining); + loadEnum("statusBarChapterProgress", s.statusBarChapterProgress, + CrossPointSettings::STATUS_BAR_CHAPTER_PROGRESS_COUNT); loadToggle("statusBarBookProgressPercentage", s.statusBarBookProgressPercentage); loadEnum("statusBarProgressBar", s.statusBarProgressBar, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_COUNT); loadEnum("statusBarProgressBarThickness", s.statusBarProgressBarThickness, @@ -899,8 +912,7 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) doc["koSyncAutoPullOnOpen"] = s.koSyncAutoPullOnOpen; doc["koSyncAutoPushOnClose"] = s.koSyncAutoPushOnClose; - doc["statusBarChapterPageCount"] = s.statusBarChapterPageCount; - doc["statusBarChapterTimeRemaining"] = s.statusBarChapterTimeRemaining; + doc["statusBarChapterProgress"] = s.statusBarChapterProgress; doc["statusBarBookProgressPercentage"] = s.statusBarBookProgressPercentage; doc["statusBarProgressBar"] = s.statusBarProgressBar; doc["statusBarProgressBarThickness"] = s.statusBarProgressBarThickness; @@ -994,10 +1006,15 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; }; - // Legacy migration: if statusBarChapterPageCount is absent this is a pre-refactor settings file. - // Populate s with migrated values now so the generic loop below picks them up as defaults and clamps them. - if (doc["statusBarChapterPageCount"].isNull()) { + // Legacy migration: if statusBarChapterProgress and statusBarChapterPageCount are absent + // this is a pre-refactor settings file. Populate s with migrated values now so the + // generic loop below picks them up as defaults and clamps them. + if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { applyLegacyStatusBarSettings(s); + } else if (doc["statusBarChapterProgress"].isNull()) { + const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); + const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); + s.statusBarChapterProgress = migrateChapterProgressFromLegacyToggles(showPages, showTime); } for (const auto& info : getSettingsList()) { diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index b875e591302..de934c65907 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,11 +227,9 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- - SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, - "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), - SettingInfo::Toggle(StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, - &CrossPointSettings::statusBarChapterTimeRemaining, "statusBarChapterTimeRemaining", - StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Enum(StrId::STR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, + {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, StrId::STR_TIME, StrId::STR_HIDE}, + "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 26134949b92..7ce0e846b60 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1991,7 +1991,9 @@ void EpubReaderActivity::renderStatusBar() const { char chapterTimeBuf[12] = {}; const char* chapterTimeEstimate = nullptr; - if (SETTINGS.statusBarChapterTimeRemaining && section->currentPage >= 0) { + if ((SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) && + section->currentPage >= 0) { const uint32_t remainingWords = section->estimateRemainingWords(static_cast(section->currentPage)); const uint64_t remainingMs = diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index d346823b3ba..749a78201fe 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -17,8 +17,7 @@ namespace { // Menu items in their natural order. Clock entries are appended only when the // DS3231 RTC is present so X4 devices don't see them at all. enum MenuItem { - ITEM_CHAPTER_PAGE_COUNT = 0, - ITEM_CHAPTER_TIME_REMAINING, + ITEM_CHAPTER_PROGRESS = 0, ITEM_BOOK_PROGRESS_PERCENTAGE, ITEM_PROGRESS_BAR, ITEM_PROGRESS_BAR_THICKNESS, @@ -35,8 +34,7 @@ constexpr int BASE_MENU_ITEMS = ITEM_CLOCK; // Items shown on every device constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is available const StrId menuNames[FULL_MENU_ITEMS] = { - StrId::STR_CHAPTER_PAGE_COUNT, - StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, + StrId::STR_CHAPTER_PROGRESS, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, @@ -51,6 +49,10 @@ const StrId menuNames[FULL_MENU_ITEMS] = { constexpr int CLOCK_FORMAT_ITEMS = 2; const StrId clockFormatNames[CLOCK_FORMAT_ITEMS] = {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}; +constexpr int CHAPTER_PROGRESS_ITEMS = 4; +const StrId chapterProgressNames[CHAPTER_PROGRESS_ITEMS] = {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, + StrId::STR_TIME, StrId::STR_HIDE}; + constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -79,6 +81,16 @@ int clockCycleIndex(const uint8_t mode) { return 0; } +const char* previewChapterTimeEstimate() { + switch (SETTINGS.statusBarChapterProgress) { + case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: + case CrossPointSettings::CHAPTER_PROGRESS_TIME: + return "15m"; + default: + return nullptr; + } +} + const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; } // namespace @@ -90,6 +102,10 @@ void StatusBarSettingsActivity::onEnter() { visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS; // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data + if (SETTINGS.statusBarChapterProgress >= CHAPTER_PROGRESS_ITEMS) { + SETTINGS.statusBarChapterProgress = CrossPointSettings::STATUS_BAR_CHAPTER_PROGRESS::CHAPTER_PROGRESS_PAGES; + } + if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; } @@ -155,11 +171,8 @@ void StatusBarSettingsActivity::loop() { void StatusBarSettingsActivity::handleSelection() { switch (selectedIndex) { - case ITEM_CHAPTER_PAGE_COUNT: - SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; - break; - case ITEM_CHAPTER_TIME_REMAINING: - SETTINGS.statusBarChapterTimeRemaining = (SETTINGS.statusBarChapterTimeRemaining + 1) % 2; + case ITEM_CHAPTER_PROGRESS: + SETTINGS.statusBarChapterProgress = (SETTINGS.statusBarChapterProgress + 1) % CHAPTER_PROGRESS_ITEMS; break; case ITEM_BOOK_PROGRESS_PERCENTAGE: SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; @@ -219,10 +232,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, [](int index) -> std::string { switch (index) { - case ITEM_CHAPTER_PAGE_COUNT: - return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); - case ITEM_CHAPTER_TIME_REMAINING: - return SETTINGS.statusBarChapterTimeRemaining ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_CHAPTER_PROGRESS: + return I18N.get(chapterProgressNames[SETTINGS.statusBarChapterProgress]); case ITEM_BOOK_PROGRESS_PERCENTAGE: return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_PROGRESS_BAR: @@ -260,8 +271,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { title = tr(STR_EXAMPLE_CHAPTER); } - GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, - SETTINGS.statusBarChapterTimeRemaining ? "15m" : nullptr); + GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, previewChapterTimeEstimate()); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, renderer.getScreenHeight() - UITheme::getInstance().getStatusBarHeight() - verticalPreviewPadding - diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 1a8e2e29787..7d627373c58 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -116,10 +116,10 @@ int UITheme::getStatusBarHeight() { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); // Add status bar margin - const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarChapterTimeRemaining || - SETTINGS.statusBarBookProgressPercentage || - SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || - SETTINGS.statusBarBattery; + const bool showStatusBar = + SETTINGS.statusBarChapterProgress != CrossPointSettings::CHAPTER_PROGRESS_HIDE || + SETTINGS.statusBarBookProgressPercentage || + SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery; const bool showProgressBar = SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) + diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index adb2bb259c4..ac42bd4b4e5 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -846,9 +846,13 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - const bool showChapterPages = SETTINGS.statusBarChapterPageCount; + const bool showChapterPages = SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; const bool showBookPercent = SETTINGS.statusBarBookProgressPercentage; - const bool showChapterTime = chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; + const bool showChapterTime = + (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) && + chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; if (showBookPercent || showChapterPages || showChapterTime) { // Right aligned text for progress counter diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index a4bb032082d..90b85a95487 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -291,6 +291,8 @@ constexpr StrId OPT_SHORTCUT_LOCATION[] = {StrId::STR_HOME_LOCATION, StrId::STR_ constexpr StrId OPT_KO_MATCH[] = {StrId::STR_FILENAME, StrId::STR_BINARY}; constexpr StrId OPT_OPDS_FILENAME_FORMAT[] = {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}; constexpr StrId OPT_BOOK_CHAPTER_HIDE[] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; +constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, StrId::STR_TIME, + StrId::STR_HIDE}; constexpr StrId OPT_BAR_THICKNESS[] = {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; constexpr StrId OPT_XTC_STATUS_BAR[] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; @@ -436,10 +438,8 @@ constexpr WebSettingDef WEB_SETTINGS[] = { WEB_ENUM(StrId::STR_OPDS_FILENAME_FORMAT, opdsFilenameFormat, OPT_OPDS_FILENAME_FORMAT, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), - WEB_TOGGLE(StrId::STR_CHAPTER_PAGE_COUNT, statusBarChapterPageCount, "statusBarChapterPageCount", - StrId::STR_CUSTOMISE_STATUS_BAR), - WEB_TOGGLE(StrId::STR_CHAPTER_TIME_REMAINING_ESTIMATE, statusBarChapterTimeRemaining, - "statusBarChapterTimeRemaining", StrId::STR_CUSTOMISE_STATUS_BAR), + WEB_ENUM(StrId::STR_CHAPTER_PROGRESS, statusBarChapterProgress, OPT_CHAPTER_PROGRESS, "statusBarChapterProgress", + StrId::STR_CUSTOMISE_STATUS_BAR), WEB_TOGGLE(StrId::STR_BOOK_PROGRESS_PERCENTAGE, statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), WEB_ENUM(StrId::STR_PROGRESS_BAR, statusBarProgressBar, OPT_BOOK_CHAPTER_HIDE, "statusBarProgressBar", From bd6de0c290fc9c248dcf291100b358b79d091691 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:29:55 -0700 Subject: [PATCH 03/20] fix(reader): harden chapter ETA word-rate accounting - Persist session words even for short uncounted sessions so ETA rate stays aligned with credited reading time - Credit each spine/page only once via a high-water mark to avoid back/forward double-counting - Preserve totalWordsRead in the browser stats editor round-trip - Remove unreachable dead code after loadSettingsDirect return --- docs/reading-stats-editor/index.html | 2 + src/JsonSettingsIO.cpp | 239 ------------------- src/ReadingStatsStore.cpp | 8 +- src/activities/reader/EpubReaderActivity.cpp | 13 +- src/activities/reader/EpubReaderActivity.h | 3 + 5 files changed, 22 insertions(+), 243 deletions(-) diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index b24329b304b..a26d1016f5d 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -3366,6 +3366,7 @@

CPR-vCodex Reading Stats Editor

chapterTitle: String(book.chapterTitle || ""), readingDays: readingDays.map(normalizeDay).filter(day => day.dayOrdinal && day.readingMs), totalReadingMs: toUInt(book.totalReadingMs), + totalWordsRead: toUInt(book.totalWordsRead), sessions: toUInt(book.sessions), lastSessionMs: toUInt(book.lastSessionMs), firstReadAt: toUInt(book.firstReadAt), @@ -3492,6 +3493,7 @@

CPR-vCodex Reading Stats Editor

chapterTitle: "", readingDays: [], totalReadingMs: 0, + totalWordsRead: 0, sessions: 0, lastSessionMs: 0, firstReadAt: 0, diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index f56db0f5810..5ebfa2beeff 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -1003,245 +1003,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* } return loadSettingsDirect(s, doc, needsResave); - - auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; }; - - // Legacy migration: if statusBarChapterProgress and statusBarChapterPageCount are absent - // this is a pre-refactor settings file. Populate s with migrated values now so the - // generic loop below picks them up as defaults and clamps them. - if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { - applyLegacyStatusBarSettings(s); - } else if (doc["statusBarChapterProgress"].isNull()) { - const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); - const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); - s.statusBarChapterProgress = migrateChapterProgressFromLegacyToggles(showPages, showTime); - } - - for (const auto& info : getSettingsList()) { - if (!info.key) continue; - // Dynamic entries (KOReader etc.) are stored in their own files - skip. - if (!info.valuePtr && !info.stringOffset) continue; - - if (info.stringOffset) { - const char* strPtr = (const char*)&s + info.stringOffset; - const std::string fieldDefault = strPtr; // current buffer = struct-initializer default - std::string val; - if (info.obfuscated) { - bool ok = false; - val = obfuscation::deobfuscateFromBase64(doc[std::string(info.key) + "_obf"] | "", &ok); - if (!ok || val.empty()) { - val = doc[info.key] | fieldDefault; - if (val != fieldDefault && needsResave) *needsResave = true; - } - } else { - val = doc[info.key] | fieldDefault; - } - char* destPtr = (char*)&s + info.stringOffset; - if (info.stringMaxLen == 0) { - LOG_ERR("CPS", "Misconfigured SettingInfo: stringMaxLen is 0 for key '%s'", info.key); - destPtr[0] = '\0'; - if (needsResave) *needsResave = true; - continue; - } - strncpy(destPtr, val.c_str(), info.stringMaxLen - 1); - destPtr[info.stringMaxLen - 1] = '\0'; - } else { - const uint8_t fieldDefault = s.*(info.valuePtr); // struct-initializer default, read before we overwrite it - uint8_t v = doc[info.key] | fieldDefault; - if (info.type == SettingType::ENUM) { - v = clamp(v, (uint8_t)info.enumValues.size(), fieldDefault); - } else if (info.type == SettingType::TOGGLE) { - v = clamp(v, (uint8_t)2, fieldDefault); - } else if (info.type == SettingType::VALUE) { - if (v < info.valueRange.min) - v = info.valueRange.min; - else if (v > info.valueRange.max) - v = info.valueRange.max; - } - s.*(info.valuePtr) = v; - } - } - - // Front button remap - managed by RemapFrontButtons sub-activity, not in SettingsList. - const uint8_t fontSizeSchemaVersion = doc["fontSizeSchemaVersion"] | static_cast(0); - if (fontSizeSchemaVersion < FONT_SIZE_SCHEMA_VERSION && !doc["fontSize"].isNull()) { - const uint8_t legacyFontSize = doc["fontSize"] | static_cast(CrossPointSettings::MEDIUM - 1); - if (legacyFontSize < static_cast(CrossPointSettings::EXTRA_LARGE)) { - s.fontSize = static_cast(legacyFontSize + 1); - if (needsResave) *needsResave = true; - } - } - - const uint8_t rawFontFamily = doc["fontFamily"] | s.fontFamily; - if (rawFontFamily >= static_cast(CrossPointSettings::FONT_FAMILY_COUNT)) { - s.fontFamily = CrossPointSettings::BOOKERLY; - if (needsResave) *needsResave = true; - } else { - s.fontFamily = rawFontFamily; - } - - using S = CrossPointSettings; - s.frontButtonBack = - clamp(doc["frontButtonBack"] | (uint8_t)S::FRONT_HW_BACK, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_BACK); - s.frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)S::FRONT_HW_CONFIRM, S::FRONT_BUTTON_HARDWARE_COUNT, - S::FRONT_HW_CONFIRM); - s.frontButtonLeft = - clamp(doc["frontButtonLeft"] | (uint8_t)S::FRONT_HW_LEFT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_LEFT); - s.frontButtonRight = - clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT); - s.displayDay = clamp(doc["displayDay"] | s.displayDay, S::DISPLAY_HEADER_MODE_COUNT, s.displayDay); - migrateDisplayHeaderSettings(s, doc, needsResave); - s.autoSyncDay = clamp(doc["autoSyncDay"] | s.autoSyncDay, static_cast(2), s.autoSyncDay); - s.syncDayWifiChoice = - clamp(doc["syncDayWifiChoice"] | s.syncDayWifiChoice, S::SYNC_DAY_WIFI_CHOICE_COUNT, s.syncDayWifiChoice); - s.syncDayReminderStarts = clamp(doc["syncDayReminderStarts"] | s.syncDayReminderStarts, - S::SYNC_DAY_REMINDER_STARTS_COUNT, s.syncDayReminderStarts); - { - const std::string sleepDirectory = doc["sleepDirectory"] | std::string(""); - strncpy(s.sleepDirectory, sleepDirectory.c_str(), sizeof(s.sleepDirectory) - 1); - s.sleepDirectory[sizeof(s.sleepDirectory) - 1] = '\0'; - } - s.sleepImageOrder = clamp(doc["sleepImageOrder"] | static_cast(S::SLEEP_IMAGE_SHUFFLE), - S::SLEEP_IMAGE_ORDER_COUNT, S::SLEEP_IMAGE_SHUFFLE); - s.timeZonePreset = - TimeZoneRegistry::clampPresetIndex(doc["timeZonePreset"] | TimeZoneRegistry::DEFAULT_TIME_ZONE_INDEX); - s.dateFormat = clamp(doc["dateFormat"] | s.dateFormat, S::DATE_FORMAT_COUNT, s.dateFormat); - s.opdsFilenameFormat = - clamp(doc["opdsFilenameFormat"] | s.opdsFilenameFormat, S::OPDS_FILENAME_FORMAT_COUNT, s.opdsFilenameFormat); - s.koSyncAutoPullOnOpen = - clamp(doc["koSyncAutoPullOnOpen"] | s.koSyncAutoPullOnOpen, static_cast(2), s.koSyncAutoPullOnOpen); - s.koSyncAutoPushOnClose = - clamp(doc["koSyncAutoPushOnClose"] | s.koSyncAutoPushOnClose, static_cast(2), s.koSyncAutoPushOnClose); - s.dailyGoalTarget = clamp(doc["dailyGoalTarget"] | s.dailyGoalTarget, S::DAILY_GOAL_TARGET_COUNT, s.dailyGoalTarget); - { - const uint8_t rawFlashcardStudyMode = doc["flashcardStudyMode"] | s.flashcardStudyMode; - const uint8_t flashcardStudyModeSchemaVersion = doc["flashcardStudyModeSchemaVersion"] | static_cast(0); - s.flashcardStudyMode = migrateStoredFlashcardStudyMode(rawFlashcardStudyMode, flashcardStudyModeSchemaVersion, - s.flashcardStudyMode, nullptr); - } - s.flashcardSessionSize = clamp(doc["flashcardSessionSize"] | s.flashcardSessionSize, S::FLASHCARD_SESSION_SIZE_COUNT, - s.flashcardSessionSize); - s.showStatsAfterReading = - clamp(doc["showStatsAfterReading"] | s.showStatsAfterReading, static_cast(2), s.showStatsAfterReading); - s.achievementsEnabled = - clamp(doc["achievementsEnabled"] | s.achievementsEnabled, static_cast(2), s.achievementsEnabled); - s.achievementPopups = - clamp(doc["achievementPopups"] | s.achievementPopups, static_cast(2), s.achievementPopups); - - const uint8_t shortcutLocationCount = S::SHORTCUT_LOCATION_COUNT; - const uint8_t shortcutOrderCount = static_cast(getShortcutDefinitions().size() + 1); - s.appsHubShortcutOrder = - clamp(doc["appsHubShortcutOrder"] | s.appsHubShortcutOrder, shortcutOrderCount, s.appsHubShortcutOrder); - s.browseFilesShortcut = - clamp(doc["browseFilesShortcut"] | s.browseFilesShortcut, shortcutLocationCount, s.browseFilesShortcut); - s.browseFilesShortcutOrder = clamp(doc["browseFilesShortcutOrder"] | s.browseFilesShortcutOrder, shortcutOrderCount, - s.browseFilesShortcutOrder); - s.statsShortcut = clamp(doc["statsShortcut"] | s.statsShortcut, shortcutLocationCount, s.statsShortcut); - s.statsShortcutOrder = - clamp(doc["statsShortcutOrder"] | s.statsShortcutOrder, shortcutOrderCount, s.statsShortcutOrder); - s.syncDayShortcut = clamp(doc["syncDayShortcut"] | s.syncDayShortcut, shortcutLocationCount, s.syncDayShortcut); - s.syncDayShortcutOrder = - clamp(doc["syncDayShortcutOrder"] | s.syncDayShortcutOrder, shortcutOrderCount, s.syncDayShortcutOrder); - s.settingsShortcut = clamp(doc["settingsShortcut"] | s.settingsShortcut, shortcutLocationCount, s.settingsShortcut); - s.settingsShortcutOrder = - clamp(doc["settingsShortcutOrder"] | s.settingsShortcutOrder, shortcutOrderCount, s.settingsShortcutOrder); - s.readingStatsShortcut = - clamp(doc["readingStatsShortcut"] | s.readingStatsShortcut, shortcutLocationCount, s.readingStatsShortcut); - s.readingStatsShortcutOrder = clamp(doc["readingStatsShortcutOrder"] | s.readingStatsShortcutOrder, - shortcutOrderCount, s.readingStatsShortcutOrder); - s.readingHeatmapShortcut = - clamp(doc["readingHeatmapShortcut"] | s.readingHeatmapShortcut, shortcutLocationCount, s.readingHeatmapShortcut); - s.readingHeatmapShortcutOrder = clamp(doc["readingHeatmapShortcutOrder"] | s.readingHeatmapShortcutOrder, - shortcutOrderCount, s.readingHeatmapShortcutOrder); - s.readingProfileShortcut = - clamp(doc["readingProfileShortcut"] | s.readingProfileShortcut, shortcutLocationCount, s.readingProfileShortcut); - s.readingProfileShortcutOrder = clamp(doc["readingProfileShortcutOrder"] | s.readingProfileShortcutOrder, - shortcutOrderCount, s.readingProfileShortcutOrder); - s.achievementsShortcut = - clamp(doc["achievementsShortcut"] | s.achievementsShortcut, shortcutLocationCount, s.achievementsShortcut); - s.achievementsShortcutOrder = clamp(doc["achievementsShortcutOrder"] | s.achievementsShortcutOrder, - shortcutOrderCount, s.achievementsShortcutOrder); - s.ifFoundShortcut = clamp(doc["ifFoundShortcut"] | s.ifFoundShortcut, shortcutLocationCount, s.ifFoundShortcut); - s.ifFoundShortcutOrder = - clamp(doc["ifFoundShortcutOrder"] | s.ifFoundShortcutOrder, shortcutOrderCount, s.ifFoundShortcutOrder); - s.readMeShortcut = clamp(doc["readMeShortcut"] | s.readMeShortcut, shortcutLocationCount, s.readMeShortcut); - s.readMeShortcutOrder = - clamp(doc["readMeShortcutOrder"] | s.readMeShortcutOrder, shortcutOrderCount, s.readMeShortcutOrder); - s.recentBooksShortcut = - clamp(doc["recentBooksShortcut"] | s.recentBooksShortcut, shortcutLocationCount, s.recentBooksShortcut); - s.recentBooksShortcutOrder = clamp(doc["recentBooksShortcutOrder"] | s.recentBooksShortcutOrder, shortcutOrderCount, - s.recentBooksShortcutOrder); - s.bookmarksShortcut = - clamp(doc["bookmarksShortcut"] | s.bookmarksShortcut, shortcutLocationCount, s.bookmarksShortcut); - s.bookmarksShortcutOrder = - clamp(doc["bookmarksShortcutOrder"] | s.bookmarksShortcutOrder, shortcutOrderCount, s.bookmarksShortcutOrder); - s.favoritesShortcut = - clamp(doc["favoritesShortcut"] | s.favoritesShortcut, shortcutLocationCount, s.favoritesShortcut); - s.favoritesShortcutOrder = - clamp(doc["favoritesShortcutOrder"] | s.favoritesShortcutOrder, shortcutOrderCount, s.favoritesShortcutOrder); - s.flashcardsShortcut = - clamp(doc["flashcardsShortcut"] | s.flashcardsShortcut, shortcutLocationCount, s.flashcardsShortcut); - s.flashcardsShortcutOrder = - clamp(doc["flashcardsShortcutOrder"] | s.flashcardsShortcutOrder, shortcutOrderCount, s.flashcardsShortcutOrder); - s.fileTransferShortcut = - clamp(doc["fileTransferShortcut"] | s.fileTransferShortcut, shortcutLocationCount, s.fileTransferShortcut); - s.fileTransferShortcutOrder = clamp(doc["fileTransferShortcutOrder"] | s.fileTransferShortcutOrder, - shortcutOrderCount, s.fileTransferShortcutOrder); - s.screenCleanShortcut = - clamp(doc["screenCleanShortcut"] | s.screenCleanShortcut, shortcutLocationCount, s.screenCleanShortcut); - s.screenCleanShortcutOrder = clamp(doc["screenCleanShortcutOrder"] | s.screenCleanShortcutOrder, shortcutOrderCount, - s.screenCleanShortcutOrder); - s.sleepShortcut = clamp(doc["sleepShortcut"] | s.sleepShortcut, shortcutLocationCount, s.sleepShortcut); - s.sleepShortcutOrder = - clamp(doc["sleepShortcutOrder"] | s.sleepShortcutOrder, shortcutOrderCount, s.sleepShortcutOrder); - s.opdsBrowserShortcut = - clamp(doc["opdsBrowserShortcut"] | s.opdsBrowserShortcut, shortcutLocationCount, s.opdsBrowserShortcut); - s.opdsBrowserShortcutOrder = clamp(doc["opdsBrowserShortcutOrder"] | s.opdsBrowserShortcutOrder, shortcutOrderCount, - s.opdsBrowserShortcutOrder); - - s.browseFilesShortcutVisible = clamp(doc["browseFilesShortcutVisible"] | s.browseFilesShortcutVisible, - static_cast(2), s.browseFilesShortcutVisible); - s.statsShortcutVisible = - clamp(doc["statsShortcutVisible"] | s.statsShortcutVisible, static_cast(2), s.statsShortcutVisible); - s.syncDayShortcutVisible = clamp(doc["syncDayShortcutVisible"] | s.syncDayShortcutVisible, static_cast(2), - s.syncDayShortcutVisible); - s.settingsShortcutVisible = clamp(doc["settingsShortcutVisible"] | s.settingsShortcutVisible, static_cast(2), - s.settingsShortcutVisible); - s.readingStatsShortcutVisible = clamp(doc["readingStatsShortcutVisible"] | s.readingStatsShortcutVisible, - static_cast(2), s.readingStatsShortcutVisible); - s.readingHeatmapShortcutVisible = clamp(doc["readingHeatmapShortcutVisible"] | s.readingHeatmapShortcutVisible, - static_cast(2), s.readingHeatmapShortcutVisible); - s.readingProfileShortcutVisible = clamp(doc["readingProfileShortcutVisible"] | s.readingProfileShortcutVisible, - static_cast(2), s.readingProfileShortcutVisible); - s.achievementsShortcutVisible = clamp(doc["achievementsShortcutVisible"] | s.achievementsShortcutVisible, - static_cast(2), s.achievementsShortcutVisible); - s.ifFoundShortcutVisible = clamp(doc["ifFoundShortcutVisible"] | s.ifFoundShortcutVisible, static_cast(2), - s.ifFoundShortcutVisible); - s.readMeShortcutVisible = - clamp(doc["readMeShortcutVisible"] | s.readMeShortcutVisible, static_cast(2), s.readMeShortcutVisible); - s.recentBooksShortcutVisible = clamp(doc["recentBooksShortcutVisible"] | s.recentBooksShortcutVisible, - static_cast(2), s.recentBooksShortcutVisible); - s.bookmarksShortcutVisible = clamp(doc["bookmarksShortcutVisible"] | s.bookmarksShortcutVisible, - static_cast(2), s.bookmarksShortcutVisible); - s.favoritesShortcutVisible = clamp(doc["favoritesShortcutVisible"] | s.favoritesShortcutVisible, - static_cast(2), s.favoritesShortcutVisible); - s.flashcardsShortcutVisible = clamp(doc["flashcardsShortcutVisible"] | s.flashcardsShortcutVisible, - static_cast(2), s.flashcardsShortcutVisible); - s.fileTransferShortcutVisible = clamp(doc["fileTransferShortcutVisible"] | s.fileTransferShortcutVisible, - static_cast(2), s.fileTransferShortcutVisible); - s.screenCleanShortcutVisible = clamp(doc["screenCleanShortcutVisible"] | s.screenCleanShortcutVisible, - static_cast(2), s.screenCleanShortcutVisible); - s.sleepShortcutVisible = - clamp(doc["sleepShortcutVisible"] | s.sleepShortcutVisible, static_cast(2), s.sleepShortcutVisible); - s.opdsBrowserShortcutVisible = clamp(doc["opdsBrowserShortcutVisible"] | s.opdsBrowserShortcutVisible, - static_cast(2), s.opdsBrowserShortcutVisible); - - normalizeShortcutOrderSettings(s); - CrossPointSettings::validateFrontButtonMapping(s); - - LOG_DBG("CPS", "Settings loaded from file"); - - return true; } // ---- KOReaderCredentialStore ---- diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index a4797366577..400d58acd06 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1454,7 +1454,6 @@ void ReadingStatsStore::endSession() { if (countedSession) { book.sessions++; book.lastSessionMs = sessionMs; - book.totalWordsRead += activeSession.sessionWordsRead; const uint32_t sessionTimestamp = getReferenceTimestamp(TimeUtils::getAuthoritativeTimestamp(), book.lastReadAt); if (isClockValid(sessionTimestamp)) { appendSessionLogEntry(TimeUtils::getLocalDayOrdinal(sessionTimestamp), sessionMs, book); @@ -1462,6 +1461,13 @@ void ReadingStatsStore::endSession() { markDirty(); } + // Always persist words read for ETA rate, including short uncounted sessions, + // because noteActivity() already credits their reading time into totalReadingMs. + if (activeSession.sessionWordsRead > 0) { + book.totalWordsRead += activeSession.sessionWordsRead; + markDirty(); + } + lastSessionSnapshot.valid = true; lastSessionSnapshot.serial = ++sessionSerialCounter; lastSessionSnapshot.bookId = book.bookId; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7ce0e846b60..3105e7ed7f5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1320,10 +1320,17 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (isForwardTurn) { // Credit words on the page being finished so chapter time estimates use a // word rate rather than page-turn rate (image/sparse pages count as 0). + // Only credit each (spine, page) once per reader session (high-water mark). if (oldPage >= 0) { - const uint16_t words = section->getPageWordCount(static_cast(oldPage)); - if (words > 0) { - READING_STATS.noteWordsRead(words); + const bool isNewProgress = oldSpineIndex > wordsCreditedSpineIndex || + (oldSpineIndex == wordsCreditedSpineIndex && oldPage > wordsCreditedPage); + if (isNewProgress) { + const uint16_t words = section->getPageWordCount(static_cast(oldPage)); + if (words > 0) { + READING_STATS.noteWordsRead(words); + } + wordsCreditedSpineIndex = oldSpineIndex; + wordsCreditedPage = oldPage; } } if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8ea2a1e45e4..c143a924601 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -45,6 +45,9 @@ class EpubReaderActivity final : public Activity { int sessionStartSpineIndex = 0; int sessionStartPage = 0; bool sessionProgressTouched = false; + // High-water mark for word-rate credits so back/forward re-reads do not inflate ETA. + int wordsCreditedSpineIndex = -1; + int wordsCreditedPage = -1; std::shared_ptr currentOverlayPageCache; EndOfBookOptions endOfBookOptions; int currentOverlayPageSpineIndex = -1; From 786b8a9e25ea8192fc71e4aea81763f3e1fe7af9 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:35:33 -0700 Subject: [PATCH 04/20] refactor(reader): simplify chapter ETA helpers and migration - Drop dual-toggle chapter-progress migration; map only the older page-count toggle to Pages/Hide - Make getEffectiveWordsPerMs active-book-only and remove unused getActiveSessionWordsRead - Inline estimateRemainingMs at the call site; DRY formatCompactDuration; one-pass estimateRemainingWords --- lib/Epub/Epub/Section.cpp | 27 ++++++------ src/JsonSettingsIO.cpp | 19 ++------ src/ReadingStatsStore.cpp | 24 +++------- src/ReadingStatsStore.h | 3 +- src/activities/reader/EpubReaderActivity.cpp | 10 ++++- src/util/ChapterTimeEstimate.cpp | 46 ++++++-------------- src/util/ChapterTimeEstimate.h | 4 -- 7 files changed, 44 insertions(+), 89 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index de61f2086d7..6aebee3d3b3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -1029,8 +1029,13 @@ uint16_t Section::getPageWordCount(const uint16_t page) const { uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { const uint16_t availablePages = pageCount; uint32_t remaining = 0; - for (uint16_t page = fromPage; page < availablePages; ++page) { - remaining += getPageWordCount(page); + uint32_t knownWords = 0; + for (uint16_t page = 0; page < availablePages; ++page) { + const uint16_t words = getPageWordCount(page); + knownWords += words; + if (page >= fromPage) { + remaining += words; + } } // Still-building / partial chapters: extrapolate unbuilt content from HTML density. @@ -1044,18 +1049,12 @@ uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { totalBytes = partialTotalBytes_; } - if (totalBytes > bytesConsumed && bytesConsumed > 0 && availablePages > 0) { - uint32_t knownWords = 0; - for (uint16_t page = 0; page < availablePages; ++page) { - knownWords += getPageWordCount(page); - } - if (knownWords > 0) { - const uint64_t unbuiltBytes = static_cast(totalBytes - bytesConsumed); - const uint64_t unbuiltWords = - (static_cast(knownWords) * unbuiltBytes) / static_cast(bytesConsumed); - if (unbuiltWords > 0 && unbuiltWords < static_cast(UINT32_MAX)) { - remaining += static_cast(unbuiltWords); - } + if (knownWords > 0 && totalBytes > bytesConsumed && bytesConsumed > 0) { + const uint64_t unbuiltBytes = static_cast(totalBytes - bytesConsumed); + const uint64_t unbuiltWords = + (static_cast(knownWords) * unbuiltBytes) / static_cast(bytesConsumed); + if (unbuiltWords > 0 && unbuiltWords < static_cast(UINT32_MAX)) { + remaining += static_cast(unbuiltWords); } } diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 5ebfa2beeff..92eed130f77 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -354,19 +354,6 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { } } -uint8_t migrateChapterProgressFromLegacyToggles(const uint8_t showPages, const uint8_t showTime) { - if (showPages && showTime) { - return CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; - } - if (showTime) { - return CrossPointSettings::CHAPTER_PROGRESS_TIME; - } - if (showPages) { - return CrossPointSettings::CHAPTER_PROGRESS_PAGES; - } - return CrossPointSettings::CHAPTER_PROGRESS_HIDE; -} - namespace { void migrateDisplayHeaderSettings(CrossPointSettings& s, const JsonDocument& doc, bool* needsResave) { if (doc["displayHeaderTime"].isNull()) { @@ -418,10 +405,10 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { applyLegacyStatusBarSettings(s); } else if (doc["statusBarChapterProgress"].isNull()) { - // Migrate the short-lived dual-toggle settings into the combined enum. + // Pre-enum settings only had a chapter page-count toggle. const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); - const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); - s.statusBarChapterProgress = migrateChapterProgressFromLegacyToggles(showPages, showTime); + s.statusBarChapterProgress = + showPages ? CrossPointSettings::CHAPTER_PROGRESS_PAGES : CrossPointSettings::CHAPTER_PROGRESS_HIDE; if (needsResave) *needsResave = true; } diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 400d58acd06..dfc4d55b6e7 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1482,30 +1482,18 @@ void ReadingStatsStore::endSession() { saveToFile(); } -uint64_t ReadingStatsStore::getActiveSessionWordsRead() const { - return activeSession.active ? activeSession.sessionWordsRead : 0; -} - double ReadingStatsStore::getEffectiveWordsPerMs() const { constexpr uint64_t MIN_RATE_WORDS = 80; constexpr uint64_t MIN_RATE_MS = 60ULL * 1000ULL; - uint64_t words = 0; - uint64_t ms = 0; - - if (activeSession.active && activeSession.bookIndex < books.size()) { - const auto& book = books[activeSession.bookIndex]; - // totalReadingMs already includes credited session time; totalWordsRead does not yet. - words = book.totalWordsRead + activeSession.sessionWordsRead; - ms = book.totalReadingMs; - } else { - // Global fallback across all books when no session is active. - for (const auto& book : books) { - words += book.totalWordsRead; - ms += book.totalReadingMs; - } + if (!activeSession.active || activeSession.bookIndex >= books.size()) { + return 0.0; } + const auto& book = books[activeSession.bookIndex]; + // totalReadingMs already includes credited session time; totalWordsRead does not yet. + const uint64_t words = book.totalWordsRead + activeSession.sessionWordsRead; + const uint64_t ms = book.totalReadingMs; if (words < MIN_RATE_WORDS || ms < MIN_RATE_MS) { return 0.0; } diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index 795621d58c9..a8c24530166 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -158,10 +158,9 @@ class ReadingStatsStore { void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); - // Blended historical + current-session word reading rate (words per millisecond). + // Active-book word reading rate (words per millisecond), including the current session. // Returns 0 when there is not enough sample data yet. double getEffectiveWordsPerMs() const; - uint64_t getActiveSessionWordsRead() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); bool updateBookMetadata(const std::string& path, const std::string& title, const std::string& author, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3105e7ed7f5..41d5cc56ba5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2003,8 +2003,14 @@ void EpubReaderActivity::renderStatusBar() const { section->currentPage >= 0) { const uint32_t remainingWords = section->estimateRemainingWords(static_cast(section->currentPage)); - const uint64_t remainingMs = - ChapterTimeEstimate::estimateRemainingMs(remainingWords, READING_STATS.getEffectiveWordsPerMs()); + const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); + uint64_t remainingMs = 0; + if (remainingWords > 0 && wordsPerMs > 0.0) { + const double ms = static_cast(remainingWords) / wordsPerMs; + if (ms > 0.0 && ms < static_cast(UINT64_MAX)) { + remainingMs = static_cast(ms); + } + } if (ChapterTimeEstimate::formatCompactDuration(remainingMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; } diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index 4b434f2afc3..3391ce40ebc 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -8,51 +8,31 @@ constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; constexpr uint64_t MS_PER_HOUR = 60ULL * MS_PER_MINUTE; constexpr uint64_t MS_PER_DAY = 24ULL * MS_PER_HOUR; constexpr uint64_t MS_PER_YEAR = 365ULL * MS_PER_DAY; + +bool formatRoundedUnit(const uint64_t totalMs, const uint64_t unitMs, const char unit, char* buf, + const size_t bufSize) { + uint64_t value = (totalMs + unitMs / 2) / unitMs; + if (value == 0) { + value = 1; + } + return snprintf(buf, bufSize, "%llu%c", static_cast(value), unit) > 0; +} } // namespace bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { if (!buf || bufSize < 3 || totalMs == 0) { return false; } - if (totalMs < MS_PER_HOUR) { - uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; - if (minutes == 0) { - minutes = 1; - } - return snprintf(buf, bufSize, "%llum", static_cast(minutes)) > 0; + return formatRoundedUnit(totalMs, MS_PER_MINUTE, 'm', buf, bufSize); } if (totalMs < MS_PER_DAY) { - uint64_t hours = (totalMs + MS_PER_HOUR / 2) / MS_PER_HOUR; - if (hours == 0) { - hours = 1; - } - return snprintf(buf, bufSize, "%lluh", static_cast(hours)) > 0; + return formatRoundedUnit(totalMs, MS_PER_HOUR, 'h', buf, bufSize); } if (totalMs < MS_PER_YEAR) { - uint64_t days = (totalMs + MS_PER_DAY / 2) / MS_PER_DAY; - if (days == 0) { - days = 1; - } - return snprintf(buf, bufSize, "%llud", static_cast(days)) > 0; - } - - uint64_t years = (totalMs + MS_PER_YEAR / 2) / MS_PER_YEAR; - if (years == 0) { - years = 1; - } - return snprintf(buf, bufSize, "%lluy", static_cast(years)) > 0; -} - -uint64_t estimateRemainingMs(const uint32_t remainingWords, const double wordsPerMs) { - if (remainingWords == 0 || wordsPerMs <= 0.0) { - return 0; - } - const double ms = static_cast(remainingWords) / wordsPerMs; - if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { - return 0; + return formatRoundedUnit(totalMs, MS_PER_DAY, 'd', buf, bufSize); } - return static_cast(ms); + return formatRoundedUnit(totalMs, MS_PER_YEAR, 'y', buf, bufSize); } } // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index dfccc708364..85eff863796 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -9,8 +9,4 @@ namespace ChapterTimeEstimate { // Returns false when buf is too small or ms is zero (nothing to show). bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); -// Estimate chapter remaining time from remaining words and an effective words/ms rate. -// Returns 0 when inputs are insufficient. -uint64_t estimateRemainingMs(uint32_t remainingWords, double wordsPerMs); - } // namespace ChapterTimeEstimate From ec6defa78fcebb45e45e3940c095974c3e345cb5 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:08:35 -0700 Subject: [PATCH 05/20] fix(stats): pair chapter ETA rate with dwell-sampled words - Store totalWordsReadingMs co-collected with totalWordsRead instead of using lifetime totalReadingMs - Persist word samples immediately and drop unpaired historical words on load - Restore dual-toggle chapter-progress migration; expose paired fields in the stats editor --- docs/reading-stats-editor/index.html | 27 ++++++++++++++++++++++++ src/JsonSettingsIO.cpp | 16 +++++++++++--- src/ReadingStatsStore.cpp | 31 ++++++++++++++-------------- src/ReadingStatsStore.h | 11 ++++++---- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index a26d1016f5d..59a6340ee45 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -736,6 +736,13 @@ grid-column: 1 / -1; } + .field .hint { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .field label { display: block; margin-bottom: 5px; @@ -1384,6 +1391,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Cover BMP path", chapterTitle: "Chapter title", totalReadingMinutes: "Total reading minutes", + totalWordsRead: "Words read (ETA samples)", + wordsReadingMinutes: "Minutes paired with words (ETA)", + wordsRateHint: "ETA rate uses words ÷ paired minutes. Unpaired words are cleared on import.", lastSessionMinutes: "Last session minutes", bookProgress: "Book progress %", chapterProgress: "Chapter progress %", @@ -1458,6 +1468,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Ruta de portada BMP", chapterTitle: "Título de capítulo", totalReadingMinutes: "Minutos totales de lectura", + totalWordsRead: "Palabras leídas (muestras ETA)", + wordsReadingMinutes: "Minutos emparejados con palabras (ETA)", + wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados. Las palabras sin emparejar se borran al importar.", lastSessionMinutes: "Minutos de la última sesión", bookProgress: "Progreso del libro %", chapterProgress: "Progreso del capítulo %", @@ -3366,6 +3379,7 @@

CPR-vCodex Reading Stats Editor

chapterTitle: String(book.chapterTitle || ""), readingDays: readingDays.map(normalizeDay).filter(day => day.dayOrdinal && day.readingMs), totalReadingMs: toUInt(book.totalReadingMs), + totalWordsReadingMs: toUInt(book.totalWordsReadingMs), totalWordsRead: toUInt(book.totalWordsRead), sessions: toUInt(book.sessions), lastSessionMs: toUInt(book.lastSessionMs), @@ -3376,6 +3390,10 @@

CPR-vCodex Reading Stats Editor

chapterProgressPercent: clampPercent(book.chapterProgressPercent), completed: Boolean(book.completed) }; + // Match firmware: unpaired historical words must not skew ETA rate. + if (!normalized.totalWordsReadingMs && normalized.totalWordsRead) { + normalized.totalWordsRead = 0; + } if (!normalized.bookId) normalized.bookId = normalized.path; if (!normalized.knownPaths.includes(normalized.path) && normalized.path) { normalized.knownPaths.unshift(normalized.path); @@ -3493,6 +3511,7 @@

CPR-vCodex Reading Stats Editor

chapterTitle: "", readingDays: [], totalReadingMs: 0, + totalWordsReadingMs: 0, totalWordsRead: 0, sessions: 0, lastSessionMs: 0, @@ -3831,6 +3850,9 @@

${escapeHtml(t("monthlyReading"))}

${textField("coverBmpPath", t("coverBmpPath"), book.coverBmpPath)} ${textField("chapterTitle", t("chapterTitle"), book.chapterTitle)} ${numberField("totalReadingMinutes", t("totalReadingMinutes"), msToMinutes(book.totalReadingMs))} + ${numberField("totalWordsRead", t("totalWordsRead"), book.totalWordsRead)} + ${numberField("wordsReadingMinutes", t("wordsReadingMinutes"), msToMinutes(book.totalWordsReadingMs))} +

${escapeHtml(t("wordsRateHint"))}

${numberField("sessions", t("sessions"), book.sessions)} ${numberField("lastSessionMinutes", t("lastSessionMinutes"), msToMinutes(book.lastSessionMs))} ${numberField("lastProgressPercent", t("bookProgress"), book.lastProgressPercent)} @@ -3879,6 +3901,11 @@

${escapeHtml(t("monthlyReading"))}

book.coverBmpPath = value("coverBmpPath"); book.chapterTitle = value("chapterTitle"); book.totalReadingMs = minutesToMs(value("totalReadingMinutes")); + book.totalWordsRead = toUInt(value("totalWordsRead")); + book.totalWordsReadingMs = minutesToMs(value("wordsReadingMinutes")); + if (!book.totalWordsReadingMs && book.totalWordsRead) { + book.totalWordsRead = 0; + } book.sessions = toUInt(value("sessions")); book.lastSessionMs = minutesToMs(value("lastSessionMinutes")); book.lastProgressPercent = clampPercent(value("lastProgressPercent")); diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 92eed130f77..c8ff99e9147 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -405,10 +405,18 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { applyLegacyStatusBarSettings(s); } else if (doc["statusBarChapterProgress"].isNull()) { - // Pre-enum settings only had a chapter page-count toggle. + // Migrate pre-enum toggles (page count, and briefly also time remaining) into the enum. const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); - s.statusBarChapterProgress = - showPages ? CrossPointSettings::CHAPTER_PROGRESS_PAGES : CrossPointSettings::CHAPTER_PROGRESS_HIDE; + const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); + if (showPages && showTime) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; + } else if (showTime) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_TIME; + } else if (showPages) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; + } else { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; + } if (needsResave) *needsResave = true; } @@ -1320,6 +1328,7 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["coverBmpPath"] = book.coverBmpPath; obj["chapterTitle"] = book.chapterTitle; obj["totalReadingMs"] = book.totalReadingMs; + obj["totalWordsReadingMs"] = book.totalWordsReadingMs; obj["totalWordsRead"] = book.totalWordsRead; obj["sessions"] = book.sessions; obj["lastSessionMs"] = book.lastSessionMs; @@ -1469,6 +1478,7 @@ bool JsonSettingsIO::loadReadingStatsDocument(ReadingStatsStore& store, const Js book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.chapterTitle = obj["chapterTitle"] | std::string(""); book.totalReadingMs = obj["totalReadingMs"] | static_cast(0); + book.totalWordsReadingMs = obj["totalWordsReadingMs"] | static_cast(0); book.totalWordsRead = obj["totalWordsRead"] | static_cast(0); book.sessions = obj["sessions"] | static_cast(0); book.lastSessionMs = obj["lastSessionMs"] | static_cast(0); diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index dfc4d55b6e7..0cb930021a8 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -522,6 +522,7 @@ void ReadingStatsStore::mergeBookInto(ReadingBookStats& primary, const ReadingBo } primary.totalReadingMs += duplicate.totalReadingMs; + primary.totalWordsReadingMs += duplicate.totalWordsReadingMs; primary.totalWordsRead += duplicate.totalWordsRead; primary.sessions += duplicate.sessions; primary.lastSessionMs = std::max(primary.lastSessionMs, duplicate.lastSessionMs); @@ -557,6 +558,11 @@ void ReadingStatsStore::normalizeBook(ReadingBookStats& book) { normalizeReadingDays(book.readingDays); book.lastProgressPercent = clampPercent(book.lastProgressPercent); book.chapterProgressPercent = clampPercent(book.chapterProgressPercent); + // Pre-pairing builds stored words against lifetime reading ms. Drop unpaired words so + // ETA rate cannot open on ~80 new words divided by hours of historical time. + if (book.totalWordsReadingMs == 0 && book.totalWordsRead > 0) { + book.totalWordsRead = 0; + } } void ReadingStatsStore::normalizeBooks() { @@ -1219,7 +1225,6 @@ void ReadingStatsStore::beginSession(const std::string& path, const std::string& activeSession.bookIndex = 0; activeSession.lastInteractionMs = millis(); activeSession.accumulatedMs = 0; - activeSession.sessionWordsRead = 0; markDirty(); } @@ -1249,11 +1254,14 @@ void ReadingStatsStore::noteActivity() { } } -void ReadingStatsStore::noteWordsRead(const uint32_t words) { - if (!activeSession.active || words == 0) { +void ReadingStatsStore::noteWordsRead(const uint32_t words, const uint32_t associatedMs) { + if (!activeSession.active || activeSession.bookIndex >= books.size() || words == 0 || associatedMs == 0) { return; } - activeSession.sessionWordsRead += words; + auto& book = books[activeSession.bookIndex]; + book.totalWordsRead += words; + book.totalWordsReadingMs += associatedMs; + markDirty(); } void ReadingStatsStore::tickActiveSession() { @@ -1461,13 +1469,6 @@ void ReadingStatsStore::endSession() { markDirty(); } - // Always persist words read for ETA rate, including short uncounted sessions, - // because noteActivity() already credits their reading time into totalReadingMs. - if (activeSession.sessionWordsRead > 0) { - book.totalWordsRead += activeSession.sessionWordsRead; - markDirty(); - } - lastSessionSnapshot.valid = true; lastSessionSnapshot.serial = ++sessionSerialCounter; lastSessionSnapshot.bookId = book.bookId; @@ -1491,13 +1492,11 @@ double ReadingStatsStore::getEffectiveWordsPerMs() const { } const auto& book = books[activeSession.bookIndex]; - // totalReadingMs already includes credited session time; totalWordsRead does not yet. - const uint64_t words = book.totalWordsRead + activeSession.sessionWordsRead; - const uint64_t ms = book.totalReadingMs; - if (words < MIN_RATE_WORDS || ms < MIN_RATE_MS) { + // Only dwell ms paired with credited page words — never lifetime totalReadingMs. + if (book.totalWordsRead < MIN_RATE_WORDS || book.totalWordsReadingMs < MIN_RATE_MS) { return 0.0; } - return static_cast(words) / static_cast(ms); + return static_cast(book.totalWordsRead) / static_cast(book.totalWordsReadingMs); } bool ReadingStatsStore::adjustBookReadingTime(const std::string& path, const uint32_t dayOrdinal, diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index a8c24530166..1569abf05b9 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -25,6 +25,8 @@ struct ReadingBookStats { std::string chapterTitle; std::vector readingDays; uint64_t totalReadingMs = 0; + // Word-rate samples only: ms co-collected with totalWordsRead (not lifetime reading time). + uint64_t totalWordsReadingMs = 0; uint64_t totalWordsRead = 0; uint32_t sessions = 0; uint32_t lastSessionMs = 0; @@ -84,7 +86,6 @@ class ReadingStatsStore { size_t bookIndex = 0; unsigned long lastInteractionMs = 0; uint64_t accumulatedMs = 0; - uint64_t sessionWordsRead = 0; uint8_t startProgressPercent = 0; bool startCompleted = false; }; @@ -152,14 +153,16 @@ class ReadingStatsStore { const std::string& coverBmpPath, uint8_t progressPercent = 0, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void noteActivity(); - void noteWordsRead(uint32_t words); + // Credit words finished on a page together with the dwell time spent on that page. + // Both are persisted immediately so ETA rate survives crashes before endSession. + void noteWordsRead(uint32_t words, uint32_t associatedMs); void tickActiveSession(); void resumeSession(); void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); - // Active-book word reading rate (words per millisecond), including the current session. - // Returns 0 when there is not enough sample data yet. + // Active-book word reading rate from paired word/dwell samples only. + // Returns 0 when there is not enough paired sample data yet. double getEffectiveWordsPerMs() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); From 4c5addd48d0c14675acd9b48a00b06407476cd82 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:09:06 -0700 Subject: [PATCH 06/20] fix(reader): correct EPUB chapter ETA credit and remaining words - Credit words by page dwell with linger-based re-read; clear dwell on jumps so skipped pages are not counted - Align remaining-word extrapolation with estimatedTotalPages and drop duplicate LUT word counts - Harden compact duration formatting and share formatRemainingFromRate --- lib/Epub/Epub/Section.cpp | 30 ++--- lib/Epub/Epub/Section.h | 1 - src/activities/reader/EpubReaderActivity.cpp | 118 +++++++++++++++---- src/activities/reader/EpubReaderActivity.h | 11 +- src/util/ChapterTimeEstimate.cpp | 41 +++++-- src/util/ChapterTimeEstimate.h | 4 + 6 files changed, 146 insertions(+), 59 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 6aebee3d3b3..e8d9ed712c3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -409,7 +409,7 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::functioncountWords() : 0; const uint16_t wordCount = words > UINT16_MAX ? UINT16_MAX : static_cast(words); ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), syncEntry.xhtmlByteOffset, - syncEntry.paragraphIndex, syncEntry.listItemIndex, wordCount}); + syncEntry.paragraphIndex, syncEntry.listItemIndex}); if (pageWordCounts_.size() < ctxPtr->lut.size()) { pageWordCounts_.resize(ctxPtr->lut.size()); } @@ -582,8 +582,9 @@ bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsume } // Per-page word counts (v41+), immediately after the li LUT. - for (const auto& entry : build_->lut) { - serialization::writePod(file, entry.wordCount); + for (size_t i = 0; i < build_->lut.size(); ++i) { + const uint16_t wordCount = (i < pageWordCounts_.size()) ? pageWordCounts_[i] : 0; + serialization::writePod(file, wordCount); } if (asPartial) { @@ -1020,9 +1021,6 @@ uint16_t Section::getPageWordCount(const uint16_t page) const { if (page < pageWordCounts_.size()) { return pageWordCounts_[page]; } - if (build_ && page < build_->lut.size()) { - return build_->lut[page].wordCount; - } return 0; } @@ -1038,21 +1036,13 @@ uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { } } - // Still-building / partial chapters: extrapolate unbuilt content from HTML density. - uint32_t bytesConsumed = 0; - uint32_t totalBytes = 0; - if (build_) { - bytesConsumed = build_->bytesConsumed; - totalBytes = build_->totalBytes; - } else if (partial_) { - bytesConsumed = partialBytesConsumed_; - totalBytes = partialTotalBytes_; - } - - if (knownWords > 0 && totalBytes > bytesConsumed && bytesConsumed > 0) { - const uint64_t unbuiltBytes = static_cast(totalBytes - bytesConsumed); + // Extrapolate unbuilt pages using the same estimatedTotalPages() the status-bar + // page denominator uses (partial watermark / rebuild EMA), so pages and time agree. + const uint16_t estimatedTotal = estimatedTotalPages(); + if (knownWords > 0 && estimatedTotal > availablePages && availablePages > 0) { + const uint64_t unbuiltPages = static_cast(estimatedTotal - availablePages); const uint64_t unbuiltWords = - (static_cast(knownWords) * unbuiltBytes) / static_cast(bytesConsumed); + (static_cast(knownWords) * unbuiltPages) / static_cast(availablePages); if (unbuiltWords > 0 && unbuiltWords < static_cast(UINT32_MAX)) { remaining += static_cast(unbuiltWords); } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 8a471a9f5ac..84bf9f13e28 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -30,7 +30,6 @@ class Section { uint32_t xhtmlByteOffset; uint16_t paragraphIndex; uint16_t listItemIndex; - uint16_t wordCount; }; // Held only while an incremental build is in progress (see startBuild). Carries the // live parser plus the strings it references (the parser stores them by reference) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 41d5cc56ba5..c7b8b6c9efc 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -337,6 +337,10 @@ void EpubReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); + if (section && section->currentPage >= 0) { + maybeCreditPageWords(currentSpineIndex, section->currentPage); + } + APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); READING_STATS.endSession(); @@ -825,6 +829,10 @@ void EpubReaderActivity::jumpToPercent(int percent) { } // Reset state so render() reloads and repositions on the target spine. + // Clear dwell tracking so the left page is not credited as read. + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; currentSpineIndex = targetSpineIndex; nextPageNumber = 0; pendingPercentJump = true; @@ -940,6 +948,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const auto& chapterResult = std::get(result.data); RenderLock lock(*this); + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; currentSpineIndex = chapterResult.spineIndex; // If anchor is not empty, it will be used later to calculate the page number. @@ -1032,6 +1043,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction if (currentSpineIndex != bookmark.spineIndex || !section || section->currentPage != static_cast(bookmark.page)) { RenderLock lock(*this); + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; currentSpineIndex = bookmark.spineIndex; nextPageNumber = static_cast(bookmark.page); sessionProgressTouched = true; @@ -1305,6 +1319,56 @@ void EpubReaderActivity::markCurrentBookAsFinished() { exitReaderAfterOptionalCompletedMove(); } +void EpubReaderActivity::notePageEnteredIfChanged() { + const int page = section ? section->currentPage : -1; + if (page < 0) { + return; + } + if (currentSpineIndex == pageEnteredSpineIndex && page == pageEnteredPage && pageEnteredMs != 0) { + return; + } + pageEnteredSpineIndex = currentSpineIndex; + pageEnteredPage = page; + pageEnteredMs = millis(); +} + +void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int page) { + if (!section || page < 0 || spineIndex < 0) { + return; + } + // Only credit the page whose dwell we have been tracking (contiguous forward leave / exit). + if (spineIndex != pageEnteredSpineIndex || page != pageEnteredPage || pageEnteredMs == 0) { + return; + } + + constexpr unsigned long WORD_CREDIT_MIN_DWELL_MS = 1500UL; + constexpr unsigned long WORD_CREDIT_REREAD_MIN_MS = 8000UL; + constexpr unsigned long WORD_CREDIT_MAX_DWELL_MS = 30UL * 60UL * 1000UL; + + const unsigned long dwellMs = millis() - pageEnteredMs; + if (dwellMs < WORD_CREDIT_MIN_DWELL_MS) { + return; + } + + const bool sameAsLastCredit = + spineIndex == lastWordsCreditedSpineIndex && page == lastWordsCreditedPage; + // Going back then forward again only re-credits after a real re-read linger. + if (sameAsLastCredit && dwellMs < WORD_CREDIT_REREAD_MIN_MS) { + return; + } + + const uint16_t words = section->getPageWordCount(static_cast(page)); + lastWordsCreditedSpineIndex = spineIndex; + lastWordsCreditedPage = page; + if (words == 0) { + return; + } + + const uint32_t associatedMs = static_cast( + dwellMs > WORD_CREDIT_MAX_DWELL_MS ? WORD_CREDIT_MAX_DWELL_MS : dwellMs); + READING_STATS.noteWordsRead(words, associatedMs); +} + void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (!section) { nextPageNumber = 0; @@ -1318,21 +1382,9 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { const int oldPage = section ? section->currentPage : nextPageNumber; if (isForwardTurn) { - // Credit words on the page being finished so chapter time estimates use a - // word rate rather than page-turn rate (image/sparse pages count as 0). - // Only credit each (spine, page) once per reader session (high-water mark). - if (oldPage >= 0) { - const bool isNewProgress = oldSpineIndex > wordsCreditedSpineIndex || - (oldSpineIndex == wordsCreditedSpineIndex && oldPage > wordsCreditedPage); - if (isNewProgress) { - const uint16_t words = section->getPageWordCount(static_cast(oldPage)); - if (words > 0) { - READING_STATS.noteWordsRead(words); - } - wordsCreditedSpineIndex = oldSpineIndex; - wordsCreditedPage = oldPage; - } - } + // Credit words + dwell on the page being finished. Jumps never call this path, so + // chapter/percent/TOC navigation does not count skipped or left pages as read. + maybeCreditPageWords(oldSpineIndex, oldPage); if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { section->currentPage++; } else { @@ -1345,6 +1397,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { } } } else { + // Backward turns never credit; re-reads are credited later if the reader lingers. if (section->currentPage > 0) { section->currentPage--; } else if (currentSpineIndex > 0) { @@ -1362,6 +1415,14 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { sessionProgressTouched = true; } lastPageTurnTime = millis(); + if (section) { + notePageEnteredIfChanged(); + } else { + // Section reload will settle enter time on the next render. + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; + } requestUpdate(); } @@ -1637,6 +1698,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderContents(page, orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); } + notePageEnteredIfChanged(); // Menus, screenshots and overlays can request a render without moving the // reader. Avoid several FAT operations for the same six-byte position file. if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage || @@ -2001,19 +2063,15 @@ void EpubReaderActivity::renderStatusBar() const { if ((SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) && section->currentPage >= 0) { - const uint32_t remainingWords = - section->estimateRemainingWords(static_cast(section->currentPage)); const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - uint64_t remainingMs = 0; - if (remainingWords > 0 && wordsPerMs > 0.0) { - const double ms = static_cast(remainingWords) / wordsPerMs; - if (ms > 0.0 && ms < static_cast(UINT64_MAX)) { - remainingMs = static_cast(ms); + if (wordsPerMs > 0.0) { + const uint32_t remainingWords = + section->estimateRemainingWords(static_cast(section->currentPage)); + if (ChapterTimeEstimate::formatRemainingFromRate(remainingWords, wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; } } - if (ChapterTimeEstimate::formatCompactDuration(remainingMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { - chapterTimeEstimate = chapterTimeBuf; - } } GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); @@ -2076,6 +2134,9 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s { RenderLock lock(*this); + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; pendingAnchor = std::move(anchor); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; @@ -2093,6 +2154,9 @@ void EpubReaderActivity::restoreSavedPosition() { { RenderLock lock(*this); + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; currentSpineIndex = pos.spineIndex; nextPageNumber = pos.pageNumber; section.reset(); @@ -2284,6 +2348,10 @@ void EpubReaderActivity::applyPendingSyncSession() { cachedChapterTotalPageCount = restorePageCount; } + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; + sync.clear(); APP_STATE.saveToFile(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index c143a924601..7a4bd10e1fd 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -45,9 +45,12 @@ class EpubReaderActivity final : public Activity { int sessionStartSpineIndex = 0; int sessionStartPage = 0; bool sessionProgressTouched = false; - // High-water mark for word-rate credits so back/forward re-reads do not inflate ETA. - int wordsCreditedSpineIndex = -1; - int wordsCreditedPage = -1; + // Word-rate samples: dwell on the page currently displayed. Jumps never credit the left page. + unsigned long pageEnteredMs = 0; + int pageEnteredSpineIndex = -1; + int pageEnteredPage = -1; + int lastWordsCreditedSpineIndex = -1; + int lastWordsCreditedPage = -1; std::shared_ptr currentOverlayPageCache; EndOfBookOptions endOfBookOptions; int currentOverlayPageSpineIndex = -1; @@ -122,6 +125,8 @@ class EpubReaderActivity final : public Activity { void exitReaderAfterOptionalCompletedMove(); void markCurrentBookAsFinished(); void pageTurn(bool isForwardTurn); + void notePageEnteredIfChanged(); + void maybeCreditPageWords(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void cacheCurrentPageForOverlay(const std::shared_ptr& page, int marginLeft, int marginTop); diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index 3391ce40ebc..d76dfed7ada 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -9,13 +9,17 @@ constexpr uint64_t MS_PER_HOUR = 60ULL * MS_PER_MINUTE; constexpr uint64_t MS_PER_DAY = 24ULL * MS_PER_HOUR; constexpr uint64_t MS_PER_YEAR = 365ULL * MS_PER_DAY; -bool formatRoundedUnit(const uint64_t totalMs, const uint64_t unitMs, const char unit, char* buf, - const size_t bufSize) { +bool formatRoundedUnit(const uint64_t value, const char unit, char* buf, const size_t bufSize) { + const int written = snprintf(buf, bufSize, "%llu%c", static_cast(value), unit); + return written > 0 && static_cast(written) < bufSize; +} + +uint64_t roundedUnits(const uint64_t totalMs, const uint64_t unitMs) { uint64_t value = (totalMs + unitMs / 2) / unitMs; if (value == 0) { value = 1; } - return snprintf(buf, bufSize, "%llu%c", static_cast(value), unit) > 0; + return value; } } // namespace @@ -23,16 +27,33 @@ bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSi if (!buf || bufSize < 3 || totalMs == 0) { return false; } - if (totalMs < MS_PER_HOUR) { - return formatRoundedUnit(totalMs, MS_PER_MINUTE, 'm', buf, bufSize); + + // Pick the unit from the rounded display value so 60m becomes 1h (not "60m"). + const uint64_t minutes = roundedUnits(totalMs, MS_PER_MINUTE); + if (minutes < 60) { + return formatRoundedUnit(minutes, 'm', buf, bufSize); } - if (totalMs < MS_PER_DAY) { - return formatRoundedUnit(totalMs, MS_PER_HOUR, 'h', buf, bufSize); + const uint64_t hours = roundedUnits(totalMs, MS_PER_HOUR); + if (hours < 24) { + return formatRoundedUnit(hours, 'h', buf, bufSize); } - if (totalMs < MS_PER_YEAR) { - return formatRoundedUnit(totalMs, MS_PER_DAY, 'd', buf, bufSize); + const uint64_t days = roundedUnits(totalMs, MS_PER_DAY); + if (days < 365) { + return formatRoundedUnit(days, 'd', buf, bufSize); + } + return formatRoundedUnit(roundedUnits(totalMs, MS_PER_YEAR), 'y', buf, bufSize); +} + +bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPerMs, char* buf, + const size_t bufSize) { + if (remainingWords == 0 || wordsPerMs <= 0.0) { + return false; + } + const double ms = static_cast(remainingWords) / wordsPerMs; + if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { + return false; } - return formatRoundedUnit(totalMs, MS_PER_YEAR, 'y', buf, bufSize); + return formatCompactDuration(static_cast(ms), buf, bufSize); } } // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index 85eff863796..bc7e621c537 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -9,4 +9,8 @@ namespace ChapterTimeEstimate { // Returns false when buf is too small or ms is zero (nothing to show). bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); +// Format remaining chapter time from remaining words and words/ms rate. +// Returns false when inputs are insufficient or the buffer is too small. +bool formatRemainingFromRate(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize); + } // namespace ChapterTimeEstimate From 117d6aead0afcae09e4c0cf4772a1a8e9722e1dd Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:09:11 -0700 Subject: [PATCH 07/20] feat(reader): add chapter time-remaining ETA for TXT - Index and cache per-page word counts with TXT page offsets - Reuse dwell-based word credit and shared remaining-time formatter in the TXT status bar --- src/activities/reader/TxtReaderActivity.cpp | 124 +++++++++++++++++++- src/activities/reader/TxtReaderActivity.h | 9 ++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 9a848341008..7563673ddf9 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -25,13 +25,14 @@ #include "fontIds.h" #include "util/AchievementPopupUtils.h" #include "util/BookIdentity.h" +#include "util/ChapterTimeEstimate.h" #include "util/CompletedBookMover.h" namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 4; // Increment when cache format changes +constexpr uint8_t CACHE_VERSION = 5; // v5: per-page word counts for chapter ETA constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -322,7 +323,10 @@ void TxtReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); + maybeCreditPageWords(currentPage); + pageOffsets.clear(); + pageWordCounts.clear(); currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -379,14 +383,20 @@ void TxtReaderActivity::loop() { if (prevTriggered && currentPage > 0) { READING_STATS.noteActivity(); + // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; + notePageEnteredIfChanged(); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); + maybeCreditPageWords(currentPage); currentPage++; + notePageEnteredIfChanged(); requestUpdate(); } else { + READING_STATS.noteActivity(); + maybeCreditPageWords(currentPage); READING_STATS.updateProgress(100, true, "", 100); exitReaderAfterOptionalCompletedMove(); } @@ -404,7 +414,10 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { statusBarTemporarilyHidden = !statusBarTemporarilyHidden; initialized = false; pageOffsets.clear(); + pageWordCounts.clear(); currentPageLines.clear(); + pageEnteredPage = -1; + pageEnteredMs = 0; pendingForceFullRefresh = true; requestUpdate(); } @@ -482,6 +495,7 @@ void TxtReaderActivity::initializeReader() { void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); + pageWordCounts.clear(); pageOffsets.push_back(0); // First page starts at offset 0 size_t offset = 0; @@ -499,6 +513,8 @@ void TxtReaderActivity::buildPageIndex() { break; } + pageWordCounts.push_back(countWordsInLines(tempLines)); + if (nextOffset <= offset) { // No progress made, avoid infinite loop break; @@ -516,9 +532,85 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); + if (pageWordCounts.size() > static_cast(totalPages)) { + pageWordCounts.resize(totalPages); + } + while (pageWordCounts.size() < static_cast(totalPages)) { + pageWordCounts.push_back(0); + } LOG_DBG("TRS", "Built page index: %d pages", totalPages); } +uint16_t TxtReaderActivity::countWordsInLines(const std::vector& lines) { + uint32_t words = 0; + for (const auto& line : lines) { + bool inWord = false; + for (const unsigned char c : line.text) { + const bool isSpace = c <= ' ' || c == 0xA0; + if (!isSpace && !inWord) { + ++words; + inWord = true; + } else if (isSpace) { + inWord = false; + } + } + } + return words > UINT16_MAX ? UINT16_MAX : static_cast(words); +} + +void TxtReaderActivity::notePageEnteredIfChanged() { + if (currentPage < 0) { + return; + } + if (currentPage == pageEnteredPage && pageEnteredMs != 0) { + return; + } + pageEnteredPage = currentPage; + pageEnteredMs = millis(); +} + +void TxtReaderActivity::maybeCreditPageWords(const int page) { + if (page < 0 || pageEnteredPage != page || pageEnteredMs == 0) { + return; + } + + constexpr unsigned long WORD_CREDIT_MIN_DWELL_MS = 1500UL; + constexpr unsigned long WORD_CREDIT_REREAD_MIN_MS = 8000UL; + constexpr unsigned long WORD_CREDIT_MAX_DWELL_MS = 30UL * 60UL * 1000UL; + + const unsigned long dwellMs = millis() - pageEnteredMs; + if (dwellMs < WORD_CREDIT_MIN_DWELL_MS) { + return; + } + if (page == lastWordsCreditedPage && dwellMs < WORD_CREDIT_REREAD_MIN_MS) { + return; + } + + uint16_t words = 0; + if (page < static_cast(pageWordCounts.size())) { + words = pageWordCounts[page]; + } + lastWordsCreditedPage = page; + if (words == 0) { + return; + } + + const uint32_t associatedMs = static_cast( + dwellMs > WORD_CREDIT_MAX_DWELL_MS ? WORD_CREDIT_MAX_DWELL_MS : dwellMs); + READING_STATS.noteWordsRead(words, associatedMs); +} + +uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { + if (fromPage < 0 || pageWordCounts.empty()) { + return 0; + } + uint32_t remaining = 0; + for (size_t page = static_cast(fromPage); page < pageWordCounts.size(); ++page) { + remaining += pageWordCounts[page]; + } + return remaining; +} + bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { outLines.clear(); const size_t fileSize = txt->getFileSize(); @@ -764,6 +856,7 @@ void TxtReaderActivity::renderPage() { // BW rendering renderLines(); + notePageEnteredIfChanged(); renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -786,7 +879,22 @@ void TxtReaderActivity::renderStatusBar() const { if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { title = txt->getTitle(); } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); + + char chapterTimeBuf[12] = {}; + const char* chapterTimeEstimate = nullptr; + if (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) { + const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); + if (wordsPerMs > 0.0) { + const uint32_t remainingWords = estimateRemainingWords(currentPage); + if (ChapterTimeEstimate::formatRemainingFromRate(remainingWords, wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; + } + } + } + + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); } void TxtReaderActivity::saveProgress() const { @@ -849,6 +957,7 @@ bool TxtReaderActivity::loadPageIndexCache() { // - uint8_t: paragraph alignment (to invalidate cache on alignment change) // - uint32_t: total pages count // - N * uint32_t: page offsets + // - N * uint16_t: page word counts (v5+) std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; @@ -919,13 +1028,20 @@ bool TxtReaderActivity::loadPageIndexCache() { // Read page offsets pageOffsets.clear(); + pageWordCounts.clear(); pageOffsets.reserve(numPages); + pageWordCounts.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { uint32_t offset; serialization::readPod(f, offset); pageOffsets.push_back(offset); } + for (uint32_t i = 0; i < numPages; i++) { + uint16_t words = 0; + serialization::readPod(f, words); + pageWordCounts.push_back(words); + } totalPages = pageOffsets.size(); LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages); @@ -955,6 +1071,10 @@ void TxtReaderActivity::savePageIndexCache() const { for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } + for (size_t i = 0; i < pageOffsets.size(); ++i) { + const uint16_t words = (i < pageWordCounts.size()) ? pageWordCounts[i] : 0; + serialization::writePod(f, words); + } LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 52ee51ff263..7da6e4db5d3 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -32,6 +32,7 @@ class TxtReaderActivity final : public Activity { // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page + std::vector pageWordCounts; // Words per page (parallel to pageOffsets) std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -41,6 +42,10 @@ class TxtReaderActivity final : public Activity { bool pendingForceFullRefresh = false; bool waitingForConfirmSecondClick = false; unsigned long firstConfirmClickMs = 0UL; + // Word-rate samples: dwell on the page currently displayed. + unsigned long pageEnteredMs = 0; + int pageEnteredPage = -1; + int lastWordsCreditedPage = -1; // Cached settings for cache validation (different fonts/margins require re-indexing) int cachedFontId = 0; @@ -63,8 +68,12 @@ class TxtReaderActivity final : public Activity { void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); + void notePageEnteredIfChanged(); + void maybeCreditPageWords(int page); + uint32_t estimateRemainingWords(int fromPage) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); + static uint16_t countWordsInLines(const std::vector& lines); public: explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) From 13d75a7e54b61a0726b7614a79f799806641f962 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:09:12 -0700 Subject: [PATCH 08/20] fix(ui): ease status-bar crowding - Tighten progress spacing and title padding when pages+time+% are shown - Fix statusBarTitle clamp against the title enum --- lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/spanish.yaml | 4 ++ src/SettingsList.cpp | 2 +- .../settings/StatusBarSettingsActivity.cpp | 6 +- src/components/themes/BaseTheme.cpp | 62 ++++++++++--------- src/network/CrossPointWebServer.cpp | 2 +- 6 files changed, 41 insertions(+), 36 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e4281ca33bc..31ff99950a3 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -289,6 +289,7 @@ STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_SET_SLEEP_COVER: "Set Cover" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" +STR_STATUS_BAR_CHAPTER_PROGRESS: "Chapter Progress" STR_PAGES: "Pages" STR_PAGES_PLUS_TIME: "Pages+Time" STR_TIME: "Time" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 5d7386e83ea..67697e63ba1 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -281,6 +281,10 @@ STR_SLEEP_COVER_FILTER: "Filtro de pantalla de suspensión" STR_SET_SLEEP_COVER: "Establecer portada" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" +STR_STATUS_BAR_CHAPTER_PROGRESS: "Progreso del capítulo" +STR_PAGES: "Páginas" +STR_PAGES_PLUS_TIME: "Páginas+Tiempo" +STR_TIME: "Tiempo" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentaje progreso libro" STR_PROGRESS_BAR: "Barra de progreso" STR_PROGRESS_BAR_THICKNESS: "Grosor de barra de progreso" diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index de934c65907..7c422db9e4d 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,7 +227,7 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- - SettingInfo::Enum(StrId::STR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, + SettingInfo::Enum(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, StrId::STR_TIME, StrId::STR_HIDE}, "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 749a78201fe..41c7c8a5e35 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -34,7 +34,7 @@ constexpr int BASE_MENU_ITEMS = ITEM_CLOCK; // Items shown on every device constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is available const StrId menuNames[FULL_MENU_ITEMS] = { - StrId::STR_CHAPTER_PROGRESS, + StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, @@ -110,10 +110,6 @@ void StatusBarSettingsActivity::onEnter() { SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; } - if (SETTINGS.statusBarTitle >= PROGRESS_BAR_THICKNESS_ITEMS) { - SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_PROGRESS_BAR_THICKNESS::PROGRESS_BAR_NORMAL; - } - if (SETTINGS.statusBarTitle >= TITLE_ITEMS) { SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE; } diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index ac42bd4b4e5..eda1f24e449 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -857,23 +857,23 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c if (showBookPercent || showChapterPages || showChapterTime) { // Right aligned text for progress counter char progressStr[48]; - - if (showChapterPages && showChapterTime && showBookPercent) { - snprintf(progressStr, sizeof(progressStr), "%d/%d (%s) %.0f%%", currentPage, pageCount, chapterTimeEstimate, - bookProgress); - } else if (showChapterPages && showChapterTime) { - snprintf(progressStr, sizeof(progressStr), "%d/%d (%s)", currentPage, pageCount, chapterTimeEstimate); - } else if (showChapterPages && showBookPercent) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress); - } else if (showChapterTime && showBookPercent) { - snprintf(progressStr, sizeof(progressStr), "%s %.0f%%", chapterTimeEstimate, bookProgress); - } else if (showBookPercent) { - snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); - } else if (showChapterPages) { - snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount); - } else { - snprintf(progressStr, sizeof(progressStr), "%s", chapterTimeEstimate); + size_t offset = 0; + if (showChapterPages) { + offset += static_cast( + snprintf(progressStr + offset, sizeof(progressStr) - offset, "%d/%d", currentPage, pageCount)); + } + if (showChapterTime && offset < sizeof(progressStr)) { + offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%s", + showChapterPages ? " (" : "", chapterTimeEstimate)); + if (showChapterPages && offset < sizeof(progressStr)) { + offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, ")")); + } + } + if (showBookPercent && offset < sizeof(progressStr)) { + offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%.0f%%", + (showChapterPages || showChapterTime) ? " " : "", bookProgress)); } + (void)offset; progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText( @@ -940,37 +940,41 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c if (!title.empty()) { textY -= textYOffset; // Centered chapter title text - // Page width minus existing content with 30px padding on each side const int rendererableScreenWidth = renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; const int batteryAreaWidth = statusBarBatteryAreaWidth(renderer, metrics, showBatteryPercentage); const int clockReserveLeft = clockOnLeft && clockTextWidth > 0 ? (clockTextWidth + 10) : 0; const int clockReserveRight = clockOnRight && clockTextWidth > 0 ? (clockTextWidth + 10) : 0; - const int titleMarginLeft = batteryAreaWidth + clockReserveLeft + 30; - const int titleMarginRight = progressTextWidth + clockReserveRight + 30; + // Wider progress clusters (pages+time+%) need less decorative title padding so the + // title can still truncate cleanly instead of colliding with the right cluster. + const int titleSidePad = (showChapterTime && showChapterPages && showBookPercent) ? 12 : 30; + const int titleMarginLeft = batteryAreaWidth + clockReserveLeft + titleSidePad; + const int titleMarginRight = progressTextWidth + clockReserveRight + titleSidePad; // Attempt to center title on the screen, but if title is too wide then later we will center it within the // available space. int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight); int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted; - int titleWidth; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); if (titleWidth > availableTitleSpace) { // Not enough space to center on the screen, center it within the remaining space instead availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; titleMarginLeftAdjusted = titleMarginLeft; } - if (titleWidth > availableTitleSpace) { - title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); - } + constexpr int MIN_TITLE_SPACE = 40; + if (availableTitleSpace >= MIN_TITLE_SPACE) { + if (titleWidth > availableTitleSpace) { + title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } - renderer.drawText(SMALL_FONT_ID, - titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + - (availableTitleSpace - titleWidth) / 2, - textY, title.c_str()); + renderer.drawText(SMALL_FONT_ID, + titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + + (availableTitleSpace - titleWidth) / 2, + textY, title.c_str()); + } } } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 90b85a95487..36ed9bf489e 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -438,7 +438,7 @@ constexpr WebSettingDef WEB_SETTINGS[] = { WEB_ENUM(StrId::STR_OPDS_FILENAME_FORMAT, opdsFilenameFormat, OPT_OPDS_FILENAME_FORMAT, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), - WEB_ENUM(StrId::STR_CHAPTER_PROGRESS, statusBarChapterProgress, OPT_CHAPTER_PROGRESS, "statusBarChapterProgress", + WEB_ENUM(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, statusBarChapterProgress, OPT_CHAPTER_PROGRESS, "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), WEB_TOGGLE(StrId::STR_BOOK_PROGRESS_PERCENTAGE, statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), From 709557087673c70c3374000f1919591c94d1d635 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:56:34 -0700 Subject: [PATCH 09/20] fix(reader): harden chapter ETA dwell, counting, and formatter Clear dwell on jumps/menus, share credit helpers, persist word-rate samples, localize ETA units, and fall back to pages until a rate exists. --- docs/reading-stats-editor/index.html | 4 +- lib/Epub/Epub/Section.cpp | 7 + lib/I18n/translations/english.yaml | 4 + lib/I18n/translations/spanish.yaml | 4 + src/ReadingStatsStore.cpp | 3 + src/ReadingStatsStore.h | 2 +- src/activities/reader/EpubReaderActivity.cpp | 127 +++++++++--------- src/activities/reader/EpubReaderActivity.h | 2 + src/activities/reader/TxtReaderActivity.cpp | 78 ++++++----- src/activities/reader/TxtReaderActivity.h | 2 + .../settings/StatusBarSettingsActivity.cpp | 8 +- src/components/themes/BaseTheme.cpp | 19 ++- src/util/ChapterTimeEstimate.cpp | 79 ++++++++--- src/util/ChapterTimeEstimate.h | 16 +++ 14 files changed, 230 insertions(+), 125 deletions(-) diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index 59a6340ee45..96236e0d222 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -1393,7 +1393,7 @@

CPR-vCodex Reading Stats Editor

totalReadingMinutes: "Total reading minutes", totalWordsRead: "Words read (ETA samples)", wordsReadingMinutes: "Minutes paired with words (ETA)", - wordsRateHint: "ETA rate uses words ÷ paired minutes. Unpaired words are cleared on import.", + wordsRateHint: "ETA rate uses words ÷ paired minutes. Unpaired words are cleared on import. Recalculate from days only updates total reading minutes.", lastSessionMinutes: "Last session minutes", bookProgress: "Book progress %", chapterProgress: "Chapter progress %", @@ -1470,7 +1470,7 @@

CPR-vCodex Reading Stats Editor

totalReadingMinutes: "Minutos totales de lectura", totalWordsRead: "Palabras leídas (muestras ETA)", wordsReadingMinutes: "Minutos emparejados con palabras (ETA)", - wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados. Las palabras sin emparejar se borran al importar.", + wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados. Las palabras sin emparejar se borran al importar. Recalcular desde días solo actualiza los minutos totales de lectura.", lastSessionMinutes: "Minutos de la última sesión", bookProgress: "Progreso del libro %", chapterProgress: "Progreso del capítulo %", diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index e8d9ed712c3..0a023a09058 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -211,6 +211,12 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { for (uint16_t i = 0; i < pageCount; ++i) { serialization::readPod(file, pageWordCounts_[i]); } + } else { + file.close(); + LOG_ERR("SCT", "Deserialization failed: missing page word counts"); + clearCache(); + pageCount = 0; + return false; } } @@ -1038,6 +1044,7 @@ uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { // Extrapolate unbuilt pages using the same estimatedTotalPages() the status-bar // page denominator uses (partial watermark / rebuild EMA), so pages and time agree. + // Remaining includes fromPage: the reader is still on that page, so its words are unread. const uint16_t estimatedTotal = estimatedTotalPages(); if (knownWords > 0 && estimatedTotal > availablePages && availablePages > 0) { const uint64_t unbuiltPages = static_cast(estimatedTotal - availablePages); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 31ff99950a3..5f5fe9de30a 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -293,6 +293,10 @@ STR_STATUS_BAR_CHAPTER_PROGRESS: "Chapter Progress" STR_PAGES: "Pages" STR_PAGES_PLUS_TIME: "Pages+Time" STR_TIME: "Time" +STR_ETA_UNIT_MINUTE: "m" +STR_ETA_UNIT_HOUR: "h" +STR_ETA_UNIT_DAY: "d" +STR_ETA_UNIT_YEAR: "y" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" STR_PROGRESS_BAR: "Progress Bar" STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 67697e63ba1..307b673fbda 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -285,6 +285,10 @@ STR_STATUS_BAR_CHAPTER_PROGRESS: "Progreso del capítulo" STR_PAGES: "Páginas" STR_PAGES_PLUS_TIME: "Páginas+Tiempo" STR_TIME: "Tiempo" +STR_ETA_UNIT_MINUTE: "m" +STR_ETA_UNIT_HOUR: "h" +STR_ETA_UNIT_DAY: "d" +STR_ETA_UNIT_YEAR: "y" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentaje progreso libro" STR_PROGRESS_BAR: "Barra de progreso" STR_PROGRESS_BAR_THICKNESS: "Grosor de barra de progreso" diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 0cb930021a8..4f148b9c764 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1262,6 +1262,9 @@ void ReadingStatsStore::noteWordsRead(const uint32_t words, const uint32_t assoc book.totalWordsRead += words; book.totalWordsReadingMs += associatedMs; markDirty(); + if (shouldSaveDeferred()) { + saveToFile(); + } } void ReadingStatsStore::tickActiveSession() { diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index 1569abf05b9..f02ff7e378a 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -154,7 +154,7 @@ class ReadingStatsStore { uint8_t chapterProgressPercent = 0); void noteActivity(); // Credit words finished on a page together with the dwell time spent on that page. - // Both are persisted immediately so ETA rate survives crashes before endSession. + // Samples are marked dirty immediately; deferred save runs when the interval is due. void noteWordsRead(uint32_t words, uint32_t associatedMs); void tickActiveSession(); void resumeSession(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c7b8b6c9efc..efb2bdad4e9 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -538,11 +538,12 @@ void EpubReaderActivity::loop() { } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); ReaderUtils::requestReaderUiTransitionRefresh(renderer); + clearPageDwell(); startActivityForResult(std::make_unique( renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, !currentPageFootnotes.empty()), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + // Always apply orientation change even if the menu was cancelled const auto& menu = std::get(result.data); applyOrientation(menu.orientation); @@ -614,6 +615,7 @@ void EpubReaderActivity::loop() { if (longPress && SETTINGS.longPressButtonBehavior == CrossPointSettings::LONG_PRESS_CHAPTER_SKIP) { READING_STATS.noteActivity(); lastPageTurnTime = millis(); + clearPageDwell(); if (!nextTriggered && section && section->currentPage > 0) { section->currentPage = 0; @@ -676,6 +678,7 @@ void EpubReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; invalidateCurrentOverlayPageCache(); + clearPageDwell(); RenderLock lock(*this); if (section) { cachedSpineIndex = currentSpineIndex; @@ -830,9 +833,7 @@ void EpubReaderActivity::jumpToPercent(int percent) { // Reset state so render() reloads and repositions on the target spine. // Clear dwell tracking so the left page is not credited as read. - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; pendingPercentJump = true; @@ -929,10 +930,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction case EpubReaderMenuActivity::MenuAction::READER_SETTINGS: { const auto before = captureReaderSettingsSnapshot(); READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult(std::make_unique(renderer, mappedInput), [this, before](const ActivityResult&) { applyReaderSettingsChanges(before); - READING_STATS.resumeSession(); + }); break; } @@ -940,17 +942,16 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const int spineIdx = currentSpineIndex; const std::string path = epub->getPath(); READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, epub, path, spineIdx), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { const auto& chapterResult = std::get(result.data); RenderLock lock(*this); - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); currentSpineIndex = chapterResult.spineIndex; // If anchor is not empty, it will be used later to calculate the page number. @@ -966,9 +967,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { const auto& footnoteResult = std::get(result.data); navigateToHref(footnoteResult.href, true); @@ -986,11 +988,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - READING_STATS.resumeSession(); + ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); @@ -1005,11 +1008,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - READING_STATS.resumeSession(); + ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); @@ -1017,9 +1021,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::DICTIONARY: { READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult(std::make_unique(renderer, mappedInput), [this](const ActivityResult&) { - READING_STATS.resumeSession(); + ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); @@ -1027,6 +1032,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::VIEW_HIGHLIGHTS: { READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, bookmarkStore.getAll(), epub, "", [this](const BookmarkStore::Bookmark& bookmark) { @@ -1037,15 +1043,13 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction return removed; }), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { const auto& bookmark = std::get(result.data); if (currentSpineIndex != bookmark.spineIndex || !section || section->currentPage != static_cast(bookmark.page)) { RenderLock lock(*this); - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); currentSpineIndex = bookmark.spineIndex; nextPageNumber = static_cast(bookmark.page); sessionProgressTouched = true; @@ -1071,11 +1075,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const uint16_t selectionSpine = static_cast(currentSpineIndex); const uint16_t selectionPage = static_cast(section->currentPage); READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop, true), [this, selectionSpine, selectionPage](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { const auto& highlight = std::get(result.data); const bool saved = @@ -1107,10 +1112,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, initialPercent), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { jumpToPercent(std::get(result.data).percent); } @@ -1135,8 +1141,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } if (!fullText.empty()) { READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult(std::make_unique(renderer, mappedInput, fullText), - [this](const ActivityResult& result) { READING_STATS.resumeSession(); }); + [this](const ActivityResult& result) { READING_STATS.resumeSession(); restartPageDwell(); }); break; } } @@ -1155,10 +1162,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction case EpubReaderMenuActivity::MenuAction::MARK_AS_FINISHED: { const std::string title = epub ? epub->getTitle() : ""; READING_STATS.noteActivity(); + clearPageDwell(); startActivityForResult( std::make_unique(renderer, mappedInput, tr(STR_MARK_AS_FINISHED_CONFIRM), title), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); + if (!result.isCancelled) { markCurrentBookAsFinished(); } else { @@ -1228,6 +1236,7 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); // Reset section to force re-layout in the new orientation. + clearPageDwell(); section.reset(); } } @@ -1235,6 +1244,7 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; + restartPageDwell(); return; } @@ -1242,6 +1252,7 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption // calculates page turn duration by dividing by number of pages pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_RATES[selectedPageTurnOption]; automaticPageTurnActive = true; + clearPageDwell(); const uint8_t statusBarHeight = statusBarTemporarilyHidden ? 0 : UITheme::getInstance().getStatusBarHeight(); // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only @@ -1319,6 +1330,22 @@ void EpubReaderActivity::markCurrentBookAsFinished() { exitReaderAfterOptionalCompletedMove(); } +void EpubReaderActivity::clearPageDwell() { + pageEnteredSpineIndex = -1; + pageEnteredPage = -1; + pageEnteredMs = 0; +} + +void EpubReaderActivity::restartPageDwell() { + if (!section || section->currentPage < 0) { + clearPageDwell(); + return; + } + pageEnteredSpineIndex = currentSpineIndex; + pageEnteredPage = section->currentPage; + pageEnteredMs = millis(); +} + void EpubReaderActivity::notePageEnteredIfChanged() { const int page = section ? section->currentPage : -1; if (page < 0) { @@ -1341,32 +1368,22 @@ void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int pa return; } - constexpr unsigned long WORD_CREDIT_MIN_DWELL_MS = 1500UL; - constexpr unsigned long WORD_CREDIT_REREAD_MIN_MS = 8000UL; - constexpr unsigned long WORD_CREDIT_MAX_DWELL_MS = 30UL * 60UL * 1000UL; - const unsigned long dwellMs = millis() - pageEnteredMs; - if (dwellMs < WORD_CREDIT_MIN_DWELL_MS) { - return; - } - const bool sameAsLastCredit = spineIndex == lastWordsCreditedSpineIndex && page == lastWordsCreditedPage; - // Going back then forward again only re-credits after a real re-read linger. - if (sameAsLastCredit && dwellMs < WORD_CREDIT_REREAD_MIN_MS) { + const uint32_t associatedMs = ChapterTimeEstimate::dwellCreditMs(dwellMs, sameAsLastCredit); + if (associatedMs == 0) { return; } const uint16_t words = section->getPageWordCount(static_cast(page)); - lastWordsCreditedSpineIndex = spineIndex; - lastWordsCreditedPage = page; if (words == 0) { return; } - const uint32_t associatedMs = static_cast( - dwellMs > WORD_CREDIT_MAX_DWELL_MS ? WORD_CREDIT_MAX_DWELL_MS : dwellMs); READING_STATS.noteWordsRead(words, associatedMs); + lastWordsCreditedSpineIndex = spineIndex; + lastWordsCreditedPage = page; } void EpubReaderActivity::pageTurn(bool isForwardTurn) { @@ -1382,9 +1399,10 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { const int oldPage = section ? section->currentPage : nextPageNumber; if (isForwardTurn) { - // Credit words + dwell on the page being finished. Jumps never call this path, so - // chapter/percent/TOC navigation does not count skipped or left pages as read. - maybeCreditPageWords(oldSpineIndex, oldPage); + // Auto page-turn must not train the reading-rate samples. + if (!automaticPageTurnActive) { + maybeCreditPageWords(oldSpineIndex, oldPage); + } if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { section->currentPage++; } else { @@ -1418,10 +1436,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (section) { notePageEnteredIfChanged(); } else { - // Section reload will settle enter time on the next render. - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); } requestUpdate(); } @@ -2060,18 +2075,10 @@ void EpubReaderActivity::renderStatusBar() const { char chapterTimeBuf[12] = {}; const char* chapterTimeEstimate = nullptr; - if ((SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) && - section->currentPage >= 0) { - const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - if (wordsPerMs > 0.0) { - const uint32_t remainingWords = - section->estimateRemainingWords(static_cast(section->currentPage)); - if (ChapterTimeEstimate::formatRemainingFromRate(remainingWords, wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf))) { - chapterTimeEstimate = chapterTimeBuf; - } - } + if (section->currentPage >= 0) { + ChapterTimeEstimate::tryFillStatusBarChapterEta( + section->estimateRemainingWords(static_cast(section->currentPage)), chapterTimeBuf, + sizeof(chapterTimeBuf), &chapterTimeEstimate); } GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); @@ -2134,9 +2141,7 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s { RenderLock lock(*this); - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); pendingAnchor = std::move(anchor); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; @@ -2154,9 +2159,7 @@ void EpubReaderActivity::restoreSavedPosition() { { RenderLock lock(*this); - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); currentSpineIndex = pos.spineIndex; nextPageNumber = pos.pageNumber; section.reset(); @@ -2348,9 +2351,7 @@ void EpubReaderActivity::applyPendingSyncSession() { cachedChapterTotalPageCount = restorePageCount; } - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); sync.clear(); APP_STATE.saveToFile(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 7a4bd10e1fd..fae9644ccc5 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -126,6 +126,8 @@ class EpubReaderActivity final : public Activity { void markCurrentBookAsFinished(); void pageTurn(bool isForwardTurn); void notePageEnteredIfChanged(); + void clearPageDwell(); + void restartPageDwell(); void maybeCreditPageWords(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 7563673ddf9..0ec06846b3b 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -32,7 +32,7 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 5; // v5: per-page word counts for chapter ETA +constexpr uint8_t CACHE_VERSION = 6; // v6: CJK/Thai-aware per-page word counts for chapter ETA constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -416,8 +416,7 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { pageOffsets.clear(); pageWordCounts.clear(); currentPageLines.clear(); - pageEnteredPage = -1; - pageEnteredMs = 0; + clearPageDwell(); pendingForceFullRefresh = true; requestUpdate(); } @@ -544,20 +543,51 @@ void TxtReaderActivity::buildPageIndex() { uint16_t TxtReaderActivity::countWordsInLines(const std::vector& lines) { uint32_t words = 0; for (const auto& line : lines) { - bool inWord = false; - for (const unsigned char c : line.text) { - const bool isSpace = c <= ' ' || c == 0xA0; - if (!isSpace && !inWord) { + const unsigned char* p = reinterpret_cast(line.text.data()); + const unsigned char* end = p + line.text.size(); + bool inSpaceWord = false; + while (p < end) { + const uint32_t cp = utf8NextCodepoint(&p); + if (cp == 0) { + break; + } + const bool isSpace = cp <= 0x20 || cp == 0xA0 || cp == 0x3000; + if (isSpace) { + inSpaceWord = false; + continue; + } + // CJK / kana / hangul / Thai: each letter is a reading unit (no spaces). + const bool perCharWord = + utf8IsCjkBreakable(cp) || (cp >= 0x0E01 && cp <= 0x0E3A) || (cp >= 0x0E40 && cp <= 0x0E4E); + // Skip CJK punctuation / fullwidth forms that utf8IsCjkBreakable includes. + const bool cjkPunctOrFullwidth = + (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFE30 && cp <= 0xFE4F) || (cp >= 0xFF01 && cp <= 0xFF60); + if (perCharWord && !cjkPunctOrFullwidth) { + ++words; + inSpaceWord = false; + } else if (!cjkPunctOrFullwidth && !inSpaceWord) { ++words; - inWord = true; - } else if (isSpace) { - inWord = false; + inSpaceWord = true; } } } return words > UINT16_MAX ? UINT16_MAX : static_cast(words); } +void TxtReaderActivity::clearPageDwell() { + pageEnteredPage = -1; + pageEnteredMs = 0; +} + +void TxtReaderActivity::restartPageDwell() { + if (currentPage < 0) { + clearPageDwell(); + return; + } + pageEnteredPage = currentPage; + pageEnteredMs = millis(); +} + void TxtReaderActivity::notePageEnteredIfChanged() { if (currentPage < 0) { return; @@ -574,15 +604,10 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - constexpr unsigned long WORD_CREDIT_MIN_DWELL_MS = 1500UL; - constexpr unsigned long WORD_CREDIT_REREAD_MIN_MS = 8000UL; - constexpr unsigned long WORD_CREDIT_MAX_DWELL_MS = 30UL * 60UL * 1000UL; - const unsigned long dwellMs = millis() - pageEnteredMs; - if (dwellMs < WORD_CREDIT_MIN_DWELL_MS) { - return; - } - if (page == lastWordsCreditedPage && dwellMs < WORD_CREDIT_REREAD_MIN_MS) { + const bool sameAsLastCredit = page == lastWordsCreditedPage; + const uint32_t associatedMs = ChapterTimeEstimate::dwellCreditMs(dwellMs, sameAsLastCredit); + if (associatedMs == 0) { return; } @@ -590,14 +615,12 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { if (page < static_cast(pageWordCounts.size())) { words = pageWordCounts[page]; } - lastWordsCreditedPage = page; if (words == 0) { return; } - const uint32_t associatedMs = static_cast( - dwellMs > WORD_CREDIT_MAX_DWELL_MS ? WORD_CREDIT_MAX_DWELL_MS : dwellMs); READING_STATS.noteWordsRead(words, associatedMs); + lastWordsCreditedPage = page; } uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { @@ -882,17 +905,8 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[12] = {}; const char* chapterTimeEstimate = nullptr; - if (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) { - const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - if (wordsPerMs > 0.0) { - const uint32_t remainingWords = estimateRemainingWords(currentPage); - if (ChapterTimeEstimate::formatRemainingFromRate(remainingWords, wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf))) { - chapterTimeEstimate = chapterTimeBuf; - } - } - } + ChapterTimeEstimate::tryFillStatusBarChapterEta(estimateRemainingWords(currentPage), chapterTimeBuf, + sizeof(chapterTimeBuf), &chapterTimeEstimate); GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 7da6e4db5d3..7672614c96a 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -69,6 +69,8 @@ class TxtReaderActivity final : public Activity { void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void notePageEnteredIfChanged(); + void clearPageDwell(); + void restartPageDwell(); void maybeCreditPageWords(int page); uint32_t estimateRemainingWords(int fromPage) const; std::string moveCompletedBookIfEnabled(); diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 41c7c8a5e35..31e58a0c869 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "ClockSyncActivity.h" @@ -84,8 +85,11 @@ int clockCycleIndex(const uint8_t mode) { const char* previewChapterTimeEstimate() { switch (SETTINGS.statusBarChapterProgress) { case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: - case CrossPointSettings::CHAPTER_PROGRESS_TIME: - return "15m"; + case CrossPointSettings::CHAPTER_PROGRESS_TIME: { + static char buf[12]; + snprintf(buf, sizeof(buf), "15%s", tr(STR_ETA_UNIT_MINUTE)); + return buf; + } default: return nullptr; } diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index eda1f24e449..cb3bad2dd33 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -846,13 +846,18 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - const bool showChapterPages = SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; + const bool wantChapterTime = + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; + const bool haveChapterTime = + wantChapterTime && chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; + // TIME-only with no rate yet would otherwise leave an empty right cluster; show pages until ETA is ready. + const bool showChapterPages = + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME && !haveChapterTime); const bool showBookPercent = SETTINGS.statusBarBookProgressPercentage; - const bool showChapterTime = - (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME) && - chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; + const bool showChapterTime = haveChapterTime; if (showBookPercent || showChapterPages || showChapterTime) { // Right aligned text for progress counter @@ -948,7 +953,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c const int clockReserveRight = clockOnRight && clockTextWidth > 0 ? (clockTextWidth + 10) : 0; // Wider progress clusters (pages+time+%) need less decorative title padding so the // title can still truncate cleanly instead of colliding with the right cluster. - const int titleSidePad = (showChapterTime && showChapterPages && showBookPercent) ? 12 : 30; + const int titleSidePad = showChapterTime ? 12 : 30; const int titleMarginLeft = batteryAreaWidth + clockReserveLeft + titleSidePad; const int titleMarginRight = progressTextWidth + clockReserveRight + titleSidePad; diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index d76dfed7ada..f15451a5bbe 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -1,5 +1,9 @@ #include "util/ChapterTimeEstimate.h" +#include +#include +#include + #include namespace ChapterTimeEstimate { @@ -9,17 +13,12 @@ constexpr uint64_t MS_PER_HOUR = 60ULL * MS_PER_MINUTE; constexpr uint64_t MS_PER_DAY = 24ULL * MS_PER_HOUR; constexpr uint64_t MS_PER_YEAR = 365ULL * MS_PER_DAY; -bool formatRoundedUnit(const uint64_t value, const char unit, char* buf, const size_t bufSize) { - const int written = snprintf(buf, bufSize, "%llu%c", static_cast(value), unit); - return written > 0 && static_cast(written) < bufSize; -} - -uint64_t roundedUnits(const uint64_t totalMs, const uint64_t unitMs) { - uint64_t value = (totalMs + unitMs / 2) / unitMs; - if (value == 0) { - value = 1; +bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const size_t bufSize) { + if (!unit || unit[0] == '\0') { + return false; } - return value; + const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); + return written > 0 && static_cast(written) < bufSize; } } // namespace @@ -28,20 +27,36 @@ bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSi return false; } - // Pick the unit from the rounded display value so 60m becomes 1h (not "60m"). - const uint64_t minutes = roundedUnits(totalMs, MS_PER_MINUTE); + // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). + uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; + if (minutes == 0) { + minutes = 1; + } if (minutes < 60) { - return formatRoundedUnit(minutes, 'm', buf, bufSize); + return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE), buf, bufSize); + } + + uint64_t hours = (minutes + 30) / 60; + if (hours == 0) { + hours = 1; } - const uint64_t hours = roundedUnits(totalMs, MS_PER_HOUR); if (hours < 24) { - return formatRoundedUnit(hours, 'h', buf, bufSize); + return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR), buf, bufSize); + } + + uint64_t days = (hours + 12) / 24; + if (days == 0) { + days = 1; } - const uint64_t days = roundedUnits(totalMs, MS_PER_DAY); if (days < 365) { - return formatRoundedUnit(days, 'd', buf, bufSize); + return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY), buf, bufSize); + } + + uint64_t years = (days + 182) / 365; + if (years == 0) { + years = 1; } - return formatRoundedUnit(roundedUnits(totalMs, MS_PER_YEAR), 'y', buf, bufSize); + return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR), buf, bufSize); } bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPerMs, char* buf, @@ -56,4 +71,32 @@ bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPe return formatCompactDuration(static_cast(ms), buf, bufSize); } +bool statusBarWantsChapterTime() { + return SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; +} + +bool tryFillStatusBarChapterEta(const uint32_t remainingWords, char* buf, const size_t bufSize, + const char** outEstimate) { + if (!outEstimate || !statusBarWantsChapterTime()) { + return false; + } + if (!formatRemainingFromRate(remainingWords, READING_STATS.getEffectiveWordsPerMs(), buf, bufSize)) { + return false; + } + *outEstimate = buf; + return true; +} + +uint32_t dwellCreditMs(const unsigned long dwellMs, const bool sameAsLastCredit) { + if (dwellMs < MIN_DWELL_MS) { + return 0; + } + if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { + return 0; + } + const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; + return static_cast(capped); +} + } // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index bc7e621c537..d847f4e2c5c 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -5,6 +5,11 @@ namespace ChapterTimeEstimate { +// Dwell thresholds for pairing page word credits with reading time. +constexpr unsigned long MIN_DWELL_MS = 1500UL; +constexpr unsigned long REREAD_MIN_MS = 8000UL; +constexpr unsigned long MAX_DWELL_MS = 30UL * 60UL * 1000UL; + // Compact single-unit duration for the status bar: 15m / 2h / 3d / 1y. // Returns false when buf is too small or ms is zero (nothing to show). bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); @@ -13,4 +18,15 @@ bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); // Returns false when inputs are insufficient or the buffer is too small. bool formatRemainingFromRate(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize); +// True when the status-bar chapter setting wants a time estimate shown. +bool statusBarWantsChapterTime(); + +// Fill buf with a chapter ETA when the status-bar setting requests time and a rate exists. +// Returns true and sets *outEstimate to buf on success; otherwise false and *outEstimate unchanged. +bool tryFillStatusBarChapterEta(uint32_t remainingWords, char* buf, size_t bufSize, const char** outEstimate); + +// Returns associated dwell ms to credit with page words, or 0 to skip credit. +// sameAsLastCredit requires a longer linger before re-crediting a re-read page. +uint32_t dwellCreditMs(unsigned long dwellMs, bool sameAsLastCredit); + } // namespace ChapterTimeEstimate From c3a735091f65fd55326833cd4a15938e2607f65e Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:57 -0700 Subject: [PATCH 10/20] fix(reader): finish chapter ETA audit fixes Restore resumeSession via openReaderSubactivity, share PageDwell and layout word counting, drop TXT per-page word RAM, and stop the editor from wiping sub-minute ETA samples. --- docs/reading-stats-editor/index.html | 20 ++- lib/Epub/Epub/Section.cpp | 13 +- lib/Epub/Epub/Section.h | 3 +- lib/Utf8/Utf8.cpp | 59 +++++++ lib/Utf8/Utf8.h | 10 ++ src/ReadingStatsStore.cpp | 1 + src/activities/reader/EpubReaderActivity.cpp | 163 +++++++------------ src/activities/reader/EpubReaderActivity.h | 10 +- src/activities/reader/TxtReaderActivity.cpp | 114 ++++--------- src/activities/reader/TxtReaderActivity.h | 10 +- src/components/themes/BaseTheme.cpp | 5 +- src/util/ChapterTimeEstimate.cpp | 56 ++++--- src/util/ChapterTimeEstimate.h | 18 +- 13 files changed, 251 insertions(+), 231 deletions(-) diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index 96236e0d222..a022a442c12 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -1393,7 +1393,7 @@

CPR-vCodex Reading Stats Editor

totalReadingMinutes: "Total reading minutes", totalWordsRead: "Words read (ETA samples)", wordsReadingMinutes: "Minutes paired with words (ETA)", - wordsRateHint: "ETA rate uses words ÷ paired minutes. Unpaired words are cleared on import. Recalculate from days only updates total reading minutes.", + wordsRateHint: "ETA rate uses words ÷ paired minutes (fractional minutes OK). Unpaired words are cleared on import when paired minutes are truly zero. Recalculate from days only updates total reading minutes.", lastSessionMinutes: "Last session minutes", bookProgress: "Book progress %", chapterProgress: "Chapter progress %", @@ -1470,7 +1470,7 @@

CPR-vCodex Reading Stats Editor

totalReadingMinutes: "Minutos totales de lectura", totalWordsRead: "Palabras leídas (muestras ETA)", wordsReadingMinutes: "Minutos emparejados con palabras (ETA)", - wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados. Las palabras sin emparejar se borran al importar. Recalcular desde días solo actualiza los minutos totales de lectura.", + wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados (se permiten fracciones). Las palabras sin emparejar se borran al importar si los minutos emparejados son realmente cero. Recalcular desde días solo actualiza los minutos totales de lectura.", lastSessionMinutes: "Minutos de la última sesión", bookProgress: "Progreso del libro %", chapterProgress: "Progreso del capítulo %", @@ -3250,8 +3250,16 @@

CPR-vCodex Reading Stats Editor

return Math.round(toUInt(ms) / MS_PER_MINUTE); } + // Fractional minutes for ETA paired-ms editing (avoids wiping sub-30s samples via round-to-0). + function msToExactMinutes(ms) { + const value = toUInt(ms) / MS_PER_MINUTE; + return Number(value.toFixed(4)); + } + function minutesToMs(minutes) { - return toUInt(minutes) * MS_PER_MINUTE; + const value = Number(minutes); + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.round(value * MS_PER_MINUTE); } function formatDuration(ms) { @@ -3851,7 +3859,7 @@

${escapeHtml(t("monthlyReading"))}

${textField("chapterTitle", t("chapterTitle"), book.chapterTitle)} ${numberField("totalReadingMinutes", t("totalReadingMinutes"), msToMinutes(book.totalReadingMs))} ${numberField("totalWordsRead", t("totalWordsRead"), book.totalWordsRead)} - ${numberField("wordsReadingMinutes", t("wordsReadingMinutes"), msToMinutes(book.totalWordsReadingMs))} + ${numberField("wordsReadingMinutes", t("wordsReadingMinutes"), msToExactMinutes(book.totalWordsReadingMs), "0.01")}

${escapeHtml(t("wordsRateHint"))}

${numberField("sessions", t("sessions"), book.sessions)} ${numberField("lastSessionMinutes", t("lastSessionMinutes"), msToMinutes(book.lastSessionMs))} @@ -4023,11 +4031,11 @@

${escapeHtml(t("monthlyReading"))}

`; } - function numberField(id, label, currentValue) { + function numberField(id, label, currentValue, step = "1") { return `
- +
`; } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 0a023a09058..2c57695cac3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -172,13 +172,17 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { serialization::readPod(file, pageCount); + // One seek for the li LUT offset — used by both the partial trailer and the word LUT. + uint32_t liLutOffset = 0; + if (pageCount > 0 || filePartial) { + file.seek(HEADER_SIZE - sizeof(uint32_t)); + serialization::readPod(file, liLutOffset); + } + if (filePartial) { // A partial's pageCount is the watermark of a suspended build. Read the watermark // trailer (appended after the li LUT + word-count table) so estimatedTotalPages can // extrapolate. - uint32_t liLutOffset = 0; - file.seek(HEADER_SIZE - sizeof(uint32_t)); - serialization::readPod(file, liLutOffset); const uint32_t trailerOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t) * 2; // li + words const bool trailerValid = @@ -200,9 +204,6 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { // Load per-page word counts (v41+) from immediately after the li LUT. pageWordCounts_.clear(); if (pageCount > 0) { - uint32_t liLutOffset = 0; - file.seek(HEADER_SIZE - sizeof(uint32_t)); - serialization::readPod(file, liLutOffset); const uint32_t wordLutOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); const uint32_t wordLutEnd = wordLutOffset + static_cast(pageCount) * sizeof(uint16_t); if (liLutOffset >= HEADER_SIZE && wordLutEnd <= file.size()) { diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 84bf9f13e28..a7f8733656c 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -157,6 +157,7 @@ class Section { // Word count for a built/available page (0 if unknown / out of range). uint16_t getPageWordCount(uint16_t page) const; // Remaining chapter words from `fromPage` inclusive, including an estimate for - // still-unbuilt pages based on HTML byte density (not page-turn rate). + // still-unbuilt pages from mean words/built-page × estimated unbuilt page count + // (estimatedTotalPages uses the same page-count model as the status bar). uint32_t estimateRemainingWords(uint16_t fromPage) const; }; diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index c0ed4c7fb8e..9f224a3f0d5 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -178,3 +178,62 @@ void utf8TruncateChars(std::string& str, const size_t numChars) { utf8RemoveLastChar(str); } } + +uint32_t utf8CountLayoutWords(const char* data, const size_t len) { + if (!data || len == 0) { + return 0; + } + + uint32_t words = 0; + size_t runBytes = 0; + + auto flushRun = [&]() { + if (runBytes == 0) { + return; + } + // Match EPUB: split oversized unspaced runs at UTF-8-safe MAX_WORD_SIZE chunks. + size_t remaining = runBytes; + while (remaining > 0) { + const size_t chunk = remaining > UTF8_LAYOUT_WORD_MAX_BYTES ? UTF8_LAYOUT_WORD_MAX_BYTES : remaining; + ++words; + remaining -= chunk; + } + runBytes = 0; + }; + + for (size_t i = 0; i < len; ++i) { + const unsigned char c = static_cast(data[i]); + if (c == ' ' || c == '\r' || c == '\n' || c == '\t') { + flushRun(); + continue; + } + + // U+00A0 (C2 A0) — counts as its own layout word, like EPUB. + if (c == 0xC2 && i + 1 < len && static_cast(data[i + 1]) == 0xA0) { + flushRun(); + ++words; + ++i; + continue; + } + + // U+202F (E2 80 AF) — narrow no-break space. + if (c == 0xE2 && i + 2 < len && static_cast(data[i + 1]) == 0x80 && + static_cast(data[i + 2]) == 0xAF) { + flushRun(); + ++words; + i += 2; + continue; + } + + // U+FEFF BOM / ZWNBSP — skip. + if (c == 0xEF && i + 2 < len && static_cast(data[i + 1]) == 0xBB && + static_cast(data[i + 2]) == 0xBF) { + i += 2; + continue; + } + + ++runBytes; + } + flushRun(); + return words; +} diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 8bdaa929c26..893fe5714b7 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -23,6 +23,16 @@ std::string utf8ComposeNfc(const std::string& in); // incomplete trailing bytes are excluded. int utf8SafeTruncateBuffer(const char* buf, int len); +// EPUB layout word-token byte cap (ChapterHtmlSlimParser::MAX_WORD_SIZE). Long +// unspaced runs (e.g. CJK) are split at this UTF-8-safe boundary and each piece +// counts as one layout word — matching Page::countWords / TextBlock::wordCount. +constexpr size_t UTF8_LAYOUT_WORD_MAX_BYTES = 200; + +// Count layout words the same way EPUB tokenizes plain text: ASCII whitespace +// separates words; U+00A0 / U+202F each count as one word; other non-whitespace +// runs count as one word per UTF8_LAYOUT_WORD_MAX_BYTES chunk. +uint32_t utf8CountLayoutWords(const char* data, size_t len); + // Returns true for CJK characters that allow line breaks on either side without hyphenation. // Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, // and fullwidth forms — the ranges where word boundaries are implicit per character. diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 4f148b9c764..515e5903a79 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1504,6 +1504,7 @@ double ReadingStatsStore::getEffectiveWordsPerMs() const { bool ReadingStatsStore::adjustBookReadingTime(const std::string& path, const uint32_t dayOrdinal, const int32_t deltaMs) { + // Manual day corrections adjust lifetime reading time only — never word-rate ETA samples. if (dayOrdinal == 0 || deltaMs == 0) { return false; } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index efb2bdad4e9..c5b110826f5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -337,7 +337,7 @@ void EpubReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - if (section && section->currentPage >= 0) { + if (!automaticPageTurnActive && section && section->currentPage >= 0) { maybeCreditPageWords(currentSpineIndex, section->currentPage); } @@ -522,7 +522,6 @@ void EpubReaderActivity::loop() { if (ReaderUtils::hasPendingConfirmSingleClickExpired(waitingForConfirmSecondClick, firstConfirmClickMs, nowMs)) { waitingForConfirmSecondClick = false; firstConfirmClickMs = 0UL; - READING_STATS.noteActivity(); int currentPage = 0; int totalPages = 0; float bookProgress = 0.0f; @@ -538,20 +537,19 @@ void EpubReaderActivity::loop() { } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); ReaderUtils::requestReaderUiTransitionRefresh(renderer); - clearPageDwell(); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty()), - [this](const ActivityResult& result) { - - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + openReaderSubactivity( + std::make_unique(renderer, mappedInput, epub->getTitle(), currentPage, totalPages, + bookProgressPercent, SETTINGS.orientation, + !currentPageFootnotes.empty()), + [this](const ActivityResult& result) { + // Always apply orientation change even if the menu was cancelled + const auto& menu = std::get(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(menu.action)); + } + }); } // Long press BACK (1s+) goes to file selection @@ -929,24 +927,16 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction switch (action) { case EpubReaderMenuActivity::MenuAction::READER_SETTINGS: { const auto before = captureReaderSettingsSnapshot(); - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult(std::make_unique(renderer, mappedInput), - [this, before](const ActivityResult&) { - applyReaderSettingsChanges(before); - - }); + openReaderSubactivity(std::make_unique(renderer, mappedInput), + [this, before](const ActivityResult&) { applyReaderSettingsChanges(before); }); break; } case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { const int spineIdx = currentSpineIndex; const std::string path = epub->getPath(); - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, epub, path, spineIdx), [this](const ActivityResult& result) { - if (!result.isCancelled) { const auto& chapterResult = std::get(result.data); RenderLock lock(*this); @@ -966,17 +956,15 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } - requestUpdate(); - }); + openReaderSubactivity( + std::make_unique(renderer, mappedInput, currentPageFootnotes), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& footnoteResult = std::get(result.data); + navigateToHref(footnoteResult.href, true); + } + requestUpdate(); + }); break; } case EpubReaderMenuActivity::MenuAction::LOOK_UP_WORD: { @@ -987,13 +975,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); @@ -1007,33 +992,24 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::DICTIONARY: { - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult(std::make_unique(renderer, mappedInput), - [this](const ActivityResult&) { - - ReaderUtils::requestReaderUiTransitionRefresh(renderer); - requestUpdate(); - }); + openReaderSubactivity(std::make_unique(renderer, mappedInput), [this](const ActivityResult&) { + ReaderUtils::requestReaderUiTransitionRefresh(renderer); + requestUpdate(); + }); break; } case EpubReaderMenuActivity::MenuAction::VIEW_HIGHLIGHTS: { - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, bookmarkStore.getAll(), epub, "", [this](const BookmarkStore::Bookmark& bookmark) { const bool removed = bookmarkStore.removeItem(bookmark); @@ -1043,7 +1019,6 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction return removed; }), [this](const ActivityResult& result) { - if (!result.isCancelled) { const auto& bookmark = std::get(result.data); if (currentSpineIndex != bookmark.spineIndex || !section || @@ -1074,13 +1049,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } const uint16_t selectionSpine = static_cast(currentSpineIndex); const uint16_t selectionPage = static_cast(section->currentPage); - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop, true), [this, selectionSpine, selectionPage](const ActivityResult& result) { - if (!result.isCancelled) { const auto& highlight = std::get(result.data); const bool saved = @@ -1111,12 +1083,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, initialPercent), [this](const ActivityResult& result) { - if (!result.isCancelled) { jumpToPercent(std::get(result.data).percent); } @@ -1140,10 +1109,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } if (!fullText.empty()) { - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult(std::make_unique(renderer, mappedInput, fullText), - [this](const ActivityResult& result) { READING_STATS.resumeSession(); restartPageDwell(); }); + openReaderSubactivity(std::make_unique(renderer, mappedInput, fullText), + [](const ActivityResult&) {}); break; } } @@ -1161,12 +1128,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::MARK_AS_FINISHED: { const std::string title = epub ? epub->getTitle() : ""; - READING_STATS.noteActivity(); - clearPageDwell(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, tr(STR_MARK_AS_FINISHED_CONFIRM), title), [this](const ActivityResult& result) { - if (!result.isCancelled) { markCurrentBookAsFinished(); } else { @@ -1330,20 +1294,31 @@ void EpubReaderActivity::markCurrentBookAsFinished() { exitReaderAfterOptionalCompletedMove(); } -void EpubReaderActivity::clearPageDwell() { - pageEnteredSpineIndex = -1; - pageEnteredPage = -1; - pageEnteredMs = 0; -} +void EpubReaderActivity::clearPageDwell() { pageDwell.clear(); } void EpubReaderActivity::restartPageDwell() { if (!section || section->currentPage < 0) { clearPageDwell(); return; } - pageEnteredSpineIndex = currentSpineIndex; - pageEnteredPage = section->currentPage; - pageEnteredMs = millis(); + pageDwell.restart(currentSpineIndex, section->currentPage, millis()); +} + +void EpubReaderActivity::resumeAfterSubactivity() { + READING_STATS.resumeSession(); + restartPageDwell(); +} + +void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, + ActivityResultHandler onResult) { + READING_STATS.noteActivity(); + clearPageDwell(); + startActivityForResult(std::move(activity), [this, onResult = std::move(onResult)](const ActivityResult& result) { + resumeAfterSubactivity(); + if (onResult) { + onResult(result); + } + }); } void EpubReaderActivity::notePageEnteredIfChanged() { @@ -1351,27 +1326,15 @@ void EpubReaderActivity::notePageEnteredIfChanged() { if (page < 0) { return; } - if (currentSpineIndex == pageEnteredSpineIndex && page == pageEnteredPage && pageEnteredMs != 0) { - return; - } - pageEnteredSpineIndex = currentSpineIndex; - pageEnteredPage = page; - pageEnteredMs = millis(); + pageDwell.noteEnteredIfChanged(currentSpineIndex, page, millis()); } void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int page) { if (!section || page < 0 || spineIndex < 0) { return; } - // Only credit the page whose dwell we have been tracking (contiguous forward leave / exit). - if (spineIndex != pageEnteredSpineIndex || page != pageEnteredPage || pageEnteredMs == 0) { - return; - } - const unsigned long dwellMs = millis() - pageEnteredMs; - const bool sameAsLastCredit = - spineIndex == lastWordsCreditedSpineIndex && page == lastWordsCreditedPage; - const uint32_t associatedMs = ChapterTimeEstimate::dwellCreditMs(dwellMs, sameAsLastCredit); + const uint32_t associatedMs = pageDwell.creditMs(spineIndex, page, millis()); if (associatedMs == 0) { return; } @@ -1382,8 +1345,7 @@ void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int pa } READING_STATS.noteWordsRead(words, associatedMs); - lastWordsCreditedSpineIndex = spineIndex; - lastWordsCreditedPage = page; + pageDwell.markCredited(spineIndex, page); } void EpubReaderActivity::pageTurn(bool isForwardTurn) { @@ -2077,8 +2039,8 @@ void EpubReaderActivity::renderStatusBar() const { const char* chapterTimeEstimate = nullptr; if (section->currentPage >= 0) { ChapterTimeEstimate::tryFillStatusBarChapterEta( - section->estimateRemainingWords(static_cast(section->currentPage)), chapterTimeBuf, - sizeof(chapterTimeBuf), &chapterTimeEstimate); + section->estimateRemainingWords(static_cast(section->currentPage)), + READING_STATS.getEffectiveWordsPerMs(), chapterTimeBuf, sizeof(chapterTimeBuf), &chapterTimeEstimate); } GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); @@ -2278,6 +2240,7 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { cachedSpineIndex = currentSpineIndex; cachedChapterTotalPageCount = section->estimatedTotalPages(); } + clearPageDwell(); section.reset(); epub.reset(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index fae9644ccc5..c46404d86e0 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -9,6 +9,7 @@ #include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" #include "activities/Activity.h" +#include "util/ChapterTimeEstimate.h" class Page; @@ -46,11 +47,7 @@ class EpubReaderActivity final : public Activity { int sessionStartPage = 0; bool sessionProgressTouched = false; // Word-rate samples: dwell on the page currently displayed. Jumps never credit the left page. - unsigned long pageEnteredMs = 0; - int pageEnteredSpineIndex = -1; - int pageEnteredPage = -1; - int lastWordsCreditedSpineIndex = -1; - int lastWordsCreditedPage = -1; + ChapterTimeEstimate::PageDwell pageDwell; std::shared_ptr currentOverlayPageCache; EndOfBookOptions endOfBookOptions; int currentOverlayPageSpineIndex = -1; @@ -128,6 +125,9 @@ class EpubReaderActivity final : public Activity { void notePageEnteredIfChanged(); void clearPageDwell(); void restartPageDwell(); + void resumeAfterSubactivity(); + // noteActivity + clear dwell, then resumeSession + restart dwell on return. + void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); void maybeCreditPageWords(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0ec06846b3b..a558b0abed1 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -32,7 +32,7 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 6; // v6: CJK/Thai-aware per-page word counts for chapter ETA +constexpr uint8_t CACHE_VERSION = 7; // v7: totalBookWords only (no per-page word array in RAM/cache) constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -326,7 +326,7 @@ void TxtReaderActivity::onExit() { maybeCreditPageWords(currentPage); pageOffsets.clear(); - pageWordCounts.clear(); + totalBookWords = 0; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -414,7 +414,7 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { statusBarTemporarilyHidden = !statusBarTemporarilyHidden; initialized = false; pageOffsets.clear(); - pageWordCounts.clear(); + totalBookWords = 0; currentPageLines.clear(); clearPageDwell(); pendingForceFullRefresh = true; @@ -494,7 +494,7 @@ void TxtReaderActivity::initializeReader() { void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); - pageWordCounts.clear(); + totalBookWords = 0; pageOffsets.push_back(0); // First page starts at offset 0 size_t offset = 0; @@ -512,7 +512,7 @@ void TxtReaderActivity::buildPageIndex() { break; } - pageWordCounts.push_back(countWordsInLines(tempLines)); + totalBookWords += countWordsInLines(tempLines); if (nextOffset <= offset) { // No progress made, avoid infinite loop @@ -531,107 +531,57 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); - if (pageWordCounts.size() > static_cast(totalPages)) { - pageWordCounts.resize(totalPages); - } - while (pageWordCounts.size() < static_cast(totalPages)) { - pageWordCounts.push_back(0); - } - LOG_DBG("TRS", "Built page index: %d pages", totalPages); + LOG_DBG("TRS", "Built page index: %d pages, %lu words", totalPages, static_cast(totalBookWords)); } uint16_t TxtReaderActivity::countWordsInLines(const std::vector& lines) { uint32_t words = 0; for (const auto& line : lines) { - const unsigned char* p = reinterpret_cast(line.text.data()); - const unsigned char* end = p + line.text.size(); - bool inSpaceWord = false; - while (p < end) { - const uint32_t cp = utf8NextCodepoint(&p); - if (cp == 0) { - break; - } - const bool isSpace = cp <= 0x20 || cp == 0xA0 || cp == 0x3000; - if (isSpace) { - inSpaceWord = false; - continue; - } - // CJK / kana / hangul / Thai: each letter is a reading unit (no spaces). - const bool perCharWord = - utf8IsCjkBreakable(cp) || (cp >= 0x0E01 && cp <= 0x0E3A) || (cp >= 0x0E40 && cp <= 0x0E4E); - // Skip CJK punctuation / fullwidth forms that utf8IsCjkBreakable includes. - const bool cjkPunctOrFullwidth = - (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFE30 && cp <= 0xFE4F) || (cp >= 0xFF01 && cp <= 0xFF60); - if (perCharWord && !cjkPunctOrFullwidth) { - ++words; - inSpaceWord = false; - } else if (!cjkPunctOrFullwidth && !inSpaceWord) { - ++words; - inSpaceWord = true; - } - } + words += utf8CountLayoutWords(line.text.data(), line.text.size()); } return words > UINT16_MAX ? UINT16_MAX : static_cast(words); } -void TxtReaderActivity::clearPageDwell() { - pageEnteredPage = -1; - pageEnteredMs = 0; -} +uint16_t TxtReaderActivity::countWordsOnCurrentPage() const { return countWordsInLines(currentPageLines); } -void TxtReaderActivity::restartPageDwell() { - if (currentPage < 0) { - clearPageDwell(); - return; - } - pageEnteredPage = currentPage; - pageEnteredMs = millis(); -} +void TxtReaderActivity::clearPageDwell() { pageDwell.clear(); } void TxtReaderActivity::notePageEnteredIfChanged() { if (currentPage < 0) { return; } - if (currentPage == pageEnteredPage && pageEnteredMs != 0) { - return; - } - pageEnteredPage = currentPage; - pageEnteredMs = millis(); + pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); } void TxtReaderActivity::maybeCreditPageWords(const int page) { - if (page < 0 || pageEnteredPage != page || pageEnteredMs == 0) { + if (page < 0) { return; } - const unsigned long dwellMs = millis() - pageEnteredMs; - const bool sameAsLastCredit = page == lastWordsCreditedPage; - const uint32_t associatedMs = ChapterTimeEstimate::dwellCreditMs(dwellMs, sameAsLastCredit); + const uint32_t associatedMs = pageDwell.creditMs(page, 0, millis()); if (associatedMs == 0) { return; } - uint16_t words = 0; - if (page < static_cast(pageWordCounts.size())) { - words = pageWordCounts[page]; - } + // Count the page being left from the currently loaded lines when it matches. + const uint16_t words = (page == currentPage) ? countWordsOnCurrentPage() : 0; if (words == 0) { return; } READING_STATS.noteWordsRead(words, associatedMs); - lastWordsCreditedPage = page; + pageDwell.markCredited(page, 0); } uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { - if (fromPage < 0 || pageWordCounts.empty()) { + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { return 0; } - uint32_t remaining = 0; - for (size_t page = static_cast(fromPage); page < pageWordCounts.size(); ++page) { - remaining += pageWordCounts[page]; - } - return remaining; + // Pro-rate the book total across remaining pages (inclusive of fromPage). Avoids a + // per-page word array in RAM while matching EPUB's "mean words × pages left" idea. + const uint32_t pagesLeft = static_cast(totalPages - fromPage); + return static_cast((static_cast(totalBookWords) * pagesLeft) / + static_cast(totalPages)); } bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { @@ -905,7 +855,8 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[12] = {}; const char* chapterTimeEstimate = nullptr; - ChapterTimeEstimate::tryFillStatusBarChapterEta(estimateRemainingWords(currentPage), chapterTimeBuf, + ChapterTimeEstimate::tryFillStatusBarChapterEta(estimateRemainingWords(currentPage), + READING_STATS.getEffectiveWordsPerMs(), chapterTimeBuf, sizeof(chapterTimeBuf), &chapterTimeEstimate); GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); @@ -1042,23 +993,19 @@ bool TxtReaderActivity::loadPageIndexCache() { // Read page offsets pageOffsets.clear(); - pageWordCounts.clear(); + totalBookWords = 0; pageOffsets.reserve(numPages); - pageWordCounts.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { uint32_t offset; serialization::readPod(f, offset); pageOffsets.push_back(offset); } - for (uint32_t i = 0; i < numPages; i++) { - uint16_t words = 0; - serialization::readPod(f, words); - pageWordCounts.push_back(words); - } + serialization::readPod(f, totalBookWords); totalPages = pageOffsets.size(); - LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages); + LOG_DBG("TRS", "Loaded page index cache: %d pages, %lu words", totalPages, + static_cast(totalBookWords)); return true; } @@ -1081,14 +1028,11 @@ void TxtReaderActivity::savePageIndexCache() const { serialization::writePod(f, cachedParagraphAlignment); serialization::writePod(f, static_cast(pageOffsets.size())); - // Write page offsets + // Write page offsets + book-wide word total (no per-page array). for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } - for (size_t i = 0; i < pageOffsets.size(); ++i) { - const uint16_t words = (i < pageWordCounts.size()) ? pageWordCounts[i] : 0; - serialization::writePod(f, words); - } + serialization::writePod(f, totalBookWords); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 7672614c96a..fd45c3a5242 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -7,6 +7,7 @@ #include "CrossPointSettings.h" #include "activities/Activity.h" +#include "util/ChapterTimeEstimate.h" class TxtReaderActivity final : public Activity { public: @@ -32,7 +33,8 @@ class TxtReaderActivity final : public Activity { // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page - std::vector pageWordCounts; // Words per page (parallel to pageOffsets) + // Book-wide word total for ETA (no per-page array — keeps RAM flat for large TXT). + uint32_t totalBookWords = 0; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -43,9 +45,7 @@ class TxtReaderActivity final : public Activity { bool waitingForConfirmSecondClick = false; unsigned long firstConfirmClickMs = 0UL; // Word-rate samples: dwell on the page currently displayed. - unsigned long pageEnteredMs = 0; - int pageEnteredPage = -1; - int lastWordsCreditedPage = -1; + ChapterTimeEstimate::PageDwell pageDwell; // Cached settings for cache validation (different fonts/margins require re-indexing) int cachedFontId = 0; @@ -70,9 +70,9 @@ class TxtReaderActivity final : public Activity { void toggleTemporaryStatusBar(); void notePageEnteredIfChanged(); void clearPageDwell(); - void restartPageDwell(); void maybeCreditPageWords(int page); uint32_t estimateRemainingWords(int fromPage) const; + uint16_t countWordsOnCurrentPage() const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); static uint16_t countWordsInLines(const std::vector& lines); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index cb3bad2dd33..ec9806784c1 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -17,6 +17,7 @@ #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/ChapterTimeEstimate.h" #include "util/TimeUtils.h" // Internal constants @@ -846,9 +847,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - const bool wantChapterTime = - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; + const bool wantChapterTime = ChapterTimeEstimate::statusBarWantsChapterTime(); const bool haveChapterTime = wantChapterTime && chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; // TIME-only with no rate yet would otherwise leave an empty right cluster; show pages until ETA is ready. diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index f15451a5bbe..4e30c9c7c54 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -2,16 +2,12 @@ #include #include -#include #include namespace ChapterTimeEstimate { namespace { constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; -constexpr uint64_t MS_PER_HOUR = 60ULL * MS_PER_MINUTE; -constexpr uint64_t MS_PER_DAY = 24ULL * MS_PER_HOUR; -constexpr uint64_t MS_PER_YEAR = 365ULL * MS_PER_DAY; bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const size_t bufSize) { if (!unit || unit[0] == '\0') { @@ -36,26 +32,17 @@ bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSi return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE), buf, bufSize); } - uint64_t hours = (minutes + 30) / 60; - if (hours == 0) { - hours = 1; - } + const uint64_t hours = (minutes + 30) / 60; // minutes >= 60 ⇒ hours >= 1 if (hours < 24) { return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR), buf, bufSize); } - uint64_t days = (hours + 12) / 24; - if (days == 0) { - days = 1; - } + const uint64_t days = (hours + 12) / 24; // hours >= 24 ⇒ days >= 1 if (days < 365) { return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY), buf, bufSize); } - uint64_t years = (days + 182) / 365; - if (years == 0) { - years = 1; - } + const uint64_t years = (days + 182) / 365; // days >= 365 ⇒ years >= 1 return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR), buf, bufSize); } @@ -76,12 +63,12 @@ bool statusBarWantsChapterTime() { SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; } -bool tryFillStatusBarChapterEta(const uint32_t remainingWords, char* buf, const size_t bufSize, - const char** outEstimate) { +bool tryFillStatusBarChapterEta(const uint32_t remainingWords, const double wordsPerMs, char* buf, + const size_t bufSize, const char** outEstimate) { if (!outEstimate || !statusBarWantsChapterTime()) { return false; } - if (!formatRemainingFromRate(remainingWords, READING_STATS.getEffectiveWordsPerMs(), buf, bufSize)) { + if (!formatRemainingFromRate(remainingWords, wordsPerMs, buf, bufSize)) { return false; } *outEstimate = buf; @@ -99,4 +86,35 @@ uint32_t dwellCreditMs(const unsigned long dwellMs, const bool sameAsLastCredit) return static_cast(capped); } +void PageDwell::clear() { + enteredMs = 0; + id0 = -1; + id1 = -1; +} + +void PageDwell::restart(const int a, const int b, const unsigned long nowMs) { + id0 = a; + id1 = b; + enteredMs = nowMs; +} + +void PageDwell::noteEnteredIfChanged(const int a, const int b, const unsigned long nowMs) { + if (a == id0 && b == id1 && enteredMs != 0) { + return; + } + restart(a, b, nowMs); +} + +uint32_t PageDwell::creditMs(const int a, const int b, const unsigned long nowMs) const { + if (a != id0 || b != id1 || enteredMs == 0) { + return 0; + } + return dwellCreditMs(nowMs - enteredMs, a == lastCredited0 && b == lastCredited1); +} + +void PageDwell::markCredited(const int a, const int b) { + lastCredited0 = a; + lastCredited1 = b; +} + } // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index d847f4e2c5c..a6ffa2057e8 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -23,10 +23,26 @@ bool statusBarWantsChapterTime(); // Fill buf with a chapter ETA when the status-bar setting requests time and a rate exists. // Returns true and sets *outEstimate to buf on success; otherwise false and *outEstimate unchanged. -bool tryFillStatusBarChapterEta(uint32_t remainingWords, char* buf, size_t bufSize, const char** outEstimate); +bool tryFillStatusBarChapterEta(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize, + const char** outEstimate); // Returns associated dwell ms to credit with page words, or 0 to skip credit. // sameAsLastCredit requires a longer linger before re-crediting a re-read page. uint32_t dwellCreditMs(unsigned long dwellMs, bool sameAsLastCredit); +// Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). +struct PageDwell { + unsigned long enteredMs = 0; + int id0 = -1; + int id1 = -1; + int lastCredited0 = -1; + int lastCredited1 = -1; + + void clear(); + void restart(int a, int b, unsigned long nowMs); + void noteEnteredIfChanged(int a, int b, unsigned long nowMs); + uint32_t creditMs(int a, int b, unsigned long nowMs) const; + void markCredited(int a, int b); +}; + } // namespace ChapterTimeEstimate From c8629fe6ca9d28ed6db1e8993fa7d89e0b05bb49 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:50:45 -0700 Subject: [PATCH 11/20] fix(reader): finish remaining chapter ETA audit findings Credit last-page words before endSession, keep TXT remaining exact via on-disk word counts, align UTF-8 layout word splits with EPUB, and drop dwell wrapper indirection. --- lib/Utf8/Utf8.cpp | 48 +++++-- lib/Utf8/Utf8.h | 6 +- src/activities/reader/EpubReaderActivity.cpp | 78 ++++++----- src/activities/reader/EpubReaderActivity.h | 5 +- src/activities/reader/TxtReaderActivity.cpp | 130 ++++++++++++++----- src/activities/reader/TxtReaderActivity.h | 14 +- src/util/ChapterTimeEstimate.h | 2 + 7 files changed, 189 insertions(+), 94 deletions(-) diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index 9f224a3f0d5..18916f970c3 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -184,21 +184,44 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { return 0; } + // Mirror ChapterHtmlSlimParser characterData: accumulate a run, flush at + // UTF8_LAYOUT_WORD_MAX_BYTES with utf8SafeTruncateBuffer (never mid-sequence). uint32_t words = 0; - size_t runBytes = 0; + char run[UTF8_LAYOUT_WORD_MAX_BYTES + 4] = {}; + int runLen = 0; auto flushRun = [&]() { - if (runBytes == 0) { + if (runLen <= 0) { return; } - // Match EPUB: split oversized unspaced runs at UTF-8-safe MAX_WORD_SIZE chunks. - size_t remaining = runBytes; - while (remaining > 0) { - const size_t chunk = remaining > UTF8_LAYOUT_WORD_MAX_BYTES ? UTF8_LAYOUT_WORD_MAX_BYTES : remaining; - ++words; - remaining -= chunk; + ++words; + runLen = 0; + }; + + auto flushAtCapacity = [&]() { + if (runLen < static_cast(UTF8_LAYOUT_WORD_MAX_BYTES)) { + return; + } + const int safeLen = utf8SafeTruncateBuffer(run, runLen); + if (safeLen <= 0) { + runLen = 0; + return; + } + if (safeLen < runLen) { + const int overflow = runLen - safeLen; + char saved[4]; + for (int j = 0; j < overflow && j < 4; ++j) { + saved[j] = run[safeLen + j]; + } + runLen = safeLen; + flushRun(); + for (int j = 0; j < overflow && j < 4; ++j) { + run[j] = saved[j]; + } + runLen = overflow; + } else { + flushRun(); } - runBytes = 0; }; for (size_t i = 0; i < len; ++i) { @@ -208,7 +231,7 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { continue; } - // U+00A0 (C2 A0) — counts as its own layout word, like EPUB. + // U+00A0 (C2 A0) — own layout word, like EPUB. if (c == 0xC2 && i + 1 < len && static_cast(data[i + 1]) == 0xA0) { flushRun(); ++words; @@ -232,7 +255,10 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { continue; } - ++runBytes; + flushAtCapacity(); + if (runLen < static_cast(sizeof(run))) { + run[runLen++] = static_cast(c); + } } flushRun(); return words; diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 893fe5714b7..b8b0318183a 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -24,13 +24,13 @@ std::string utf8ComposeNfc(const std::string& in); int utf8SafeTruncateBuffer(const char* buf, int len); // EPUB layout word-token byte cap (ChapterHtmlSlimParser::MAX_WORD_SIZE). Long -// unspaced runs (e.g. CJK) are split at this UTF-8-safe boundary and each piece -// counts as one layout word — matching Page::countWords / TextBlock::wordCount. +// unspaced runs (e.g. CJK) are split with utf8SafeTruncateBuffer at this budget +// — matching Page::countWords / TextBlock::wordCount tokenization. constexpr size_t UTF8_LAYOUT_WORD_MAX_BYTES = 200; // Count layout words the same way EPUB tokenizes plain text: ASCII whitespace // separates words; U+00A0 / U+202F each count as one word; other non-whitespace -// runs count as one word per UTF8_LAYOUT_WORD_MAX_BYTES chunk. +// runs flush every UTF8_LAYOUT_WORD_MAX_BYTES at a UTF-8 boundary. uint32_t utf8CountLayoutWords(const char* data, size_t len); // Returns true for CJK characters that allow line breaks on either side without hyphenation. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c5b110826f5..3e5acae0a4e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -337,9 +337,8 @@ void EpubReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - if (!automaticPageTurnActive && section && section->currentPage >= 0) { - maybeCreditPageWords(currentSpineIndex, section->currentPage); - } + // Paths that already called creditCurrentPageWords + endSession are no-ops here. + creditCurrentPageWords(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -554,6 +553,7 @@ void EpubReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + creditCurrentPageWords(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -613,7 +613,7 @@ void EpubReaderActivity::loop() { if (longPress && SETTINGS.longPressButtonBehavior == CrossPointSettings::LONG_PRESS_CHAPTER_SKIP) { READING_STATS.noteActivity(); lastPageTurnTime = millis(); - clearPageDwell(); + pageDwell.clear(); if (!nextTriggered && section && section->currentPage > 0) { section->currentPage = 0; @@ -676,7 +676,7 @@ void EpubReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; invalidateCurrentOverlayPageCache(); - clearPageDwell(); + pageDwell.clear(); RenderLock lock(*this); if (section) { cachedSpineIndex = currentSpineIndex; @@ -831,7 +831,7 @@ void EpubReaderActivity::jumpToPercent(int percent) { // Reset state so render() reloads and repositions on the target spine. // Clear dwell tracking so the left page is not credited as read. - clearPageDwell(); + pageDwell.clear(); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; pendingPercentJump = true; @@ -941,7 +941,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const auto& chapterResult = std::get(result.data); RenderLock lock(*this); - clearPageDwell(); + pageDwell.clear(); currentSpineIndex = chapterResult.spineIndex; // If anchor is not empty, it will be used later to calculate the page number. @@ -1024,7 +1024,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction if (currentSpineIndex != bookmark.spineIndex || !section || section->currentPage != static_cast(bookmark.page)) { RenderLock lock(*this); - clearPageDwell(); + pageDwell.clear(); currentSpineIndex = bookmark.spineIndex; nextPageNumber = static_cast(bookmark.page); sessionProgressTouched = true; @@ -1200,7 +1200,7 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); // Reset section to force re-layout in the new orientation. - clearPageDwell(); + pageDwell.clear(); section.reset(); } } @@ -1208,7 +1208,11 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; - restartPageDwell(); + if (!section || section->currentPage < 0) { + pageDwell.clear(); + } else { + pageDwell.restart(currentSpineIndex, section->currentPage, millis()); + } return; } @@ -1216,7 +1220,7 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption // calculates page turn duration by dividing by number of pages pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_RATES[selectedPageTurnOption]; automaticPageTurnActive = true; - clearPageDwell(); + pageDwell.clear(); const uint8_t statusBarHeight = statusBarTemporarilyHidden ? 0 : UITheme::getInstance().getStatusBarHeight(); // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only @@ -1279,6 +1283,7 @@ std::string EpubReaderActivity::moveCompletedBookIfEnabled() { } void EpubReaderActivity::exitReaderAfterOptionalCompletedMove() { + creditCurrentPageWords(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -1294,25 +1299,19 @@ void EpubReaderActivity::markCurrentBookAsFinished() { exitReaderAfterOptionalCompletedMove(); } -void EpubReaderActivity::clearPageDwell() { pageDwell.clear(); } - -void EpubReaderActivity::restartPageDwell() { - if (!section || section->currentPage < 0) { - clearPageDwell(); - return; - } - pageDwell.restart(currentSpineIndex, section->currentPage, millis()); -} - void EpubReaderActivity::resumeAfterSubactivity() { READING_STATS.resumeSession(); - restartPageDwell(); + if (!section || section->currentPage < 0) { + pageDwell.clear(); + } else { + pageDwell.restart(currentSpineIndex, section->currentPage, millis()); + } } void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult) { READING_STATS.noteActivity(); - clearPageDwell(); + pageDwell.clear(); startActivityForResult(std::move(activity), [this, onResult = std::move(onResult)](const ActivityResult& result) { resumeAfterSubactivity(); if (onResult) { @@ -1321,12 +1320,15 @@ void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activ }); } -void EpubReaderActivity::notePageEnteredIfChanged() { - const int page = section ? section->currentPage : -1; - if (page < 0) { +void EpubReaderActivity::creditCurrentPageWords() { + if (automaticPageTurnActive) { + pageDwell.clear(); return; } - pageDwell.noteEnteredIfChanged(currentSpineIndex, page, millis()); + if (section && section->currentPage >= 0) { + maybeCreditPageWords(currentSpineIndex, section->currentPage); + } + pageDwell.clear(); } void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int page) { @@ -1395,10 +1397,10 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { sessionProgressTouched = true; } lastPageTurnTime = millis(); - if (section) { - notePageEnteredIfChanged(); + if (section && section->currentPage >= 0) { + pageDwell.noteEnteredIfChanged(currentSpineIndex, section->currentPage, millis()); } else { - clearPageDwell(); + pageDwell.clear(); } requestUpdate(); } @@ -1675,7 +1677,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderContents(page, orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); } - notePageEnteredIfChanged(); + { + const int page = section ? section->currentPage : -1; + if (page >= 0) { + pageDwell.noteEnteredIfChanged(currentSpineIndex, page, millis()); + } + } // Menus, screenshots and overlays can request a render without moving the // reader. Avoid several FAT operations for the same six-byte position file. if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage || @@ -2103,7 +2110,7 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s { RenderLock lock(*this); - clearPageDwell(); + pageDwell.clear(); pendingAnchor = std::move(anchor); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; @@ -2121,7 +2128,7 @@ void EpubReaderActivity::restoreSavedPosition() { { RenderLock lock(*this); - clearPageDwell(); + pageDwell.clear(); currentSpineIndex = pos.spineIndex; nextPageNumber = pos.pageNumber; section.reset(); @@ -2240,7 +2247,8 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { cachedSpineIndex = currentSpineIndex; cachedChapterTotalPageCount = section->estimatedTotalPages(); } - clearPageDwell(); + // Credit before releasing the section — onExit cannot credit after section.reset(). + creditCurrentPageWords(); section.reset(); epub.reset(); } @@ -2314,7 +2322,7 @@ void EpubReaderActivity::applyPendingSyncSession() { cachedChapterTotalPageCount = restorePageCount; } - clearPageDwell(); + pageDwell.clear(); sync.clear(); APP_STATE.saveToFile(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index c46404d86e0..7d0731387fb 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -122,12 +122,11 @@ class EpubReaderActivity final : public Activity { void exitReaderAfterOptionalCompletedMove(); void markCurrentBookAsFinished(); void pageTurn(bool isForwardTurn); - void notePageEnteredIfChanged(); - void clearPageDwell(); - void restartPageDwell(); void resumeAfterSubactivity(); // noteActivity + clear dwell, then resumeSession + restart dwell on return. void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); + // Credit the current page's dwell sample while the reading session is still active. + void creditCurrentPageWords(); void maybeCreditPageWords(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index a558b0abed1..d989f327cf8 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -32,7 +32,7 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 7; // v7: totalBookWords only (no per-page word array in RAM/cache) +constexpr uint8_t CACHE_VERSION = 8; // v8: on-disk per-page words for exact remaining (not loaded into RAM) constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -323,10 +323,12 @@ void TxtReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - maybeCreditPageWords(currentPage); + // Paths that already credited + endSession are no-ops here. + creditCurrentPageWords(); pageOffsets.clear(); totalBookWords = 0; + wordCountsFileOffset = 0; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -357,6 +359,7 @@ void TxtReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + creditCurrentPageWords(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -385,14 +388,14 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; - notePageEnteredIfChanged(); + pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); currentPage++; - notePageEnteredIfChanged(); + pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else { READING_STATS.noteActivity(); @@ -415,8 +418,9 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { initialized = false; pageOffsets.clear(); totalBookWords = 0; + wordCountsFileOffset = 0; currentPageLines.clear(); - clearPageDwell(); + pageDwell.clear(); pendingForceFullRefresh = true; requestUpdate(); } @@ -446,6 +450,7 @@ std::string TxtReaderActivity::moveCompletedBookIfEnabled() { } void TxtReaderActivity::exitReaderAfterOptionalCompletedMove() { + creditCurrentPageWords(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -480,10 +485,7 @@ void TxtReaderActivity::initializeReader() { // Try to load cached page index first if (!loadPageIndexCache()) { - // Cache not found, build page index - buildPageIndex(); - // Save to cache for next time - savePageIndexCache(); + buildPageIndex(); // builds and saves cache (including on-disk word table) } // Load saved progress @@ -495,8 +497,13 @@ void TxtReaderActivity::initializeReader() { void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); totalBookWords = 0; + wordCountsFileOffset = 0; pageOffsets.push_back(0); // First page starts at offset 0 + // Temporary during indexing only — written to disk then freed (not kept as a member). + std::vector pageWords; + pageWords.reserve(256); + size_t offset = 0; const size_t fileSize = txt->getFileSize(); @@ -512,7 +519,10 @@ void TxtReaderActivity::buildPageIndex() { break; } - totalBookWords += countWordsInLines(tempLines); + const uint32_t words32 = countWordsInLines(tempLines); + const uint16_t words = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); + pageWords.push_back(words); + totalBookWords += words; if (nextOffset <= offset) { // No progress made, avoid infinite loop @@ -531,26 +541,27 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); + if (pageWords.size() > static_cast(totalPages)) { + pageWords.resize(totalPages); + } + while (pageWords.size() < static_cast(totalPages)) { + pageWords.push_back(0); + } LOG_DBG("TRS", "Built page index: %d pages, %lu words", totalPages, static_cast(totalBookWords)); + savePageIndexCache(pageWords); } -uint16_t TxtReaderActivity::countWordsInLines(const std::vector& lines) { +uint32_t TxtReaderActivity::countWordsInLines(const std::vector& lines) const { uint32_t words = 0; for (const auto& line : lines) { words += utf8CountLayoutWords(line.text.data(), line.text.size()); } - return words > UINT16_MAX ? UINT16_MAX : static_cast(words); + return words; } -uint16_t TxtReaderActivity::countWordsOnCurrentPage() const { return countWordsInLines(currentPageLines); } - -void TxtReaderActivity::clearPageDwell() { pageDwell.clear(); } - -void TxtReaderActivity::notePageEnteredIfChanged() { - if (currentPage < 0) { - return; - } - pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); +void TxtReaderActivity::creditCurrentPageWords() { + maybeCreditPageWords(currentPage); + pageDwell.clear(); } void TxtReaderActivity::maybeCreditPageWords(const int page) { @@ -564,7 +575,7 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { } // Count the page being left from the currently loaded lines when it matches. - const uint16_t words = (page == currentPage) ? countWordsOnCurrentPage() : 0; + const uint32_t words = (page == currentPage) ? countWordsInLines(currentPageLines) : 0; if (words == 0) { return; } @@ -574,11 +585,17 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { } uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages) { + return 0; + } + uint32_t remaining = 0; + if (sumRemainingWordsFromCache(fromPage, remaining)) { + return remaining; + } + // Fallback if the on-disk word table is unavailable: pro-rate the book total. + if (totalBookWords == 0) { return 0; } - // Pro-rate the book total across remaining pages (inclusive of fromPage). Avoids a - // per-page word array in RAM while matching EPUB's "mean words × pages left" idea. const uint32_t pagesLeft = static_cast(totalPages - fromPage); return static_cast((static_cast(totalBookWords) * pagesLeft) / static_cast(totalPages)); @@ -829,7 +846,9 @@ void TxtReaderActivity::renderPage() { // BW rendering renderLines(); - notePageEnteredIfChanged(); + if (currentPage >= 0) { + pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); + } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -911,18 +930,19 @@ void TxtReaderActivity::loadProgress() { } bool TxtReaderActivity::loadPageIndexCache() { - // Cache file format (using serialization module): + // Cache file format (serialization module, little-endian POD writes): // - uint32_t: magic "TXTI" // - uint8_t: cache version // - uint32_t: file size (to validate cache) // - int32_t: viewport width // - int32_t: lines per page - // - int32_t: font ID (to invalidate cache on font change) - // - int32_t: screen margin (to invalidate cache on margin change) - // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - int32_t: font ID + // - int32_t: screen margin + // - uint8_t: paragraph alignment // - uint32_t: total pages count // - N * uint32_t: page offsets - // - N * uint16_t: page word counts (v5+) + // - uint32_t: totalBookWords + // - N * uint16_t: per-page word counts (on disk only; not loaded into RAM) std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; @@ -931,7 +951,6 @@ bool TxtReaderActivity::loadPageIndexCache() { return false; } - // Read and validate header using serialization module uint32_t magic; serialization::readPod(f, magic); if (magic != CACHE_MAGIC) { @@ -991,9 +1010,9 @@ bool TxtReaderActivity::loadPageIndexCache() { uint32_t numPages; serialization::readPod(f, numPages); - // Read page offsets pageOffsets.clear(); totalBookWords = 0; + wordCountsFileOffset = 0; pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1002,6 +1021,16 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.push_back(offset); } serialization::readPod(f, totalBookWords); + wordCountsFileOffset = static_cast(f.position()); + + // Ensure the on-disk word table is present without loading it into RAM. + const uint64_t wordTableBytes = static_cast(numPages) * sizeof(uint16_t); + if (wordCountsFileOffset == 0 || + static_cast(wordCountsFileOffset) + wordTableBytes > static_cast(f.size())) { + LOG_DBG("TRS", "Cache missing per-page word table, rebuilding"); + wordCountsFileOffset = 0; + return false; + } totalPages = pageOffsets.size(); LOG_DBG("TRS", "Loaded page index cache: %d pages, %lu words", totalPages, @@ -1009,15 +1038,15 @@ bool TxtReaderActivity::loadPageIndexCache() { return true; } -void TxtReaderActivity::savePageIndexCache() const { +void TxtReaderActivity::savePageIndexCache(const std::vector& pageWords) { std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; if (!Storage.openFileForWrite("TRS", cachePath, f)) { LOG_ERR("TRS", "Failed to save page index cache"); + wordCountsFileOffset = 0; return; } - // Write header using serialization module serialization::writePod(f, CACHE_MAGIC); serialization::writePod(f, CACHE_VERSION); serialization::writePod(f, static_cast(txt->getFileSize())); @@ -1028,15 +1057,44 @@ void TxtReaderActivity::savePageIndexCache() const { serialization::writePod(f, cachedParagraphAlignment); serialization::writePod(f, static_cast(pageOffsets.size())); - // Write page offsets + book-wide word total (no per-page array). for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } serialization::writePod(f, totalBookWords); + wordCountsFileOffset = static_cast(f.position()); + for (size_t i = 0; i < pageOffsets.size(); ++i) { + const uint16_t words = (i < pageWords.size()) ? pageWords[i] : 0; + serialization::writePod(f, words); + } LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } +bool TxtReaderActivity::sumRemainingWordsFromCache(const int fromPage, uint32_t& outRemaining) const { + outRemaining = 0; + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || wordCountsFileOffset == 0 || !txt) { + return false; + } + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("TRS", cachePath, f)) { + return false; + } + if (!f.seek(wordCountsFileOffset + static_cast(fromPage) * sizeof(uint16_t))) { + return false; + } + + uint32_t remaining = 0; + for (int page = fromPage; page < totalPages; ++page) { + uint16_t words = 0; + serialization::readPod(f, words); + remaining += words; + } + outRemaining = remaining; + return true; +} + ScreenshotInfo TxtReaderActivity::getScreenshotInfo() const { ScreenshotInfo info; info.readerType = ScreenshotInfo::ReaderType::Txt; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index fd45c3a5242..6c7c00fb6c8 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -33,8 +33,11 @@ class TxtReaderActivity final : public Activity { // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page - // Book-wide word total for ETA (no per-page array — keeps RAM flat for large TXT). + // Book-wide word total. Per-page counts live only in the index cache on disk + // (not in RAM) so large TXT files stay within ESP32-C3 heap limits. uint32_t totalBookWords = 0; + // Byte offset of the per-page word table inside index.bin after a successful load/save. + uint32_t wordCountsFileOffset = 0; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -63,19 +66,18 @@ class TxtReaderActivity final : public Activity { bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); void buildPageIndex(); bool loadPageIndexCache(); - void savePageIndexCache() const; + void savePageIndexCache(const std::vector& pageWords); + bool sumRemainingWordsFromCache(int fromPage, uint32_t& outRemaining) const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); - void notePageEnteredIfChanged(); - void clearPageDwell(); + void creditCurrentPageWords(); void maybeCreditPageWords(int page); uint32_t estimateRemainingWords(int fromPage) const; - uint16_t countWordsOnCurrentPage() const; + uint32_t countWordsInLines(const std::vector& lines) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); - static uint16_t countWordsInLines(const std::vector& lines); public: explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index a6ffa2057e8..df05a32f8ca 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -31,6 +31,8 @@ bool tryFillStatusBarChapterEta(uint32_t remainingWords, double wordsPerMs, char uint32_t dwellCreditMs(unsigned long dwellMs, bool sameAsLastCredit); // Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). +// clear() resets the active dwell window only; lastCredited* is kept so re-reads +// of the same page still require REREAD_MIN_MS before another credit. struct PageDwell { unsigned long enteredMs = 0; int id0 = -1; From 7935ff34777efd600d6306e70c81fbd51742c78c Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:13:06 +0000 Subject: [PATCH 12/20] fix(reader): address remaining chapter ETA audit findings Keep lastSessionSnapshot on idempotent endSession, cache TXT remaining words, share dwell credit helper, and document intentional ETA tradeoffs. --- agent-docs/reading-stats.md | 9 ++ lib/Epub/Epub/Page.cpp | 2 + lib/Epub/Epub/Section.h | 3 + lib/Utf8/Utf8.h | 15 ++-- src/ReadingStatsStore.cpp | 3 +- src/ReadingStatsStore.h | 5 +- src/activities/reader/EpubReaderActivity.cpp | 27 +++--- src/activities/reader/TxtReaderActivity.cpp | 92 +++++++++++++++----- src/activities/reader/TxtReaderActivity.h | 7 +- src/util/ChapterTimeEstimate.cpp | 37 +++++--- src/util/ChapterTimeEstimate.h | 8 +- 11 files changed, 149 insertions(+), 59 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index 0303d48d72c..af508dc2bcc 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -29,6 +29,15 @@ metrics. - When changing export/import schema, preserve backward compatibility or add a clear migration path. +## Chapter time remaining (status bar) + +- Rate is live-session only (`getEffectiveWordsPerMs`): no active session ⇒ no ETA. +- EPUB credits use `TextBlock::wordCount()` / `Page::countWords()`; TXT uses + `utf8CountLayoutWords` on plain line text — rates are not bit-identical across formats. +- Per-page word caches store `uint16` (saturate) for EPUB sections and TXT `index.bin`. +- `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other + locales fall back to English. + ## Design Rules - Do not save stats on every tiny interaction. Debounce or save on activity exit diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 39301453061..26275adc54f 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -357,6 +357,8 @@ void PageTableFragment::recordFontUsage(FontCacheManager& fontCacheManager, cons } uint32_t Page::countWords() const { + // Post-layout token count (hyphenation / ruby / focus pieces via TextBlock::wordCount). + // Distinct from utf8CountLayoutWords used by the TXT reader for plain-text lines. uint32_t words = 0; for (const auto& element : elements) { if (!element) continue; diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index a7f8733656c..65bb7f99b43 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -63,6 +63,7 @@ class Section { bool partial_ = false; uint16_t partialPageCount_ = 0; // Per-page word counts from the section cache / in-progress build. Empty until loaded. + // Each entry is uint16 (saturates at UINT16_MAX); matches TXT index.bin word table. std::vector pageWordCounts_; // Parse watermark from the partial's trailer, for estimating the total page count. uint32_t partialBytesConsumed_ = 0; @@ -155,6 +156,8 @@ class Section { std::optional getXhtmlByteOffsetForPage(uint16_t page) const; // Word count for a built/available page (0 if unknown / out of range). + // Stored as uint16 (saturates at UINT16_MAX) to match the section cache layout — + // same ceiling as TXT index.bin per-page words; pathological dense pages undercount. uint16_t getPageWordCount(uint16_t page) const; // Remaining chapter words from `fromPage` inclusive, including an estimate for // still-unbuilt pages from mean words/built-page × estimated unbuilt page count diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index b8b0318183a..b754adec0d7 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -24,13 +24,18 @@ std::string utf8ComposeNfc(const std::string& in); int utf8SafeTruncateBuffer(const char* buf, int len); // EPUB layout word-token byte cap (ChapterHtmlSlimParser::MAX_WORD_SIZE). Long -// unspaced runs (e.g. CJK) are split with utf8SafeTruncateBuffer at this budget -// — matching Page::countWords / TextBlock::wordCount tokenization. +// unspaced runs (e.g. CJK) are split with utf8SafeTruncateBuffer at this budget. constexpr size_t UTF8_LAYOUT_WORD_MAX_BYTES = 200; -// Count layout words the same way EPUB tokenizes plain text: ASCII whitespace -// separates words; U+00A0 / U+202F each count as one word; other non-whitespace -// runs flush every UTF8_LAYOUT_WORD_MAX_BYTES at a UTF-8 boundary. +// Count layout words the same way plain-text EPUB tokenization does before +// hyphenation: ASCII whitespace separates words; U+00A0 / U+202F each count as +// one word; other non-whitespace runs flush every UTF8_LAYOUT_WORD_MAX_BYTES at +// a UTF-8 boundary. +// +// Intentional divergence from EPUB page credits: EpubReaderActivity credits via +// TextBlock::wordCount() / Page::countWords() (post-layout tokens after +// hyphenation splits, ruby, focus pieces). TXT uses this helper on line text, so +// cross-format words/ms rates are not bit-identical. uint32_t utf8CountLayoutWords(const char* data, size_t len); // Returns true for CJK characters that allow line breaks on either side without hyphenation. diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 515e5903a79..12a81c3714e 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1450,7 +1450,7 @@ bool ReadingStatsStore::removeBook(const std::string& path) { void ReadingStatsStore::endSession() { if (!activeSession.active || activeSession.bookIndex >= books.size()) { - lastSessionSnapshot = {}; + // Already ended — keep lastSessionSnapshot for post-exit UI (e.g. completion banner). activeSession = {}; return; } @@ -1490,6 +1490,7 @@ double ReadingStatsStore::getEffectiveWordsPerMs() const { constexpr uint64_t MIN_RATE_WORDS = 80; constexpr uint64_t MIN_RATE_MS = 60ULL * 1000ULL; + // Live status-bar rate only: no active session ⇒ no ETA (rate is not a lifetime average). if (!activeSession.active || activeSession.bookIndex >= books.size()) { return 0.0; } diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index f02ff7e378a..c2ac194ec34 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -161,8 +161,9 @@ class ReadingStatsStore { void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); - // Active-book word reading rate from paired word/dwell samples only. - // Returns 0 when there is not enough paired sample data yet. + // Live status-bar rate only: paired word/dwell samples for the active book. + // Returns 0 with no active session or when paired samples are below the gate + // (rate is not a lifetime average and vanishes when the session ends). double getEffectiveWordsPerMs() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3e5acae0a4e..e7765e66ec0 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -337,7 +337,9 @@ void EpubReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - // Paths that already called creditCurrentPageWords + endSession are no-ops here. + // Credit if this path did not already (early exits credit before endSession). + // endSession is idempotent: a second call keeps lastSessionSnapshot for the + // post-read stats banner. recordSessionEnded dedupes by snapshot serial. creditCurrentPageWords(); APP_STATE.readerActivityLoadCount = 0; @@ -1336,18 +1338,14 @@ void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int pa return; } - const uint32_t associatedMs = pageDwell.creditMs(spineIndex, page, millis()); - if (associatedMs == 0) { - return; - } - const uint16_t words = section->getPageWordCount(static_cast(page)); - if (words == 0) { + const uint32_t associatedMs = + ChapterTimeEstimate::takeDwellCreditMs(pageDwell, spineIndex, page, words, millis()); + if (associatedMs == 0) { return; } READING_STATS.noteWordsRead(words, associatedMs); - pageDwell.markCredited(spineIndex, page); } void EpubReaderActivity::pageTurn(bool isForwardTurn) { @@ -2042,12 +2040,17 @@ void EpubReaderActivity::renderStatusBar() const { title = epub->getTitle(); } - char chapterTimeBuf[12] = {}; + // Sized for multi-byte unit suffixes; formatCompactDuration fails closed if still too small. + char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; if (section->currentPage >= 0) { - ChapterTimeEstimate::tryFillStatusBarChapterEta( - section->estimateRemainingWords(static_cast(section->currentPage)), - READING_STATS.getEffectiveWordsPerMs(), chapterTimeBuf, sizeof(chapterTimeBuf), &chapterTimeEstimate); + const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); + // Skip remaining-words walk when rate is 0 (tryFill would no-op anyway). + if (wordsPerMs > 0.0) { + ChapterTimeEstimate::tryFillStatusBarChapterEta( + section->estimateRemainingWords(static_cast(section->currentPage)), wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf), &chapterTimeEstimate); + } } GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index d989f327cf8..733f17b0075 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -323,12 +323,16 @@ void TxtReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); - // Paths that already credited + endSession are no-ops here. + // Credit if this path did not already (early exits credit before endSession). + // endSession is idempotent: a second call keeps lastSessionSnapshot for the + // post-read stats banner. recordSessionEnded dedupes by snapshot serial. creditCurrentPageWords(); pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + cachedRemainingWords = 0; + cachedRemainingValid = false; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -388,12 +392,23 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; + invalidateRemainingWordsCache(); pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); + if (cachedRemainingValid) { + // Match on-disk uint16 saturation used when the index was built. + const uint32_t words32 = countWordsInLines(currentPageLines); + const uint16_t pageWords = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); + if (cachedRemainingWords > pageWords) { + cachedRemainingWords -= pageWords; + } else { + cachedRemainingWords = 0; + } + } currentPage++; pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); @@ -415,10 +430,13 @@ void TxtReaderActivity::requestCurrentPageFullRefresh() { void TxtReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; + // Full reinit is required: status-bar height changes the viewport and linesPerPage, + // which invalidates pageOffsets and the on-disk word table (different page breaks). initialized = false; pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + invalidateRemainingWordsCache(); currentPageLines.clear(); pageDwell.clear(); pendingForceFullRefresh = true; @@ -490,6 +508,7 @@ void TxtReaderActivity::initializeReader() { // Load saved progress loadProgress(); + invalidateRemainingWordsCache(); initialized = true; } @@ -498,9 +517,11 @@ void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + invalidateRemainingWordsCache(); pageOffsets.push_back(0); // First page starts at offset 0 // Temporary during indexing only — written to disk then freed (not kept as a member). + // Per-page counts are uint16 like EPUB section cache (saturate pathological dense pages). std::vector pageWords; pageWords.reserve(256); @@ -569,31 +590,24 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - const uint32_t associatedMs = pageDwell.creditMs(page, 0, millis()); - if (associatedMs == 0) { - return; - } - // Count the page being left from the currently loaded lines when it matches. const uint32_t words = (page == currentPage) ? countWordsInLines(currentPageLines) : 0; - if (words == 0) { + const uint32_t associatedMs = + ChapterTimeEstimate::takeDwellCreditMs(pageDwell, page, 0, words, millis()); + if (associatedMs == 0) { return; } READING_STATS.noteWordsRead(words, associatedMs); - pageDwell.markCredited(page, 0); } -uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages) { - return 0; - } - uint32_t remaining = 0; - if (sumRemainingWordsFromCache(fromPage, remaining)) { - return remaining; - } - // Fallback if the on-disk word table is unavailable: pro-rate the book total. - if (totalBookWords == 0) { +void TxtReaderActivity::invalidateRemainingWordsCache() { + cachedRemainingWords = 0; + cachedRemainingValid = false; +} + +uint32_t TxtReaderActivity::proRateRemainingWords(const int fromPage) const { + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { return 0; } const uint32_t pagesLeft = static_cast(totalPages - fromPage); @@ -601,6 +615,25 @@ uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { static_cast(totalPages)); } +void TxtReaderActivity::ensureRemainingWordsCache() { + if (cachedRemainingValid) { + return; + } + if (currentPage < 0 || totalPages <= 0 || currentPage >= totalPages) { + cachedRemainingWords = 0; + cachedRemainingValid = true; + return; + } + uint32_t remaining = 0; + if (sumRemainingWordsFromCache(currentPage, remaining)) { + cachedRemainingWords = remaining; + } else { + // Fallback if the on-disk word table is unavailable or a read fails. + cachedRemainingWords = proRateRemainingWords(currentPage); + } + cachedRemainingValid = true; +} + bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { outLines.clear(); const size_t fileSize = txt->getFileSize(); @@ -849,6 +882,10 @@ void TxtReaderActivity::renderPage() { if (currentPage >= 0) { pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); } + // Only touch SD / fill remaining-words cache when the status bar can show time. + if (ChapterTimeEstimate::statusBarWantsChapterTime() && READING_STATS.getEffectiveWordsPerMs() > 0.0) { + ensureRemainingWordsCache(); + } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -872,11 +909,16 @@ void TxtReaderActivity::renderStatusBar() const { title = txt->getTitle(); } - char chapterTimeBuf[12] = {}; + // Sized for multi-byte unit suffixes; formatCompactDuration fails closed if still too small. + char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; - ChapterTimeEstimate::tryFillStatusBarChapterEta(estimateRemainingWords(currentPage), - READING_STATS.getEffectiveWordsPerMs(), chapterTimeBuf, - sizeof(chapterTimeBuf), &chapterTimeEstimate); + const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); + // Gate before any remaining-words work: tryFill also checks the setting, but we must not + // call ensureRemainingWordsCache / SD I/O when rate is 0 or time is hidden (done in render). + if (wordsPerMs > 0.0 && cachedRemainingValid) { + ChapterTimeEstimate::tryFillStatusBarChapterEta(cachedRemainingWords, wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf), &chapterTimeEstimate); + } GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); } @@ -1013,6 +1055,7 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + invalidateRemainingWordsCache(); pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1088,7 +1131,10 @@ bool TxtReaderActivity::sumRemainingWordsFromCache(const int fromPage, uint32_t& uint32_t remaining = 0; for (int page = fromPage; page < totalPages; ++page) { uint16_t words = 0; - serialization::readPod(f, words); + const int n = f.read(reinterpret_cast(&words), sizeof(words)); + if (n != static_cast(sizeof(words))) { + return false; + } remaining += words; } outRemaining = remaining; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 6c7c00fb6c8..fea4395f6e9 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -38,6 +38,9 @@ class TxtReaderActivity final : public Activity { uint32_t totalBookWords = 0; // Byte offset of the per-page word table inside index.bin after a successful load/save. uint32_t wordCountsFileOffset = 0; + // Remaining words from currentPage inclusive; refreshed from disk when invalid. + uint32_t cachedRemainingWords = 0; + bool cachedRemainingValid = false; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -68,13 +71,15 @@ class TxtReaderActivity final : public Activity { bool loadPageIndexCache(); void savePageIndexCache(const std::vector& pageWords); bool sumRemainingWordsFromCache(int fromPage, uint32_t& outRemaining) const; + uint32_t proRateRemainingWords(int fromPage) const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void creditCurrentPageWords(); void maybeCreditPageWords(int page); - uint32_t estimateRemainingWords(int fromPage) const; + void invalidateRemainingWordsCache(); + void ensureRemainingWordsCache(); uint32_t countWordsInLines(const std::vector& lines) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index 4e30c9c7c54..cff2e6b858a 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -13,9 +13,22 @@ bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const if (!unit || unit[0] == '\0') { return false; } + // ETA unit suffixes are authored in EN+ES only; other locales fall back to + // English via I18n (intentional — do not invent unit translations everywhere). const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); return written > 0 && static_cast(written) < bufSize; } + +uint32_t dwellCreditMs(const unsigned long dwellMs, const bool sameAsLastCredit) { + if (dwellMs < MIN_DWELL_MS) { + return 0; + } + if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { + return 0; + } + const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; + return static_cast(capped); +} } // namespace bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { @@ -75,17 +88,6 @@ bool tryFillStatusBarChapterEta(const uint32_t remainingWords, const double word return true; } -uint32_t dwellCreditMs(const unsigned long dwellMs, const bool sameAsLastCredit) { - if (dwellMs < MIN_DWELL_MS) { - return 0; - } - if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { - return 0; - } - const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; - return static_cast(capped); -} - void PageDwell::clear() { enteredMs = 0; id0 = -1; @@ -117,4 +119,17 @@ void PageDwell::markCredited(const int a, const int b) { lastCredited1 = b; } +uint32_t takeDwellCreditMs(PageDwell& dwell, const int id0, const int id1, const uint32_t words, + const unsigned long nowMs) { + if (words == 0) { + return 0; + } + const uint32_t associatedMs = dwell.creditMs(id0, id1, nowMs); + if (associatedMs == 0) { + return 0; + } + dwell.markCredited(id0, id1); + return associatedMs; +} + } // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index df05a32f8ca..cef41969edf 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -26,10 +26,6 @@ bool statusBarWantsChapterTime(); bool tryFillStatusBarChapterEta(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize, const char** outEstimate); -// Returns associated dwell ms to credit with page words, or 0 to skip credit. -// sameAsLastCredit requires a longer linger before re-crediting a re-read page. -uint32_t dwellCreditMs(unsigned long dwellMs, bool sameAsLastCredit); - // Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). // clear() resets the active dwell window only; lastCredited* is kept so re-reads // of the same page still require REREAD_MIN_MS before another credit. @@ -47,4 +43,8 @@ struct PageDwell { void markCredited(int a, int b); }; +// If dwell qualifies and words > 0, marks the page credited and returns associated ms. +// Caller should pass that ms to READING_STATS.noteWordsRead. Returns 0 to skip. +uint32_t takeDwellCreditMs(PageDwell& dwell, int id0, int id1, uint32_t words, unsigned long nowMs); + } // namespace ChapterTimeEstimate From 5ab1595cf15074c4a7d8f6b1f2663cf560f4d88c Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:40:31 +0000 Subject: [PATCH 13/20] fix(reader): simplify chapter ETA after audit pass Drop the TXT remaining-words RAM cache, compose Pages+Time from existing strings, and harden dwell credit / overflow edge cases. --- agent-docs/reading-stats.md | 4 + lib/Epub/Epub/Section.cpp | 16 +-- lib/Epub/Epub/Section.h | 4 + lib/I18n/translations/english.yaml | 1 - lib/I18n/translations/spanish.yaml | 1 - lib/Utf8/Utf8.cpp | 47 ++++++--- src/ReadingStatsStore.cpp | 12 ++- src/SettingsList.cpp | 3 +- src/activities/reader/EpubReaderActivity.cpp | 25 ++--- src/activities/reader/TxtReaderActivity.cpp | 98 ++++++++----------- src/activities/reader/TxtReaderActivity.h | 8 +- src/activities/reader/XtcReaderActivity.cpp | 4 + src/activities/settings/SettingsActivity.cpp | 8 ++ .../settings/StatusBarSettingsActivity.cpp | 30 +++++- src/network/CrossPointWebServer.cpp | 17 +++- src/util/ChapterTimeEstimate.cpp | 23 +++-- src/util/ChapterTimeEstimate.h | 14 ++- 17 files changed, 185 insertions(+), 130 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index af508dc2bcc..86f7cb0e969 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -35,8 +35,12 @@ metrics. - EPUB credits use `TextBlock::wordCount()` / `Page::countWords()`; TXT uses `utf8CountLayoutWords` on plain line text — rates are not bit-identical across formats. - Per-page word caches store `uint16` (saturate) for EPUB sections and TXT `index.bin`. +- EPUB keeps per-chapter word counts in RAM (~2 B × pages); TXT keeps them on disk only. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. +- The Pages+Time setting label is composed as `STR_PAGES + '+' + STR_TIME` (no + dedicated translation key). +- XTC has no word ETA (bitmap pages, no status-bar chapter-time slot). ## Design Rules diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 2c57695cac3..b720c339f5f 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -268,11 +268,7 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::function partialPageCount_) { - pageWordCounts_.resize(partialPageCount_); - } + syncPageWordCountsToReadablePages(); // Remove a stale tmp .bin from a crash-interrupted build; this build recreates it. { @@ -699,10 +695,14 @@ void Section::suspendBuild() { buildComplete_ = false; pageCount = partial_ ? partialPageCount_ : 0; builtPageCount_ = 0; - if (partial_ && pageWordCounts_.size() > pageCount) { - pageWordCounts_.resize(pageCount); - } else if (!partial_) { + syncPageWordCountsToReadablePages(); +} + +void Section::syncPageWordCountsToReadablePages() { + if (!partial_) { pageWordCounts_.clear(); + } else if (pageWordCounts_.size() > pageCount) { + pageWordCounts_.resize(pageCount); } } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 65bb7f99b43..10e18f4720c 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -64,11 +64,15 @@ class Section { uint16_t partialPageCount_ = 0; // Per-page word counts from the section cache / in-progress build. Empty until loaded. // Each entry is uint16 (saturates at UINT16_MAX); matches TXT index.bin word table. + // Kept in RAM (~2 B × chapter pages) — acceptable vs TXT's disk-only table; chapters + // are much smaller than whole-book TXT indexes. std::vector pageWordCounts_; // Parse watermark from the partial's trailer, for estimating the total page count. uint32_t partialBytesConsumed_ = 0; uint32_t partialTotalBytes_ = 0; bool finalizeBuild(); + // Keep pageWordCounts_ aligned with currently readable pages after pageCount is set. + void syncPageWordCountsToReadablePages(); // Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the // header, stamp the version byte, and swap the tmp .bin over filePath. bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 5f5fe9de30a..ad7979c4eba 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -291,7 +291,6 @@ STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" STR_STATUS_BAR_CHAPTER_PROGRESS: "Chapter Progress" STR_PAGES: "Pages" -STR_PAGES_PLUS_TIME: "Pages+Time" STR_TIME: "Time" STR_ETA_UNIT_MINUTE: "m" STR_ETA_UNIT_HOUR: "h" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 307b673fbda..d89acfb7415 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -283,7 +283,6 @@ STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" STR_STATUS_BAR_CHAPTER_PROGRESS: "Progreso del capítulo" STR_PAGES: "Páginas" -STR_PAGES_PLUS_TIME: "Páginas+Tiempo" STR_TIME: "Tiempo" STR_ETA_UNIT_MINUTE: "m" STR_ETA_UNIT_HOUR: "h" diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index 18916f970c3..d276c00bce1 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -184,40 +184,59 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { return 0; } - // Mirror ChapterHtmlSlimParser characterData: accumulate a run, flush at + // Mirror ChapterHtmlSlimParser characterData: accumulate a run length, flush at // UTF8_LAYOUT_WORD_MAX_BYTES with utf8SafeTruncateBuffer (never mid-sequence). + // Only the trailing ≤4 bytes are kept on the stack for boundary checks. uint32_t words = 0; - char run[UTF8_LAYOUT_WORD_MAX_BYTES + 4] = {}; int runLen = 0; + char tail[4] = {}; + int tailLen = 0; + + auto clearRun = [&]() { + runLen = 0; + tailLen = 0; + }; auto flushRun = [&]() { if (runLen <= 0) { return; } ++words; - runLen = 0; + clearRun(); + }; + + auto appendByte = [&](const unsigned char c) { + if (tailLen < 4) { + tail[tailLen++] = static_cast(c); + } else { + tail[0] = tail[1]; + tail[1] = tail[2]; + tail[2] = tail[3]; + tail[3] = static_cast(c); + } + ++runLen; }; auto flushAtCapacity = [&]() { if (runLen < static_cast(UTF8_LAYOUT_WORD_MAX_BYTES)) { return; } - const int safeLen = utf8SafeTruncateBuffer(run, runLen); - if (safeLen <= 0) { - runLen = 0; + const int safeTail = utf8SafeTruncateBuffer(tail, tailLen); + if (safeTail <= 0) { + clearRun(); return; } - if (safeLen < runLen) { - const int overflow = runLen - safeLen; + if (safeTail < tailLen) { + const int overflow = tailLen - safeTail; char saved[4]; for (int j = 0; j < overflow && j < 4; ++j) { - saved[j] = run[safeLen + j]; + saved[j] = tail[safeTail + j]; } - runLen = safeLen; - flushRun(); + ++words; for (int j = 0; j < overflow && j < 4; ++j) { - run[j] = saved[j]; + tail[j] = saved[j]; } + tailLen = overflow; runLen = overflow; } else { flushRun(); @@ -256,9 +275,7 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { } flushAtCapacity(); - if (runLen < static_cast(sizeof(run))) { - run[runLen++] = static_cast(c); - } + appendByte(c); } flushRun(); return words; diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 12a81c3714e..b3ab8a2aa1c 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -1259,8 +1259,16 @@ void ReadingStatsStore::noteWordsRead(const uint32_t words, const uint32_t assoc return; } auto& book = books[activeSession.bookIndex]; - book.totalWordsRead += words; - book.totalWordsReadingMs += associatedMs; + if (book.totalWordsRead > UINT64_MAX - words) { + book.totalWordsRead = UINT64_MAX; + } else { + book.totalWordsRead += words; + } + if (book.totalWordsReadingMs > UINT64_MAX - associatedMs) { + book.totalWordsReadingMs = UINT64_MAX; + } else { + book.totalWordsReadingMs += associatedMs; + } markDirty(); if (shouldSaveDeferred()) { saveToFile(); diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index 7c422db9e4d..9f664ceb74a 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,8 +227,9 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- + // Index 1 (Pages+Time) is composed at display time from STR_PAGES + '+' + STR_TIME. SettingInfo::Enum(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, - {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, StrId::STR_TIME, StrId::STR_HIDE}, + {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}, "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e7765e66ec0..ef0bc775e82 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -433,6 +433,11 @@ void EpubReaderActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back)) { automaticPageTurnActive = false; + if (section && section->currentPage >= 0) { + pageDwell.restart(currentSpineIndex, section->currentPage, millis()); + } else { + pageDwell.clear(); + } // updates chapter title space to indicate page turn disabled requestUpdate(); return; @@ -1323,11 +1328,7 @@ void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activ } void EpubReaderActivity::creditCurrentPageWords() { - if (automaticPageTurnActive) { - pageDwell.clear(); - return; - } - if (section && section->currentPage >= 0) { + if (!automaticPageTurnActive && section && section->currentPage >= 0) { maybeCreditPageWords(currentSpineIndex, section->currentPage); } pageDwell.clear(); @@ -1339,8 +1340,7 @@ void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int pa } const uint16_t words = section->getPageWordCount(static_cast(page)); - const uint32_t associatedMs = - ChapterTimeEstimate::takeDwellCreditMs(pageDwell, spineIndex, page, words, millis()); + const uint32_t associatedMs = pageDwell.takeCredit(spineIndex, page, words, millis()); if (associatedMs == 0) { return; } @@ -2045,11 +2045,12 @@ void EpubReaderActivity::renderStatusBar() const { const char* chapterTimeEstimate = nullptr; if (section->currentPage >= 0) { const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - // Skip remaining-words walk when rate is 0 (tryFill would no-op anyway). - if (wordsPerMs > 0.0) { - ChapterTimeEstimate::tryFillStatusBarChapterEta( - section->estimateRemainingWords(static_cast(section->currentPage)), wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf), &chapterTimeEstimate); + // Skip remaining-words walk when rate is 0 or time is hidden. + if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && + ChapterTimeEstimate::formatRemainingFromRate( + section->estimateRemainingWords(static_cast(section->currentPage)), wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; } } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 733f17b0075..653b2e19446 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -331,8 +331,6 @@ void TxtReaderActivity::onExit() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; - cachedRemainingWords = 0; - cachedRemainingValid = false; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -392,23 +390,12 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; - invalidateRemainingWordsCache(); pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); - if (cachedRemainingValid) { - // Match on-disk uint16 saturation used when the index was built. - const uint32_t words32 = countWordsInLines(currentPageLines); - const uint16_t pageWords = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); - if (cachedRemainingWords > pageWords) { - cachedRemainingWords -= pageWords; - } else { - cachedRemainingWords = 0; - } - } currentPage++; pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); @@ -436,7 +423,6 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; - invalidateRemainingWordsCache(); currentPageLines.clear(); pageDwell.clear(); pendingForceFullRefresh = true; @@ -508,7 +494,6 @@ void TxtReaderActivity::initializeReader() { // Load saved progress loadProgress(); - invalidateRemainingWordsCache(); initialized = true; } @@ -517,7 +502,6 @@ void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; - invalidateRemainingWordsCache(); pageOffsets.push_back(0); // First page starts at offset 0 // Temporary during indexing only — written to disk then freed (not kept as a member). @@ -590,10 +574,16 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - // Count the page being left from the currently loaded lines when it matches. - const uint32_t words = (page == currentPage) ? countWordsInLines(currentPageLines) : 0; - const uint32_t associatedMs = - ChapterTimeEstimate::takeDwellCreditMs(pageDwell, page, 0, words, millis()); + // Prefer the on-disk index word for `page` so credit still works if currentPage + // has already moved; fall back to live lines only when the cache entry is missing. + uint32_t words = 0; + uint16_t cachedWords = 0; + if (readCachedPageWordCount(page, cachedWords)) { + words = cachedWords; + } else if (page == currentPage) { + words = countWordsInLines(currentPageLines); + } + const uint32_t associatedMs = pageDwell.takeCredit(page, 0, words, millis()); if (associatedMs == 0) { return; } @@ -601,13 +591,15 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { READING_STATS.noteWordsRead(words, associatedMs); } -void TxtReaderActivity::invalidateRemainingWordsCache() { - cachedRemainingWords = 0; - cachedRemainingValid = false; -} - -uint32_t TxtReaderActivity::proRateRemainingWords(const int fromPage) const { - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { +uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages) { + return 0; + } + uint32_t remaining = 0; + if (sumRemainingWordsFromCache(fromPage, remaining)) { + return remaining; + } + if (totalBookWords == 0) { return 0; } const uint32_t pagesLeft = static_cast(totalPages - fromPage); @@ -615,25 +607,6 @@ uint32_t TxtReaderActivity::proRateRemainingWords(const int fromPage) const { static_cast(totalPages)); } -void TxtReaderActivity::ensureRemainingWordsCache() { - if (cachedRemainingValid) { - return; - } - if (currentPage < 0 || totalPages <= 0 || currentPage >= totalPages) { - cachedRemainingWords = 0; - cachedRemainingValid = true; - return; - } - uint32_t remaining = 0; - if (sumRemainingWordsFromCache(currentPage, remaining)) { - cachedRemainingWords = remaining; - } else { - // Fallback if the on-disk word table is unavailable or a read fails. - cachedRemainingWords = proRateRemainingWords(currentPage); - } - cachedRemainingValid = true; -} - bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { outLines.clear(); const size_t fileSize = txt->getFileSize(); @@ -882,10 +855,6 @@ void TxtReaderActivity::renderPage() { if (currentPage >= 0) { pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); } - // Only touch SD / fill remaining-words cache when the status bar can show time. - if (ChapterTimeEstimate::statusBarWantsChapterTime() && READING_STATS.getEffectiveWordsPerMs() > 0.0) { - ensureRemainingWordsCache(); - } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -913,11 +882,11 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - // Gate before any remaining-words work: tryFill also checks the setting, but we must not - // call ensureRemainingWordsCache / SD I/O when rate is 0 or time is hidden (done in render). - if (wordsPerMs > 0.0 && cachedRemainingValid) { - ChapterTimeEstimate::tryFillStatusBarChapterEta(cachedRemainingWords, wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf), &chapterTimeEstimate); + // Gate SD remaining-word sum: e-ink paints roughly once per page turn. + if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && + ChapterTimeEstimate::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; } GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); @@ -1055,7 +1024,6 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; - invalidateRemainingWordsCache(); pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1113,6 +1081,24 @@ void TxtReaderActivity::savePageIndexCache(const std::vector& pageWord LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } +bool TxtReaderActivity::readCachedPageWordCount(const int page, uint16_t& outWords) const { + outWords = 0; + if (page < 0 || totalPages <= 0 || page >= totalPages || wordCountsFileOffset == 0 || !txt) { + return false; + } + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!Storage.openFileForRead("TRS", cachePath, f)) { + return false; + } + if (!f.seek(wordCountsFileOffset + static_cast(page) * sizeof(uint16_t))) { + return false; + } + const int n = f.read(reinterpret_cast(&outWords), sizeof(outWords)); + return n == static_cast(sizeof(outWords)); +} + bool TxtReaderActivity::sumRemainingWordsFromCache(const int fromPage, uint32_t& outRemaining) const { outRemaining = 0; if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || wordCountsFileOffset == 0 || !txt) { diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index fea4395f6e9..09b473f392f 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -38,9 +38,6 @@ class TxtReaderActivity final : public Activity { uint32_t totalBookWords = 0; // Byte offset of the per-page word table inside index.bin after a successful load/save. uint32_t wordCountsFileOffset = 0; - // Remaining words from currentPage inclusive; refreshed from disk when invalid. - uint32_t cachedRemainingWords = 0; - bool cachedRemainingValid = false; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -70,16 +67,15 @@ class TxtReaderActivity final : public Activity { void buildPageIndex(); bool loadPageIndexCache(); void savePageIndexCache(const std::vector& pageWords); + bool readCachedPageWordCount(int page, uint16_t& outWords) const; bool sumRemainingWordsFromCache(int fromPage, uint32_t& outRemaining) const; - uint32_t proRateRemainingWords(int fromPage) const; + uint32_t estimateRemainingWords(int fromPage) const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void creditCurrentPageWords(); void maybeCreditPageWords(int page); - void invalidateRemainingWordsCache(); - void ensureRemainingWordsCache(); uint32_t countWordsInLines(const std::vector& lines) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index cafd2acc93a..fc667e1d35f 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -3,6 +3,10 @@ * * XTC ebook reader activity implementation * Displays pre-rendered XTC pages on e-ink display + * + * Intentionally has no chapter word-ETA / page-dwell sampling: XTC pages are + * bitmaps without a word count, and this reader does not use drawStatusBar's + * chapter-time slot. */ #include "XtcReaderActivity.h" diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 3ded5865478..2cfe578a80d 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -50,6 +50,7 @@ #include "activities/util/ConfirmationActivity.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/ChapterTimeEstimate.h" #include "util/HeaderDateUtils.h" #include "util/ShortcutRegistry.h" #include "util/ShortcutUiMetadata.h" @@ -345,6 +346,13 @@ std::string getSettingValueText(const SettingInfo& setting) { return ""; } const uint8_t value = SETTINGS.*(setting.valuePtr); + if (setting.nameId == StrId::STR_STATUS_BAR_CHAPTER_PROGRESS && + value == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME) { + char buf[64]; + if (ChapterTimeEstimate::formatPagesPlusTime(buf, sizeof(buf))) { + return buf; + } + } const size_t safeIndex = std::min(value, setting.enumValues.size() - 1); return I18N.get(setting.enumValues[safeIndex]); } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 31e58a0c869..419a136f940 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -6,12 +6,14 @@ #include #include +#include #include "ClockSyncActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/ChapterTimeEstimate.h" #include "util/TimeUtils.h" namespace { @@ -51,8 +53,6 @@ constexpr int CLOCK_FORMAT_ITEMS = 2; const StrId clockFormatNames[CLOCK_FORMAT_ITEMS] = {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}; constexpr int CHAPTER_PROGRESS_ITEMS = 4; -const StrId chapterProgressNames[CHAPTER_PROGRESS_ITEMS] = {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, - StrId::STR_TIME, StrId::STR_HIDE}; constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -86,7 +86,9 @@ const char* previewChapterTimeEstimate() { switch (SETTINGS.statusBarChapterProgress) { case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: case CrossPointSettings::CHAPTER_PROGRESS_TIME: { - static char buf[12]; + // static: pointer must outlive this function; only consumed in the same + // render call stack by drawStatusBar. Sized like reader chapterTimeBuf. + static char buf[24]; snprintf(buf, sizeof(buf), "15%s", tr(STR_ETA_UNIT_MINUTE)); return buf; } @@ -95,6 +97,24 @@ const char* previewChapterTimeEstimate() { } } +std::string chapterProgressLabel(const uint8_t mode) { + switch (mode) { + case CrossPointSettings::CHAPTER_PROGRESS_PAGES: + return tr(STR_PAGES); + case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: { + char buf[64]; + if (ChapterTimeEstimate::formatPagesPlusTime(buf, sizeof(buf))) { + return buf; + } + return tr(STR_PAGES); + } + case CrossPointSettings::CHAPTER_PROGRESS_TIME: + return tr(STR_TIME); + default: + return tr(STR_HIDE); + } +} + const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; } // namespace @@ -105,7 +125,7 @@ void StatusBarSettingsActivity::onEnter() { selectedIndex = 0; visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS; - // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data + // Clamp enum settings in case of corrupt/migrated data if (SETTINGS.statusBarChapterProgress >= CHAPTER_PROGRESS_ITEMS) { SETTINGS.statusBarChapterProgress = CrossPointSettings::STATUS_BAR_CHAPTER_PROGRESS::CHAPTER_PROGRESS_PAGES; } @@ -233,7 +253,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { [](int index) -> std::string { switch (index) { case ITEM_CHAPTER_PROGRESS: - return I18N.get(chapterProgressNames[SETTINGS.statusBarChapterProgress]); + return chapterProgressLabel(SETTINGS.statusBarChapterProgress); case ITEM_BOOK_PROGRESS_PERCENTAGE: return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_PROGRESS_BAR: diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 36ed9bf489e..f8d1c53783b 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -34,6 +34,7 @@ #include "html/SettingsPageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" #include "util/BookCacheUtils.h" +#include "util/ChapterTimeEstimate.h" #include "util/IfFoundFile.h" #include "version.h" @@ -291,8 +292,8 @@ constexpr StrId OPT_SHORTCUT_LOCATION[] = {StrId::STR_HOME_LOCATION, StrId::STR_ constexpr StrId OPT_KO_MATCH[] = {StrId::STR_FILENAME, StrId::STR_BINARY}; constexpr StrId OPT_OPDS_FILENAME_FORMAT[] = {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}; constexpr StrId OPT_BOOK_CHAPTER_HIDE[] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; -constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES_PLUS_TIME, StrId::STR_TIME, - StrId::STR_HIDE}; +// Index 1 (Pages+Time) is composed at display time from STR_PAGES + '+' + STR_TIME. +constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}; constexpr StrId OPT_BAR_THICKNESS[] = {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; constexpr StrId OPT_XTC_STATUS_BAR[] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; @@ -1912,7 +1913,17 @@ void CrossPointWebServer::handleGetSettings() const { } else { seenOption = true; } - sendJsonEscaped(server.get(), I18N.get(s.options[i])); + if (s.key && strcmp(s.key, "statusBarChapterProgress") == 0 && + i == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME) { + char pagesPlusTime[64]; + if (ChapterTimeEstimate::formatPagesPlusTime(pagesPlusTime, sizeof(pagesPlusTime))) { + sendJsonEscaped(server.get(), pagesPlusTime); + } else { + sendJsonEscaped(server.get(), I18N.get(s.options[i])); + } + } else { + sendJsonEscaped(server.get(), I18N.get(s.options[i])); + } } server->sendContent("]", 1); break; diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index cff2e6b858a..2934b9f7683 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -76,16 +76,12 @@ bool statusBarWantsChapterTime() { SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; } -bool tryFillStatusBarChapterEta(const uint32_t remainingWords, const double wordsPerMs, char* buf, - const size_t bufSize, const char** outEstimate) { - if (!outEstimate || !statusBarWantsChapterTime()) { +bool formatPagesPlusTime(char* buf, const size_t bufSize) { + if (!buf || bufSize == 0) { return false; } - if (!formatRemainingFromRate(remainingWords, wordsPerMs, buf, bufSize)) { - return false; - } - *outEstimate = buf; - return true; + const int written = snprintf(buf, bufSize, "%s+%s", tr(STR_PAGES), tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; } void PageDwell::clear() { @@ -111,6 +107,10 @@ uint32_t PageDwell::creditMs(const int a, const int b, const unsigned long nowMs if (a != id0 || b != id1 || enteredMs == 0) { return 0; } + // millis() wrap (~49.7d): treat as no credit rather than a huge unsigned delta. + if (nowMs < enteredMs) { + return 0; + } return dwellCreditMs(nowMs - enteredMs, a == lastCredited0 && b == lastCredited1); } @@ -119,16 +119,15 @@ void PageDwell::markCredited(const int a, const int b) { lastCredited1 = b; } -uint32_t takeDwellCreditMs(PageDwell& dwell, const int id0, const int id1, const uint32_t words, - const unsigned long nowMs) { +uint32_t PageDwell::takeCredit(const int a, const int b, const uint32_t words, const unsigned long nowMs) { if (words == 0) { return 0; } - const uint32_t associatedMs = dwell.creditMs(id0, id1, nowMs); + const uint32_t associatedMs = creditMs(a, b, nowMs); if (associatedMs == 0) { return 0; } - dwell.markCredited(id0, id1); + markCredited(a, b); return associatedMs; } diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index cef41969edf..29757838495 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -21,10 +21,8 @@ bool formatRemainingFromRate(uint32_t remainingWords, double wordsPerMs, char* b // True when the status-bar chapter setting wants a time estimate shown. bool statusBarWantsChapterTime(); -// Fill buf with a chapter ETA when the status-bar setting requests time and a rate exists. -// Returns true and sets *outEstimate to buf on success; otherwise false and *outEstimate unchanged. -bool tryFillStatusBarChapterEta(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize, - const char** outEstimate); +// Compose "Pages+Time" from STR_PAGES + '+' + STR_TIME (no dedicated i18n key). +bool formatPagesPlusTime(char* buf, size_t bufSize); // Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). // clear() resets the active dwell window only; lastCredited* is kept so re-reads @@ -39,12 +37,12 @@ struct PageDwell { void clear(); void restart(int a, int b, unsigned long nowMs); void noteEnteredIfChanged(int a, int b, unsigned long nowMs); + // If dwell qualifies and words > 0, marks credited and returns associated ms. + uint32_t takeCredit(int a, int b, uint32_t words, unsigned long nowMs); + + private: uint32_t creditMs(int a, int b, unsigned long nowMs) const; void markCredited(int a, int b); }; -// If dwell qualifies and words > 0, marks the page credited and returns associated ms. -// Caller should pass that ms to READING_STATS.noteWordsRead. Returns 0 to skip. -uint32_t takeDwellCreditMs(PageDwell& dwell, int id0, int id1, uint32_t words, unsigned long nowMs); - } // namespace ChapterTimeEstimate From 13e1c157912d759c27b33d82aee8e0e5b019506d Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:03:45 +0000 Subject: [PATCH 14/20] fix(reader): harden chapter ETA I/O, labels, and remaining-word math Cache TXT remaining words, share chapter-progress labels, avoid SD credit on the hot path, and keep EPUB known-word totals for ETA. --- agent-docs/reading-stats.md | 6 +- lib/Epub/Epub/Section.cpp | 26 ++-- lib/Epub/Epub/Section.h | 3 + lib/Utf8/Utf8.cpp | 52 +++----- src/SettingsList.cpp | 3 +- src/activities/reader/TxtReaderActivity.cpp | 116 +++++++++++++----- src/activities/reader/TxtReaderActivity.h | 8 ++ src/activities/settings/SettingsActivity.cpp | 5 +- .../settings/StatusBarSettingsActivity.cpp | 45 +++---- src/components/themes/BaseTheme.cpp | 19 +-- src/network/CrossPointWebServer.cpp | 11 +- src/util/ChapterTimeEstimate.cpp | 25 ++++ src/util/ChapterTimeEstimate.h | 5 + 13 files changed, 199 insertions(+), 125 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index 86f7cb0e969..69592b0e99e 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -38,9 +38,11 @@ metrics. - EPUB keeps per-chapter word counts in RAM (~2 B × pages); TXT keeps them on disk only. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. -- The Pages+Time setting label is composed as `STR_PAGES + '+' + STR_TIME` (no - dedicated translation key). +- The Pages+Time setting label is composed via `formatChapterProgressLabel` + (`STR_PAGES + '+' + STR_TIME`); always use that helper for enum display. - XTC has no word ETA (bitmap pages, no status-bar chapter-time slot). +- TXT caches remaining words in RAM between paints; EPUB keeps a running + `knownPageWordsTotal_` for ETA extrapolation. ## Design Rules diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index b720c339f5f..8e2364ee160 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -203,6 +203,7 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { // Load per-page word counts (v41+) from immediately after the li LUT. pageWordCounts_.clear(); + knownPageWordsTotal_ = 0; if (pageCount > 0) { const uint32_t wordLutOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); const uint32_t wordLutEnd = wordLutOffset + static_cast(pageCount) * sizeof(uint16_t); @@ -212,6 +213,7 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { for (uint16_t i = 0; i < pageCount; ++i) { serialization::readPod(file, pageWordCounts_[i]); } + recomputeKnownPageWordsTotal(); } else { file.close(); LOG_ERR("SCT", "Deserialization failed: missing page word counts"); @@ -416,7 +418,10 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::functionlut.size()) { pageWordCounts_.resize(ctxPtr->lut.size()); } - pageWordCounts_[ctxPtr->lut.size() - 1] = wordCount; + const size_t wordIndex = ctxPtr->lut.size() - 1; + const uint16_t previousWords = pageWordCounts_[wordIndex]; + pageWordCounts_[wordIndex] = wordCount; + knownPageWordsTotal_ = knownPageWordsTotal_ - previousWords + wordCount; }, spec.embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, spec.imageRendering, std::move(tocAnchors), popupFn, ctxPtr->cssParser); @@ -704,6 +709,14 @@ void Section::syncPageWordCountsToReadablePages() { } else if (pageWordCounts_.size() > pageCount) { pageWordCounts_.resize(pageCount); } + recomputeKnownPageWordsTotal(); +} + +void Section::recomputeKnownPageWordsTotal() { + knownPageWordsTotal_ = 0; + for (const uint16_t words : pageWordCounts_) { + knownPageWordsTotal_ += words; + } } void Section::abandonBuild() { @@ -730,6 +743,7 @@ void Section::abandonBuild() { pageCount = 0; builtPageCount_ = 0; pageWordCounts_.clear(); + knownPageWordsTotal_ = 0; } std::unique_ptr Section::loadPageDuringBuild(const int page) { @@ -1034,19 +1048,15 @@ uint16_t Section::getPageWordCount(const uint16_t page) const { uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { const uint16_t availablePages = pageCount; uint32_t remaining = 0; - uint32_t knownWords = 0; - for (uint16_t page = 0; page < availablePages; ++page) { - const uint16_t words = getPageWordCount(page); - knownWords += words; - if (page >= fromPage) { - remaining += words; - } + for (uint16_t page = fromPage; page < availablePages; ++page) { + remaining += getPageWordCount(page); } // Extrapolate unbuilt pages using the same estimatedTotalPages() the status-bar // page denominator uses (partial watermark / rebuild EMA), so pages and time agree. // Remaining includes fromPage: the reader is still on that page, so its words are unread. const uint16_t estimatedTotal = estimatedTotalPages(); + const uint32_t knownWords = knownPageWordsTotal_; if (knownWords > 0 && estimatedTotal > availablePages && availablePages > 0) { const uint64_t unbuiltPages = static_cast(estimatedTotal - availablePages); const uint64_t unbuiltWords = diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 10e18f4720c..766a70234fd 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -67,12 +67,15 @@ class Section { // Kept in RAM (~2 B × chapter pages) — acceptable vs TXT's disk-only table; chapters // are much smaller than whole-book TXT indexes. std::vector pageWordCounts_; + // Sum of pageWordCounts_ (built/loaded pages only); avoids a second full walk in ETA. + uint32_t knownPageWordsTotal_ = 0; // Parse watermark from the partial's trailer, for estimating the total page count. uint32_t partialBytesConsumed_ = 0; uint32_t partialTotalBytes_ = 0; bool finalizeBuild(); // Keep pageWordCounts_ aligned with currently readable pages after pageCount is set. void syncPageWordCountsToReadablePages(); + void recomputeKnownPageWordsTotal(); // Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the // header, stamp the version byte, and swap the tmp .bin over filePath. bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes); diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index d276c00bce1..20e1b7f7a40 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -184,60 +184,33 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { return 0; } - // Mirror ChapterHtmlSlimParser characterData: accumulate a run length, flush at - // UTF8_LAYOUT_WORD_MAX_BYTES with utf8SafeTruncateBuffer (never mid-sequence). - // Only the trailing ≤4 bytes are kept on the stack for boundary checks. + // Mirror ChapterHtmlSlimParser characterData: track run start/length in `data`, + // flush at UTF8_LAYOUT_WORD_MAX_BYTES with utf8SafeTruncateBuffer (never mid-sequence). uint32_t words = 0; + size_t runStart = 0; int runLen = 0; - char tail[4] = {}; - int tailLen = 0; - - auto clearRun = [&]() { - runLen = 0; - tailLen = 0; - }; auto flushRun = [&]() { if (runLen <= 0) { return; } ++words; - clearRun(); - }; - - auto appendByte = [&](const unsigned char c) { - if (tailLen < 4) { - tail[tailLen++] = static_cast(c); - } else { - tail[0] = tail[1]; - tail[1] = tail[2]; - tail[2] = tail[3]; - tail[3] = static_cast(c); - } - ++runLen; + runLen = 0; }; auto flushAtCapacity = [&]() { if (runLen < static_cast(UTF8_LAYOUT_WORD_MAX_BYTES)) { return; } - const int safeTail = utf8SafeTruncateBuffer(tail, tailLen); - if (safeTail <= 0) { - clearRun(); + const int safeLen = utf8SafeTruncateBuffer(data + runStart, runLen); + if (safeLen <= 0) { + runLen = 0; return; } - if (safeTail < tailLen) { - const int overflow = tailLen - safeTail; - char saved[4]; - for (int j = 0; j < overflow && j < 4; ++j) { - saved[j] = tail[safeTail + j]; - } + if (safeLen < runLen) { + runStart += static_cast(safeLen); + runLen -= safeLen; ++words; - for (int j = 0; j < overflow && j < 4; ++j) { - tail[j] = saved[j]; - } - tailLen = overflow; - runLen = overflow; } else { flushRun(); } @@ -275,7 +248,10 @@ uint32_t utf8CountLayoutWords(const char* data, const size_t len) { } flushAtCapacity(); - appendByte(c); + if (runLen == 0) { + runStart = i; + } + ++runLen; } flushRun(); return words; diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index 9f664ceb74a..86e7f302762 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,7 +227,8 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- - // Index 1 (Pages+Time) is composed at display time from STR_PAGES + '+' + STR_TIME. + // Index 1 is CHAPTER_PROGRESS_PAGES_TIME — display via formatChapterProgressLabel only + // (placeholder StrId here is never shown raw). SettingInfo::Enum(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}, "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 653b2e19446..0cfbf7acc67 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -42,6 +42,23 @@ std::string getStableProgressPath(const std::string& bookId) { std::string getLegacyProgressPath(Txt& txt) { return txt.getCachePath() + "/progress.bin"; } +// Open index.bin and seek to the per-page word entry for `page`. Shared by single-page +// reads and remaining-word sums. +bool openWordCountsFileAt(Txt& book, const uint32_t wordCountsFileOffset, const int page, const int totalPages, + FsFile& outFile) { + if (page < 0 || totalPages <= 0 || page >= totalPages || wordCountsFileOffset == 0) { + return false; + } + const std::string cachePath = book.getCachePath() + "/index.bin"; + if (!Storage.openFileForRead("TRS", cachePath, outFile)) { + return false; + } + if (!outFile.seek(wordCountsFileOffset + static_cast(page) * sizeof(uint16_t))) { + return false; + } + return true; +} + void exitReaderToHomeOrStats(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath) { READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -331,6 +348,8 @@ void TxtReaderActivity::onExit() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + cachedRemainingWords = 0; + cachedRemainingValid = false; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -390,12 +409,22 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; + invalidateRemainingWordsCache(); pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); + if (cachedRemainingValid) { + const uint32_t words32 = countWordsInLines(currentPageLines); + const uint16_t pageWords = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); + if (cachedRemainingWords > pageWords) { + cachedRemainingWords -= pageWords; + } else { + cachedRemainingWords = 0; + } + } currentPage++; pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); @@ -421,9 +450,8 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { // which invalidates pageOffsets and the on-disk word table (different page breaks). initialized = false; pageOffsets.clear(); - totalBookWords = 0; - wordCountsFileOffset = 0; currentPageLines.clear(); + invalidateRemainingWordsCache(); pageDwell.clear(); pendingForceFullRefresh = true; requestUpdate(); @@ -494,6 +522,7 @@ void TxtReaderActivity::initializeReader() { // Load saved progress loadProgress(); + invalidateRemainingWordsCache(); initialized = true; } @@ -502,6 +531,7 @@ void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + invalidateRemainingWordsCache(); pageOffsets.push_back(0); // First page starts at offset 0 // Temporary during indexing only — written to disk then freed (not kept as a member). @@ -546,12 +576,7 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); - if (pageWords.size() > static_cast(totalPages)) { - pageWords.resize(totalPages); - } - while (pageWords.size() < static_cast(totalPages)) { - pageWords.push_back(0); - } + pageWords.resize(static_cast(totalPages), 0); LOG_DBG("TRS", "Built page index: %d pages, %lu words", totalPages, static_cast(totalBookWords)); savePageIndexCache(pageWords); } @@ -574,14 +599,16 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - // Prefer the on-disk index word for `page` so credit still works if currentPage - // has already moved; fall back to live lines only when the cache entry is missing. + // Prefer live lines when still on that page (avoids SD I/O on the forward-turn hot + // path). Fall back to the on-disk index when currentPage has already moved. uint32_t words = 0; - uint16_t cachedWords = 0; - if (readCachedPageWordCount(page, cachedWords)) { - words = cachedWords; - } else if (page == currentPage) { + if (page == currentPage) { words = countWordsInLines(currentPageLines); + } else { + uint16_t cachedWords = 0; + if (readCachedPageWordCount(page, cachedWords)) { + words = cachedWords; + } } const uint32_t associatedMs = pageDwell.takeCredit(page, 0, words, millis()); if (associatedMs == 0) { @@ -591,6 +618,19 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { READING_STATS.noteWordsRead(words, associatedMs); } +void TxtReaderActivity::invalidateRemainingWordsCache() { + cachedRemainingWords = 0; + cachedRemainingValid = false; +} + +void TxtReaderActivity::ensureRemainingWordsCache() { + if (cachedRemainingValid) { + return; + } + cachedRemainingWords = estimateRemainingWords(currentPage); + cachedRemainingValid = true; +} + uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages) { return 0; @@ -607,6 +647,27 @@ uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { static_cast(totalPages)); } +void TxtReaderActivity::resumeAfterSubactivity() { + READING_STATS.resumeSession(); + if (currentPage < 0) { + pageDwell.clear(); + } else { + pageDwell.restart(currentPage, 0, millis()); + } +} + +void TxtReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, + ActivityResultHandler onResult) { + READING_STATS.noteActivity(); + pageDwell.clear(); + startActivityForResult(std::move(activity), [this, onResult = std::move(onResult)](const ActivityResult& result) { + resumeAfterSubactivity(); + if (onResult) { + onResult(result); + } + }); +} + bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { outLines.clear(); const size_t fileSize = txt->getFileSize(); @@ -855,6 +916,9 @@ void TxtReaderActivity::renderPage() { if (currentPage >= 0) { pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); } + if (ChapterTimeEstimate::statusBarWantsChapterTime() && READING_STATS.getEffectiveWordsPerMs() > 0.0) { + ensureRemainingWordsCache(); + } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -882,9 +946,8 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - // Gate SD remaining-word sum: e-ink paints roughly once per page turn. - if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && - ChapterTimeEstimate::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, + if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && cachedRemainingValid && + ChapterTimeEstimate::formatRemainingFromRate(cachedRemainingWords, wordsPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; } @@ -1024,6 +1087,7 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.clear(); totalBookWords = 0; wordCountsFileOffset = 0; + invalidateRemainingWordsCache(); pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1083,16 +1147,11 @@ void TxtReaderActivity::savePageIndexCache(const std::vector& pageWord bool TxtReaderActivity::readCachedPageWordCount(const int page, uint16_t& outWords) const { outWords = 0; - if (page < 0 || totalPages <= 0 || page >= totalPages || wordCountsFileOffset == 0 || !txt) { + if (!txt) { return false; } - - std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; - if (!Storage.openFileForRead("TRS", cachePath, f)) { - return false; - } - if (!f.seek(wordCountsFileOffset + static_cast(page) * sizeof(uint16_t))) { + if (!openWordCountsFileAt(*txt, wordCountsFileOffset, page, totalPages, f)) { return false; } const int n = f.read(reinterpret_cast(&outWords), sizeof(outWords)); @@ -1101,16 +1160,11 @@ bool TxtReaderActivity::readCachedPageWordCount(const int page, uint16_t& outWor bool TxtReaderActivity::sumRemainingWordsFromCache(const int fromPage, uint32_t& outRemaining) const { outRemaining = 0; - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || wordCountsFileOffset == 0 || !txt) { + if (!txt) { return false; } - - std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; - if (!Storage.openFileForRead("TRS", cachePath, f)) { - return false; - } - if (!f.seek(wordCountsFileOffset + static_cast(fromPage) * sizeof(uint16_t))) { + if (!openWordCountsFileAt(*txt, wordCountsFileOffset, fromPage, totalPages, f)) { return false; } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 09b473f392f..0fbc79ce0be 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -2,6 +2,7 @@ #include +#include #include #include @@ -38,6 +39,9 @@ class TxtReaderActivity final : public Activity { uint32_t totalBookWords = 0; // Byte offset of the per-page word table inside index.bin after a successful load/save. uint32_t wordCountsFileOffset = 0; + // Remaining words from currentPage inclusive; refreshed from disk when invalid. + uint32_t cachedRemainingWords = 0; + bool cachedRemainingValid = false; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -69,6 +73,8 @@ class TxtReaderActivity final : public Activity { void savePageIndexCache(const std::vector& pageWords); bool readCachedPageWordCount(int page, uint16_t& outWords) const; bool sumRemainingWordsFromCache(int fromPage, uint32_t& outRemaining) const; + void invalidateRemainingWordsCache(); + void ensureRemainingWordsCache(); uint32_t estimateRemainingWords(int fromPage) const; void saveProgress() const; void loadProgress(); @@ -76,6 +82,8 @@ class TxtReaderActivity final : public Activity { void toggleTemporaryStatusBar(); void creditCurrentPageWords(); void maybeCreditPageWords(int page); + void resumeAfterSubactivity(); + void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); uint32_t countWordsInLines(const std::vector& lines) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 2cfe578a80d..63dfba0ed9a 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -346,10 +346,9 @@ std::string getSettingValueText(const SettingInfo& setting) { return ""; } const uint8_t value = SETTINGS.*(setting.valuePtr); - if (setting.nameId == StrId::STR_STATUS_BAR_CHAPTER_PROGRESS && - value == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME) { + if (setting.nameId == StrId::STR_STATUS_BAR_CHAPTER_PROGRESS) { char buf[64]; - if (ChapterTimeEstimate::formatPagesPlusTime(buf, sizeof(buf))) { + if (ChapterTimeEstimate::formatChapterProgressLabel(value, buf, sizeof(buf))) { return buf; } } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 419a136f940..bcacf840edb 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -82,36 +82,15 @@ int clockCycleIndex(const uint8_t mode) { return 0; } -const char* previewChapterTimeEstimate() { +bool fillPreviewChapterTimeEstimate(char* buf, const size_t bufSize) { switch (SETTINGS.statusBarChapterProgress) { case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: case CrossPointSettings::CHAPTER_PROGRESS_TIME: { - // static: pointer must outlive this function; only consumed in the same - // render call stack by drawStatusBar. Sized like reader chapterTimeBuf. - static char buf[24]; - snprintf(buf, sizeof(buf), "15%s", tr(STR_ETA_UNIT_MINUTE)); - return buf; + const int written = snprintf(buf, bufSize, "15%s", tr(STR_ETA_UNIT_MINUTE)); + return written > 0 && static_cast(written) < bufSize; } default: - return nullptr; - } -} - -std::string chapterProgressLabel(const uint8_t mode) { - switch (mode) { - case CrossPointSettings::CHAPTER_PROGRESS_PAGES: - return tr(STR_PAGES); - case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: { - char buf[64]; - if (ChapterTimeEstimate::formatPagesPlusTime(buf, sizeof(buf))) { - return buf; - } - return tr(STR_PAGES); - } - case CrossPointSettings::CHAPTER_PROGRESS_TIME: - return tr(STR_TIME); - default: - return tr(STR_HIDE); + return false; } } @@ -252,8 +231,13 @@ void StatusBarSettingsActivity::render(RenderLock&&) { [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, [](int index) -> std::string { switch (index) { - case ITEM_CHAPTER_PROGRESS: - return chapterProgressLabel(SETTINGS.statusBarChapterProgress); + case ITEM_CHAPTER_PROGRESS: { + char buf[64]; + if (ChapterTimeEstimate::formatChapterProgressLabel(SETTINGS.statusBarChapterProgress, buf, sizeof(buf))) { + return buf; + } + return tr(STR_HIDE); + } case ITEM_BOOK_PROGRESS_PERCENTAGE: return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_PROGRESS_BAR: @@ -291,7 +275,12 @@ void StatusBarSettingsActivity::render(RenderLock&&) { title = tr(STR_EXAMPLE_CHAPTER); } - GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, previewChapterTimeEstimate()); + char previewChapterTimeBuf[24] = {}; + const char* previewChapterTime = nullptr; + if (fillPreviewChapterTimeEstimate(previewChapterTimeBuf, sizeof(previewChapterTimeBuf))) { + previewChapterTime = previewChapterTimeBuf; + } + GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, previewChapterTime); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, renderer.getScreenHeight() - UITheme::getInstance().getStatusBarHeight() - verticalPreviewPadding - diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index ec9806784c1..4e735d6a5f4 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -862,22 +862,25 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c // Right aligned text for progress counter char progressStr[48]; size_t offset = 0; + auto appendProgress = [&](const int written) { + if (written > 0) { + offset += static_cast(written); + } + }; if (showChapterPages) { - offset += static_cast( - snprintf(progressStr + offset, sizeof(progressStr) - offset, "%d/%d", currentPage, pageCount)); + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%d/%d", currentPage, pageCount)); } if (showChapterTime && offset < sizeof(progressStr)) { - offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%s", - showChapterPages ? " (" : "", chapterTimeEstimate)); + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%s", + showChapterPages ? " (" : "", chapterTimeEstimate)); if (showChapterPages && offset < sizeof(progressStr)) { - offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, ")")); + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, ")")); } } if (showBookPercent && offset < sizeof(progressStr)) { - offset += static_cast(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%.0f%%", - (showChapterPages || showChapterTime) ? " " : "", bookProgress)); + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%.0f%%", + (showChapterPages || showChapterTime) ? " " : "", bookProgress)); } - (void)offset; progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText( diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index f8d1c53783b..b40e90e2f2a 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -292,7 +292,7 @@ constexpr StrId OPT_SHORTCUT_LOCATION[] = {StrId::STR_HOME_LOCATION, StrId::STR_ constexpr StrId OPT_KO_MATCH[] = {StrId::STR_FILENAME, StrId::STR_BINARY}; constexpr StrId OPT_OPDS_FILENAME_FORMAT[] = {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}; constexpr StrId OPT_BOOK_CHAPTER_HIDE[] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; -// Index 1 (Pages+Time) is composed at display time from STR_PAGES + '+' + STR_TIME. +// Index 1 is CHAPTER_PROGRESS_PAGES_TIME — JSON options use formatChapterProgressLabel. constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}; constexpr StrId OPT_BAR_THICKNESS[] = {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; @@ -1913,11 +1913,10 @@ void CrossPointWebServer::handleGetSettings() const { } else { seenOption = true; } - if (s.key && strcmp(s.key, "statusBarChapterProgress") == 0 && - i == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME) { - char pagesPlusTime[64]; - if (ChapterTimeEstimate::formatPagesPlusTime(pagesPlusTime, sizeof(pagesPlusTime))) { - sendJsonEscaped(server.get(), pagesPlusTime); + if (s.key && strcmp(s.key, "statusBarChapterProgress") == 0) { + char label[64]; + if (ChapterTimeEstimate::formatChapterProgressLabel(i, label, sizeof(label))) { + sendJsonEscaped(server.get(), label); } else { sendJsonEscaped(server.get(), I18N.get(s.options[i])); } diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index 2934b9f7683..0714a8c1b95 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -84,7 +84,32 @@ bool formatPagesPlusTime(char* buf, const size_t bufSize) { return written > 0 && static_cast(written) < bufSize; } +bool formatChapterProgressLabel(const uint8_t mode, char* buf, const size_t bufSize) { + if (!buf || bufSize == 0) { + return false; + } + switch (mode) { + case CrossPointSettings::CHAPTER_PROGRESS_PAGES: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_PAGES)); + return written > 0 && static_cast(written) < bufSize; + } + case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: + return formatPagesPlusTime(buf, bufSize); + case CrossPointSettings::CHAPTER_PROGRESS_TIME: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; + } + case CrossPointSettings::CHAPTER_PROGRESS_HIDE: + default: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_HIDE)); + return written > 0 && static_cast(written) < bufSize; + } + } +} + void PageDwell::clear() { + // Intentionally leave lastCredited* so a clear+re-enter of the same page still + // requires REREAD_MIN_MS before another credit. enteredMs = 0; id0 = -1; id1 = -1; diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index 29757838495..5c94a77bab9 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -24,6 +24,11 @@ bool statusBarWantsChapterTime(); // Compose "Pages+Time" from STR_PAGES + '+' + STR_TIME (no dedicated i18n key). bool formatPagesPlusTime(char* buf, size_t bufSize); +// Label for statusBarChapterProgress enum index. Always use this instead of raw +// StrId lookup — index CHAPTER_PROGRESS_PAGES_TIME is composed, and settings +// tables may store a placeholder StrId for that slot. +bool formatChapterProgressLabel(uint8_t mode, char* buf, size_t bufSize); + // Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). // clear() resets the active dwell window only; lastCredited* is kept so re-reads // of the same page still require REREAD_MIN_MS before another credit. From f61d70a9af33b85985a3cc4119da339b010fa68a Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:31:03 -0700 Subject: [PATCH 15/20] refactor(reader): simplify TXT chapter ETA to pro-rata words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the TXT per-page word table, remaining-words cache, and shared utf8CountLayoutWords helper. Keep EPUB exact words; TXT credits average words/page and estimates remaining from totalBookWords × pages left. --- agent-docs/reading-stats.md | 14 +- lib/Epub/Epub/Page.cpp | 1 - lib/Utf8/Utf8.cpp | 78 --------- lib/Utf8/Utf8.h | 15 -- src/activities/reader/TxtReaderActivity.cpp | 167 ++++---------------- src/activities/reader/TxtReaderActivity.h | 16 +- 6 files changed, 39 insertions(+), 252 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index 69592b0e99e..fdd590b33f8 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -32,17 +32,19 @@ metrics. ## Chapter time remaining (status bar) - Rate is live-session only (`getEffectiveWordsPerMs`): no active session ⇒ no ETA. -- EPUB credits use `TextBlock::wordCount()` / `Page::countWords()`; TXT uses - `utf8CountLayoutWords` on plain line text — rates are not bit-identical across formats. -- Per-page word caches store `uint16` (saturate) for EPUB sections and TXT `index.bin`. -- EPUB keeps per-chapter word counts in RAM (~2 B × pages); TXT keeps them on disk only. +- EPUB credits use `TextBlock::wordCount()` / `Page::countWords()` with a per-section + word LUT for exact remaining. +- TXT uses whitespace word totals + pro-rata remaining / average words-per-page + credits (no per-page word table in RAM or `index.bin`). +- Per-page word caches store `uint16` (saturate) for EPUB sections only. +- EPUB keeps per-chapter word counts in RAM (~2 B × pages); TXT keeps only + `totalBookWords`. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. - The Pages+Time setting label is composed via `formatChapterProgressLabel` (`STR_PAGES + '+' + STR_TIME`); always use that helper for enum display. - XTC has no word ETA (bitmap pages, no status-bar chapter-time slot). -- TXT caches remaining words in RAM between paints; EPUB keeps a running - `knownPageWordsTotal_` for ETA extrapolation. +- EPUB keeps a running `knownPageWordsTotal_` for ETA extrapolation. ## Design Rules diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 26275adc54f..5adac322c3a 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -358,7 +358,6 @@ void PageTableFragment::recordFontUsage(FontCacheManager& fontCacheManager, cons uint32_t Page::countWords() const { // Post-layout token count (hyphenation / ruby / focus pieces via TextBlock::wordCount). - // Distinct from utf8CountLayoutWords used by the TXT reader for plain-text lines. uint32_t words = 0; for (const auto& element : elements) { if (!element) continue; diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index 20e1b7f7a40..c0ed4c7fb8e 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -178,81 +178,3 @@ void utf8TruncateChars(std::string& str, const size_t numChars) { utf8RemoveLastChar(str); } } - -uint32_t utf8CountLayoutWords(const char* data, const size_t len) { - if (!data || len == 0) { - return 0; - } - - // Mirror ChapterHtmlSlimParser characterData: track run start/length in `data`, - // flush at UTF8_LAYOUT_WORD_MAX_BYTES with utf8SafeTruncateBuffer (never mid-sequence). - uint32_t words = 0; - size_t runStart = 0; - int runLen = 0; - - auto flushRun = [&]() { - if (runLen <= 0) { - return; - } - ++words; - runLen = 0; - }; - - auto flushAtCapacity = [&]() { - if (runLen < static_cast(UTF8_LAYOUT_WORD_MAX_BYTES)) { - return; - } - const int safeLen = utf8SafeTruncateBuffer(data + runStart, runLen); - if (safeLen <= 0) { - runLen = 0; - return; - } - if (safeLen < runLen) { - runStart += static_cast(safeLen); - runLen -= safeLen; - ++words; - } else { - flushRun(); - } - }; - - for (size_t i = 0; i < len; ++i) { - const unsigned char c = static_cast(data[i]); - if (c == ' ' || c == '\r' || c == '\n' || c == '\t') { - flushRun(); - continue; - } - - // U+00A0 (C2 A0) — own layout word, like EPUB. - if (c == 0xC2 && i + 1 < len && static_cast(data[i + 1]) == 0xA0) { - flushRun(); - ++words; - ++i; - continue; - } - - // U+202F (E2 80 AF) — narrow no-break space. - if (c == 0xE2 && i + 2 < len && static_cast(data[i + 1]) == 0x80 && - static_cast(data[i + 2]) == 0xAF) { - flushRun(); - ++words; - i += 2; - continue; - } - - // U+FEFF BOM / ZWNBSP — skip. - if (c == 0xEF && i + 2 < len && static_cast(data[i + 1]) == 0xBB && - static_cast(data[i + 2]) == 0xBF) { - i += 2; - continue; - } - - flushAtCapacity(); - if (runLen == 0) { - runStart = i; - } - ++runLen; - } - flushRun(); - return words; -} diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index b754adec0d7..8bdaa929c26 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -23,21 +23,6 @@ std::string utf8ComposeNfc(const std::string& in); // incomplete trailing bytes are excluded. int utf8SafeTruncateBuffer(const char* buf, int len); -// EPUB layout word-token byte cap (ChapterHtmlSlimParser::MAX_WORD_SIZE). Long -// unspaced runs (e.g. CJK) are split with utf8SafeTruncateBuffer at this budget. -constexpr size_t UTF8_LAYOUT_WORD_MAX_BYTES = 200; - -// Count layout words the same way plain-text EPUB tokenization does before -// hyphenation: ASCII whitespace separates words; U+00A0 / U+202F each count as -// one word; other non-whitespace runs flush every UTF8_LAYOUT_WORD_MAX_BYTES at -// a UTF-8 boundary. -// -// Intentional divergence from EPUB page credits: EpubReaderActivity credits via -// TextBlock::wordCount() / Page::countWords() (post-layout tokens after -// hyphenation splits, ruby, focus pieces). TXT uses this helper on line text, so -// cross-format words/ms rates are not bit-identical. -uint32_t utf8CountLayoutWords(const char* data, size_t len); - // Returns true for CJK characters that allow line breaks on either side without hyphenation. // Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, // and fullwidth forms — the ranges where word boundaries are implicit per character. diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0cfbf7acc67..2243fa67470 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -32,7 +31,8 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 8; // v8: on-disk per-page words for exact remaining (not loaded into RAM) +// v9: totalBookWords only (pro-rata remaining / average page credits; no per-page word table). +constexpr uint8_t CACHE_VERSION = 9; constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -42,23 +42,6 @@ std::string getStableProgressPath(const std::string& bookId) { std::string getLegacyProgressPath(Txt& txt) { return txt.getCachePath() + "/progress.bin"; } -// Open index.bin and seek to the per-page word entry for `page`. Shared by single-page -// reads and remaining-word sums. -bool openWordCountsFileAt(Txt& book, const uint32_t wordCountsFileOffset, const int page, const int totalPages, - FsFile& outFile) { - if (page < 0 || totalPages <= 0 || page >= totalPages || wordCountsFileOffset == 0) { - return false; - } - const std::string cachePath = book.getCachePath() + "/index.bin"; - if (!Storage.openFileForRead("TRS", cachePath, outFile)) { - return false; - } - if (!outFile.seek(wordCountsFileOffset + static_cast(page) * sizeof(uint16_t))) { - return false; - } - return true; -} - void exitReaderToHomeOrStats(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath) { READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -347,9 +330,6 @@ void TxtReaderActivity::onExit() { pageOffsets.clear(); totalBookWords = 0; - wordCountsFileOffset = 0; - cachedRemainingWords = 0; - cachedRemainingValid = false; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -409,22 +389,12 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; - invalidateRemainingWordsCache(); pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); - if (cachedRemainingValid) { - const uint32_t words32 = countWordsInLines(currentPageLines); - const uint16_t pageWords = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); - if (cachedRemainingWords > pageWords) { - cachedRemainingWords -= pageWords; - } else { - cachedRemainingWords = 0; - } - } currentPage++; pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); requestUpdate(); @@ -447,11 +417,10 @@ void TxtReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; // Full reinit is required: status-bar height changes the viewport and linesPerPage, - // which invalidates pageOffsets and the on-disk word table (different page breaks). + // which invalidates pageOffsets (different page breaks). initialized = false; pageOffsets.clear(); currentPageLines.clear(); - invalidateRemainingWordsCache(); pageDwell.clear(); pendingForceFullRefresh = true; requestUpdate(); @@ -517,12 +486,11 @@ void TxtReaderActivity::initializeReader() { // Try to load cached page index first if (!loadPageIndexCache()) { - buildPageIndex(); // builds and saves cache (including on-disk word table) + buildPageIndex(); // builds and saves cache } // Load saved progress loadProgress(); - invalidateRemainingWordsCache(); initialized = true; } @@ -530,15 +498,8 @@ void TxtReaderActivity::initializeReader() { void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); totalBookWords = 0; - wordCountsFileOffset = 0; - invalidateRemainingWordsCache(); pageOffsets.push_back(0); // First page starts at offset 0 - // Temporary during indexing only — written to disk then freed (not kept as a member). - // Per-page counts are uint16 like EPUB section cache (saturate pathological dense pages). - std::vector pageWords; - pageWords.reserve(256); - size_t offset = 0; const size_t fileSize = txt->getFileSize(); @@ -554,10 +515,7 @@ void TxtReaderActivity::buildPageIndex() { break; } - const uint32_t words32 = countWordsInLines(tempLines); - const uint16_t words = words32 > UINT16_MAX ? UINT16_MAX : static_cast(words32); - pageWords.push_back(words); - totalBookWords += words; + totalBookWords += countWordsInLines(tempLines); if (nextOffset <= offset) { // No progress made, avoid infinite loop @@ -576,15 +534,24 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); - pageWords.resize(static_cast(totalPages), 0); LOG_DBG("TRS", "Built page index: %d pages, %lu words", totalPages, static_cast(totalBookWords)); - savePageIndexCache(pageWords); + savePageIndexCache(); } uint32_t TxtReaderActivity::countWordsInLines(const std::vector& lines) const { + // Simple whitespace tokenization is enough for TXT pro-rata / average credits. + // EPUB keeps layout-token counts; cross-format rates are intentionally coarse. uint32_t words = 0; for (const auto& line : lines) { - words += utf8CountLayoutWords(line.text.data(), line.text.size()); + bool inWord = false; + for (const unsigned char c : line.text) { + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + inWord = false; + } else if (!inWord) { + inWord = true; + ++words; + } + } } return words; } @@ -599,17 +566,7 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - // Prefer live lines when still on that page (avoids SD I/O on the forward-turn hot - // path). Fall back to the on-disk index when currentPage has already moved. - uint32_t words = 0; - if (page == currentPage) { - words = countWordsInLines(currentPageLines); - } else { - uint16_t cachedWords = 0; - if (readCachedPageWordCount(page, cachedWords)) { - words = cachedWords; - } - } + const uint32_t words = averageWordsPerPage(); const uint32_t associatedMs = pageDwell.takeCredit(page, 0, words, millis()); if (associatedMs == 0) { return; @@ -618,30 +575,18 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { READING_STATS.noteWordsRead(words, associatedMs); } -void TxtReaderActivity::invalidateRemainingWordsCache() { - cachedRemainingWords = 0; - cachedRemainingValid = false; -} - -void TxtReaderActivity::ensureRemainingWordsCache() { - if (cachedRemainingValid) { - return; +uint32_t TxtReaderActivity::averageWordsPerPage() const { + if (totalPages <= 0 || totalBookWords == 0) { + return 0; } - cachedRemainingWords = estimateRemainingWords(currentPage); - cachedRemainingValid = true; + return totalBookWords / static_cast(totalPages); } uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages) { - return 0; - } - uint32_t remaining = 0; - if (sumRemainingWordsFromCache(fromPage, remaining)) { - return remaining; - } - if (totalBookWords == 0) { + if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { return 0; } + // Pro-rate the book total across remaining pages (inclusive of fromPage). const uint32_t pagesLeft = static_cast(totalPages - fromPage); return static_cast((static_cast(totalBookWords) * pagesLeft) / static_cast(totalPages)); @@ -916,9 +861,6 @@ void TxtReaderActivity::renderPage() { if (currentPage >= 0) { pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); } - if (ChapterTimeEstimate::statusBarWantsChapterTime() && READING_STATS.getEffectiveWordsPerMs() > 0.0) { - ensureRemainingWordsCache(); - } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -946,8 +888,8 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && cachedRemainingValid && - ChapterTimeEstimate::formatRemainingFromRate(cachedRemainingWords, wordsPerMs, chapterTimeBuf, + if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && + ChapterTimeEstimate::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; } @@ -1016,7 +958,6 @@ bool TxtReaderActivity::loadPageIndexCache() { // - uint32_t: total pages count // - N * uint32_t: page offsets // - uint32_t: totalBookWords - // - N * uint16_t: per-page word counts (on disk only; not loaded into RAM) std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; @@ -1086,8 +1027,6 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.clear(); totalBookWords = 0; - wordCountsFileOffset = 0; - invalidateRemainingWordsCache(); pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1096,16 +1035,6 @@ bool TxtReaderActivity::loadPageIndexCache() { pageOffsets.push_back(offset); } serialization::readPod(f, totalBookWords); - wordCountsFileOffset = static_cast(f.position()); - - // Ensure the on-disk word table is present without loading it into RAM. - const uint64_t wordTableBytes = static_cast(numPages) * sizeof(uint16_t); - if (wordCountsFileOffset == 0 || - static_cast(wordCountsFileOffset) + wordTableBytes > static_cast(f.size())) { - LOG_DBG("TRS", "Cache missing per-page word table, rebuilding"); - wordCountsFileOffset = 0; - return false; - } totalPages = pageOffsets.size(); LOG_DBG("TRS", "Loaded page index cache: %d pages, %lu words", totalPages, @@ -1113,12 +1042,11 @@ bool TxtReaderActivity::loadPageIndexCache() { return true; } -void TxtReaderActivity::savePageIndexCache(const std::vector& pageWords) { +void TxtReaderActivity::savePageIndexCache() { std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; if (!Storage.openFileForWrite("TRS", cachePath, f)) { LOG_ERR("TRS", "Failed to save page index cache"); - wordCountsFileOffset = 0; return; } @@ -1136,51 +1064,10 @@ void TxtReaderActivity::savePageIndexCache(const std::vector& pageWord serialization::writePod(f, static_cast(offset)); } serialization::writePod(f, totalBookWords); - wordCountsFileOffset = static_cast(f.position()); - for (size_t i = 0; i < pageOffsets.size(); ++i) { - const uint16_t words = (i < pageWords.size()) ? pageWords[i] : 0; - serialization::writePod(f, words); - } LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } -bool TxtReaderActivity::readCachedPageWordCount(const int page, uint16_t& outWords) const { - outWords = 0; - if (!txt) { - return false; - } - FsFile f; - if (!openWordCountsFileAt(*txt, wordCountsFileOffset, page, totalPages, f)) { - return false; - } - const int n = f.read(reinterpret_cast(&outWords), sizeof(outWords)); - return n == static_cast(sizeof(outWords)); -} - -bool TxtReaderActivity::sumRemainingWordsFromCache(const int fromPage, uint32_t& outRemaining) const { - outRemaining = 0; - if (!txt) { - return false; - } - FsFile f; - if (!openWordCountsFileAt(*txt, wordCountsFileOffset, fromPage, totalPages, f)) { - return false; - } - - uint32_t remaining = 0; - for (int page = fromPage; page < totalPages; ++page) { - uint16_t words = 0; - const int n = f.read(reinterpret_cast(&words), sizeof(words)); - if (n != static_cast(sizeof(words))) { - return false; - } - remaining += words; - } - outRemaining = remaining; - return true; -} - ScreenshotInfo TxtReaderActivity::getScreenshotInfo() const { ScreenshotInfo info; info.readerType = ScreenshotInfo::ReaderType::Txt; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 0fbc79ce0be..4f5cf2d4215 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -34,14 +34,9 @@ class TxtReaderActivity final : public Activity { // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page - // Book-wide word total. Per-page counts live only in the index cache on disk - // (not in RAM) so large TXT files stay within ESP32-C3 heap limits. + // Book-wide word total for pro-rata remaining / average words-per-page credits. + // Avoids a per-page word table in RAM or on disk (ESP32-C3 heap + simpler cache). uint32_t totalBookWords = 0; - // Byte offset of the per-page word table inside index.bin after a successful load/save. - uint32_t wordCountsFileOffset = 0; - // Remaining words from currentPage inclusive; refreshed from disk when invalid. - uint32_t cachedRemainingWords = 0; - bool cachedRemainingValid = false; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -70,12 +65,9 @@ class TxtReaderActivity final : public Activity { bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); void buildPageIndex(); bool loadPageIndexCache(); - void savePageIndexCache(const std::vector& pageWords); - bool readCachedPageWordCount(int page, uint16_t& outWords) const; - bool sumRemainingWordsFromCache(int fromPage, uint32_t& outRemaining) const; - void invalidateRemainingWordsCache(); - void ensureRemainingWordsCache(); + void savePageIndexCache(); uint32_t estimateRemainingWords(int fromPage) const; + uint32_t averageWordsPerPage() const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); From bf888da47a4bc292513eeba3012014365504a536 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:33:53 -0700 Subject: [PATCH 16/20] refactor(reader): slim PageDwell API without changing gates Collapse restart/noteEnteredIfChanged/creditMs/markCredited into clear + noteEntered + takeCredit. Same min/re-read dwell rules; fewer call-site branches in EPUB/TXT readers. --- src/activities/reader/EpubReaderActivity.cpp | 32 +++++----------- src/activities/reader/TxtReaderActivity.cpp | 12 ++---- src/util/ChapterTimeEstimate.cpp | 39 ++++++-------------- src/util/ChapterTimeEstimate.h | 9 ++--- 4 files changed, 28 insertions(+), 64 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ef0bc775e82..60b1285d4f8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -433,11 +433,8 @@ void EpubReaderActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back)) { automaticPageTurnActive = false; - if (section && section->currentPage >= 0) { - pageDwell.restart(currentSpineIndex, section->currentPage, millis()); - } else { - pageDwell.clear(); - } + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); // updates chapter title space to indicate page turn disabled requestUpdate(); return; @@ -1215,11 +1212,8 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; - if (!section || section->currentPage < 0) { - pageDwell.clear(); - } else { - pageDwell.restart(currentSpineIndex, section->currentPage, millis()); - } + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); return; } @@ -1308,11 +1302,8 @@ void EpubReaderActivity::markCurrentBookAsFinished() { void EpubReaderActivity::resumeAfterSubactivity() { READING_STATS.resumeSession(); - if (!section || section->currentPage < 0) { - pageDwell.clear(); - } else { - pageDwell.restart(currentSpineIndex, section->currentPage, millis()); - } + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); } void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, @@ -1395,11 +1386,8 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { sessionProgressTouched = true; } lastPageTurnTime = millis(); - if (section && section->currentPage >= 0) { - pageDwell.noteEnteredIfChanged(currentSpineIndex, section->currentPage, millis()); - } else { - pageDwell.clear(); - } + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis()); requestUpdate(); } @@ -1677,9 +1665,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { } { const int page = section ? section->currentPage : -1; - if (page >= 0) { - pageDwell.noteEnteredIfChanged(currentSpineIndex, page, millis()); - } + pageDwell.noteEntered(page >= 0 ? currentSpineIndex : -1, page, millis()); } // Menus, screenshots and overlays can request a render without moving the // reader. Avoid several FAT operations for the same six-byte position file. diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 2243fa67470..443165916df 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -389,14 +389,14 @@ void TxtReaderActivity::loop() { READING_STATS.noteActivity(); // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; - pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); + pageDwell.noteEntered(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); maybeCreditPageWords(currentPage); currentPage++; - pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); + pageDwell.noteEntered(currentPage, 0, millis()); requestUpdate(); } else { READING_STATS.noteActivity(); @@ -594,11 +594,7 @@ uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { void TxtReaderActivity::resumeAfterSubactivity() { READING_STATS.resumeSession(); - if (currentPage < 0) { - pageDwell.clear(); - } else { - pageDwell.restart(currentPage, 0, millis()); - } + pageDwell.noteEntered(currentPage, 0, millis(), true); } void TxtReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, @@ -859,7 +855,7 @@ void TxtReaderActivity::renderPage() { // BW rendering renderLines(); if (currentPage >= 0) { - pageDwell.noteEnteredIfChanged(currentPage, 0, millis()); + pageDwell.noteEntered(currentPage, 0, millis()); } renderStatusBar(); diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp index 0714a8c1b95..665675c1c40 100644 --- a/src/util/ChapterTimeEstimate.cpp +++ b/src/util/ChapterTimeEstimate.cpp @@ -115,44 +115,29 @@ void PageDwell::clear() { id1 = -1; } -void PageDwell::restart(const int a, const int b, const unsigned long nowMs) { - id0 = a; - id1 = b; - enteredMs = nowMs; -} - -void PageDwell::noteEnteredIfChanged(const int a, const int b, const unsigned long nowMs) { - if (a == id0 && b == id1 && enteredMs != 0) { +void PageDwell::noteEntered(const int a, const int b, const unsigned long nowMs, const bool forceRestart) { + if (a < 0) { + clear(); return; } - restart(a, b, nowMs); -} - -uint32_t PageDwell::creditMs(const int a, const int b, const unsigned long nowMs) const { - if (a != id0 || b != id1 || enteredMs == 0) { - return 0; - } - // millis() wrap (~49.7d): treat as no credit rather than a huge unsigned delta. - if (nowMs < enteredMs) { - return 0; + if (!forceRestart && a == id0 && b == id1 && enteredMs != 0) { + return; } - return dwellCreditMs(nowMs - enteredMs, a == lastCredited0 && b == lastCredited1); -} - -void PageDwell::markCredited(const int a, const int b) { - lastCredited0 = a; - lastCredited1 = b; + id0 = a; + id1 = b; + enteredMs = nowMs; } uint32_t PageDwell::takeCredit(const int a, const int b, const uint32_t words, const unsigned long nowMs) { - if (words == 0) { + if (words == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { return 0; } - const uint32_t associatedMs = creditMs(a, b, nowMs); + const uint32_t associatedMs = dwellCreditMs(nowMs - enteredMs, a == lastCredited0 && b == lastCredited1); if (associatedMs == 0) { return 0; } - markCredited(a, b); + lastCredited0 = a; + lastCredited1 = b; return associatedMs; } diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h index 5c94a77bab9..503000556ae 100644 --- a/src/util/ChapterTimeEstimate.h +++ b/src/util/ChapterTimeEstimate.h @@ -40,14 +40,11 @@ struct PageDwell { int lastCredited1 = -1; void clear(); - void restart(int a, int b, unsigned long nowMs); - void noteEnteredIfChanged(int a, int b, unsigned long nowMs); + // a < 0 clears. forceRestart always resets the dwell clock; otherwise no-op + // when already tracking (a, b). + void noteEntered(int a, int b, unsigned long nowMs, bool forceRestart = false); // If dwell qualifies and words > 0, marks credited and returns associated ms. uint32_t takeCredit(int a, int b, uint32_t words, unsigned long nowMs); - - private: - uint32_t creditMs(int a, int b, unsigned long nowMs) const; - void markCredited(int a, int b); }; } // namespace ChapterTimeEstimate From c78791899614752fa58f2ac7ba63fceb36d703ed Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:43:34 -0700 Subject: [PATCH 17/20] refactor(reader): dissolve ChapterTimeEstimate into owners Move PageDwell to a header-only helper, chapter-progress labels onto CrossPointSettings, and ETA duration formatting into ReaderUtils. --- agent-docs/reading-stats.md | 2 +- src/CrossPointSettings.cpp | 27 ++++ src/CrossPointSettings.h | 10 ++ src/activities/reader/EpubReaderActivity.cpp | 6 +- src/activities/reader/EpubReaderActivity.h | 4 +- src/activities/reader/ReaderUtils.h | 50 ++++++ src/activities/reader/TxtReaderActivity.cpp | 7 +- src/activities/reader/TxtReaderActivity.h | 4 +- src/activities/settings/SettingsActivity.cpp | 3 +- .../settings/StatusBarSettingsActivity.cpp | 3 +- src/components/themes/BaseTheme.cpp | 3 +- src/network/CrossPointWebServer.cpp | 3 +- src/util/ChapterTimeEstimate.cpp | 144 ------------------ src/util/ChapterTimeEstimate.h | 50 ------ src/util/PageDwell.h | 58 +++++++ 15 files changed, 160 insertions(+), 214 deletions(-) delete mode 100644 src/util/ChapterTimeEstimate.cpp delete mode 100644 src/util/ChapterTimeEstimate.h create mode 100644 src/util/PageDwell.h diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index fdd590b33f8..9c7a0a5fef0 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -41,7 +41,7 @@ metrics. `totalBookWords`. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. -- The Pages+Time setting label is composed via `formatChapterProgressLabel` +- The Pages+Time setting label is composed via `CrossPointSettings::formatChapterProgressLabel` (`STR_PAGES + '+' + STR_TIME`); always use that helper for enum display. - XTC has no word ETA (bitmap pages, no status-bar chapter-time slot). - EPUB keeps a running `knownPageWordsTotal_` for ETA extrapolation. diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 9215937f191..4820e8ffff0 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -2,10 +2,12 @@ #include #include +#include #include #include #include +#include #include #include @@ -490,3 +492,28 @@ int CrossPointSettings::getReaderFontId() const { } } } + +bool CrossPointSettings::formatChapterProgressLabel(const uint8_t mode, char* buf, const size_t bufSize) { + if (!buf || bufSize == 0) { + return false; + } + switch (mode) { + case CHAPTER_PROGRESS_PAGES: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_PAGES)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_PAGES_TIME: { + const int written = snprintf(buf, bufSize, "%s+%s", tr(STR_PAGES), tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_TIME: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_HIDE: + default: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_HIDE)); + return written > 0 && static_cast(written) < bufSize; + } + } +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 7ba8fa269c9..d443d94701b 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -3,6 +3,7 @@ #include #include +#include #include class CrossPointSettings { @@ -466,6 +467,15 @@ class CrossPointSettings { void normalizeDisplayDay(); int getRefreshFrequency() const; bool getForcedReaderRefreshMode(HalDisplay::RefreshMode& mode) const; + + // Chapter Progress setting: Pages+Time / Time modes want a chapter ETA slot. + bool statusBarWantsChapterTime() const { + return statusBarChapterProgress == CHAPTER_PROGRESS_PAGES_TIME || + statusBarChapterProgress == CHAPTER_PROGRESS_TIME; + } + // Label for statusBarChapterProgress enum index. Pages+Time is composed from + // STR_PAGES + '+' + STR_TIME (no dedicated i18n key). + static bool formatChapterProgressLabel(uint8_t mode, char* buf, size_t bufSize); }; // Helper macro to access settings diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 60b1285d4f8..7b4c985abf0 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -45,7 +45,7 @@ #include "fontIds.h" #include "util/AchievementPopupUtils.h" #include "util/BookIdentity.h" -#include "util/ChapterTimeEstimate.h" +#include "util/PageDwell.h" #include "util/CompletedBookMover.h" #include "util/ScreenshotUtil.h" @@ -2032,8 +2032,8 @@ void EpubReaderActivity::renderStatusBar() const { if (section->currentPage >= 0) { const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); // Skip remaining-words walk when rate is 0 or time is hidden. - if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && - ChapterTimeEstimate::formatRemainingFromRate( + if (SETTINGS.statusBarWantsChapterTime() && wordsPerMs > 0.0 && + ReaderUtils::formatRemainingFromRate( section->estimateRemainingWords(static_cast(section->currentPage)), wordsPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 7d0731387fb..a0b42242e4e 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -9,7 +9,7 @@ #include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" #include "activities/Activity.h" -#include "util/ChapterTimeEstimate.h" +#include "util/PageDwell.h" class Page; @@ -47,7 +47,7 @@ class EpubReaderActivity final : public Activity { int sessionStartPage = 0; bool sessionProgressTouched = false; // Word-rate samples: dwell on the page currently displayed. Jumps never credit the left page. - ChapterTimeEstimate::PageDwell pageDwell; + PageDwell pageDwell; std::shared_ptr currentOverlayPageCache; EndOfBookOptions endOfBookOptions; int currentOverlayPageSpineIndex = -1; diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index f4aa09a0678..6ad33705f25 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -3,10 +3,13 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -240,4 +243,51 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) { renderer.restoreBwBuffer(); } +inline bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { + if (!buf || bufSize < 3 || totalMs == 0) { + return false; + } + constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; + auto formatRoundedUnit = [&](const uint64_t value, const char* unit) { + if (!unit || unit[0] == '\0') { + return false; + } + // ETA unit suffixes are authored in EN+ES only; other locales fall back to + // English via I18n (intentional — do not invent unit translations everywhere). + const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); + return written > 0 && static_cast(written) < bufSize; + }; + + // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). + uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; + if (minutes == 0) { + minutes = 1; + } + if (minutes < 60) { + return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE)); + } + const uint64_t hours = (minutes + 30) / 60; + if (hours < 24) { + return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR)); + } + const uint64_t days = (hours + 12) / 24; + if (days < 365) { + return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY)); + } + const uint64_t years = (days + 182) / 365; + return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR)); +} + +inline bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPerMs, char* buf, + const size_t bufSize) { + if (remainingWords == 0 || wordsPerMs <= 0.0) { + return false; + } + const double ms = static_cast(remainingWords) / wordsPerMs; + if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { + return false; + } + return formatCompactDuration(static_cast(ms), buf, bufSize); +} + } // namespace ReaderUtils diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 443165916df..fd5daaa6787 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -24,7 +24,6 @@ #include "fontIds.h" #include "util/AchievementPopupUtils.h" #include "util/BookIdentity.h" -#include "util/ChapterTimeEstimate.h" #include "util/CompletedBookMover.h" namespace { @@ -884,9 +883,9 @@ void TxtReaderActivity::renderStatusBar() const { char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - if (ChapterTimeEstimate::statusBarWantsChapterTime() && wordsPerMs > 0.0 && - ChapterTimeEstimate::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf))) { + if (SETTINGS.statusBarWantsChapterTime() && wordsPerMs > 0.0 && + ReaderUtils::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, + sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 4f5cf2d4215..20bd20b5a23 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -8,7 +8,7 @@ #include "CrossPointSettings.h" #include "activities/Activity.h" -#include "util/ChapterTimeEstimate.h" +#include "util/PageDwell.h" class TxtReaderActivity final : public Activity { public: @@ -47,7 +47,7 @@ class TxtReaderActivity final : public Activity { bool waitingForConfirmSecondClick = false; unsigned long firstConfirmClickMs = 0UL; // Word-rate samples: dwell on the page currently displayed. - ChapterTimeEstimate::PageDwell pageDwell; + PageDwell pageDwell; // Cached settings for cache validation (different fonts/margins require re-indexing) int cachedFontId = 0; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 63dfba0ed9a..52aa535c2f4 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -50,7 +50,6 @@ #include "activities/util/ConfirmationActivity.h" #include "components/UITheme.h" #include "fontIds.h" -#include "util/ChapterTimeEstimate.h" #include "util/HeaderDateUtils.h" #include "util/ShortcutRegistry.h" #include "util/ShortcutUiMetadata.h" @@ -348,7 +347,7 @@ std::string getSettingValueText(const SettingInfo& setting) { const uint8_t value = SETTINGS.*(setting.valuePtr); if (setting.nameId == StrId::STR_STATUS_BAR_CHAPTER_PROGRESS) { char buf[64]; - if (ChapterTimeEstimate::formatChapterProgressLabel(value, buf, sizeof(buf))) { + if (CrossPointSettings::formatChapterProgressLabel(value, buf, sizeof(buf))) { return buf; } } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index bcacf840edb..81a29488429 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -13,7 +13,6 @@ #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" -#include "util/ChapterTimeEstimate.h" #include "util/TimeUtils.h" namespace { @@ -233,7 +232,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { switch (index) { case ITEM_CHAPTER_PROGRESS: { char buf[64]; - if (ChapterTimeEstimate::formatChapterProgressLabel(SETTINGS.statusBarChapterProgress, buf, sizeof(buf))) { + if (CrossPointSettings::formatChapterProgressLabel(SETTINGS.statusBarChapterProgress, buf, sizeof(buf))) { return buf; } return tr(STR_HIDE); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 4e735d6a5f4..916d51ef3c2 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -17,7 +17,6 @@ #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" -#include "util/ChapterTimeEstimate.h" #include "util/TimeUtils.h" // Internal constants @@ -847,7 +846,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - const bool wantChapterTime = ChapterTimeEstimate::statusBarWantsChapterTime(); + const bool wantChapterTime = SETTINGS.statusBarWantsChapterTime(); const bool haveChapterTime = wantChapterTime && chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; // TIME-only with no rate yet would otherwise leave an empty right cluster; show pages until ETA is ready. diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index b40e90e2f2a..564af090010 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -34,7 +34,6 @@ #include "html/SettingsPageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" #include "util/BookCacheUtils.h" -#include "util/ChapterTimeEstimate.h" #include "util/IfFoundFile.h" #include "version.h" @@ -1915,7 +1914,7 @@ void CrossPointWebServer::handleGetSettings() const { } if (s.key && strcmp(s.key, "statusBarChapterProgress") == 0) { char label[64]; - if (ChapterTimeEstimate::formatChapterProgressLabel(i, label, sizeof(label))) { + if (CrossPointSettings::formatChapterProgressLabel(i, label, sizeof(label))) { sendJsonEscaped(server.get(), label); } else { sendJsonEscaped(server.get(), I18N.get(s.options[i])); diff --git a/src/util/ChapterTimeEstimate.cpp b/src/util/ChapterTimeEstimate.cpp deleted file mode 100644 index 665675c1c40..00000000000 --- a/src/util/ChapterTimeEstimate.cpp +++ /dev/null @@ -1,144 +0,0 @@ -#include "util/ChapterTimeEstimate.h" - -#include -#include - -#include - -namespace ChapterTimeEstimate { -namespace { -constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; - -bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const size_t bufSize) { - if (!unit || unit[0] == '\0') { - return false; - } - // ETA unit suffixes are authored in EN+ES only; other locales fall back to - // English via I18n (intentional — do not invent unit translations everywhere). - const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); - return written > 0 && static_cast(written) < bufSize; -} - -uint32_t dwellCreditMs(const unsigned long dwellMs, const bool sameAsLastCredit) { - if (dwellMs < MIN_DWELL_MS) { - return 0; - } - if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { - return 0; - } - const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; - return static_cast(capped); -} -} // namespace - -bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { - if (!buf || bufSize < 3 || totalMs == 0) { - return false; - } - - // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). - uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; - if (minutes == 0) { - minutes = 1; - } - if (minutes < 60) { - return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE), buf, bufSize); - } - - const uint64_t hours = (minutes + 30) / 60; // minutes >= 60 ⇒ hours >= 1 - if (hours < 24) { - return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR), buf, bufSize); - } - - const uint64_t days = (hours + 12) / 24; // hours >= 24 ⇒ days >= 1 - if (days < 365) { - return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY), buf, bufSize); - } - - const uint64_t years = (days + 182) / 365; // days >= 365 ⇒ years >= 1 - return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR), buf, bufSize); -} - -bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPerMs, char* buf, - const size_t bufSize) { - if (remainingWords == 0 || wordsPerMs <= 0.0) { - return false; - } - const double ms = static_cast(remainingWords) / wordsPerMs; - if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { - return false; - } - return formatCompactDuration(static_cast(ms), buf, bufSize); -} - -bool statusBarWantsChapterTime() { - return SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || - SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME; -} - -bool formatPagesPlusTime(char* buf, const size_t bufSize) { - if (!buf || bufSize == 0) { - return false; - } - const int written = snprintf(buf, bufSize, "%s+%s", tr(STR_PAGES), tr(STR_TIME)); - return written > 0 && static_cast(written) < bufSize; -} - -bool formatChapterProgressLabel(const uint8_t mode, char* buf, const size_t bufSize) { - if (!buf || bufSize == 0) { - return false; - } - switch (mode) { - case CrossPointSettings::CHAPTER_PROGRESS_PAGES: { - const int written = snprintf(buf, bufSize, "%s", tr(STR_PAGES)); - return written > 0 && static_cast(written) < bufSize; - } - case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: - return formatPagesPlusTime(buf, bufSize); - case CrossPointSettings::CHAPTER_PROGRESS_TIME: { - const int written = snprintf(buf, bufSize, "%s", tr(STR_TIME)); - return written > 0 && static_cast(written) < bufSize; - } - case CrossPointSettings::CHAPTER_PROGRESS_HIDE: - default: { - const int written = snprintf(buf, bufSize, "%s", tr(STR_HIDE)); - return written > 0 && static_cast(written) < bufSize; - } - } -} - -void PageDwell::clear() { - // Intentionally leave lastCredited* so a clear+re-enter of the same page still - // requires REREAD_MIN_MS before another credit. - enteredMs = 0; - id0 = -1; - id1 = -1; -} - -void PageDwell::noteEntered(const int a, const int b, const unsigned long nowMs, const bool forceRestart) { - if (a < 0) { - clear(); - return; - } - if (!forceRestart && a == id0 && b == id1 && enteredMs != 0) { - return; - } - id0 = a; - id1 = b; - enteredMs = nowMs; -} - -uint32_t PageDwell::takeCredit(const int a, const int b, const uint32_t words, const unsigned long nowMs) { - if (words == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { - return 0; - } - const uint32_t associatedMs = dwellCreditMs(nowMs - enteredMs, a == lastCredited0 && b == lastCredited1); - if (associatedMs == 0) { - return 0; - } - lastCredited0 = a; - lastCredited1 = b; - return associatedMs; -} - -} // namespace ChapterTimeEstimate diff --git a/src/util/ChapterTimeEstimate.h b/src/util/ChapterTimeEstimate.h deleted file mode 100644 index 503000556ae..00000000000 --- a/src/util/ChapterTimeEstimate.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include -#include - -namespace ChapterTimeEstimate { - -// Dwell thresholds for pairing page word credits with reading time. -constexpr unsigned long MIN_DWELL_MS = 1500UL; -constexpr unsigned long REREAD_MIN_MS = 8000UL; -constexpr unsigned long MAX_DWELL_MS = 30UL * 60UL * 1000UL; - -// Compact single-unit duration for the status bar: 15m / 2h / 3d / 1y. -// Returns false when buf is too small or ms is zero (nothing to show). -bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); - -// Format remaining chapter time from remaining words and words/ms rate. -// Returns false when inputs are insufficient or the buffer is too small. -bool formatRemainingFromRate(uint32_t remainingWords, double wordsPerMs, char* buf, size_t bufSize); - -// True when the status-bar chapter setting wants a time estimate shown. -bool statusBarWantsChapterTime(); - -// Compose "Pages+Time" from STR_PAGES + '+' + STR_TIME (no dedicated i18n key). -bool formatPagesPlusTime(char* buf, size_t bufSize); - -// Label for statusBarChapterProgress enum index. Always use this instead of raw -// StrId lookup — index CHAPTER_PROGRESS_PAGES_TIME is composed, and settings -// tables may store a placeholder StrId for that slot. -bool formatChapterProgressLabel(uint8_t mode, char* buf, size_t bufSize); - -// Shared page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). -// clear() resets the active dwell window only; lastCredited* is kept so re-reads -// of the same page still require REREAD_MIN_MS before another credit. -struct PageDwell { - unsigned long enteredMs = 0; - int id0 = -1; - int id1 = -1; - int lastCredited0 = -1; - int lastCredited1 = -1; - - void clear(); - // a < 0 clears. forceRestart always resets the dwell clock; otherwise no-op - // when already tracking (a, b). - void noteEntered(int a, int b, unsigned long nowMs, bool forceRestart = false); - // If dwell qualifies and words > 0, marks credited and returns associated ms. - uint32_t takeCredit(int a, int b, uint32_t words, unsigned long nowMs); -}; - -} // namespace ChapterTimeEstimate diff --git a/src/util/PageDwell.h b/src/util/PageDwell.h new file mode 100644 index 00000000000..d34ad649543 --- /dev/null +++ b/src/util/PageDwell.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +// Page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). +// clear() resets the active dwell window only; lastCredited* is kept so re-reads +// of the same page still require REREAD_MIN_MS before another credit. +struct PageDwell { + static constexpr unsigned long MIN_DWELL_MS = 1500UL; + static constexpr unsigned long REREAD_MIN_MS = 8000UL; + static constexpr unsigned long MAX_DWELL_MS = 30UL * 60UL * 1000UL; + + unsigned long enteredMs = 0; + int id0 = -1; + int id1 = -1; + int lastCredited0 = -1; + int lastCredited1 = -1; + + void clear() { + enteredMs = 0; + id0 = -1; + id1 = -1; + } + + // a < 0 clears. forceRestart always resets the dwell clock; otherwise no-op + // when already tracking (a, b). + void noteEntered(const int a, const int b, const unsigned long nowMs, const bool forceRestart = false) { + if (a < 0) { + clear(); + return; + } + if (!forceRestart && a == id0 && b == id1 && enteredMs != 0) { + return; + } + id0 = a; + id1 = b; + enteredMs = nowMs; + } + + // If dwell qualifies and words > 0, marks credited and returns associated ms. + uint32_t takeCredit(const int a, const int b, const uint32_t words, const unsigned long nowMs) { + if (words == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { + return 0; + } + const unsigned long dwellMs = nowMs - enteredMs; + if (dwellMs < MIN_DWELL_MS) { + return 0; + } + const bool sameAsLastCredit = a == lastCredited0 && b == lastCredited1; + if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { + return 0; + } + lastCredited0 = a; + lastCredited1 = b; + const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; + return static_cast(capped); + } +}; From 4da329703a277dba9269f9a00324bbc9b734f176 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:52:40 -0700 Subject: [PATCH 18/20] refactor(reader): switch chapter ETA from word-rate to page-rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credit one page per qualified dwell sample and estimate remaining time from pages left ÷ pages/ms. Drop Section/TXT word tables, Page::countWords, and word-sample stats fields in favor of paired page samples. --- agent-docs/reading-stats.md | 18 ++-- docs/reading-stats-editor/index.html | 40 ++++---- lib/Epub/Epub/Page.cpp | 31 ------- lib/Epub/Epub/Page.h | 4 - lib/Epub/Epub/Section.cpp | 98 +------------------- lib/Epub/Epub/Section.h | 19 ---- src/JsonSettingsIO.cpp | 8 +- src/ReadingStatsStore.cpp | 39 ++++---- src/ReadingStatsStore.h | 14 +-- src/activities/reader/EpubReaderActivity.cpp | 24 +++-- src/activities/reader/ReaderUtils.h | 6 +- src/activities/reader/TxtReaderActivity.cpp | 67 +++---------- src/activities/reader/TxtReaderActivity.h | 6 -- src/util/PageDwell.h | 6 +- 14 files changed, 94 insertions(+), 286 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index 9c7a0a5fef0..cdb312ab264 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -31,20 +31,18 @@ metrics. ## Chapter time remaining (status bar) -- Rate is live-session only (`getEffectiveWordsPerMs`): no active session ⇒ no ETA. -- EPUB credits use `TextBlock::wordCount()` / `Page::countWords()` with a per-section - word LUT for exact remaining. -- TXT uses whitespace word totals + pro-rata remaining / average words-per-page - credits (no per-page word table in RAM or `index.bin`). -- Per-page word caches store `uint16` (saturate) for EPUB sections only. -- EPUB keeps per-chapter word counts in RAM (~2 B × pages); TXT keeps only - `totalBookWords`. +- Rate is live-session only (`getEffectivePagesPerMs`): no active session ⇒ no ETA. +- Credits 1 page per qualified dwell sample (EPUB and TXT); ETA = remaining pages ÷ pages/ms. +- Gate: ≥3 paired pages and ≥60s paired dwell ms. +- Remaining pages use the same page-count model as the status bar (EPUB + `estimatedTotalPages`, TXT `totalPages`), inclusive of the current page. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. - The Pages+Time setting label is composed via `CrossPointSettings::formatChapterProgressLabel` (`STR_PAGES + '+' + STR_TIME`); always use that helper for enum display. -- XTC has no word ETA (bitmap pages, no status-bar chapter-time slot). -- EPUB keeps a running `knownPageWordsTotal_` for ETA extrapolation. +- XTC has no chapter ETA (bitmap pages, no status-bar chapter-time slot). +- Older `totalWordsRead` / `totalWordsReadingMs` samples are ignored; page-rate + fields start fresh. ## Design Rules diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index a022a442c12..38ccb1ca621 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -1391,9 +1391,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Cover BMP path", chapterTitle: "Chapter title", totalReadingMinutes: "Total reading minutes", - totalWordsRead: "Words read (ETA samples)", - wordsReadingMinutes: "Minutes paired with words (ETA)", - wordsRateHint: "ETA rate uses words ÷ paired minutes (fractional minutes OK). Unpaired words are cleared on import when paired minutes are truly zero. Recalculate from days only updates total reading minutes.", + totalPagesRead: "Pages read (ETA samples)", + pagesReadingMinutes: "Minutes paired with pages (ETA)", + pagesRateHint: "ETA rate uses pages ÷ paired minutes (fractional minutes OK). Unpaired pages are cleared on import when paired minutes are truly zero. Recalculate from days only updates total reading minutes.", lastSessionMinutes: "Last session minutes", bookProgress: "Book progress %", chapterProgress: "Chapter progress %", @@ -1468,9 +1468,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Ruta de portada BMP", chapterTitle: "Título de capítulo", totalReadingMinutes: "Minutos totales de lectura", - totalWordsRead: "Palabras leídas (muestras ETA)", - wordsReadingMinutes: "Minutos emparejados con palabras (ETA)", - wordsRateHint: "La tasa ETA usa palabras ÷ minutos emparejados (se permiten fracciones). Las palabras sin emparejar se borran al importar si los minutos emparejados son realmente cero. Recalcular desde días solo actualiza los minutos totales de lectura.", + totalPagesRead: "Páginas leídas (muestras ETA)", + pagesReadingMinutes: "Minutos emparejados con páginas (ETA)", + pagesRateHint: "La tasa ETA usa páginas ÷ minutos emparejados (se permiten fracciones). Las páginas sin emparejar se borran al importar si los minutos emparejados son realmente cero. Recalcular desde días solo actualiza los minutos totales de lectura.", lastSessionMinutes: "Minutos de la última sesión", bookProgress: "Progreso del libro %", chapterProgress: "Progreso del capítulo %", @@ -3387,8 +3387,8 @@

CPR-vCodex Reading Stats Editor

chapterTitle: String(book.chapterTitle || ""), readingDays: readingDays.map(normalizeDay).filter(day => day.dayOrdinal && day.readingMs), totalReadingMs: toUInt(book.totalReadingMs), - totalWordsReadingMs: toUInt(book.totalWordsReadingMs), - totalWordsRead: toUInt(book.totalWordsRead), + totalPagesReadingMs: toUInt(book.totalPagesReadingMs), + totalPagesRead: toUInt(book.totalPagesRead), sessions: toUInt(book.sessions), lastSessionMs: toUInt(book.lastSessionMs), firstReadAt: toUInt(book.firstReadAt), @@ -3398,9 +3398,9 @@

CPR-vCodex Reading Stats Editor

chapterProgressPercent: clampPercent(book.chapterProgressPercent), completed: Boolean(book.completed) }; - // Match firmware: unpaired historical words must not skew ETA rate. - if (!normalized.totalWordsReadingMs && normalized.totalWordsRead) { - normalized.totalWordsRead = 0; + // Match firmware: unpaired page samples must not skew ETA rate. + if (!normalized.totalPagesReadingMs && normalized.totalPagesRead) { + normalized.totalPagesRead = 0; } if (!normalized.bookId) normalized.bookId = normalized.path; if (!normalized.knownPaths.includes(normalized.path) && normalized.path) { @@ -3519,8 +3519,8 @@

CPR-vCodex Reading Stats Editor

chapterTitle: "", readingDays: [], totalReadingMs: 0, - totalWordsReadingMs: 0, - totalWordsRead: 0, + totalPagesReadingMs: 0, + totalPagesRead: 0, sessions: 0, lastSessionMs: 0, firstReadAt: 0, @@ -3858,9 +3858,9 @@

${escapeHtml(t("monthlyReading"))}

${textField("coverBmpPath", t("coverBmpPath"), book.coverBmpPath)} ${textField("chapterTitle", t("chapterTitle"), book.chapterTitle)} ${numberField("totalReadingMinutes", t("totalReadingMinutes"), msToMinutes(book.totalReadingMs))} - ${numberField("totalWordsRead", t("totalWordsRead"), book.totalWordsRead)} - ${numberField("wordsReadingMinutes", t("wordsReadingMinutes"), msToExactMinutes(book.totalWordsReadingMs), "0.01")} -

${escapeHtml(t("wordsRateHint"))}

+ ${numberField("totalPagesRead", t("totalPagesRead"), book.totalPagesRead)} + ${numberField("pagesReadingMinutes", t("pagesReadingMinutes"), msToExactMinutes(book.totalPagesReadingMs), "0.01")} +

${escapeHtml(t("pagesRateHint"))}

${numberField("sessions", t("sessions"), book.sessions)} ${numberField("lastSessionMinutes", t("lastSessionMinutes"), msToMinutes(book.lastSessionMs))} ${numberField("lastProgressPercent", t("bookProgress"), book.lastProgressPercent)} @@ -3909,10 +3909,10 @@

${escapeHtml(t("monthlyReading"))}

book.coverBmpPath = value("coverBmpPath"); book.chapterTitle = value("chapterTitle"); book.totalReadingMs = minutesToMs(value("totalReadingMinutes")); - book.totalWordsRead = toUInt(value("totalWordsRead")); - book.totalWordsReadingMs = minutesToMs(value("wordsReadingMinutes")); - if (!book.totalWordsReadingMs && book.totalWordsRead) { - book.totalWordsRead = 0; + book.totalPagesRead = toUInt(value("totalPagesRead")); + book.totalPagesReadingMs = minutesToMs(value("pagesReadingMinutes")); + if (!book.totalPagesReadingMs && book.totalPagesRead) { + book.totalPagesRead = 0; } book.sessions = toUInt(value("sessions")); book.lastSessionMs = minutesToMs(value("lastSessionMinutes")); diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 5adac322c3a..080b0cbaea5 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -221,20 +221,6 @@ uint16_t PageTableFragment::getHeight() const { return total; } -uint32_t PageTableFragment::countWords() const { - uint32_t words = 0; - for (const auto& row : rows) { - for (const auto& cell : row.cells) { - for (const auto& line : cell.lines) { - if (line) { - words += line->wordCount(); - } - } - } - } - return words; -} - void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, const uint8_t bionicReadingMode) { if (columnCount == 0 || columnCount > MAX_TABLE_CELLS_PER_ROW || rows.empty() || width < 2) { @@ -356,23 +342,6 @@ void PageTableFragment::recordFontUsage(FontCacheManager& fontCacheManager, cons } } -uint32_t Page::countWords() const { - // Post-layout token count (hyphenation / ruby / focus pieces via TextBlock::wordCount). - uint32_t words = 0; - for (const auto& element : elements) { - if (!element) continue; - if (element->getTag() == TAG_PageLine) { - const auto& line = static_cast(*element); - if (line.getBlock()) { - words += line.getBlock()->wordCount(); - } - } else if (element->getTag() == TAG_PageTableFragment) { - words += static_cast(*element).countWords(); - } - } - return words; -} - void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, const uint8_t bionicReadingMode) const { for (auto& element : elements) { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 20194e7ecb5..f099b10eb99 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -120,7 +120,6 @@ class PageTableFragment final : public PageElement { PageElementTag getTag() const override { return TAG_PageTableFragment; } static std::unique_ptr deserialize(FsFile& file); uint16_t getHeight() const; - uint32_t countWords() const; void recordFontUsage(FontCacheManager& fontCacheManager, int fontId, uint8_t bionicReadingMode = 0) const; }; @@ -149,9 +148,6 @@ class Page { bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); - // Count laid-out text words (PageLine + table cells). Image-only / sparse pages return 0. - uint32_t countWords() const; - // Check if page contains any images (used to force full refresh) bool hasImages() const { return std::any_of(elements.begin(), elements.end(), [](const std::shared_ptr& el) { diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 8e2364ee160..b8806700326 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -23,7 +23,7 @@ namespace { // v40: progressive/partial cache, with vCodex ruby blocks, paragraph/list // mapping and XHTML byte offsets retained. // v41: per-page word counts appended after the li LUT (chapter time-remaining estimates). -constexpr uint8_t SECTION_FILE_VERSION = 41; +constexpr uint8_t SECTION_FILE_VERSION = 42; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -181,10 +181,8 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { if (filePartial) { // A partial's pageCount is the watermark of a suspended build. Read the watermark - // trailer (appended after the li LUT + word-count table) so estimatedTotalPages can - // extrapolate. - const uint32_t trailerOffset = - liLutOffset + static_cast(pageCount) * sizeof(uint16_t) * 2; // li + words + // trailer (appended after the li LUT) so estimatedTotalPages can extrapolate. + const uint32_t trailerOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); const bool trailerValid = pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size(); if (!trailerValid) { @@ -201,28 +199,6 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { partialPageCount_ = pageCount; } - // Load per-page word counts (v41+) from immediately after the li LUT. - pageWordCounts_.clear(); - knownPageWordsTotal_ = 0; - if (pageCount > 0) { - const uint32_t wordLutOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); - const uint32_t wordLutEnd = wordLutOffset + static_cast(pageCount) * sizeof(uint16_t); - if (liLutOffset >= HEADER_SIZE && wordLutEnd <= file.size()) { - pageWordCounts_.resize(pageCount); - file.seek(wordLutOffset); - for (uint16_t i = 0; i < pageCount; ++i) { - serialization::readPod(file, pageWordCounts_[i]); - } - recomputeKnownPageWordsTotal(); - } else { - file.close(); - LOG_ERR("SCT", "Deserialization failed: missing page word counts"); - clearCache(); - pageCount = 0; - return false; - } - } - // Explicit close() required: member variable persists beyond function scope file.close(); LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : ""); @@ -270,7 +246,6 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::function page, const ChapterHtmlSlimParser::ParagraphLutEntry syncEntry) { - const uint32_t words = page ? page->countWords() : 0; - const uint16_t wordCount = words > UINT16_MAX ? UINT16_MAX : static_cast(words); ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), syncEntry.xhtmlByteOffset, syncEntry.paragraphIndex, syncEntry.listItemIndex}); - if (pageWordCounts_.size() < ctxPtr->lut.size()) { - pageWordCounts_.resize(ctxPtr->lut.size()); - } - const size_t wordIndex = ctxPtr->lut.size() - 1; - const uint16_t previousWords = pageWordCounts_[wordIndex]; - pageWordCounts_[wordIndex] = wordCount; - knownPageWordsTotal_ = knownPageWordsTotal_ - previousWords + wordCount; }, spec.embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, spec.imageRendering, std::move(tocAnchors), popupFn, ctxPtr->cssParser); @@ -589,14 +555,8 @@ bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsume serialization::writePod(file, entry.listItemIndex); } - // Per-page word counts (v41+), immediately after the li LUT. - for (size_t i = 0; i < build_->lut.size(); ++i) { - const uint16_t wordCount = (i < pageWordCounts_.size()) ? pageWordCounts_[i] : 0; - serialization::writePod(file, wordCount); - } - if (asPartial) { - // Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t) * 2. + // Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t). serialization::writePod(file, bytesConsumed); serialization::writePod(file, totalBytes); } @@ -700,23 +660,6 @@ void Section::suspendBuild() { buildComplete_ = false; pageCount = partial_ ? partialPageCount_ : 0; builtPageCount_ = 0; - syncPageWordCountsToReadablePages(); -} - -void Section::syncPageWordCountsToReadablePages() { - if (!partial_) { - pageWordCounts_.clear(); - } else if (pageWordCounts_.size() > pageCount) { - pageWordCounts_.resize(pageCount); - } - recomputeKnownPageWordsTotal(); -} - -void Section::recomputeKnownPageWordsTotal() { - knownPageWordsTotal_ = 0; - for (const uint16_t words : pageWordCounts_) { - knownPageWordsTotal_ += words; - } } void Section::abandonBuild() { @@ -742,8 +685,6 @@ void Section::abandonBuild() { partialPageCount_ = 0; pageCount = 0; builtPageCount_ = 0; - pageWordCounts_.clear(); - knownPageWordsTotal_ = 0; } std::unique_ptr Section::loadPageDuringBuild(const int page) { @@ -1037,34 +978,3 @@ std::optional Section::getPageForListItemIndex(const uint16_t liIndex) return resultPage; } - -uint16_t Section::getPageWordCount(const uint16_t page) const { - if (page < pageWordCounts_.size()) { - return pageWordCounts_[page]; - } - return 0; -} - -uint32_t Section::estimateRemainingWords(const uint16_t fromPage) const { - const uint16_t availablePages = pageCount; - uint32_t remaining = 0; - for (uint16_t page = fromPage; page < availablePages; ++page) { - remaining += getPageWordCount(page); - } - - // Extrapolate unbuilt pages using the same estimatedTotalPages() the status-bar - // page denominator uses (partial watermark / rebuild EMA), so pages and time agree. - // Remaining includes fromPage: the reader is still on that page, so its words are unread. - const uint16_t estimatedTotal = estimatedTotalPages(); - const uint32_t knownWords = knownPageWordsTotal_; - if (knownWords > 0 && estimatedTotal > availablePages && availablePages > 0) { - const uint64_t unbuiltPages = static_cast(estimatedTotal - availablePages); - const uint64_t unbuiltWords = - (static_cast(knownWords) * unbuiltPages) / static_cast(availablePages); - if (unbuiltWords > 0 && unbuiltWords < static_cast(UINT32_MAX)) { - remaining += static_cast(unbuiltWords); - } - } - - return remaining; -} diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 766a70234fd..eb2c25fd630 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -62,20 +62,10 @@ class Section { // Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them. bool partial_ = false; uint16_t partialPageCount_ = 0; - // Per-page word counts from the section cache / in-progress build. Empty until loaded. - // Each entry is uint16 (saturates at UINT16_MAX); matches TXT index.bin word table. - // Kept in RAM (~2 B × chapter pages) — acceptable vs TXT's disk-only table; chapters - // are much smaller than whole-book TXT indexes. - std::vector pageWordCounts_; - // Sum of pageWordCounts_ (built/loaded pages only); avoids a second full walk in ETA. - uint32_t knownPageWordsTotal_ = 0; // Parse watermark from the partial's trailer, for estimating the total page count. uint32_t partialBytesConsumed_ = 0; uint32_t partialTotalBytes_ = 0; bool finalizeBuild(); - // Keep pageWordCounts_ aligned with currently readable pages after pageCount is set. - void syncPageWordCountsToReadablePages(); - void recomputeKnownPageWordsTotal(); // Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the // header, stamp the version byte, and swap the tmp .bin over filePath. bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes); @@ -161,13 +151,4 @@ class Section { // XHTML byte boundary retained for KOReader's position mapper. std::optional getXhtmlByteOffsetForPage(uint16_t page) const; - - // Word count for a built/available page (0 if unknown / out of range). - // Stored as uint16 (saturates at UINT16_MAX) to match the section cache layout — - // same ceiling as TXT index.bin per-page words; pathological dense pages undercount. - uint16_t getPageWordCount(uint16_t page) const; - // Remaining chapter words from `fromPage` inclusive, including an estimate for - // still-unbuilt pages from mean words/built-page × estimated unbuilt page count - // (estimatedTotalPages uses the same page-count model as the status bar). - uint32_t estimateRemainingWords(uint16_t fromPage) const; }; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index c8ff99e9147..15ed31336b3 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -1328,8 +1328,8 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["coverBmpPath"] = book.coverBmpPath; obj["chapterTitle"] = book.chapterTitle; obj["totalReadingMs"] = book.totalReadingMs; - obj["totalWordsReadingMs"] = book.totalWordsReadingMs; - obj["totalWordsRead"] = book.totalWordsRead; + obj["totalPagesReadingMs"] = book.totalPagesReadingMs; + obj["totalPagesRead"] = book.totalPagesRead; obj["sessions"] = book.sessions; obj["lastSessionMs"] = book.lastSessionMs; obj["firstReadAt"] = book.firstReadAt; @@ -1478,8 +1478,8 @@ bool JsonSettingsIO::loadReadingStatsDocument(ReadingStatsStore& store, const Js book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.chapterTitle = obj["chapterTitle"] | std::string(""); book.totalReadingMs = obj["totalReadingMs"] | static_cast(0); - book.totalWordsReadingMs = obj["totalWordsReadingMs"] | static_cast(0); - book.totalWordsRead = obj["totalWordsRead"] | static_cast(0); + book.totalPagesReadingMs = obj["totalPagesReadingMs"] | static_cast(0); + book.totalPagesRead = obj["totalPagesRead"] | static_cast(0); book.sessions = obj["sessions"] | static_cast(0); book.lastSessionMs = obj["lastSessionMs"] | static_cast(0); book.firstReadAt = obj["firstReadAt"] | static_cast(0); diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index b3ab8a2aa1c..6b88b9e2105 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -522,8 +522,8 @@ void ReadingStatsStore::mergeBookInto(ReadingBookStats& primary, const ReadingBo } primary.totalReadingMs += duplicate.totalReadingMs; - primary.totalWordsReadingMs += duplicate.totalWordsReadingMs; - primary.totalWordsRead += duplicate.totalWordsRead; + primary.totalPagesReadingMs += duplicate.totalPagesReadingMs; + primary.totalPagesRead += duplicate.totalPagesRead; primary.sessions += duplicate.sessions; primary.lastSessionMs = std::max(primary.lastSessionMs, duplicate.lastSessionMs); if (primary.firstReadAt == 0 || (duplicate.firstReadAt != 0 && duplicate.firstReadAt < primary.firstReadAt)) { @@ -558,10 +558,9 @@ void ReadingStatsStore::normalizeBook(ReadingBookStats& book) { normalizeReadingDays(book.readingDays); book.lastProgressPercent = clampPercent(book.lastProgressPercent); book.chapterProgressPercent = clampPercent(book.chapterProgressPercent); - // Pre-pairing builds stored words against lifetime reading ms. Drop unpaired words so - // ETA rate cannot open on ~80 new words divided by hours of historical time. - if (book.totalWordsReadingMs == 0 && book.totalWordsRead > 0) { - book.totalWordsRead = 0; + // Drop unpaired page samples so ETA rate cannot open on pages without dwell ms. + if (book.totalPagesReadingMs == 0 && book.totalPagesRead > 0) { + book.totalPagesRead = 0; } } @@ -1254,20 +1253,20 @@ void ReadingStatsStore::noteActivity() { } } -void ReadingStatsStore::noteWordsRead(const uint32_t words, const uint32_t associatedMs) { - if (!activeSession.active || activeSession.bookIndex >= books.size() || words == 0 || associatedMs == 0) { +void ReadingStatsStore::notePagesRead(const uint32_t pages, const uint32_t associatedMs) { + if (!activeSession.active || activeSession.bookIndex >= books.size() || pages == 0 || associatedMs == 0) { return; } auto& book = books[activeSession.bookIndex]; - if (book.totalWordsRead > UINT64_MAX - words) { - book.totalWordsRead = UINT64_MAX; + if (book.totalPagesRead > UINT64_MAX - pages) { + book.totalPagesRead = UINT64_MAX; } else { - book.totalWordsRead += words; + book.totalPagesRead += pages; } - if (book.totalWordsReadingMs > UINT64_MAX - associatedMs) { - book.totalWordsReadingMs = UINT64_MAX; + if (book.totalPagesReadingMs > UINT64_MAX - associatedMs) { + book.totalPagesReadingMs = UINT64_MAX; } else { - book.totalWordsReadingMs += associatedMs; + book.totalPagesReadingMs += associatedMs; } markDirty(); if (shouldSaveDeferred()) { @@ -1494,8 +1493,8 @@ void ReadingStatsStore::endSession() { saveToFile(); } -double ReadingStatsStore::getEffectiveWordsPerMs() const { - constexpr uint64_t MIN_RATE_WORDS = 80; +double ReadingStatsStore::getEffectivePagesPerMs() const { + constexpr uint64_t MIN_RATE_PAGES = 3; constexpr uint64_t MIN_RATE_MS = 60ULL * 1000ULL; // Live status-bar rate only: no active session ⇒ no ETA (rate is not a lifetime average). @@ -1504,16 +1503,16 @@ double ReadingStatsStore::getEffectiveWordsPerMs() const { } const auto& book = books[activeSession.bookIndex]; - // Only dwell ms paired with credited page words — never lifetime totalReadingMs. - if (book.totalWordsRead < MIN_RATE_WORDS || book.totalWordsReadingMs < MIN_RATE_MS) { + // Only dwell ms paired with credited pages — never lifetime totalReadingMs. + if (book.totalPagesRead < MIN_RATE_PAGES || book.totalPagesReadingMs < MIN_RATE_MS) { return 0.0; } - return static_cast(book.totalWordsRead) / static_cast(book.totalWordsReadingMs); + return static_cast(book.totalPagesRead) / static_cast(book.totalPagesReadingMs); } bool ReadingStatsStore::adjustBookReadingTime(const std::string& path, const uint32_t dayOrdinal, const int32_t deltaMs) { - // Manual day corrections adjust lifetime reading time only — never word-rate ETA samples. + // Manual day corrections adjust lifetime reading time only — never page-rate ETA samples. if (dayOrdinal == 0 || deltaMs == 0) { return false; } diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index c2ac194ec34..f0bd2111964 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -25,9 +25,9 @@ struct ReadingBookStats { std::string chapterTitle; std::vector readingDays; uint64_t totalReadingMs = 0; - // Word-rate samples only: ms co-collected with totalWordsRead (not lifetime reading time). - uint64_t totalWordsReadingMs = 0; - uint64_t totalWordsRead = 0; + // Page-rate samples only: ms co-collected with totalPagesRead (not lifetime reading time). + uint64_t totalPagesReadingMs = 0; + uint64_t totalPagesRead = 0; uint32_t sessions = 0; uint32_t lastSessionMs = 0; uint32_t firstReadAt = 0; @@ -153,18 +153,18 @@ class ReadingStatsStore { const std::string& coverBmpPath, uint8_t progressPercent = 0, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void noteActivity(); - // Credit words finished on a page together with the dwell time spent on that page. + // Credit pages finished together with the dwell time spent on that page. // Samples are marked dirty immediately; deferred save runs when the interval is due. - void noteWordsRead(uint32_t words, uint32_t associatedMs); + void notePagesRead(uint32_t pages, uint32_t associatedMs); void tickActiveSession(); void resumeSession(); void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); - // Live status-bar rate only: paired word/dwell samples for the active book. + // Live status-bar rate only: paired page/dwell samples for the active book. // Returns 0 with no active session or when paired samples are below the gate // (rate is not a lifetime average and vanishes when the session ends). - double getEffectiveWordsPerMs() const; + double getEffectivePagesPerMs() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); bool updateBookMetadata(const std::string& path, const std::string& title, const std::string& author, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7b4c985abf0..4b8e2c4a210 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1330,13 +1330,13 @@ void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int pa return; } - const uint16_t words = section->getPageWordCount(static_cast(page)); - const uint32_t associatedMs = pageDwell.takeCredit(spineIndex, page, words, millis()); + constexpr uint32_t kPages = 1; + const uint32_t associatedMs = pageDwell.takeCredit(spineIndex, page, kPages, millis()); if (associatedMs == 0) { return; } - READING_STATS.noteWordsRead(words, associatedMs); + READING_STATS.notePagesRead(kPages, associatedMs); } void EpubReaderActivity::pageTurn(bool isForwardTurn) { @@ -2030,13 +2030,17 @@ void EpubReaderActivity::renderStatusBar() const { char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; if (section->currentPage >= 0) { - const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - // Skip remaining-words walk when rate is 0 or time is hidden. - if (SETTINGS.statusBarWantsChapterTime() && wordsPerMs > 0.0 && - ReaderUtils::formatRemainingFromRate( - section->estimateRemainingWords(static_cast(section->currentPage)), wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf))) { - chapterTimeEstimate = chapterTimeBuf; + const double pagesPerMs = READING_STATS.getEffectivePagesPerMs(); + if (SETTINGS.statusBarWantsChapterTime() && pagesPerMs > 0.0) { + const uint16_t estimatedTotal = section->estimatedTotalPages(); + const int current = section->currentPage; + const uint32_t remainingPages = + (current >= 0 && estimatedTotal > static_cast(current)) + ? static_cast(estimatedTotal - static_cast(current)) + : 0; + if (ReaderUtils::formatRemainingFromRate(remainingPages, pagesPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; + } } } diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 6ad33705f25..790f036b8b9 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -278,12 +278,12 @@ inline bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_ return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR)); } -inline bool formatRemainingFromRate(const uint32_t remainingWords, const double wordsPerMs, char* buf, +inline bool formatRemainingFromRate(const uint32_t remainingPages, const double pagesPerMs, char* buf, const size_t bufSize) { - if (remainingWords == 0 || wordsPerMs <= 0.0) { + if (remainingPages == 0 || pagesPerMs <= 0.0) { return false; } - const double ms = static_cast(remainingWords) / wordsPerMs; + const double ms = static_cast(remainingPages) / pagesPerMs; if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { return false; } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index fd5daaa6787..a4504ed8e65 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -30,8 +30,8 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -// v9: totalBookWords only (pro-rata remaining / average page credits; no per-page word table). -constexpr uint8_t CACHE_VERSION = 9; +// v10: page offsets only (chapter ETA uses page-rate; no word totals). +constexpr uint8_t CACHE_VERSION = 10; constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -328,7 +328,6 @@ void TxtReaderActivity::onExit() { creditCurrentPageWords(); pageOffsets.clear(); - totalBookWords = 0; currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -496,7 +495,6 @@ void TxtReaderActivity::initializeReader() { void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); - totalBookWords = 0; pageOffsets.push_back(0); // First page starts at offset 0 size_t offset = 0; @@ -514,8 +512,6 @@ void TxtReaderActivity::buildPageIndex() { break; } - totalBookWords += countWordsInLines(tempLines); - if (nextOffset <= offset) { // No progress made, avoid infinite loop break; @@ -533,28 +529,10 @@ void TxtReaderActivity::buildPageIndex() { } totalPages = pageOffsets.size(); - LOG_DBG("TRS", "Built page index: %d pages, %lu words", totalPages, static_cast(totalBookWords)); + LOG_DBG("TRS", "Built page index: %d pages", totalPages); savePageIndexCache(); } -uint32_t TxtReaderActivity::countWordsInLines(const std::vector& lines) const { - // Simple whitespace tokenization is enough for TXT pro-rata / average credits. - // EPUB keeps layout-token counts; cross-format rates are intentionally coarse. - uint32_t words = 0; - for (const auto& line : lines) { - bool inWord = false; - for (const unsigned char c : line.text) { - if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { - inWord = false; - } else if (!inWord) { - inWord = true; - ++words; - } - } - } - return words; -} - void TxtReaderActivity::creditCurrentPageWords() { maybeCreditPageWords(currentPage); pageDwell.clear(); @@ -565,30 +543,13 @@ void TxtReaderActivity::maybeCreditPageWords(const int page) { return; } - const uint32_t words = averageWordsPerPage(); - const uint32_t associatedMs = pageDwell.takeCredit(page, 0, words, millis()); + constexpr uint32_t kPages = 1; + const uint32_t associatedMs = pageDwell.takeCredit(page, 0, kPages, millis()); if (associatedMs == 0) { return; } - READING_STATS.noteWordsRead(words, associatedMs); -} - -uint32_t TxtReaderActivity::averageWordsPerPage() const { - if (totalPages <= 0 || totalBookWords == 0) { - return 0; - } - return totalBookWords / static_cast(totalPages); -} - -uint32_t TxtReaderActivity::estimateRemainingWords(const int fromPage) const { - if (fromPage < 0 || totalPages <= 0 || fromPage >= totalPages || totalBookWords == 0) { - return 0; - } - // Pro-rate the book total across remaining pages (inclusive of fromPage). - const uint32_t pagesLeft = static_cast(totalPages - fromPage); - return static_cast((static_cast(totalBookWords) * pagesLeft) / - static_cast(totalPages)); + READING_STATS.notePagesRead(kPages, associatedMs); } void TxtReaderActivity::resumeAfterSubactivity() { @@ -882,10 +843,11 @@ void TxtReaderActivity::renderStatusBar() const { // Sized for multi-byte unit suffixes; formatCompactDuration fails closed if still too small. char chapterTimeBuf[24] = {}; const char* chapterTimeEstimate = nullptr; - const double wordsPerMs = READING_STATS.getEffectiveWordsPerMs(); - if (SETTINGS.statusBarWantsChapterTime() && wordsPerMs > 0.0 && - ReaderUtils::formatRemainingFromRate(estimateRemainingWords(currentPage), wordsPerMs, chapterTimeBuf, - sizeof(chapterTimeBuf))) { + const double pagesPerMs = READING_STATS.getEffectivePagesPerMs(); + const uint32_t remainingPages = + (currentPage >= 0 && totalPages > currentPage) ? static_cast(totalPages - currentPage) : 0; + if (SETTINGS.statusBarWantsChapterTime() && pagesPerMs > 0.0 && + ReaderUtils::formatRemainingFromRate(remainingPages, pagesPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { chapterTimeEstimate = chapterTimeBuf; } @@ -952,7 +914,6 @@ bool TxtReaderActivity::loadPageIndexCache() { // - uint8_t: paragraph alignment // - uint32_t: total pages count // - N * uint32_t: page offsets - // - uint32_t: totalBookWords std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; @@ -1021,7 +982,6 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, numPages); pageOffsets.clear(); - totalBookWords = 0; pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { @@ -1029,11 +989,9 @@ bool TxtReaderActivity::loadPageIndexCache() { serialization::readPod(f, offset); pageOffsets.push_back(offset); } - serialization::readPod(f, totalBookWords); totalPages = pageOffsets.size(); - LOG_DBG("TRS", "Loaded page index cache: %d pages, %lu words", totalPages, - static_cast(totalBookWords)); + LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages); return true; } @@ -1058,7 +1016,6 @@ void TxtReaderActivity::savePageIndexCache() { for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } - serialization::writePod(f, totalBookWords); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 20bd20b5a23..b7afc69a37b 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -34,9 +34,6 @@ class TxtReaderActivity final : public Activity { // Streaming text reader - stores file offsets for each page std::vector pageOffsets; // File offset for start of each page - // Book-wide word total for pro-rata remaining / average words-per-page credits. - // Avoids a per-page word table in RAM or on disk (ESP32-C3 heap + simpler cache). - uint32_t totalBookWords = 0; std::vector currentPageLines; int linesPerPage = 0; int viewportWidth = 0; @@ -66,8 +63,6 @@ class TxtReaderActivity final : public Activity { void buildPageIndex(); bool loadPageIndexCache(); void savePageIndexCache(); - uint32_t estimateRemainingWords(int fromPage) const; - uint32_t averageWordsPerPage() const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); @@ -76,7 +71,6 @@ class TxtReaderActivity final : public Activity { void maybeCreditPageWords(int page); void resumeAfterSubactivity(); void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); - uint32_t countWordsInLines(const std::vector& lines) const; std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); diff --git a/src/util/PageDwell.h b/src/util/PageDwell.h index d34ad649543..a455b71c629 100644 --- a/src/util/PageDwell.h +++ b/src/util/PageDwell.h @@ -37,9 +37,9 @@ struct PageDwell { enteredMs = nowMs; } - // If dwell qualifies and words > 0, marks credited and returns associated ms. - uint32_t takeCredit(const int a, const int b, const uint32_t words, const unsigned long nowMs) { - if (words == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { + // If dwell qualifies and pages > 0, marks credited and returns associated ms. + uint32_t takeCredit(const int a, const int b, const uint32_t pages, const unsigned long nowMs) { + if (pages == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { return 0; } const unsigned long dwellMs = nowMs - enteredMs; From faa99d603bc4b94df44ad19e5d4349f615448c77 Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:06:54 -0700 Subject: [PATCH 19/20] fix(reader): tidy chapter ETA naming, cache version, and helpers Rename page-credit helpers, keep Section cache at v40 with accurate comments, move ETA formatters into ReaderUtils.cpp, restore const on TXT cache save, and clarify chapter-progress / legacy word-field handling. --- lib/Epub/Epub/Section.cpp | 7 ++- src/JsonSettingsIO.cpp | 2 + src/SettingsList.cpp | 4 +- src/activities/reader/EpubReaderActivity.cpp | 16 ++--- src/activities/reader/EpubReaderActivity.h | 4 +- src/activities/reader/ReaderUtils.cpp | 63 ++++++++++++++++++++ src/activities/reader/ReaderUtils.h | 52 ++-------------- src/activities/reader/TxtReaderActivity.cpp | 18 +++--- src/activities/reader/TxtReaderActivity.h | 6 +- src/network/CrossPointWebServer.cpp | 5 +- 10 files changed, 100 insertions(+), 77 deletions(-) create mode 100644 src/activities/reader/ReaderUtils.cpp diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index b8806700326..f72ce47620e 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -22,8 +22,9 @@ namespace { // first render). // v40: progressive/partial cache, with vCodex ruby blocks, paragraph/list // mapping and XHTML byte offsets retained. -// v41: per-page word counts appended after the li LUT (chapter time-remaining estimates). -constexpr uint8_t SECTION_FILE_VERSION = 42; +// (A transient v41 word-count LUT was explored for chapter ETA then abandoned; +// page-rate ETA needs no section format change, so the on-disk layout stays v40.) +constexpr uint8_t SECTION_FILE_VERSION = 40; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -172,7 +173,7 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { serialization::readPod(file, pageCount); - // One seek for the li LUT offset — used by both the partial trailer and the word LUT. + // One seek for the li LUT offset (also locates the partial watermark trailer). uint32_t liLutOffset = 0; if (pageCount > 0 || filePartial) { file.seek(HEADER_SIZE - sizeof(uint32_t)); diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 15ed31336b3..df6a0b507d2 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -1478,6 +1478,8 @@ bool JsonSettingsIO::loadReadingStatsDocument(ReadingStatsStore& store, const Js book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.chapterTitle = obj["chapterTitle"] | std::string(""); book.totalReadingMs = obj["totalReadingMs"] | static_cast(0); + // Page-rate ETA samples. Legacy totalWordsRead / totalWordsReadingMs (word-rate + // prototypes) are intentionally ignored — rates are not convertible. book.totalPagesReadingMs = obj["totalPagesReadingMs"] | static_cast(0); book.totalPagesRead = obj["totalPagesRead"] | static_cast(0); book.sessions = obj["sessions"] | static_cast(0); diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index 86e7f302762..14a5f8aef8a 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,8 +227,8 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- - // Index 1 is CHAPTER_PROGRESS_PAGES_TIME — display via formatChapterProgressLabel only - // (placeholder StrId here is never shown raw). + // Enum length only — UI labels always come from formatChapterProgressLabel + // (Pages+Time is composed; these StrIds are never shown raw). SettingInfo::Enum(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}, "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 4b8e2c4a210..c4fd89c8c2e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -340,7 +340,7 @@ void EpubReaderActivity::onExit() { // Credit if this path did not already (early exits credit before endSession). // endSession is idempotent: a second call keeps lastSessionSnapshot for the // post-read stats banner. recordSessionEnded dedupes by snapshot serial. - creditCurrentPageWords(); + creditCurrentPage(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); @@ -557,7 +557,7 @@ void EpubReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - creditCurrentPageWords(); + creditCurrentPage(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -1284,7 +1284,7 @@ std::string EpubReaderActivity::moveCompletedBookIfEnabled() { } void EpubReaderActivity::exitReaderAfterOptionalCompletedMove() { - creditCurrentPageWords(); + creditCurrentPage(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -1318,14 +1318,14 @@ void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activ }); } -void EpubReaderActivity::creditCurrentPageWords() { +void EpubReaderActivity::creditCurrentPage() { if (!automaticPageTurnActive && section && section->currentPage >= 0) { - maybeCreditPageWords(currentSpineIndex, section->currentPage); + maybeCreditPage(currentSpineIndex, section->currentPage); } pageDwell.clear(); } -void EpubReaderActivity::maybeCreditPageWords(const int spineIndex, const int page) { +void EpubReaderActivity::maybeCreditPage(const int spineIndex, const int page) { if (!section || page < 0 || spineIndex < 0) { return; } @@ -1354,7 +1354,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (isForwardTurn) { // Auto page-turn must not train the reading-rate samples. if (!automaticPageTurnActive) { - maybeCreditPageWords(oldSpineIndex, oldPage); + maybeCreditPage(oldSpineIndex, oldPage); } if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { section->currentPage++; @@ -2242,7 +2242,7 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { cachedChapterTotalPageCount = section->estimatedTotalPages(); } // Credit before releasing the section — onExit cannot credit after section.reset(). - creditCurrentPageWords(); + creditCurrentPage(); section.reset(); epub.reset(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index a0b42242e4e..d77067508f5 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -126,8 +126,8 @@ class EpubReaderActivity final : public Activity { // noteActivity + clear dwell, then resumeSession + restart dwell on return. void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); // Credit the current page's dwell sample while the reading session is still active. - void creditCurrentPageWords(); - void maybeCreditPageWords(int spineIndex, int page); + void creditCurrentPage(); + void maybeCreditPage(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void cacheCurrentPageForOverlay(const std::shared_ptr& page, int marginLeft, int marginTop); diff --git a/src/activities/reader/ReaderUtils.cpp b/src/activities/reader/ReaderUtils.cpp new file mode 100644 index 00000000000..a055f450250 --- /dev/null +++ b/src/activities/reader/ReaderUtils.cpp @@ -0,0 +1,63 @@ +#include "ReaderUtils.h" + +#include + +#include +#include + +namespace ReaderUtils { +namespace { +constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; + +bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const size_t bufSize) { + if (!unit || unit[0] == '\0') { + return false; + } + // ETA unit suffixes are authored in EN+ES only; other locales fall back to + // English via I18n (intentional — do not invent unit translations everywhere). + const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); + return written > 0 && static_cast(written) < bufSize; +} +} // namespace + +bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { + if (!buf || bufSize < 3 || totalMs == 0) { + return false; + } + + // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). + uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; + if (minutes == 0) { + minutes = 1; + } + if (minutes < 60) { + return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE), buf, bufSize); + } + + const uint64_t hours = (minutes + 30) / 60; + if (hours < 24) { + return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR), buf, bufSize); + } + + const uint64_t days = (hours + 12) / 24; + if (days < 365) { + return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY), buf, bufSize); + } + + const uint64_t years = (days + 182) / 365; + return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR), buf, bufSize); +} + +bool formatRemainingFromRate(const uint32_t remainingPages, const double pagesPerMs, char* buf, + const size_t bufSize) { + if (remainingPages == 0 || pagesPerMs <= 0.0) { + return false; + } + const double ms = static_cast(remainingPages) / pagesPerMs; + if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { + return false; + } + return formatCompactDuration(static_cast(ms), buf, bufSize); +} + +} // namespace ReaderUtils diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 790f036b8b9..e41948b6741 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -3,12 +3,11 @@ #include #include #include -#include #include #include #include -#include +#include #include #include #include @@ -243,51 +242,8 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) { renderer.restoreBwBuffer(); } -inline bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { - if (!buf || bufSize < 3 || totalMs == 0) { - return false; - } - constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; - auto formatRoundedUnit = [&](const uint64_t value, const char* unit) { - if (!unit || unit[0] == '\0') { - return false; - } - // ETA unit suffixes are authored in EN+ES only; other locales fall back to - // English via I18n (intentional — do not invent unit translations everywhere). - const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); - return written > 0 && static_cast(written) < bufSize; - }; - - // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). - uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; - if (minutes == 0) { - minutes = 1; - } - if (minutes < 60) { - return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE)); - } - const uint64_t hours = (minutes + 30) / 60; - if (hours < 24) { - return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR)); - } - const uint64_t days = (hours + 12) / 24; - if (days < 365) { - return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY)); - } - const uint64_t years = (days + 182) / 365; - return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR)); -} - -inline bool formatRemainingFromRate(const uint32_t remainingPages, const double pagesPerMs, char* buf, - const size_t bufSize) { - if (remainingPages == 0 || pagesPerMs <= 0.0) { - return false; - } - const double ms = static_cast(remainingPages) / pagesPerMs; - if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { - return false; - } - return formatCompactDuration(static_cast(ms), buf, bufSize); -} +// Compact chapter-ETA duration / remaining-time helpers (defined in ReaderUtils.cpp). +bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); +bool formatRemainingFromRate(uint32_t remainingPages, double pagesPerMs, char* buf, size_t bufSize); } // namespace ReaderUtils diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index a4504ed8e65..633320a8baf 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -325,7 +325,7 @@ void TxtReaderActivity::onExit() { // Credit if this path did not already (early exits credit before endSession). // endSession is idempotent: a second call keeps lastSessionSnapshot for the // post-read stats banner. recordSessionEnded dedupes by snapshot serial. - creditCurrentPageWords(); + creditCurrentPage(); pageOffsets.clear(); currentPageLines.clear(); @@ -358,7 +358,7 @@ void TxtReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - creditCurrentPageWords(); + creditCurrentPage(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -392,13 +392,13 @@ void TxtReaderActivity::loop() { } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); - maybeCreditPageWords(currentPage); + maybeCreditPage(currentPage); currentPage++; pageDwell.noteEntered(currentPage, 0, millis()); requestUpdate(); } else { READING_STATS.noteActivity(); - maybeCreditPageWords(currentPage); + maybeCreditPage(currentPage); READING_STATS.updateProgress(100, true, "", 100); exitReaderAfterOptionalCompletedMove(); } @@ -449,7 +449,7 @@ std::string TxtReaderActivity::moveCompletedBookIfEnabled() { } void TxtReaderActivity::exitReaderAfterOptionalCompletedMove() { - creditCurrentPageWords(); + creditCurrentPage(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -533,12 +533,12 @@ void TxtReaderActivity::buildPageIndex() { savePageIndexCache(); } -void TxtReaderActivity::creditCurrentPageWords() { - maybeCreditPageWords(currentPage); +void TxtReaderActivity::creditCurrentPage() { + maybeCreditPage(currentPage); pageDwell.clear(); } -void TxtReaderActivity::maybeCreditPageWords(const int page) { +void TxtReaderActivity::maybeCreditPage(const int page) { if (page < 0) { return; } @@ -995,7 +995,7 @@ bool TxtReaderActivity::loadPageIndexCache() { return true; } -void TxtReaderActivity::savePageIndexCache() { +void TxtReaderActivity::savePageIndexCache() const { std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; if (!Storage.openFileForWrite("TRS", cachePath, f)) { diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index b7afc69a37b..c36eca7ae81 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -62,13 +62,13 @@ class TxtReaderActivity final : public Activity { bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); void buildPageIndex(); bool loadPageIndexCache(); - void savePageIndexCache(); + void savePageIndexCache() const; void saveProgress() const; void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); - void creditCurrentPageWords(); - void maybeCreditPageWords(int page); + void creditCurrentPage(); + void maybeCreditPage(int page); void resumeAfterSubactivity(); void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); std::string moveCompletedBookIfEnabled(); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 564af090010..48129b27761 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -291,7 +291,8 @@ constexpr StrId OPT_SHORTCUT_LOCATION[] = {StrId::STR_HOME_LOCATION, StrId::STR_ constexpr StrId OPT_KO_MATCH[] = {StrId::STR_FILENAME, StrId::STR_BINARY}; constexpr StrId OPT_OPDS_FILENAME_FORMAT[] = {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}; constexpr StrId OPT_BOOK_CHAPTER_HIDE[] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; -// Index 1 is CHAPTER_PROGRESS_PAGES_TIME — JSON options use formatChapterProgressLabel. +// Length-only for WEB_ENUM: JSON option labels always use formatChapterProgressLabel +// (Pages+Time is composed; these StrIds are never sent to the client). constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}; constexpr StrId OPT_BAR_THICKNESS[] = {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; @@ -1917,7 +1918,7 @@ void CrossPointWebServer::handleGetSettings() const { if (CrossPointSettings::formatChapterProgressLabel(i, label, sizeof(label))) { sendJsonEscaped(server.get(), label); } else { - sendJsonEscaped(server.get(), I18N.get(s.options[i])); + sendJsonEscaped(server.get(), ""); } } else { sendJsonEscaped(server.get(), I18N.get(s.options[i])); From 298d0aed22b3b9ec859d59bd41a58354b23e4cfd Mon Sep 17 00:00:00 2001 From: kinland <16787581+kinland@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:38:45 -0700 Subject: [PATCH 20/20] Change "Pages+Time" to "Pages + Time" for consistency w/ similar settings --- agent-docs/reading-stats.md | 4 ++-- src/CrossPointSettings.cpp | 2 +- src/CrossPointSettings.h | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index cdb312ab264..e227fb5a163 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -38,8 +38,8 @@ metrics. `estimatedTotalPages`, TXT `totalPages`), inclusive of the current page. - `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other locales fall back to English. -- The Pages+Time setting label is composed via `CrossPointSettings::formatChapterProgressLabel` - (`STR_PAGES + '+' + STR_TIME`); always use that helper for enum display. +- The Pages + Time setting label is composed via `CrossPointSettings::formatChapterProgressLabel` + (`STR_PAGES + ' + ' + STR_TIME`); always use that helper for enum display. - XTC has no chapter ETA (bitmap pages, no status-bar chapter-time slot). - Older `totalWordsRead` / `totalWordsReadingMs` samples are ignored; page-rate fields start fresh. diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 4820e8ffff0..03a90d16a7b 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -503,7 +503,7 @@ bool CrossPointSettings::formatChapterProgressLabel(const uint8_t mode, char* bu return written > 0 && static_cast(written) < bufSize; } case CHAPTER_PROGRESS_PAGES_TIME: { - const int written = snprintf(buf, bufSize, "%s+%s", tr(STR_PAGES), tr(STR_TIME)); + const int written = snprintf(buf, bufSize, "%s + %s", tr(STR_PAGES), tr(STR_TIME)); return written > 0 && static_cast(written) < bufSize; } case CHAPTER_PROGRESS_TIME: { diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d443d94701b..da9b998c9da 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -474,7 +474,7 @@ class CrossPointSettings { statusBarChapterProgress == CHAPTER_PROGRESS_TIME; } // Label for statusBarChapterProgress enum index. Pages+Time is composed from - // STR_PAGES + '+' + STR_TIME (no dedicated i18n key). + // STR_PAGES + ' + ' + STR_TIME (no dedicated i18n key). static bool formatChapterProgressLabel(uint8_t mode, char* buf, size_t bufSize); };