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 <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";
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user