diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 92839eb7..a2d44ffa 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -31,6 +31,17 @@ 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) { + const auto* line = dynamic_cast(element.get()); + if (line) { + 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..53958236 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // 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()); @@ -110,6 +112,9 @@ bool CrossPointSettings::loadFromFile() { } serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; + 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..a71499db 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 + uint8_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 ac0ffd51..60d32ecc 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -5,6 +5,10 @@ #include #include +#include +#include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" @@ -17,6 +21,28 @@ namespace { constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; + +std::string formatMinutes(const float minutes) { + if (minutes <= 0.0f) { + return ""; + } + + const int totalMinutes = static_cast(std::ceil(minutes)); + if (totalMinutes < 60) { + return std::to_string(totalMinutes) + "m"; + } + + const int hours = totalMinutes / 60; + const int mins = totalMinutes % 60; + + char buffer[12]; + if (mins == 0) { + std::snprintf(buffer, sizeof(buffer), "%dh", hours); + } else { + std::snprintf(buffer, sizeof(buffer), "%dh %02dm", hours, mins); + } + return std::string(buffer); +} } // namespace void EpubReaderActivity::taskTrampoline(void* param) { @@ -428,9 +454,20 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + std::string timeLeftText; + 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) + - " " + std::to_string(bookProgress) + "%"; + " " + std::to_string(bookProgress) + "%" + + (timeLeftText.empty() ? std::string() : " " + timeLeftText + " left"); progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, progress.c_str()); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 702db172..cda78eb2 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, {80, 240, 10}), + SettingInfo::Toggle("Show Time Left", &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,