add per book reading time tracking and display on home screen

This commit is contained in:
Matthijs Mars 2026-01-09 21:07:13 +01:00
parent d4ae108d9b
commit 02ebc2cf6e
4 changed files with 237 additions and 0 deletions

View File

@ -4,6 +4,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <vector> #include <vector>
@ -60,6 +61,8 @@ void HomeActivity::onEnter() {
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4); lastBookTitle.resize(lastBookTitle.length() - 4);
} }
loadReadingTime();
} }
selectorIndex = 0; selectorIndex = 0;
@ -256,6 +259,10 @@ void HomeActivity::render() const {
if (!lastBookAuthor.empty()) { if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; 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 // Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
@ -274,8 +281,15 @@ void HomeActivity::render() const {
trimmedAuthor.append("..."); trimmedAuthor.append("...");
} }
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); 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, renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2,
"Continue Reading", !bookSelected); "Continue Reading", !bookSelected);
} else { } else {
@ -337,3 +351,72 @@ void HomeActivity::render() const {
renderer.displayBuffer(); 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<uint8_t*>(&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<uint32_t>(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";
}

View File

@ -14,6 +14,7 @@ class HomeActivity final : public Activity {
bool updateRequired = false; bool updateRequired = false;
bool hasContinueReading = false; bool hasContinueReading = false;
bool hasOpdsUrl = false; bool hasOpdsUrl = false;
uint32_t lastBookSeconds = 0;
std::string lastBookTitle; std::string lastBookTitle;
std::string lastBookAuthor; std::string lastBookAuthor;
const std::function<void()> onContinueReading; const std::function<void()> onContinueReading;
@ -26,6 +27,8 @@ class HomeActivity final : public Activity {
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
int getMenuItemCount() const; int getMenuItemCount() const;
void loadReadingTime();
static std::string formatDuration(uint32_t seconds);
public: public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -5,6 +5,13 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <utility>
#include <vector>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
@ -17,6 +24,16 @@ 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;
constexpr const char* readingStatsFilePath = "/ReadingStats.csv";
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) {
@ -63,6 +80,9 @@ void EpubReaderActivity::onEnter() {
} }
f.close(); f.close();
} }
loadReadingStats();
lastPageInteractionMs = millis();
// We may want a better condition to detect if we are opening for the first time. // 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. // This will trigger if the book is re-opened at Chapter 0.
if (currentSpineIndex == 0) { if (currentSpineIndex == 0) {
@ -116,6 +136,7 @@ void EpubReaderActivity::loop() {
// Enter chapter selection activity // Enter chapter selection activity
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
recordReadingTimeDelta();
// Don't start activity transition while rendering // Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
@ -139,12 +160,14 @@ void EpubReaderActivity::loop() {
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes directly to home
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
recordReadingTimeDelta();
onGoHome(); onGoHome();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes to file selection
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
recordReadingTimeDelta();
onGoBack(); onGoBack();
return; return;
} }
@ -158,6 +181,8 @@ void EpubReaderActivity::loop() {
return; return;
} }
recordReadingTimeDelta();
// any botton press when at end of the book goes back to the last page // any botton press when at end of the book goes back to the last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount() - 1; currentSpineIndex = epub->getSpineItemsCount() - 1;
@ -409,6 +434,127 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderer.restoreBwBuffer(); 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<uint32_t>(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<uint8_t*>(&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<uint32_t>(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<std::pair<std::string, uint32_t>> 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<uint8_t*>(&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<uint32_t>(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<std::string, uint32_t>& 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<const uint8_t*>(line.c_str()), line.size());
}
f.close();
}
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) const { const int orientedMarginLeft) const {
// determine visible status bar elements // determine visible status bar elements

View File

@ -16,6 +16,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int nextPageNumber = 0; int nextPageNumber = 0;
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
bool updateRequired = false; bool updateRequired = false;
unsigned long lastPageInteractionMs = 0;
uint32_t currentBookSeconds = 0;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
@ -25,6 +27,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight, void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft); int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
void recordReadingTimeDelta();
void loadReadingStats();
void persistReadingStats() const;
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,