From b3ab864102f7064726940c25e06abbc69560421d Mon Sep 17 00:00:00 2001 From: Matthijs Mars Date: Fri, 9 Jan 2026 21:24:32 +0100 Subject: [PATCH] merge branch 'main' into feature/track-reading-time --- lib/Epub/Epub/Page.cpp | 10 ++++ lib/Epub/Epub/Page.h | 2 + lib/Epub/Epub/Section.cpp | 61 ++++++++++++++++++++ lib/Epub/Epub/Section.h | 6 ++ lib/Epub/Epub/blocks/TextBlock.h | 1 + src/CrossPointSettings.cpp | 17 +++++- src/CrossPointSettings.h | 4 ++ src/activities/reader/EpubReaderActivity.cpp | 13 ++++- src/activities/settings/SettingsActivity.cpp | 21 +++++-- src/activities/settings/SettingsActivity.h | 25 ++++---- 10 files changed, 140 insertions(+), 20 deletions(-) diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 92839eb7..b8acd701 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -31,6 +31,16 @@ void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, co } } +size_t Page::wordCount() const { + size_t count = 0; + for (const auto& element : elements) { + // Only PageLine is stored in elements; avoid RTTI to stay compatible with -fno-rtti + const auto* line = static_cast(element.get()); + count += line->wordCount(); + } + return count; +} + bool Page::serialize(FsFile& file) const { const uint16_t count = elements.size(); serialization::writePod(file, count); diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 20061941..1e65796b 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -29,6 +29,7 @@ class PageLine final : public PageElement { PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + size_t wordCount() const { return block ? block->wordCount() : 0; } bool serialize(FsFile& file) override; static std::unique_ptr deserialize(FsFile& file); }; @@ -38,6 +39,7 @@ class Page { // the list of block index and line numbers on this page std::vector> elements; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; + size_t wordCount() const; bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 18b81aae..059d095a 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -233,3 +233,64 @@ std::unique_ptr Section::loadPageFromSectionFile() { file.close(); return page; } + +std::unique_ptr Section::loadPageAt(const uint16_t pageIndex) const { + FsFile localFile; + if (!SdMan.openFileForRead("SCT", filePath, localFile)) { + return nullptr; + } + + localFile.seek(HEADER_SIZE - sizeof(uint32_t)); + uint32_t lutOffset; + serialization::readPod(localFile, lutOffset); + if (pageIndex >= pageCount) { + localFile.close(); + return nullptr; + } + + localFile.seek(lutOffset + sizeof(uint32_t) * pageIndex); + uint32_t pagePos; + serialization::readPod(localFile, pagePos); + localFile.seek(pagePos); + + auto page = Page::deserialize(localFile); + localFile.close(); + return page; +} + +bool Section::ensureWordCountsLoaded() const { + if (wordCountsLoaded) { + return true; + } + + pageWordCounts.clear(); + pageWordCounts.reserve(pageCount); + + for (uint16_t i = 0; i < pageCount; i++) { + auto page = loadPageAt(i); + if (!page) { + pageWordCounts.clear(); + return false; + } + pageWordCounts.push_back(static_cast(page->wordCount())); + } + + wordCountsLoaded = true; + return true; +} + +uint32_t Section::getWordsLeftFrom(const uint16_t pageIndex) const { + if (pageIndex >= pageCount) { + return 0; + } + + if (!ensureWordCountsLoaded()) { + return 0; + } + + uint32_t total = 0; + for (size_t i = pageIndex; i < pageWordCounts.size(); i++) { + total += pageWordCounts[i]; + } + return total; +} diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index bac95efd..6deaddc0 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include "Epub.h" @@ -13,6 +14,8 @@ class Section { GfxRenderer& renderer; std::string filePath; FsFile file; + mutable std::vector pageWordCounts; + mutable bool wordCountsLoaded = false; void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight); @@ -36,4 +39,7 @@ class Section { const std::function& progressSetupFn = nullptr, const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSectionFile(); + std::unique_ptr loadPageAt(uint16_t pageIndex) const; + bool ensureWordCountsLoaded() const; + uint32_t getWordsLeftFrom(uint16_t pageIndex) const; }; diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 415a18f3..7535c888 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -36,6 +36,7 @@ class TextBlock final : public Block { // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } + size_t wordCount() const { return words.size(); } bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); }; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index cd8b56f7..ee90dbbf 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,9 +12,9 @@ CrossPointSettings CrossPointSettings::instance; namespace { -constexpr uint8_t SETTINGS_FILE_VERSION = 1; +constexpr uint8_t SETTINGS_FILE_VERSION = 2; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 17; +constexpr uint8_t SETTINGS_COUNT = 19; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -46,6 +46,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); + serialization::writePod(outputFile, readingSpeedWpm); + serialization::writePod(outputFile, showTimeLeftInChapter); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -60,7 +62,7 @@ bool CrossPointSettings::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); - if (version != SETTINGS_FILE_VERSION) { + if (version != SETTINGS_FILE_VERSION && version != 1) { Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); inputFile.close(); return false; @@ -110,6 +112,15 @@ bool CrossPointSettings::loadFromFile() { } serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; + if (version == 1) { + uint8_t wpmV1; + serialization::readPod(inputFile, wpmV1); + readingSpeedWpm = wpmV1; + } else { + serialization::readPod(inputFile, readingSpeedWpm); + } + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, showTimeLeftInChapter); } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 3a2a3503..8d66a3e0 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -61,6 +61,10 @@ class CrossPointSettings { // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; + // Reading speed (words per minute) for time-left estimate + uint16_t readingSpeedWpm = 200; + // Toggle to show time remaining in the current chapter + uint8_t showTimeLeftInChapter = 0; // Duration of the power button press uint8_t shortPwrBtn = 0; // EPUB reading orientation settings diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 0a6054f3..d0b31e83 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -24,7 +24,6 @@ namespace { constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; -constexpr const char* readingStatsFilePath = "/ReadingStats.csv"; std::string formatMinutes(const float minutes) { if (minutes <= 0.0f) { @@ -572,10 +571,20 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in if (showProgress) { // Calculate progress in book const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + std::string timeLeftText; const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + if (SETTINGS.showTimeLeftInChapter && SETTINGS.readingSpeedWpm > 0) { + const uint32_t wordsLeft = section->getWordsLeftFrom(section->currentPage); + if (wordsLeft > 0) { + const float minutesLeft = + static_cast(wordsLeft) / static_cast(std::max(1, SETTINGS.readingSpeedWpm)); + timeLeftText = formatMinutes(minutesLeft); + } + } // Right aligned text for progress counter - const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + + const std::string progress = (timeLeftText.empty() ? std::string() : timeLeftText + " ") + + std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + " " + std::to_string(bookProgress) + "%"; progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 702db172..83c2e33a 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,7 +13,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 18; +constexpr int settingsCount = 20; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -35,6 +35,8 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Value("Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, {"Justify", "Left", "Center", "Right"}), + SettingInfo::Value("Reading Speed (WPM)", &CrossPointSettings::readingSpeedWpm, {150, 300, 5}), + SettingInfo::Toggle("Show Time Left In Chapter", &CrossPointSettings::showTimeLeftInChapter), SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, @@ -127,14 +129,21 @@ void SettingsActivity::toggleCurrentSetting() { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); - } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { + } else if (setting.type == SettingType::VALUE && setting.valuePtr16 != nullptr) { // Decreasing would also be nice for large ranges I think but oh well can't have everything - const int8_t currentValue = SETTINGS.*(setting.valuePtr); + const uint16_t currentValue = SETTINGS.*(setting.valuePtr16); // Wrap to minValue if exceeding setting value boundary if (currentValue + setting.valueRange.step > setting.valueRange.max) { - SETTINGS.*(setting.valuePtr) = setting.valueRange.min; + SETTINGS.*(setting.valuePtr16) = setting.valueRange.min; } else { - SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; + SETTINGS.*(setting.valuePtr16) = currentValue + setting.valueRange.step; + } + } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { + const uint16_t currentValue = SETTINGS.*(setting.valuePtr); + if (currentValue + setting.valueRange.step > setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = static_cast(setting.valueRange.min); + } else { + SETTINGS.*(setting.valuePtr) = static_cast(currentValue + setting.valueRange.step); } } else if (setting.type == SettingType::ACTION) { if (strcmp(setting.name, "Calibre Settings") == 0) { @@ -202,6 +211,8 @@ void SettingsActivity::render() const { } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); valueText = settingsList[i].enumValues[value]; + } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr16 != nullptr) { + valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr16)); } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 157689e3..c4fb3da3 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -15,32 +15,37 @@ enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; // Structure to hold setting information struct SettingInfo { - const char* name; // Display name of the setting - SettingType type; // Type of setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE) + const char* name; // Display name of the setting + SettingType type; // Type of setting + uint8_t CrossPointSettings::* valuePtr; // Pointer for 8-bit settings (TOGGLE/ENUM) + uint16_t CrossPointSettings::* valuePtr16; // Pointer for 16-bit VALUE settings std::vector enumValues; struct ValueRange { - uint8_t min; - uint8_t max; - uint8_t step; + uint16_t min; + uint16_t max; + uint16_t step; }; // Bounds/step for VALUE type settings ValueRange valueRange; // Static constructors static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; + return {name, SettingType::TOGGLE, ptr, nullptr}; } static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { - return {name, SettingType::ENUM, ptr, std::move(values)}; + return {name, SettingType::ENUM, ptr, nullptr, std::move(values)}; } - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr, nullptr}; } + + static SettingInfo Value(const char* name, uint16_t CrossPointSettings::* ptr, const ValueRange valueRange) { + return {name, SettingType::VALUE, nullptr, ptr, {}, valueRange}; + } static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { - return {name, SettingType::VALUE, ptr, {}, valueRange}; + return {name, SettingType::VALUE, ptr, nullptr, {}, valueRange}; } };