diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index dc031fd..cf3e1f1 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -61,6 +62,8 @@ void HomeActivity::onEnter() { } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { lastBookTitle.resize(lastBookTitle.length() - 4); } + + loadReadingTime(); } selectorIndex = 0; @@ -257,6 +260,10 @@ void HomeActivity::render() const { if (!lastBookAuthor.empty()) { totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; } + const bool showReadTime = lastBookSeconds > 0; + if (showReadTime) { + totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID); + } // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; @@ -275,8 +282,15 @@ void HomeActivity::render() const { trimmedAuthor.append("..."); } renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + titleYStart += renderer.getLineHeight(UI_10_FONT_ID); } + if (showReadTime) { + const std::string timeText = std::string("Read time: ") + formatDuration(lastBookSeconds); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, timeText.c_str(), !bookSelected); + } + + // Footer label stays at the bottom of the card renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, "Continue Reading", !bookSelected); } else { @@ -343,3 +357,72 @@ void HomeActivity::render() const { renderer.displayBuffer(); } + +void HomeActivity::loadReadingTime() { + lastBookSeconds = 0; + if (APP_STATE.openEpubPath.empty()) { + return; + } + + FsFile f; + if (!SdMan.openFileForRead("ERS", "/ReadingStats.csv", f)) { + return; + } + + const size_t fileSize = f.size(); + if (fileSize == 0) { + f.close(); + return; + } + + std::string content; + content.resize(fileSize); + const auto bytesRead = f.read(reinterpret_cast(&content[0]), fileSize); + f.close(); + if (bytesRead != fileSize) { + return; + } + + size_t pos = 0; + while (pos < content.size()) { + const size_t eol = content.find('\n', pos); + const size_t lineEnd = (eol == std::string::npos) ? content.size() : eol; + const auto line = content.substr(pos, lineEnd - pos); + + const auto comma = line.find(','); + if (comma != std::string::npos) { + const auto path = line.substr(0, comma); + if (path == APP_STATE.openEpubPath) { + lastBookSeconds = static_cast(strtoul(line.c_str() + comma + 1, nullptr, 10)); + break; + } + } + + if (eol == std::string::npos) { + break; + } + pos = eol + 1; + } +} + +std::string HomeActivity::formatDuration(const uint32_t seconds) { + if (seconds < 60) { + return std::to_string(seconds) + "s"; + } + + const uint32_t minutesTotal = seconds / 60; + if (minutesTotal < 60) { + return std::to_string(minutesTotal) + "m"; + } + + const uint32_t hours = minutesTotal / 60; + const uint32_t minutes = minutesTotal % 60; + + if (hours < 24) { + return std::to_string(hours) + "h " + std::to_string(minutes) + "m"; + } + + const uint32_t days = hours / 24; + const uint32_t remHours = hours % 24; + return std::to_string(days) + "d " + std::to_string(remHours) + "h"; +} diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 84cb5bf..8798982 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -14,6 +14,7 @@ class HomeActivity final : public Activity { bool updateRequired = false; bool hasContinueReading = false; bool hasOpdsUrl = false; + uint32_t lastBookSeconds = 0; std::string lastBookTitle; std::string lastBookAuthor; const std::function onContinueReading; @@ -26,6 +27,8 @@ class HomeActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render() const; int getMenuItemCount() const; + void loadReadingTime(); + static std::string formatDuration(uint32_t seconds); public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f51cf9b..78ca241 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -5,6 +5,13 @@ #include #include +#include +#include +#include +#include +#include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" @@ -17,6 +24,16 @@ 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) { + return ""; + } + + const int totalMinutes = static_cast(std::floor(minutes)); + return std::to_string(totalMinutes) + "m"; +} } // namespace void EpubReaderActivity::taskTrampoline(void* param) { @@ -63,6 +80,9 @@ void EpubReaderActivity::onEnter() { } f.close(); } + + loadReadingStats(); + lastPageInteractionMs = millis(); // We may want a better condition to detect if we are opening for the first time. // This will trigger if the book is re-opened at Chapter 0. if (currentSpineIndex == 0) { @@ -116,6 +136,7 @@ void EpubReaderActivity::loop() { // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + recordReadingTimeDelta(); // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); @@ -139,12 +160,14 @@ void EpubReaderActivity::loop() { // Long press BACK (1s+) goes directly to home if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + recordReadingTimeDelta(); onGoHome(); return; } // Short press BACK goes to file selection if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + recordReadingTimeDelta(); onGoBack(); return; } @@ -160,6 +183,8 @@ void EpubReaderActivity::loop() { return; } + recordReadingTimeDelta(); + // any botton press when at end of the book goes back to the last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { currentSpineIndex = epub->getSpineItemsCount() - 1; @@ -411,6 +436,127 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or renderer.restoreBwBuffer(); } +void EpubReaderActivity::recordReadingTimeDelta() { + if (!epub) { + return; + } + + const unsigned long now = millis(); + const unsigned long deltaMs = now - lastPageInteractionMs; + lastPageInteractionMs = now; + + const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); + if (deltaMs < 1000 || deltaMs > sleepTimeoutMs) { + return; // ignore very short taps and anything longer than the sleep timeout + } + + currentBookSeconds += static_cast(deltaMs / 1000); // whole seconds only + persistReadingStats(); +} + +void EpubReaderActivity::loadReadingStats() { + currentBookSeconds = 0; + + FsFile f; + if (!SdMan.openFileForRead("ERS", readingStatsFilePath, f)) { + return; // No stats file yet + } + + const size_t fileSize = f.size(); + if (fileSize == 0) { + f.close(); + return; + } + + std::string content; + content.resize(fileSize); + const auto bytesRead = f.read(reinterpret_cast(&content[0]), fileSize); + f.close(); + if (bytesRead != fileSize) { + return; + } + + size_t pos = 0; + while (pos < content.size()) { + const size_t eol = content.find('\n', pos); + const size_t lineEnd = (eol == std::string::npos) ? content.size() : eol; + const auto line = content.substr(pos, lineEnd - pos); + + const auto comma = line.find(','); + if (comma != std::string::npos) { + const auto path = line.substr(0, comma); + if (path == epub->getPath()) { + currentBookSeconds = static_cast(strtoul(line.c_str() + comma + 1, nullptr, 10)); + break; + } + } + + if (eol == std::string::npos) { + break; + } + pos = eol + 1; + } +} + +void EpubReaderActivity::persistReadingStats() const { + if (!epub) { + return; + } + + std::vector> rows; + + FsFile f; + if (SdMan.openFileForRead("ERS", readingStatsFilePath, f)) { + const size_t fileSize = f.size(); + if (fileSize > 0) { + std::string content; + content.resize(fileSize); + const auto bytesRead = f.read(reinterpret_cast(&content[0]), fileSize); + if (bytesRead == fileSize) { + size_t pos = 0; + while (pos < content.size()) { + const size_t eol = content.find('\n', pos); + const size_t lineEnd = (eol == std::string::npos) ? content.size() : eol; + const auto line = content.substr(pos, lineEnd - pos); + + const auto comma = line.find(','); + if (comma != std::string::npos) { + const auto path = line.substr(0, comma); + const uint32_t seconds = static_cast(strtoul(line.c_str() + comma + 1, nullptr, 10)); + rows.emplace_back(path, seconds); + } + + if (eol == std::string::npos) { + break; + } + pos = eol + 1; + } + } + } + f.close(); + } + + const auto existing = std::find_if(rows.begin(), rows.end(), [this](const std::pair& row) { + return row.first == epub->getPath(); + }); + + if (existing != rows.end()) { + existing->second = currentBookSeconds; + } else { + rows.emplace_back(epub->getPath(), currentBookSeconds); + } + + if (!SdMan.openFileForWrite("ERS", readingStatsFilePath, f)) { + return; + } + + for (const auto& row : rows) { + const std::string line = row.first + "," + std::to_string(row.second) + "\n"; + f.write(reinterpret_cast(line.c_str()), line.size()); + } + f.close(); +} + void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { // determine visible status bar elements diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 63d4887..7e3adca 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -16,6 +16,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int nextPageNumber = 0; int pagesUntilFullRefresh = 0; bool updateRequired = false; + unsigned long lastPageInteractionMs = 0; + uint32_t currentBookSeconds = 0; const std::function onGoBack; const std::function onGoHome; @@ -25,6 +27,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity { void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + void recordReadingTimeDelta(); + void loadReadingStats(); + void persistReadingStats() const; public: explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub,