From 2c24ee3f81ad4165237f9594418b90a6fdabfa5e Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 26 Jan 2026 02:08:59 -0500 Subject: [PATCH] Add reading lists feature with pinning and management Adds full support for book lists managed by the Companion App: - New /list API endpoints (GET/POST) for uploading, retrieving, and deleting lists - BookListStore for binary serialization of lists to /.lists/ directory - ListViewActivity for viewing list contents with book thumbnails - Reading Lists tab in My Library with pin/unpin and delete actions - Pinnable list shortcut on home screen (split button layout) - Automatic cleanup of pinned status when lists are deleted --- docs/webserver-api-reference.md | 72 ++++++ src/BookListStore.cpp | 248 +++++++++++++++++++ src/BookListStore.h | 85 +++++++ src/CrossPointSettings.cpp | 10 +- src/CrossPointSettings.h | 3 + src/activities/home/HomeActivity.cpp | 85 ++++++- src/activities/home/HomeActivity.h | 7 +- src/activities/home/ListViewActivity.cpp | 275 +++++++++++++++++++++ src/activities/home/ListViewActivity.h | 52 ++++ src/activities/home/MyLibraryActivity.cpp | 277 +++++++++++++++++++++- src/activities/home/MyLibraryActivity.h | 28 ++- src/main.cpp | 40 +++- src/network/CrossPointWebServer.cpp | 143 +++++++++++ src/network/CrossPointWebServer.h | 4 + 14 files changed, 1296 insertions(+), 33 deletions(-) create mode 100644 src/BookListStore.cpp create mode 100644 src/BookListStore.h create mode 100644 src/activities/home/ListViewActivity.cpp create mode 100644 src/activities/home/ListViewActivity.h diff --git a/docs/webserver-api-reference.md b/docs/webserver-api-reference.md index edd8d60..c80e9af 100644 --- a/docs/webserver-api-reference.md +++ b/docs/webserver-api-reference.md @@ -181,6 +181,77 @@ Source: `src/network/CrossPointWebServer.cpp` and `CrossPointWebServer.h` - Falls back to copy+delete if rename fails - Uses `deleteFolderRecursive()` for folder cleanup +### GET /list +**Handler:** `handleListGet()` +**Query params:** +- `name` (optional): Specific list name to retrieve +**Response:** JSON array of lists (if no name) or single list details (if name specified) +**Content-Type:** application/json + +**Response (all lists - no name param):** +```json +[ + {"name": "MyReadingList", "path": "/.lists/MyReadingList.bin", "bookCount": 5}, + {"name": "Favorites", "path": "/.lists/Favorites.bin", "bookCount": 12} +] +``` + +**Response (specific list - with name param):** +```json +{ + "name": "MyReadingList", + "path": "/.lists/MyReadingList.bin", + "books": [ + {"order": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "path": "/Books/gatsby.epub"}, + {"order": 2, "title": "1984", "author": "George Orwell", "path": "/Books/1984.epub"} + ] +} +``` + +**Errors:** +- 404: List not found (when `name` specified but doesn't exist) + +**Notes:** +- Lists are stored in `/.lists/` directory as `.bin` files +- Uses `BookListStore::listAllLists()` and `BookListStore::loadList()` + +### POST /list +**Handler:** `handleListPost()` +**Query params:** +- `action` (required): "upload" or "delete" +- `name` (required): List name (without .bin extension) +**Request body (for upload):** CSV text with one book per line +**Content-Type:** text/plain (for upload body) + +**Input format (POST body for upload action):** +``` +1,The Great Gatsby,F. Scott Fitzgerald,/Books/gatsby.epub +2,1984,George Orwell,/Books/1984.epub +3,Pride and Prejudice,Jane Austen,/Books/pride.epub +``` +Format: `order,title,author,path` (one per line) + +**Response (upload success):** +```json +{"success": true, "path": "/.lists/MyReadingList.bin"} +``` + +**Response (delete success):** +```json +{"success": true} +``` + +**Errors:** +- 400: Missing action or name parameter, empty name, failed to parse list data +- 404: List not found (for delete action) +- 500: Failed to save/delete list + +**Notes:** +- Lists are stored as binary files in `/.lists/` directory +- Uses `BookListStore::parseFromText()`, `BookListStore::saveList()`, `BookListStore::deleteList()` +- Order field determines display order (books are sorted by order when loaded) +- Overwrites existing list with same name on upload + ## WebSocket Protocol (port 81) **Handler:** `onWebSocketEvent()` via `wsEventCallback()` trampoline @@ -258,5 +329,6 @@ Source: `src/network/CrossPointWebServer.cpp` and `CrossPointWebServer.h` - `` - JSON serialization - `` - SD card operations (SdMan singleton) - `` - Epub cache management +- `BookListStore.h` - Book list management (lists feature) - `BookManager.h` - Book deletion, archiving, recent books - `StringUtils.h` - File extension checking diff --git a/src/BookListStore.cpp b/src/BookListStore.cpp new file mode 100644 index 0000000..f46609c --- /dev/null +++ b/src/BookListStore.cpp @@ -0,0 +1,248 @@ +#include "BookListStore.h" + +#include +#include +#include + +#include +#include + +namespace { +constexpr const char* LOG_TAG = "BLS"; +constexpr uint8_t LIST_FILE_VERSION = 1; + +// Helper to trim whitespace from a string +std::string trim(const std::string& str) { + const size_t first = str.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + const size_t last = str.find_last_not_of(" \t\r\n"); + return str.substr(first, last - first + 1); +} + +// Helper to parse a single CSV line +// Format: order,Title,Author,/path/to/file.epub +bool parseCsvLine(const std::string& line, BookListItem& item) { + if (line.empty()) return false; + + // Find first comma (after order) + size_t pos1 = line.find(','); + if (pos1 == std::string::npos) return false; + + // Find second comma (after title) + size_t pos2 = line.find(',', pos1 + 1); + if (pos2 == std::string::npos) return false; + + // Find third comma (after author) + size_t pos3 = line.find(',', pos2 + 1); + if (pos3 == std::string::npos) return false; + + // Parse order + std::string orderStr = trim(line.substr(0, pos1)); + int order = atoi(orderStr.c_str()); + if (order <= 0 || order > 255) return false; + item.order = static_cast(order); + + // Parse title, author, path + item.title = trim(line.substr(pos1 + 1, pos2 - pos1 - 1)); + item.author = trim(line.substr(pos2 + 1, pos3 - pos2 - 1)); + item.path = trim(line.substr(pos3 + 1)); + + // Validate we have at least a path + if (item.path.empty()) return false; + + return true; +} +} // namespace + +bool BookListStore::parseFromText(const std::string& text, BookList& list) { + list.books.clear(); + + std::istringstream stream(text); + std::string line; + + while (std::getline(stream, line)) { + line = trim(line); + if (line.empty()) continue; + + BookListItem item; + if (parseCsvLine(line, item)) { + list.books.push_back(item); + } else { + Serial.printf("[%lu] [%s] Failed to parse line: %s\n", millis(), LOG_TAG, line.c_str()); + } + } + + // Sort by order + std::sort(list.books.begin(), list.books.end(), + [](const BookListItem& a, const BookListItem& b) { return a.order < b.order; }); + + Serial.printf("[%lu] [%s] Parsed %d books from text\n", millis(), LOG_TAG, list.books.size()); + return !list.books.empty(); +} + +bool BookListStore::saveList(const BookList& list) { + if (list.name.empty()) { + Serial.printf("[%lu] [%s] Cannot save list with empty name\n", millis(), LOG_TAG); + return false; + } + + // Ensure lists directory exists + SdMan.mkdir(LISTS_DIR); + + const std::string path = getListPath(list.name); + + FsFile outputFile; + if (!SdMan.openFileForWrite(LOG_TAG, path, outputFile)) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), LOG_TAG, path.c_str()); + return false; + } + + // Write version + serialization::writePod(outputFile, LIST_FILE_VERSION); + + // Write book count + const uint8_t count = static_cast(std::min(list.books.size(), size_t(255))); + serialization::writePod(outputFile, count); + + // Write each book + for (size_t i = 0; i < count; i++) { + const auto& book = list.books[i]; + serialization::writePod(outputFile, book.order); + serialization::writeString(outputFile, book.title); + serialization::writeString(outputFile, book.author); + serialization::writeString(outputFile, book.path); + } + + outputFile.close(); + Serial.printf("[%lu] [%s] Saved list '%s' with %d books to %s\n", millis(), LOG_TAG, list.name.c_str(), count, + path.c_str()); + return true; +} + +bool BookListStore::loadList(const std::string& name, BookList& list) { + const std::string path = getListPath(name); + + FsFile inputFile; + if (!SdMan.openFileForRead(LOG_TAG, path, inputFile)) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), LOG_TAG, path.c_str()); + return false; + } + + // Read version + uint8_t version; + serialization::readPod(inputFile, version); + if (version != LIST_FILE_VERSION) { + Serial.printf("[%lu] [%s] Unknown file version: %d\n", millis(), LOG_TAG, version); + inputFile.close(); + return false; + } + + // Read book count + uint8_t count; + serialization::readPod(inputFile, count); + + list.name = name; + list.books.clear(); + list.books.reserve(count); + + // Read each book + for (uint8_t i = 0; i < count; i++) { + BookListItem item; + serialization::readPod(inputFile, item.order); + serialization::readString(inputFile, item.title); + serialization::readString(inputFile, item.author); + serialization::readString(inputFile, item.path); + list.books.push_back(item); + } + + inputFile.close(); + + // Sort by order (should already be sorted, but ensure it) + std::sort(list.books.begin(), list.books.end(), + [](const BookListItem& a, const BookListItem& b) { return a.order < b.order; }); + + Serial.printf("[%lu] [%s] Loaded list '%s' with %d books\n", millis(), LOG_TAG, name.c_str(), count); + return true; +} + +bool BookListStore::deleteList(const std::string& name) { + const std::string path = getListPath(name); + + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [%s] List not found: %s\n", millis(), LOG_TAG, path.c_str()); + return false; + } + + if (!SdMan.remove(path.c_str())) { + Serial.printf("[%lu] [%s] Failed to delete list: %s\n", millis(), LOG_TAG, path.c_str()); + return false; + } + + Serial.printf("[%lu] [%s] Deleted list: %s\n", millis(), LOG_TAG, name.c_str()); + return true; +} + +std::vector BookListStore::listAllLists() { + std::vector lists; + + FsFile dir = SdMan.open(LISTS_DIR); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return lists; + } + + char name[128]; + FsFile entry; + while ((entry = dir.openNextFile())) { + if (!entry.isDirectory()) { + entry.getName(name, sizeof(name)); + std::string filename(name); + + // Only include .bin files + if (filename.length() > 4 && filename.substr(filename.length() - 4) == ".bin") { + // Strip .bin extension + lists.push_back(filename.substr(0, filename.length() - 4)); + } + } + entry.close(); + } + dir.close(); + + // Sort alphabetically + std::sort(lists.begin(), lists.end()); + + return lists; +} + +bool BookListStore::listExists(const std::string& name) { + const std::string path = getListPath(name); + return SdMan.exists(path.c_str()); +} + +std::string BookListStore::getListPath(const std::string& name) { + return std::string(LISTS_DIR) + "/" + name + ".bin"; +} + +int BookListStore::getBookCount(const std::string& name) { + const std::string path = getListPath(name); + + FsFile inputFile; + if (!SdMan.openFileForRead(LOG_TAG, path, inputFile)) { + return -1; + } + + // Read version + uint8_t version; + serialization::readPod(inputFile, version); + if (version != LIST_FILE_VERSION) { + inputFile.close(); + return -1; + } + + // Read book count + uint8_t count; + serialization::readPod(inputFile, count); + inputFile.close(); + + return count; +} diff --git a/src/BookListStore.h b/src/BookListStore.h new file mode 100644 index 0000000..f37d9eb --- /dev/null +++ b/src/BookListStore.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include + +struct BookListItem { + uint8_t order; // Custom sort order (1-based) + std::string title; + std::string author; + std::string path; +}; + +struct BookList { + std::string name; + std::vector books; +}; + +/** + * BookListStore - Manages custom book lists stored as .bin files + * + * Lists are stored in /.lists/ directory on the SD card. + * Each list is a binary file containing ordered book entries. + */ +class BookListStore { + public: + static constexpr const char* LISTS_DIR = "/.lists"; + + /** + * Parse a list from CSV text format + * Format: "order,Title,Author,/path/to/file.epub" (one per line) + * @param text The CSV text to parse + * @param list Output BookList (name must be set before calling) + * @return true if parsing succeeded + */ + static bool parseFromText(const std::string& text, BookList& list); + + /** + * Save a list to disk as a .bin file + * @param list The list to save (name field determines filename) + * @return true if save succeeded + */ + static bool saveList(const BookList& list); + + /** + * Load a list from disk + * @param name The list name (without .bin extension) + * @param list Output BookList + * @return true if load succeeded + */ + static bool loadList(const std::string& name, BookList& list); + + /** + * Delete a list from disk + * @param name The list name (without .bin extension) + * @return true if delete succeeded + */ + static bool deleteList(const std::string& name); + + /** + * Get all available list names + * @return Vector of list names (without .bin extension) + */ + static std::vector listAllLists(); + + /** + * Check if a list exists + * @param name The list name (without .bin extension) + * @return true if list exists + */ + static bool listExists(const std::string& name); + + /** + * Get the full path for a list + * @param name The list name (without .bin extension) + * @return Full path including /.lists/ and .bin extension + */ + static std::string getListPath(const std::string& name); + + /** + * Get the book count for a list without loading full contents + * @param name The list name (without .bin extension) + * @return Book count, or -1 if list doesn't exist + */ + static int getBookCount(const std::string& name); + +}; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index bdd0677..ad5e24b 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -15,7 +15,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 22; +constexpr uint8_t SETTINGS_COUNT = 23; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -52,6 +52,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, hyphenationEnabled); serialization::writePod(outputFile, customFontIndex); serialization::writePod(outputFile, fallbackFontFamily); + serialization::writeString(outputFile, std::string(pinnedListName)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -127,6 +128,13 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, fallbackFontFamily); if (++settingsRead >= fileSettingsCount) break; + { + std::string pinnedStr; + serialization::readString(inputFile, pinnedStr); + strncpy(pinnedListName, pinnedStr.c_str(), sizeof(pinnedListName) - 1); + pinnedListName[sizeof(pinnedListName) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index a66d949..066d906 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -97,6 +97,9 @@ class CrossPointSettings { // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // Pinned list name (empty = none pinned) + char pinnedListName[64] = ""; + ~CrossPointSettings() = default; // Get singleton instance diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index a193e17..dc77545 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -30,7 +30,7 @@ void HomeActivity::taskTrampoline(void* param) { } int HomeActivity::getMenuItemCount() const { - int count = 3; // My Library, File transfer, Settings + int count = 4; // Lists, My Library, File transfer, Settings if (hasContinueReading) count++; if (hasOpdsUrl) count++; return count; @@ -220,6 +220,7 @@ void HomeActivity::loop() { // Calculate dynamic indices based on which options are available int idx = 0; const int continueIdx = hasContinueReading ? idx++ : -1; + const int listsIdx = idx++; const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; @@ -227,6 +228,8 @@ void HomeActivity::loop() { if (selectorIndex == continueIdx) { onContinueReading(); + } else if (selectorIndex == listsIdx) { + onListsOpen(); } else if (selectorIndex == myLibraryIdx) { onMyLibraryOpen(); } else if (selectorIndex == opdsLibraryIdx) { @@ -274,17 +277,29 @@ void HomeActivity::render() { // --- Calculate layout from bottom up --- + // Build lists button label (dynamic based on pinned list) + std::string listsLabel; + if (strlen(SETTINGS.pinnedListName) > 0) { + listsLabel = std::string(SETTINGS.pinnedListName); + } else { + listsLabel = "ReadingLists"; + } + // Build menu items dynamically (need count for layout calculation) - std::vector menuItems = {"My Library", "File Transfer", "Settings"}; + // First row is split: [Lists] [My Library] + // Rest are full width: Calibre Library (if configured), File Transfer, Settings + std::vector fullWidthItems = {"File Transfer", "Settings"}; if (hasOpdsUrl) { - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + fullWidthItems.insert(fullWidthItems.begin(), "Calibre Library"); } const int menuTileWidth = pageWidth - 2 * margin; constexpr int menuTileHeight = 45; constexpr int menuSpacing = 8; + const int halfTileWidth = (menuTileWidth - menuSpacing) / 2; // Account for spacing between halves + // 1 row for split buttons + full-width rows const int totalMenuHeight = - static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; + menuTileHeight + static_cast(fullWidthItems.size()) * (menuTileHeight + menuSpacing); // Anchor menu to bottom of screen const int menuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; @@ -580,10 +595,61 @@ void HomeActivity::render() { } // --- Bottom menu tiles (anchored to bottom) --- - for (size_t i = 0; i < menuItems.size(); ++i) { - const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); + + // First row: Split buttons [Lists] [My Library] + const int firstRowY = menuStartY; + const int baseMenuIndex = hasContinueReading ? 1 : 0; + + // Lists button (left half) + { + const int tileX = margin; + const bool selected = selectorIndex == baseMenuIndex; + + if (selected) { + renderer.fillRect(tileX, firstRowY, halfTileWidth, menuTileHeight); + } else { + renderer.drawRect(tileX, firstRowY, halfTileWidth, menuTileHeight); + } + + // Truncate lists label if needed + std::string truncatedLabel = listsLabel; + const int maxLabelWidth = halfTileWidth - 16; // Padding + while (renderer.getTextWidth(UI_10_FONT_ID, truncatedLabel.c_str()) > maxLabelWidth && truncatedLabel.length() > 3) { + truncatedLabel = truncatedLabel.substr(0, truncatedLabel.length() - 4) + "..."; + } + + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, truncatedLabel.c_str()); + const int textX = tileX + (halfTileWidth - textWidth) / 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int textY = firstRowY + (menuTileHeight - lineHeight) / 2; + renderer.drawText(UI_10_FONT_ID, textX, textY, truncatedLabel.c_str(), !selected); + } + + // My Library button (right half) + { + const int tileX = margin + halfTileWidth + menuSpacing; + const bool selected = selectorIndex == baseMenuIndex + 1; + + if (selected) { + renderer.fillRect(tileX, firstRowY, halfTileWidth, menuTileHeight); + } else { + renderer.drawRect(tileX, firstRowY, halfTileWidth, menuTileHeight); + } + + const char* label = "My Library"; + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); + const int textX = tileX + (halfTileWidth - textWidth) / 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int textY = firstRowY + (menuTileHeight - lineHeight) / 2; + renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); + } + + // Full-width menu items (Calibre Library if configured, File Transfer, Settings) + for (size_t i = 0; i < fullWidthItems.size(); ++i) { + // Index offset: base + 2 (for Lists and My Library) + i + const int overallIndex = baseMenuIndex + 2 + static_cast(i); constexpr int tileX = margin; - const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); + const int tileY = firstRowY + menuTileHeight + menuSpacing + static_cast(i) * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; if (selected) { @@ -592,13 +658,12 @@ void HomeActivity::render() { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } - const char* label = menuItems[i]; + const char* label = fullWidthItems[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); - const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text + const int textY = tileY + (menuTileHeight - lineHeight) / 2; - // Invert text when the tile is selected, to contrast with the filled background renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index ad5e271..c0807f7 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -26,6 +26,7 @@ class HomeActivity final : public Activity { std::string lastBookAuthor; std::string coverBmpPath; const std::function onContinueReading; + const std::function onListsOpen; // Goes to pinned list or lists tab const std::function onMyLibraryOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; @@ -41,11 +42,13 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onContinueReading, const std::function& onMyLibraryOpen, - const std::function& onSettingsOpen, const std::function& onFileTransferOpen, + const std::function& onContinueReading, const std::function& onListsOpen, + const std::function& onMyLibraryOpen, const std::function& onSettingsOpen, + const std::function& onFileTransferOpen, const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), + onListsOpen(onListsOpen), onMyLibraryOpen(onMyLibraryOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen), diff --git a/src/activities/home/ListViewActivity.cpp b/src/activities/home/ListViewActivity.cpp new file mode 100644 index 0000000..18079f7 --- /dev/null +++ b/src/activities/home/ListViewActivity.cpp @@ -0,0 +1,275 @@ +#include "ListViewActivity.h" + +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +namespace { +// Layout constants (matching MyLibraryActivity's Recent tab) +constexpr int HEADER_Y = 15; +constexpr int CONTENT_START_Y = 60; +constexpr int LINE_HEIGHT = 65; // Two-line items (title + author) +constexpr int LEFT_MARGIN = 20; +constexpr int RIGHT_MARGIN = 40; +constexpr int MICRO_THUMB_WIDTH = 45; +constexpr int MICRO_THUMB_HEIGHT = 60; +constexpr int THUMB_RIGHT_MARGIN = 50; + +// Timing thresholds +constexpr int SKIP_PAGE_MS = 700; + +// Helper function to get the micro-thumb path for a book based on its file path +std::string getMicroThumbPathForBook(const std::string& bookPath) { + const size_t hash = std::hash{}(bookPath); + + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp"; + } else if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) { + return "/.crosspoint/xtc_" + std::to_string(hash) + "/micro_thumb.bmp"; + } else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT")) { + return "/.crosspoint/txt_" + std::to_string(hash) + "/micro_thumb.bmp"; + } + return ""; +} +} // namespace + +int ListViewActivity::getPageItems() const { + const int screenHeight = renderer.getScreenHeight(); + const int bottomBarHeight = 60; + const int availableHeight = screenHeight - CONTENT_START_Y - bottomBarHeight; + int items = availableHeight / LINE_HEIGHT; + if (items < 1) { + items = 1; + } + return items; +} + +int ListViewActivity::getTotalPages() const { + const int itemCount = static_cast(bookList.books.size()); + const int pageItems = getPageItems(); + if (itemCount == 0) return 1; + return (itemCount + pageItems - 1) / pageItems; +} + +int ListViewActivity::getCurrentPage() const { + const int pageItems = getPageItems(); + return selectorIndex / pageItems + 1; +} + +void ListViewActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ListViewActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Load the list + if (!BookListStore::loadList(listName, bookList)) { + Serial.printf("[%lu] [LVA] Failed to load list: %s\n", millis(), listName.c_str()); + } + + selectorIndex = 0; + updateRequired = true; + + xTaskCreate(&ListViewActivity::taskTrampoline, "ListViewActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void ListViewActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + bookList.books.clear(); +} + +void ListViewActivity::loop() { + const int itemCount = static_cast(bookList.books.size()); + const int pageItems = getPageItems(); + + // Back button - return to My Library + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (onBack) { + onBack(); + } + return; + } + + // Confirm button - open selected book + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!bookList.books.empty() && selectorIndex < itemCount) { + const auto& book = bookList.books[selectorIndex]; + // Check if file exists before opening + if (SdMan.exists(book.path.c_str())) { + if (onSelectBook) { + onSelectBook(book.path); + } + } else { + Serial.printf("[%lu] [LVA] Book file not found: %s\n", millis(), book.path.c_str()); + // Could show an error message here + } + } + return; + } + + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + + // Navigation: Up/Down moves through items + if (upReleased && itemCount > 0) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; + } else { + selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + } + updateRequired = true; + } else if (downReleased && itemCount > 0) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; + } else { + selectorIndex = (selectorIndex + 1) % itemCount; + } + updateRequired = true; + } +} + +void ListViewActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ListViewActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int bookCount = static_cast(bookList.books.size()); + + // Draw header with list name + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, listName.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, HEADER_Y, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); + + if (bookCount == 0) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "This list is empty"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, + LINE_HEIGHT); + + // Calculate available text width + const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10; + const int thumbX = pageWidth - THUMB_RIGHT_MARGIN - MICRO_THUMB_WIDTH; + + // Draw items + for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { + const auto& book = bookList.books[i]; + const int y = CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Try to load and draw micro-thumbnail + bool hasThumb = false; + const std::string microThumbPath = getMicroThumbPathForBook(book.path); + + if (!microThumbPath.empty() && SdMan.exists(microThumbPath.c_str())) { + FsFile thumbFile; + if (SdMan.openFileForRead("LVA", microThumbPath, thumbFile)) { + Bitmap bitmap(thumbFile); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + const int bmpW = bitmap.getWidth(); + const int bmpH = bitmap.getHeight(); + const float scaleX = static_cast(MICRO_THUMB_WIDTH) / static_cast(bmpW); + const float scaleY = static_cast(MICRO_THUMB_HEIGHT) / static_cast(bmpH); + const float scale = std::min(scaleX, scaleY); + const int drawnW = static_cast(bmpW * scale); + const int drawnH = static_cast(bmpH * scale); + + const int thumbY = y + (LINE_HEIGHT - drawnH) / 2; + if (isSelected) { + renderer.fillRect(thumbX, thumbY, drawnW, drawnH, false); + } + renderer.drawBitmap(bitmap, thumbX, thumbY, MICRO_THUMB_WIDTH, MICRO_THUMB_HEIGHT, 0, 0, isSelected); + hasThumb = true; + } + thumbFile.close(); + } + } + + // Use full width if no thumbnail + const int availableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + + // Line 1: Title + std::string title = book.title; + if (title.empty()) { + // Fallback: extract filename from path + title = book.path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + const size_t dot = title.find_last_of('.'); + if (dot != std::string::npos) { + title.resize(dot); + } + } + auto truncatedBookTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedBookTitle.c_str(), !isSelected); + + // Line 2: Author + if (!book.author.empty()) { + auto truncatedAuthor = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), availableWidth); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected); + } + } + + // Draw scroll indicator + const int screenHeight = renderer.getScreenHeight(); + const int contentHeight = screenHeight - CONTENT_START_Y - 60; + ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); + + // Draw side button hints + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Back", "Open", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/home/ListViewActivity.h b/src/activities/home/ListViewActivity.h new file mode 100644 index 0000000..32448c7 --- /dev/null +++ b/src/activities/home/ListViewActivity.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" +#include "BookListStore.h" + +/** + * ListViewActivity - Displays the contents of a single book list + * + * Shows books in custom order with title, author, and thumbnail. + * Allows selecting a book to open in the reader. + */ +class ListViewActivity final : public Activity { + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + std::string listName; + BookList bookList; + int selectorIndex = 0; + bool updateRequired = false; + + // Callbacks + const std::function onBack; + const std::function onSelectBook; + + // Pagination helpers + int getPageItems() const; + int getTotalPages() const; + int getCurrentPage() const; + + // Rendering + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit ListViewActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& listName, + const std::function& onBack, + const std::function& onSelectBook) + : Activity("ListView", renderer, mappedInput), listName(listName), onBack(onBack), onSelectBook(onSelectBook) {} + + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 59d6d69..283dfbd 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -5,8 +5,11 @@ #include #include +#include +#include "BookListStore.h" #include "BookManager.h" +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" @@ -63,6 +66,7 @@ int MyLibraryActivity::getPageItems() const { const int screenHeight = renderer.getScreenHeight(); const int bottomBarHeight = 60; // Space for button hints const int availableHeight = screenHeight - CONTENT_START_Y - bottomBarHeight; + // Recent tab uses taller items (title + author), Lists and Files use single-line items const int lineHeight = (currentTab == Tab::Recent) ? RECENTS_LINE_HEIGHT : LINE_HEIGHT; int items = availableHeight / lineHeight; if (items < 1) { @@ -74,6 +78,8 @@ int MyLibraryActivity::getPageItems() const { int MyLibraryActivity::getCurrentItemCount() const { if (currentTab == Tab::Recent) { return static_cast(recentBooks.size()); + } else if (currentTab == Tab::Lists) { + return static_cast(lists.size()); } return static_cast(files.size()); } @@ -104,6 +110,8 @@ void MyLibraryActivity::loadRecentBooks() { } } +void MyLibraryActivity::loadLists() { lists = BookListStore::listAllLists(); } + void MyLibraryActivity::loadFiles() { files.clear(); @@ -155,8 +163,9 @@ void MyLibraryActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - // Load data for both tabs + // Load data for all tabs loadRecentBooks(); + loadLists(); loadFiles(); selectorIndex = 0; @@ -184,6 +193,7 @@ void MyLibraryActivity::onExit() { renderingMutex = nullptr; recentBooks.clear(); + lists.clear(); files.clear(); } @@ -259,6 +269,53 @@ void MyLibraryActivity::executeAction() { updateRequired = true; } +void MyLibraryActivity::togglePinForSelectedList() { + if (lists.empty() || selectorIndex >= static_cast(lists.size())) return; + + const std::string& selected = lists[selectorIndex]; + if (selected == SETTINGS.pinnedListName) { + // Unpin - clear the pinned list + SETTINGS.pinnedListName[0] = '\0'; + } else { + // Pin this list (replaces any previously pinned list) + strncpy(SETTINGS.pinnedListName, selected.c_str(), sizeof(SETTINGS.pinnedListName) - 1); + SETTINGS.pinnedListName[sizeof(SETTINGS.pinnedListName) - 1] = '\0'; + } + SETTINGS.saveToFile(); + updateRequired = true; +} + +void MyLibraryActivity::openListActionMenu() { + listActionTargetName = lists[selectorIndex]; + listMenuSelection = 0; // Default to Pin/Unpin + uiState = UIState::ListActionMenu; + ignoreNextConfirmRelease = true; + updateRequired = true; +} + +void MyLibraryActivity::executeListAction() { + // Clear pinned status if deleting the pinned list + if (listActionTargetName == SETTINGS.pinnedListName) { + SETTINGS.pinnedListName[0] = '\0'; + SETTINGS.saveToFile(); + } + + BookListStore::deleteList(listActionTargetName); + + // Reload lists + loadLists(); + + // Adjust selector if needed + if (selectorIndex >= static_cast(lists.size()) && !lists.empty()) { + selectorIndex = static_cast(lists.size()) - 1; + } else if (lists.empty()) { + selectorIndex = 0; + } + + uiState = UIState::Normal; + updateRequired = true; +} + void MyLibraryActivity::loop() { // Handle action menu state if (uiState == UIState::ActionMenu) { @@ -312,6 +369,64 @@ void MyLibraryActivity::loop() { return; } + // Handle list action menu state + if (uiState == UIState::ListActionMenu) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::Normal; + ignoreNextConfirmRelease = false; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + listMenuSelection = 0; // Pin/Unpin + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + listMenuSelection = 1; // Delete + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Ignore the release from the long-press that opened this menu + if (ignoreNextConfirmRelease) { + ignoreNextConfirmRelease = false; + return; + } + if (listMenuSelection == 0) { + // Pin/Unpin - toggle and return to normal + togglePinForSelectedList(); + uiState = UIState::Normal; + } else { + // Delete - go to confirmation + uiState = UIState::ListConfirmingDelete; + } + updateRequired = true; + return; + } + + return; + } + + // Handle list delete confirmation state + if (uiState == UIState::ListConfirmingDelete) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::Normal; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + executeListAction(); + return; + } + + return; + } + // Normal state handling const int itemCount = getCurrentItemCount(); const int pageItems = getPageItems(); @@ -328,6 +443,15 @@ void MyLibraryActivity::loop() { return; } + // Long press Confirm to open list action menu (only on Lists tab) + constexpr unsigned long LIST_ACTION_MENU_MS = 700; + if (currentTab == Tab::Lists && mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() >= LIST_ACTION_MENU_MS && !lists.empty() && + selectorIndex < static_cast(lists.size())) { + openListActionMenu(); + return; + } + // Long press Confirm to open action menu (only for files, not directories) if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) { @@ -353,6 +477,13 @@ void MyLibraryActivity::loop() { if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { onSelectBook(recentBooks[selectorIndex].path, currentTab); } + } else if (currentTab == Tab::Lists) { + // Lists tab - open selected list + if (!lists.empty() && selectorIndex < static_cast(lists.size())) { + if (onSelectList) { + onSelectList(lists[selectorIndex]); + } + } } else { // Files tab if (!files.empty() && selectorIndex < static_cast(files.size())) { @@ -396,18 +527,32 @@ void MyLibraryActivity::loop() { return; } - // Tab switching: Left/Right always control tabs - if (leftReleased && currentTab == Tab::Files) { - currentTab = Tab::Recent; - selectorIndex = 0; - updateRequired = true; - return; + // Tab switching: Left/Right always control tabs (Recent <-> Lists <-> Files) + if (leftReleased) { + if (currentTab == Tab::Files) { + currentTab = Tab::Lists; + selectorIndex = 0; + updateRequired = true; + return; + } else if (currentTab == Tab::Lists) { + currentTab = Tab::Recent; + selectorIndex = 0; + updateRequired = true; + return; + } } - if (rightReleased && currentTab == Tab::Recent) { - currentTab = Tab::Files; - selectorIndex = 0; - updateRequired = true; - return; + if (rightReleased) { + if (currentTab == Tab::Recent) { + currentTab = Tab::Lists; + selectorIndex = 0; + updateRequired = true; + return; + } else if (currentTab == Tab::Lists) { + currentTab = Tab::Files; + selectorIndex = 0; + updateRequired = true; + return; + } } // Navigation: Up/Down moves through items only @@ -459,14 +604,30 @@ void MyLibraryActivity::render() const { return; } + if (uiState == UIState::ListActionMenu) { + renderListActionMenu(); + renderer.displayBuffer(); + return; + } + + if (uiState == UIState::ListConfirmingDelete) { + renderListDeleteConfirmation(); + renderer.displayBuffer(); + return; + } + // Normal state - draw library view // Draw tab bar - std::vector tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}; + std::vector tabs = {{"Recent", currentTab == Tab::Recent}, + {"Reading Lists", currentTab == Tab::Lists}, + {"Files", currentTab == Tab::Files}}; ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); // Draw content based on current tab if (currentTab == Tab::Recent) { renderRecentTab(); + } else if (currentTab == Tab::Lists) { + renderListsTab(); } else { renderFilesTab(); } @@ -599,6 +760,36 @@ void MyLibraryActivity::renderRecentTab() const { } } +void MyLibraryActivity::renderListsTab() const { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int listCount = static_cast(lists.size()); + + if (listCount == 0) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No lists found"); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Create lists in Companion App"); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, + LINE_HEIGHT); + + // Draw items + for (int i = pageStartIndex; i < listCount && i < pageStartIndex + pageItems; i++) { + // Add indicator for pinned list + std::string displayName = lists[i]; + if (displayName == SETTINGS.pinnedListName) { + displayName = "• " + displayName + " •"; + } + auto item = renderer.truncatedText(UI_10_FONT_ID, displayName.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), + i != selectorIndex); + } +} + void MyLibraryActivity::renderFilesTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); @@ -688,3 +879,63 @@ void MyLibraryActivity::renderConfirmation() const { const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } + +void MyLibraryActivity::renderListActionMenu() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 20, "List Actions", true, EpdFontFamily::BOLD); + + // Show list name + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, 70, truncatedName.c_str()); + + // Menu options + const int menuStartY = pageHeight / 2 - 30; + constexpr int menuLineHeight = 40; + constexpr int menuItemWidth = 120; + const int menuX = (pageWidth - menuItemWidth) / 2; + + // Pin/Unpin option (dynamic label) + const bool isPinned = (listActionTargetName == SETTINGS.pinnedListName); + const char* pinLabel = isPinned ? "Unpin" : "Pin"; + + if (listMenuSelection == 0) { + renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight); + } + renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, pinLabel, listMenuSelection != 0); + + // Delete option + if (listMenuSelection == 1) { + renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight); + } + renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", listMenuSelection != 1); + + // Draw side button hints (up/down navigation) + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void MyLibraryActivity::renderListDeleteConfirmation() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete List?", true, EpdFontFamily::BOLD); + + // Show list name + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, truncatedName.c_str()); + + // Warning text + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "List will be permanently deleted!", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 25, "This cannot be undone."); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index 4d32003..6507ce5 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -8,6 +8,7 @@ #include #include "../Activity.h" +#include "BookListStore.h" #include "RecentBooksStore.h" // Cached thumbnail existence info for Recent tab @@ -20,8 +21,8 @@ struct ThumbExistsCache { class MyLibraryActivity final : public Activity { public: - enum class Tab { Recent, Files }; - enum class UIState { Normal, ActionMenu, Confirming }; + enum class Tab { Recent, Lists, Files }; + enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete }; enum class ActionType { Archive, Delete }; private: @@ -47,6 +48,13 @@ class MyLibraryActivity final : public Activity { static constexpr int MAX_THUMB_CACHE = 10; static ThumbExistsCache thumbExistsCache[MAX_THUMB_CACHE]; + // Lists tab state + std::vector lists; + + // List action menu state (for Lists tab) + int listMenuSelection = 0; // 0 = Pin/Unpin, 1 = Delete + std::string listActionTargetName; + // Files tab state (from FileSelectionActivity) std::string basepath = "/"; std::vector files; @@ -54,6 +62,7 @@ class MyLibraryActivity final : public Activity { // Callbacks const std::function onGoHome; const std::function onSelectBook; + const std::function onSelectList; // Number of items that fit on a page int getPageItems() const; @@ -63,6 +72,7 @@ class MyLibraryActivity final : public Activity { // Data loading void loadRecentBooks(); + void loadLists(); void loadFiles(); size_t findEntry(const std::string& name) const; @@ -71,6 +81,7 @@ class MyLibraryActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render() const; void renderRecentTab() const; + void renderListsTab() const; void renderFilesTab() const; void renderActionMenu() const; void renderConfirmation() const; @@ -80,16 +91,27 @@ class MyLibraryActivity final : public Activity { void executeAction(); bool isSelectedItemAFile() const; + // List pinning + void togglePinForSelectedList(); + + // List action menu + void openListActionMenu(); + void executeListAction(); + void renderListActionMenu() const; + void renderListDeleteConfirmation() const; + public: explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome, const std::function& onSelectBook, + const std::function& onSelectList, Tab initialTab = Tab::Recent, std::string initialPath = "/") : Activity("MyLibrary", renderer, mappedInput), currentTab(initialTab), basepath(initialPath.empty() ? "/" : std::move(initialPath)), onGoHome(onGoHome), - onSelectBook(onSelectBook) {} + onSelectBook(onSelectBook), + onSelectList(onSelectList) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/main.cpp b/src/main.cpp index 39c146a..addb427 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include #include "Battery.h" +#include "BookListStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -18,6 +19,7 @@ #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/home/HomeActivity.h" +#include "activities/home/ListViewActivity.h" #include "activities/home/MyLibraryActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" @@ -187,6 +189,7 @@ void enterDeepSleep() { } void onGoHome(); +void onGoToMyLibrary(); void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { exitActivity(); @@ -195,6 +198,35 @@ void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fro } void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } +// Open a book from a list view +void onGoToReaderFromList(const std::string& bookPath) { + exitActivity(); + // When opening from a list, treat it like opening from Recent (will return to list view via back) + enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Recent, onGoHome, + onGoToMyLibraryWithTab)); +} + +// View a specific list +void onGoToListView(const std::string& listName) { + exitActivity(); + enterNewActivity( + new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList)); +} + +// Go to pinned list (if exists) or Lists tab +void onGoToListsOrPinned() { + exitActivity(); + if (strlen(SETTINGS.pinnedListName) > 0 && BookListStore::listExists(SETTINGS.pinnedListName)) { + // Go directly to pinned list + enterNewActivity( + new ListViewActivity(renderer, mappedInputManager, SETTINGS.pinnedListName, onGoHome, onGoToReaderFromList)); + } else { + // Go to Lists tab in My Library + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, + MyLibraryActivity::Tab::Lists)); + } +} + void onGoToFileTransfer() { exitActivity(); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); @@ -207,12 +239,12 @@ void onGoToSettings() { void onGoToMyLibrary() { exitActivity(); - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView)); } void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { exitActivity(); - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path)); + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, tab, path)); } void onGoToBrowser() { @@ -222,8 +254,8 @@ void onGoToBrowser() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, - onGoToFileTransfer, onGoToBrowser)); + enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned, + onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser)); } void setupDisplayAndFonts() { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index d6b8f92..4714ae9 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -9,7 +9,9 @@ #include +#include "BookListStore.h" #include "BookManager.h" +#include "CrossPointSettings.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "util/StringUtils.h" @@ -124,6 +126,10 @@ void CrossPointWebServer::begin() { // Move endpoint server->on("/move", HTTP_POST, [this] { handleMove(); }); + // List management endpoints + server->on("/list", HTTP_GET, [this] { handleListGet(); }); + server->on("/list", HTTP_POST, [this] { handleListPost(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -1483,3 +1489,140 @@ void CrossPointWebServer::handleMove() const { server->send(200, "text/plain", "Moved (but source may still exist)"); } } + +void CrossPointWebServer::handleListGet() const { + Serial.printf("[%lu] [WEB] GET /list request\n", millis()); + + if (server->hasArg("name")) { + // Return specific list contents + const String name = server->arg("name"); + BookList list; + + if (!BookListStore::loadList(name.c_str(), list)) { + server->send(404, "application/json", "{\"error\":\"List not found\"}"); + return; + } + + // Build JSON response with full list details + JsonDocument doc; + doc["name"] = list.name; + doc["path"] = BookListStore::getListPath(list.name); + + JsonArray booksArray = doc["books"].to(); + for (const auto& book : list.books) { + JsonObject bookObj = booksArray.add(); + bookObj["order"] = book.order; + bookObj["title"] = book.title; + bookObj["author"] = book.author; + bookObj["path"] = book.path; + } + + String response; + serializeJson(doc, response); + server->send(200, "application/json", response); + + Serial.printf("[%lu] [WEB] Returned list '%s' with %d books\n", millis(), name.c_str(), list.books.size()); + } else { + // Return all lists + const auto lists = BookListStore::listAllLists(); + + JsonDocument doc; + JsonArray arr = doc.to(); + + for (const auto& name : lists) { + JsonObject listObj = arr.add(); + listObj["name"] = name; + listObj["path"] = BookListStore::getListPath(name); + listObj["bookCount"] = BookListStore::getBookCount(name); + } + + String response; + serializeJson(doc, response); + server->send(200, "application/json", response); + + Serial.printf("[%lu] [WEB] Returned %d lists\n", millis(), lists.size()); + } +} + +void CrossPointWebServer::handleListPost() const { + Serial.printf("[%lu] [WEB] POST /list request\n", millis()); + + // Validate required parameters + if (!server->hasArg("action")) { + server->send(400, "application/json", "{\"error\":\"Missing action parameter\"}"); + return; + } + + if (!server->hasArg("name")) { + server->send(400, "application/json", "{\"error\":\"Missing name parameter\"}"); + return; + } + + const String action = server->arg("action"); + const String name = server->arg("name"); + + if (name.isEmpty()) { + server->send(400, "application/json", "{\"error\":\"Name cannot be empty\"}"); + return; + } + + if (action == "upload") { + // Get the POST body + const String body = server->arg("plain"); + if (body.isEmpty()) { + server->send(400, "application/json", "{\"error\":\"Missing request body\"}"); + return; + } + + Serial.printf("[%lu] [WEB] Uploading list '%s' (%d bytes)\n", millis(), name.c_str(), body.length()); + + // Parse the CSV body + BookList list; + list.name = name.c_str(); + + if (!BookListStore::parseFromText(body.c_str(), list)) { + server->send(400, "application/json", "{\"error\":\"Failed to parse list data\"}"); + return; + } + + // Save the list + if (!BookListStore::saveList(list)) { + server->send(500, "application/json", "{\"error\":\"Failed to save list\"}"); + return; + } + + // Return success with path + JsonDocument doc; + doc["success"] = true; + doc["path"] = BookListStore::getListPath(name.c_str()); + + String response; + serializeJson(doc, response); + server->send(200, "application/json", response); + + Serial.printf("[%lu] [WEB] List '%s' uploaded successfully\n", millis(), name.c_str()); + + } else if (action == "delete") { + if (!BookListStore::listExists(name.c_str())) { + server->send(404, "application/json", "{\"error\":\"List not found\"}"); + return; + } + + if (!BookListStore::deleteList(name.c_str())) { + server->send(500, "application/json", "{\"error\":\"Failed to delete list\"}"); + return; + } + + // Clear pinned list if we just deleted it + if (strcmp(SETTINGS.pinnedListName, name.c_str()) == 0) { + SETTINGS.pinnedListName[0] = '\0'; + SETTINGS.saveToFile(); + } + + server->send(200, "application/json", "{\"success\":true}"); + Serial.printf("[%lu] [WEB] List '%s' deleted successfully\n", millis(), name.c_str()); + + } else { + server->send(400, "application/json", "{\"error\":\"Invalid action. Use 'upload' or 'delete'\"}"); + } +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index e87aac8..5ff3882 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -88,4 +88,8 @@ class CrossPointWebServer { // Helper for copy operations bool copyFile(const String& srcPath, const String& destPath) const; bool copyFolder(const String& srcPath, const String& destPath) const; + + // List management handlers + void handleListGet() const; + void handleListPost() const; };