mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-07 08:07:40 +03:00
add per book reading time tracking and display on home screen
This commit is contained in:
parent
d4ae108d9b
commit
02ebc2cf6e
@ -4,6 +4,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
@ -60,6 +61,8 @@ void HomeActivity::onEnter() {
|
||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
|
||||
lastBookTitle.resize(lastBookTitle.length() - 4);
|
||||
}
|
||||
|
||||
loadReadingTime();
|
||||
}
|
||||
|
||||
selectorIndex = 0;
|
||||
@ -256,6 +259,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;
|
||||
@ -274,8 +281,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 {
|
||||
@ -337,3 +351,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<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";
|
||||
}
|
||||
|
||||
@ -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<void()> 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,
|
||||
|
||||
@ -5,6 +5,13 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<int>(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;
|
||||
}
|
||||
@ -158,6 +181,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;
|
||||
@ -409,6 +434,127 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> 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<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,
|
||||
const int orientedMarginLeft) const {
|
||||
// determine visible status bar elements
|
||||
|
||||
@ -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<void()> onGoBack;
|
||||
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,
|
||||
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> epub,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user