Merge pull request #1 from MatthijsMars/feature/time-left-in-chapter

Feature/time left in chapter
This commit is contained in:
Matthijs Mars 2026-01-09 20:40:45 +01:00 committed by GitHub
commit eb82c12499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 153 additions and 19 deletions

View File

@ -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<PageLine*>(element.get());
count += line->wordCount();
}
return count;
}
bool Page::serialize(FsFile& file) const { bool Page::serialize(FsFile& file) const {
const uint16_t count = elements.size(); const uint16_t count = elements.size();
serialization::writePod(file, count); serialization::writePod(file, count);

View File

@ -29,6 +29,7 @@ class PageLine final : public PageElement {
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; 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; bool serialize(FsFile& file) override;
static std::unique_ptr<PageLine> deserialize(FsFile& file); static std::unique_ptr<PageLine> deserialize(FsFile& file);
}; };
@ -38,6 +39,7 @@ class Page {
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
size_t wordCount() const;
bool serialize(FsFile& file) const; bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file); static std::unique_ptr<Page> deserialize(FsFile& file);
}; };

View File

@ -233,3 +233,64 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
file.close(); file.close();
return page; return page;
} }
std::unique_ptr<Page> 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<uint32_t>(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;
}

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <vector>
#include "Epub.h" #include "Epub.h"
@ -13,6 +14,8 @@ class Section {
GfxRenderer& renderer; GfxRenderer& renderer;
std::string filePath; std::string filePath;
FsFile file; FsFile file;
mutable std::vector<uint32_t> pageWordCounts;
mutable bool wordCountsLoaded = false;
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight); uint16_t viewportWidth, uint16_t viewportHeight);
@ -36,4 +39,7 @@ class Section {
const std::function<void()>& progressSetupFn = nullptr, const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr); const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile(); std::unique_ptr<Page> loadPageFromSectionFile();
std::unique_ptr<Page> loadPageAt(uint16_t pageIndex) const;
bool ensureWordCountsLoaded() const;
uint32_t getWordsLeftFrom(uint16_t pageIndex) const;
}; };

View File

@ -36,6 +36,7 @@ class TextBlock final : public Block {
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const; void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; } BlockType getType() override { return TEXT_BLOCK; }
size_t wordCount() const { return words.size(); }
bool serialize(FsFile& file) const; bool serialize(FsFile& file) const;
static std::unique_ptr<TextBlock> deserialize(FsFile& file); static std::unique_ptr<TextBlock> deserialize(FsFile& file);
}; };

View File

@ -12,9 +12,9 @@
CrossPointSettings CrossPointSettings::instance; CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 2;
// Increment this when adding new persisted settings fields // 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"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -46,6 +46,8 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writePod(outputFile, sleepScreenCoverMode);
serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writeString(outputFile, std::string(opdsServerUrl));
serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, textAntiAliasing);
serialization::writePod(outputFile, readingSpeedWpm);
serialization::writePod(outputFile, showTimeLeftInChapter);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -60,7 +62,7 @@ bool CrossPointSettings::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(inputFile, 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); Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close(); inputFile.close();
return false; return false;
@ -110,6 +112,15 @@ bool CrossPointSettings::loadFromFile() {
} }
serialization::readPod(inputFile, textAntiAliasing); serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break; 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); } while (false);
inputFile.close(); inputFile.close();

View File

@ -61,6 +61,10 @@ class CrossPointSettings {
// Text rendering settings // Text rendering settings
uint8_t extraParagraphSpacing = 1; uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 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 // Duration of the power button press
uint8_t shortPwrBtn = 0; uint8_t shortPwrBtn = 0;
// EPUB reading orientation settings // EPUB reading orientation settings

View File

@ -5,6 +5,10 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
@ -17,6 +21,15 @@ namespace {
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000; constexpr unsigned long goHomeMs = 1000;
constexpr int statusBarMargin = 19; constexpr int statusBarMargin = 19;
std::string formatMinutes(const float minutes) {
if (minutes <= 0.0f) {
return "";
}
const int totalMinutes = static_cast<int>(std::floor(minutes));
return std::to_string(totalMinutes) + "m";
}
} // namespace } // namespace
void EpubReaderActivity::taskTrampoline(void* param) { void EpubReaderActivity::taskTrampoline(void* param) {
@ -426,10 +439,20 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
if (showProgress) { if (showProgress) {
// Calculate progress in book // Calculate progress in book
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount; const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
std::string timeLeftText;
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); 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<float>(wordsLeft) / static_cast<float>(std::max<uint8_t>(1, SETTINGS.readingSpeedWpm));
timeLeftText = formatMinutes(minutesLeft);
}
}
// Right aligned text for progress counter // 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) + "%"; " " + std::to_string(bookProgress) + "%";
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,

View File

@ -13,7 +13,7 @@
// Define the static settings list // Define the static settings list
namespace { namespace {
constexpr int settingsCount = 18; constexpr int settingsCount = 20;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), 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::Value("Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}), {"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, SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}), {"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
@ -127,14 +129,21 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr); const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(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 // 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 // Wrap to minValue if exceeding setting value boundary
if (currentValue + setting.valueRange.step > setting.valueRange.max) { if (currentValue + setting.valueRange.step > setting.valueRange.max) {
SETTINGS.*(setting.valuePtr) = setting.valueRange.min; SETTINGS.*(setting.valuePtr16) = setting.valueRange.min;
} else { } 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<uint8_t>(setting.valueRange.min);
} else {
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(currentValue + setting.valueRange.step);
} }
} else if (setting.type == SettingType::ACTION) { } else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "Calibre Settings") == 0) { 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) { } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
valueText = settingsList[i].enumValues[value]; 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) { } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
} }

View File

@ -15,32 +15,37 @@ enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
// Structure to hold setting information // Structure to hold setting information
struct SettingInfo { struct SettingInfo {
const char* name; // Display name of the setting const char* name; // Display name of the setting
SettingType type; // Type of setting SettingType type; // Type of setting
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE) uint8_t CrossPointSettings::* valuePtr; // Pointer for 8-bit settings (TOGGLE/ENUM)
uint16_t CrossPointSettings::* valuePtr16; // Pointer for 16-bit VALUE settings
std::vector<std::string> enumValues; std::vector<std::string> enumValues;
struct ValueRange { struct ValueRange {
uint8_t min; uint16_t min;
uint8_t max; uint16_t max;
uint8_t step; uint16_t step;
}; };
// Bounds/step for VALUE type settings // Bounds/step for VALUE type settings
ValueRange valueRange; ValueRange valueRange;
// Static constructors // Static constructors
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { 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<std::string> values) { static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> 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) { 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};
} }
}; };