From e4c8ef07dd86ef8112d3ff42a7b37f5f8f86b62a Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 4 Jan 2026 19:15:54 -0500 Subject: [PATCH] Add recent books to home screen --- src/RecentBooksStore.cpp | 86 +++++++++ src/RecentBooksStore.h | 32 ++++ src/activities/home/HomeActivity.cpp | 76 +++++--- src/activities/home/HomeActivity.h | 8 +- src/activities/home/RecentBooksActivity.cpp | 182 +++++++++++++++++++ src/activities/home/RecentBooksActivity.h | 36 ++++ src/activities/reader/EpubReaderActivity.cpp | 4 +- src/activities/reader/XtcReaderActivity.cpp | 4 +- src/main.cpp | 12 +- 9 files changed, 408 insertions(+), 32 deletions(-) create mode 100644 src/RecentBooksStore.cpp create mode 100644 src/RecentBooksStore.h create mode 100644 src/activities/home/RecentBooksActivity.cpp create mode 100644 src/activities/home/RecentBooksActivity.h diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp new file mode 100644 index 00000000..03cfbbd7 --- /dev/null +++ b/src/RecentBooksStore.cpp @@ -0,0 +1,86 @@ +#include "RecentBooksStore.h" + +#include +#include +#include + +#include + +namespace { +constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 1; +constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; +constexpr int MAX_RECENT_BOOKS = 10; +} // namespace + +RecentBooksStore RecentBooksStore::instance; + +void RecentBooksStore::addBook(const std::string& path) { + // Remove existing entry if present + auto it = std::find(recentBooks.begin(), recentBooks.end(), path); + if (it != recentBooks.end()) { + recentBooks.erase(it); + } + + // Add to front + recentBooks.insert(recentBooks.begin(), path); + + // Trim to max size + if (recentBooks.size() > MAX_RECENT_BOOKS) { + recentBooks.resize(MAX_RECENT_BOOKS); + } + + saveToFile(); +} + +bool RecentBooksStore::saveToFile() const { + // Make sure the directory exists + SdMan.mkdir("/.crosspoint"); + + FsFile outputFile; + if (!SdMan.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) { + return false; + } + + serialization::writePod(outputFile, RECENT_BOOKS_FILE_VERSION); + const uint8_t count = static_cast(recentBooks.size()); + serialization::writePod(outputFile, count); + + for (const auto& book : recentBooks) { + serialization::writeString(outputFile, book); + } + + outputFile.close(); + Serial.printf("[%lu] [RBS] Recent books saved to file (%d entries)\n", millis(), count); + return true; +} + +bool RecentBooksStore::loadFromFile() { + FsFile inputFile; + if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) { + return false; + } + + uint8_t version; + serialization::readPod(inputFile, version); + if (version != RECENT_BOOKS_FILE_VERSION) { + Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); + inputFile.close(); + return false; + } + + uint8_t count; + serialization::readPod(inputFile, count); + + recentBooks.clear(); + recentBooks.reserve(count); + + for (uint8_t i = 0; i < count; i++) { + std::string path; + serialization::readString(inputFile, path); + recentBooks.push_back(path); + } + + inputFile.close(); + Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), count); + return true; +} diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h new file mode 100644 index 00000000..b98bd406 --- /dev/null +++ b/src/RecentBooksStore.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include + +class RecentBooksStore { + // Static instance + static RecentBooksStore instance; + + std::vector recentBooks; + + public: + ~RecentBooksStore() = default; + + // Get singleton instance + static RecentBooksStore& getInstance() { return instance; } + + // Add a book path to the recent list (moves to front if already exists) + void addBook(const std::string& path); + + // Get the list of recent book paths (most recent first) + const std::vector& getBooks() const { return recentBooks; } + + // Get the count of recent books + int getCount() const { return static_cast(recentBooks.size()); } + + bool saveToFile() const; + + bool loadFromFile(); +}; + +// Helper macro to access recent books store +#define RECENT_BOOKS RecentBooksStore::getInstance() diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 11107fdc..ff0e6b8f 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -6,6 +6,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" @@ -14,7 +15,12 @@ void HomeActivity::taskTrampoline(void* param) { self->displayTaskLoop(); } -int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } +int HomeActivity::getMenuItemCount() const { + int count = 3; // Base: Browse, File transfer, Settings + if (hasContinueReading) count++; + if (hasRecentBooks) count++; + return count; +} void HomeActivity::onEnter() { Activity::onEnter(); @@ -24,6 +30,9 @@ void HomeActivity::onEnter() { // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); + // Check if we have more than one recent book + hasRecentBooks = RECENT_BOOKS.getCount() > 1; + if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; @@ -86,26 +95,29 @@ void HomeActivity::loop() { const int menuCount = getMenuItemCount(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (hasContinueReading) { - // Menu: Continue Reading, Browse, File transfer, Settings - if (selectorIndex == 0) { - onContinueReading(); - } else if (selectorIndex == 1) { - onReaderOpen(); - } else if (selectorIndex == 2) { - onFileTransferOpen(); - } else if (selectorIndex == 3) { - onSettingsOpen(); - } - } else { - // Menu: Browse, File transfer, Settings - if (selectorIndex == 0) { - onReaderOpen(); - } else if (selectorIndex == 1) { - onFileTransferOpen(); - } else if (selectorIndex == 2) { - onSettingsOpen(); - } + // Menu order: [Continue Reading], [Recent Books], Browse, File transfer, Settings + // Calculate index offsets based on what's shown + int idx = selectorIndex; + + if (hasContinueReading && idx == 0) { + onContinueReading(); + return; + } + if (hasContinueReading) idx--; + + if (hasRecentBooks && idx == 0) { + onRecentBooksOpen(); + return; + } + if (hasRecentBooks) idx--; + + // Now idx is 0-based for: Browse, File transfer, Settings + if (idx == 0) { + onReaderOpen(); + } else if (idx == 1) { + onFileTransferOpen(); + } else if (idx == 2) { + onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; @@ -277,11 +289,13 @@ void HomeActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } - // --- Bottom menu tiles (indices 1-3) --- + // --- Bottom menu tiles --- + // Menu tiles: [Recent books], Browse files, File transfer, Settings + const int menuTileCount = hasRecentBooks ? 4 : 3; const int menuTileWidth = pageWidth - 2 * margin; constexpr int menuTileHeight = 50; constexpr int menuSpacing = 10; - constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + const int totalMenuHeight = menuTileCount * menuTileHeight + (menuTileCount - 1) * menuSpacing; int menuStartY = bookY + bookHeight + 20; // Ensure we don't collide with the bottom button legend @@ -290,9 +304,8 @@ void HomeActivity::render() const { menuStartY = maxMenuStartY; } - for (int i = 0; i < 3; ++i) { - constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"}; - const int overallIndex = i + (getMenuItemCount() - 3); + for (int i = 0; i < menuTileCount; ++i) { + const int overallIndex = i + (getMenuItemCount() - menuTileCount); constexpr int tileX = margin; const int tileY = menuStartY + i * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; @@ -303,7 +316,16 @@ void HomeActivity::render() const { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } - const char* label = items[i]; + // Determine label based on tile index and whether recent books is shown + const char* label; + if (hasRecentBooks) { + constexpr const char* itemsWithRecent[4] = {"Recent books", "Browse files", "File transfer", "Settings"}; + label = itemsWithRecent[i]; + } else { + constexpr const char* itemsNoRecent[3] = {"Browse files", "File transfer", "Settings"}; + label = itemsNoRecent[i]; + } + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = tileX + (menuTileWidth - textWidth) / 2; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index b6c9767d..30b0d011 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,12 +13,14 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + bool hasRecentBooks = false; std::string lastBookTitle; std::string lastBookAuthor; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; + const std::function onRecentBooksOpen; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -28,12 +30,14 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onContinueReading, const std::function& onReaderOpen, - const std::function& onSettingsOpen, const std::function& onFileTransferOpen) + const std::function& onSettingsOpen, const std::function& onFileTransferOpen, + const std::function& onRecentBooksOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), - onFileTransferOpen(onFileTransferOpen) {} + onFileTransferOpen(onFileTransferOpen), + onRecentBooksOpen(onRecentBooksOpen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp new file mode 100644 index 00000000..22e311a9 --- /dev/null +++ b/src/activities/home/RecentBooksActivity.cpp @@ -0,0 +1,182 @@ +#include "RecentBooksActivity.h" + +#include +#include +#include + +#include "MappedInputManager.h" +#include "RecentBooksStore.h" +#include "fontIds.h" + +namespace { +// Time threshold for treating a long press as a page-up/page-down +constexpr int SKIP_PAGE_MS = 700; +} // namespace + +int RecentBooksActivity::getPageItems() const { + // Layout constants used in render + constexpr int startY = 60; + constexpr int lineHeight = 30; + + const int screenHeight = renderer.getScreenHeight(); + const int availableHeight = screenHeight - startY; + int items = availableHeight / lineHeight; + + // Ensure we always have at least one item per page to avoid division by zero + if (items < 1) { + items = 1; + } + return items; +} + +void RecentBooksActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void RecentBooksActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Load book titles from recent books list + bookTitles.clear(); + const auto& books = RECENT_BOOKS.getBooks(); + bookTitles.reserve(books.size()); + + for (const auto& path : books) { + // Skip if file no longer exists + if (!SdMan.exists(path.c_str())) { + bookTitles.emplace_back("[Missing]"); + continue; + } + + // Extract filename from path for display + std::string title = path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + + const std::string ext5 = title.length() >= 5 ? title.substr(title.length() - 5) : ""; + const std::string ext4 = title.length() >= 4 ? title.substr(title.length() - 4) : ""; + + // If epub, try to load the metadata for title + if (ext5 == ".epub") { + Epub epub(path, "/.crosspoint"); + epub.load(false); + if (!epub.getTitle().empty()) { + title = std::string(epub.getTitle()); + } + } else if (ext5 == ".xtch") { + title.resize(title.length() - 5); + } else if (ext4 == ".xtc") { + title.resize(title.length() - 4); + } + + bookTitles.push_back(title); + } + + selectorIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&RecentBooksActivity::taskTrampoline, "RecentBooksActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void RecentBooksActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + bookTitles.clear(); +} + +void RecentBooksActivity::loop() { + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = getPageItems(); + const int bookCount = RECENT_BOOKS.getCount(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (bookCount > 0 && selectorIndex < bookCount) { + const auto& books = RECENT_BOOKS.getBooks(); + onSelectBook(books[selectorIndex]); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + } else if (prevReleased && bookCount > 0) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + bookCount) % bookCount; + } else { + selectorIndex = (selectorIndex + bookCount - 1) % bookCount; + } + updateRequired = true; + } else if (nextReleased && bookCount > 0) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % bookCount; + } else { + selectorIndex = (selectorIndex + 1) % bookCount; + } + updateRequired = true; + } +} + +void RecentBooksActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void RecentBooksActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int bookCount = RECENT_BOOKS.getCount(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Recent Books", true, EpdFontFamily::BOLD); + + // Help text + const auto labels = mappedInput.mapLabels("« Back", "Open", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (bookCount == 0) { + renderer.drawText(UI_10_FONT_ID, 20, 60, "No recent books"); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); + + for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { + auto item = renderer.truncatedText(UI_10_FONT_ID, bookTitles[i].c_str(), pageWidth - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, item.c_str(), i != selectorIndex); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/home/RecentBooksActivity.h b/src/activities/home/RecentBooksActivity.h new file mode 100644 index 00000000..1bb693cf --- /dev/null +++ b/src/activities/home/RecentBooksActivity.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +class RecentBooksActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectorIndex = 0; + bool updateRequired = false; + std::vector bookTitles; // Display titles for each book + const std::function onGoBack; + const std::function onSelectBook; + + // Number of items that fit on a page + int getPageItems() const; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoBack, + const std::function& onSelectBook) + : Activity("RecentBooks", renderer, mappedInput), onGoBack(onGoBack), onSelectBook(onSelectBook) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d3cd5016..2b0da344 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -9,6 +9,7 @@ #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" @@ -76,9 +77,10 @@ void EpubReaderActivity::onEnter() { } } - // Save current epub as last opened epub + // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(epub->getPath()); // Trigger first update updateRequired = true; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c0580cf6..28823e9a 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -14,6 +14,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" #include "fontIds.h" @@ -41,9 +42,10 @@ void XtcReaderActivity::onEnter() { // Load saved progress loadProgress(); - // Save current XTC as last opened book + // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(xtc->getPath()); // Trigger first update updateRequired = true; diff --git a/src/main.cpp b/src/main.cpp index e81448bd..a56e15b6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,9 +11,11 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/home/HomeActivity.h" +#include "activities/home/RecentBooksActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" @@ -222,10 +224,16 @@ void onGoToSettings() { enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); } +void onGoHome(); +void onGoToRecentBooks() { + exitActivity(); + enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); +} + void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings, - onGoToFileTransfer)); + onGoToFileTransfer, onGoToRecentBooks)); } void setupDisplayAndFonts() { @@ -289,6 +297,8 @@ void setup() { enterNewActivity(new BootActivity(renderer, mappedInputManager)); APP_STATE.loadFromFile(); + RECENT_BOOKS.loadFromFile(); + if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else {