From 55c38150cb15c355b06c1087d07a4d2f0b0f193d Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 2 Jan 2026 19:39:09 -0500 Subject: [PATCH] Adds margin setting and calibre web Also made the menu items dynamic and capital case. Looks better from a design perspective but I suppose this is an optional thing to accept --- lib/OpdsParser/OpdsParser.cpp | 198 ++++++++++++ lib/OpdsParser/OpdsParser.h | 77 +++++ src/CrossPointSettings.cpp | 28 +- src/CrossPointSettings.h | 5 + .../browser/OpdsBookBrowserActivity.cpp | 305 ++++++++++++++++++ .../browser/OpdsBookBrowserActivity.h | 56 ++++ src/activities/home/HomeActivity.cpp | 82 +++-- src/activities/home/HomeActivity.h | 8 +- src/activities/reader/EpubReaderActivity.cpp | 5 +- src/activities/settings/SettingsActivity.cpp | 25 +- src/main.cpp | 46 ++- src/network/HttpDownloader.cpp | 128 ++++++++ src/network/HttpDownloader.h | 42 +++ 13 files changed, 964 insertions(+), 41 deletions(-) create mode 100644 lib/OpdsParser/OpdsParser.cpp create mode 100644 lib/OpdsParser/OpdsParser.h create mode 100644 src/activities/browser/OpdsBookBrowserActivity.cpp create mode 100644 src/activities/browser/OpdsBookBrowserActivity.h create mode 100644 src/network/HttpDownloader.cpp create mode 100644 src/network/HttpDownloader.h diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp new file mode 100644 index 00000000..21117d3d --- /dev/null +++ b/lib/OpdsParser/OpdsParser.cpp @@ -0,0 +1,198 @@ +#include "OpdsParser.h" + +#include +#include + +OpdsParser::~OpdsParser() { + if (parser) { + XML_StopParser(parser, XML_FALSE); + XML_SetElementHandler(parser, nullptr, nullptr); + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; + } +} + +bool OpdsParser::parse(const char* xmlData, const size_t length) { + clear(); + + parser = XML_ParserCreate(nullptr); + if (!parser) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis()); + return false; + } + + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); + + // Parse in chunks to avoid large buffer allocations + const char* currentPos = xmlData; + size_t remaining = length; + constexpr size_t chunkSize = 1024; + + while (remaining > 0) { + void* const buf = XML_GetBuffer(parser, chunkSize); + if (!buf) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis()); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + const size_t toRead = remaining < chunkSize ? remaining : chunkSize; + memcpy(buf, currentPos, toRead); + + const bool isFinal = (remaining == toRead); + if (XML_ParseBuffer(parser, static_cast(toRead), isFinal) == XML_STATUS_ERROR) { + Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), + XML_ErrorString(XML_GetErrorCode(parser))); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + currentPos += toRead; + remaining -= toRead; + } + + // Clean up parser + XML_ParserFree(parser); + parser = nullptr; + + Serial.printf("[%lu] [OPDS] Parsed %zu books\n", millis(), books.size()); + return true; +} + +void OpdsParser::clear() { + books.clear(); + currentBook = OpdsBook{}; + currentText.clear(); + inEntry = false; + inTitle = false; + inAuthor = false; + inAuthorName = false; + inId = false; +} + +const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], name) == 0) { + return atts[i + 1]; + } + } + return nullptr; +} + +void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { + auto* self = static_cast(userData); + + // Check for entry element (with or without namespace prefix) + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + self->inEntry = true; + self->currentBook = OpdsBook{}; + return; + } + + if (!self->inEntry) return; + + // Check for title element + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + self->inTitle = true; + self->currentText.clear(); + return; + } + + // Check for author element + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = true; + return; + } + + // Check for author name element + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + self->inAuthorName = true; + self->currentText.clear(); + return; + } + + // Check for id element + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + self->inId = true; + self->currentText.clear(); + return; + } + + // Check for link element with acquisition rel and epub type + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { + const char* rel = findAttribute(atts, "rel"); + const char* type = findAttribute(atts, "type"); + const char* href = findAttribute(atts, "href"); + + // Look for acquisition link with epub type + if (rel && type && href) { + if (strstr(rel, "opds-spec.org/acquisition") != nullptr && strcmp(type, "application/epub+zip") == 0) { + self->currentBook.epubUrl = href; + } + } + } +} + +void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { + auto* self = static_cast(userData); + + // Check for entry end + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + // Only add book if it has required fields + if (!self->currentBook.title.empty() && !self->currentBook.epubUrl.empty()) { + self->books.push_back(self->currentBook); + } + self->inEntry = false; + self->currentBook = OpdsBook{}; + return; + } + + if (!self->inEntry) return; + + // Check for title end + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + if (self->inTitle) { + self->currentBook.title = self->currentText; + } + self->inTitle = false; + return; + } + + // Check for author end + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = false; + return; + } + + // Check for author name end + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + if (self->inAuthorName) { + self->currentBook.author = self->currentText; + } + self->inAuthorName = false; + return; + } + + // Check for id end + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + if (self->inId) { + self->currentBook.id = self->currentText; + } + self->inId = false; + return; + } +} + +void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { + auto* self = static_cast(userData); + + // Only accumulate text when in a text element + if (self->inTitle || self->inAuthorName || self->inId) { + self->currentText.append(s, len); + } +} diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h new file mode 100644 index 00000000..55bd4274 --- /dev/null +++ b/lib/OpdsParser/OpdsParser.h @@ -0,0 +1,77 @@ +#pragma once +#include + +#include +#include + +/** + * Represents a book entry from an OPDS feed. + */ +struct OpdsBook { + std::string title; + std::string author; + std::string epubUrl; // Relative URL like "/books/get/epub/3/Calibre_Library" + std::string id; +}; + +/** + * Parser for OPDS (Open Publication Distribution System) Atom feeds. + * Uses the Expat XML parser to parse OPDS catalog entries. + * + * Usage: + * OpdsParser parser; + * if (parser.parse(xmlData, xmlLength)) { + * for (const auto& book : parser.getBooks()) { + * // Process book entries + * } + * } + */ +class OpdsParser { + public: + OpdsParser() = default; + ~OpdsParser(); + + // Disable copy + OpdsParser(const OpdsParser&) = delete; + OpdsParser& operator=(const OpdsParser&) = delete; + + /** + * Parse an OPDS XML feed. + * @param xmlData Pointer to the XML data + * @param length Length of the XML data + * @return true if parsing succeeded, false on error + */ + bool parse(const char* xmlData, size_t length); + + /** + * Get the parsed books. + * @return Vector of OpdsBook entries + */ + const std::vector& getBooks() const { return books; } + + /** + * Clear all parsed books. + */ + void clear(); + + private: + // Expat callbacks + static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); + static void XMLCALL endElement(void* userData, const XML_Char* name); + static void XMLCALL characterData(void* userData, const XML_Char* s, int len); + + // Helper to find attribute value + static const char* findAttribute(const XML_Char** atts, const char* name); + + XML_Parser parser = nullptr; + std::vector books; + OpdsBook currentBook; + std::string currentText; + + // Parser state + bool inEntry = false; + bool inTitle = false; + bool inAuthor = false; + bool inAuthorName = false; + bool inId = false; +}; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 74bc0d26..eb434653 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "fontIds.h" @@ -12,7 +13,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 = 11; +constexpr uint8_t SETTINGS_COUNT = 13; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -38,6 +39,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, fontSize); serialization::writePod(outputFile, lineSpacing); serialization::writePod(outputFile, paragraphAlignment); + serialization::writePod(outputFile, sideMargin); + serialization::writeString(outputFile, std::string(opdsServerUrl)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -86,6 +89,15 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, paragraphAlignment); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, sideMargin); + if (++settingsRead >= fileSettingsCount) break; + { + std::string urlStr; + serialization::readString(inputFile, urlStr); + strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); + opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); @@ -170,3 +182,17 @@ int CrossPointSettings::getReaderFontId() const { } } } + +int CrossPointSettings::getReaderSideMargin() const { + switch (sideMargin) { + case MARGIN_NONE: + return 0; + case MARGIN_SMALL: + default: + return 5; + case MARGIN_MEDIUM: + return 20; + case MARGIN_LARGE: + return 30; + } +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index b9cce85e..e7194e8a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -44,6 +44,7 @@ class CrossPointSettings { enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 }; enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 }; enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 }; + enum SIDE_MARGIN { MARGIN_NONE = 0, MARGIN_SMALL = 1, MARGIN_MEDIUM = 2, MARGIN_LARGE = 3 }; // Sleep screen settings uint8_t sleepScreen = DARK; @@ -64,6 +65,9 @@ class CrossPointSettings { uint8_t fontSize = MEDIUM; uint8_t lineSpacing = NORMAL; uint8_t paragraphAlignment = JUSTIFIED; + uint8_t sideMargin = MARGIN_SMALL; + // OPDS browser settings + char opdsServerUrl[128] = ""; // e.g., "https://home.jmitch.com/books" ~CrossPointSettings() = default; @@ -77,6 +81,7 @@ class CrossPointSettings { bool loadFromFile(); float getReaderLineCompression() const; + int getReaderSideMargin() const; }; // Helper macro to access settings diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp new file mode 100644 index 00000000..8b3239ca --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -0,0 +1,305 @@ +#include "OpdsBookBrowserActivity.h" + +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "fontIds.h" +#include "network/HttpDownloader.h" + +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +constexpr char OPDS_FEED_PATH[] = "/opds/navcatalog/4f6e6577657374?library_id=Calibre_Library"; + +// Prepend http:// if no protocol specified (server will redirect to https if needed) +std::string ensureProtocol(const std::string& url) { + if (url.find("://") == std::string::npos) { + return "http://" + url; + } + return url; +} +} // namespace + +void OpdsBookBrowserActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OpdsBookBrowserActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = BrowserState::LOADING; + books.clear(); + selectorIndex = 0; + errorMessage.clear(); + statusMessage = "Loading..."; + updateRequired = true; + + xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask", + 4096, // Stack size (larger for HTTP operations) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Fetch books after setting up the display task + fetchBooks(); +} + +void OpdsBookBrowserActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + books.clear(); +} + +void OpdsBookBrowserActivity::loop() { + // Handle error state - Confirm retries, Back goes home + if (state == BrowserState::ERROR) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchBooks(); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } + return; + } + + // Handle loading state - only Back goes home + if (state == BrowserState::LOADING) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } + return; + } + + // Handle downloading state - no input allowed + if (state == BrowserState::DOWNLOADING) { + return; + } + + // Handle book list state + if (state == BrowserState::BOOK_LIST) { + 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; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!books.empty()) { + downloadBook(books[selectorIndex]); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } else if (prevReleased && !books.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + books.size()) % books.size(); + } else { + selectorIndex = (selectorIndex + books.size() - 1) % books.size(); + } + updateRequired = true; + } else if (nextReleased && !books.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % books.size(); + } else { + selectorIndex = (selectorIndex + 1) % books.size(); + } + updateRequired = true; + } + } +} + +void OpdsBookBrowserActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OpdsBookBrowserActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Browse Books", true, EpdFontFamily::BOLD); + + if (state == BrowserState::LOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::ERROR) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::DOWNLOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Downloading..."); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, statusMessage.c_str()); + if (downloadTotal > 0) { + const int percent = (downloadProgress * 100) / downloadTotal; + char progressText[32]; + snprintf(progressText, sizeof(progressText), "%d%%", percent); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, progressText); + } + renderer.displayBuffer(); + return; + } + + // Book list state + const auto labels = mappedInput.mapLabels("« Back", "Download", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (books.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No books found"); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); + + for (size_t i = pageStartIndex; i < books.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { + // Format: "Title - Author" or just "Title" if no author + std::string displayText = books[i].title; + if (!books[i].author.empty()) { + displayText += " - " + books[i].author; + } + + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != static_cast(selectorIndex)); + } + + renderer.displayBuffer(); +} + +void OpdsBookBrowserActivity::fetchBooks() { + const char* serverUrl = SETTINGS.opdsServerUrl; + if (strlen(serverUrl) == 0) { + state = BrowserState::ERROR; + errorMessage = "No server URL configured"; + updateRequired = true; + return; + } + + std::string url = ensureProtocol(serverUrl) + OPDS_FEED_PATH; + Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); + + std::string content; + if (!HttpDownloader::fetchUrl(url, content)) { + state = BrowserState::ERROR; + errorMessage = "Failed to fetch feed"; + updateRequired = true; + return; + } + + OpdsParser parser; + if (!parser.parse(content.c_str(), content.size())) { + state = BrowserState::ERROR; + errorMessage = "Failed to parse feed"; + updateRequired = true; + return; + } + + books = parser.getBooks(); + selectorIndex = 0; + + if (books.empty()) { + state = BrowserState::ERROR; + errorMessage = "No books found"; + updateRequired = true; + return; + } + + state = BrowserState::BOOK_LIST; + updateRequired = true; +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) { + state = BrowserState::DOWNLOADING; + statusMessage = book.title; + downloadProgress = 0; + downloadTotal = 0; + updateRequired = true; + + // Build full download URL + std::string downloadUrl = ensureProtocol(SETTINGS.opdsServerUrl) + book.epubUrl; + + // Create sanitized filename + std::string filename = "/" + sanitizeFilename(book.title) + ".epub"; + + Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str()); + + const auto result = HttpDownloader::downloadToFile( + downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + downloadProgress = downloaded; + downloadTotal = total; + updateRequired = true; + }); + + if (result == HttpDownloader::OK) { + Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); + state = BrowserState::BOOK_LIST; + updateRequired = true; + } else { + state = BrowserState::ERROR; + errorMessage = "Download failed"; + updateRequired = true; + } +} + +std::string OpdsBookBrowserActivity::sanitizeFilename(const std::string& title) const { + std::string result; + result.reserve(title.size()); + + for (char c : title) { + // Replace invalid filename characters with underscore + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') { + result += '_'; + } else if (c >= 32 && c < 127) { + // Keep printable ASCII characters + result += c; + } + // Skip non-printable characters + } + + // Trim leading/trailing spaces and dots + size_t start = result.find_first_not_of(" ."); + if (start == std::string::npos) { + return "book"; // Fallback if title is all invalid characters + } + size_t end = result.find_last_not_of(" ."); + result = result.substr(start, end - start + 1); + + // Limit filename length (SD card FAT32 has 255 char limit, but let's be safe) + if (result.length() > 100) { + result = result.substr(0, 100); + } + + return result.empty() ? "book" : result; +} diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h new file mode 100644 index 00000000..aee43ae4 --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +/** + * Activity for browsing and downloading books from an OPDS server. + * Displays a list of available books and allows downloading EPUBs. + */ +class OpdsBookBrowserActivity final : public Activity { + public: + enum class BrowserState { + LOADING, // Fetching OPDS feed + BOOK_LIST, // Displaying books + DOWNLOADING, // Downloading selected EPUB + ERROR // Error state with message + }; + + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoHome) + : Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + BrowserState state = BrowserState::LOADING; + std::vector books; + int selectorIndex = 0; + std::string errorMessage; + std::string statusMessage; + size_t downloadProgress = 0; + size_t downloadTotal = 0; + + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + void fetchBooks(); + void downloadBook(const OpdsBook& book); + std::string sanitizeFilename(const std::string& title) const; +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 11107fdc..c23af98c 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -3,7 +3,10 @@ #include #include #include +#include +#include +#include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" @@ -14,7 +17,12 @@ void HomeActivity::taskTrampoline(void* param) { self->displayTaskLoop(); } -int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } +int HomeActivity::getMenuItemCount() const { + int count = 3; // Browse files, File transfer, Settings + if (hasContinueReading) count++; + if (hasBrowserUrl) count++; + return count; +} void HomeActivity::onEnter() { Activity::onEnter(); @@ -24,6 +32,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 browser URL is configured + hasBrowserUrl = strlen(SETTINGS.opdsServerUrl) > 0; + if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; @@ -86,26 +97,24 @@ 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(); - } + // Calculate dynamic indices based on which options are available + int idx = 0; + const int continueIdx = hasContinueReading ? idx++ : -1; + const int browseFilesIdx = idx++; + const int browseBookIdx = hasBrowserUrl ? idx++ : -1; + const int fileTransferIdx = idx++; + const int settingsIdx = idx; + + if (selectorIndex == continueIdx) { + onContinueReading(); + } else if (selectorIndex == browseFilesIdx) { + onReaderOpen(); + } else if (selectorIndex == browseBookIdx) { + onBrowserOpen(); + } else if (selectorIndex == fileTransferIdx) { + onFileTransferOpen(); + } else if (selectorIndex == settingsIdx) { + onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; @@ -277,24 +286,33 @@ 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) --- - const int menuTileWidth = pageWidth - 2 * margin; - constexpr int menuTileHeight = 50; - constexpr int menuSpacing = 10; - constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + // --- Bottom menu tiles --- + // Build menu items dynamically + std::vector menuItems; + menuItems.push_back("Browse Files"); + if (hasBrowserUrl) { + menuItems.push_back("Calibre Library"); + } + menuItems.push_back("File Transfer"); + menuItems.push_back("Settings"); - int menuStartY = bookY + bookHeight + 20; + const int menuTileWidth = pageWidth - 2 * margin; + constexpr int menuTileHeight = 45; + constexpr int menuSpacing = 8; + const int totalMenuHeight = static_cast(menuItems.size()) * menuTileHeight + + (static_cast(menuItems.size()) - 1) * menuSpacing; + + int menuStartY = bookY + bookHeight + 15; // Ensure we don't collide with the bottom button legend const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; if (menuStartY > maxMenuStartY) { 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 (size_t i = 0; i < menuItems.size(); ++i) { + const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); constexpr int tileX = margin; - const int tileY = menuStartY + i * (menuTileHeight + menuSpacing); + const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; if (selected) { @@ -303,7 +321,7 @@ void HomeActivity::render() const { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } - const char* label = items[i]; + const char* label = menuItems[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..21c21312 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 hasBrowserUrl = 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 onBrowserOpen; 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& onBrowserOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), - onFileTransferOpen(onFileTransferOpen) {} + onFileTransferOpen(onFileTransferOpen), + onBrowserOpen(onBrowserOpen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 4348625d..2a90f4ee 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -17,7 +17,6 @@ constexpr int pagesPerRefresh = 15; constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr int topPadding = 5; -constexpr int horizontalPadding = 5; constexpr int statusBarMargin = 19; } // namespace @@ -254,8 +253,8 @@ void EpubReaderActivity::renderScreen() { renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += topPadding; - orientedMarginLeft += horizontalPadding; - orientedMarginRight += horizontalPadding; + orientedMarginLeft += SETTINGS.getReaderSideMargin(); + orientedMarginRight += SETTINGS.getReaderSideMargin(); orientedMarginBottom += statusBarMargin; if (!section) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index d9d19411..4cfd9bee 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,15 +1,17 @@ #include "SettingsActivity.h" #include +#include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 12; +constexpr int settingsCount = 14; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, @@ -38,6 +40,8 @@ const SettingInfo settingsList[settingsCount] = { SettingType::ENUM, &CrossPointSettings::paragraphAlignment, {"Justify", "Left", "Center", "Right"}}, + {"Reader Side Margin", SettingType::ENUM, &CrossPointSettings::sideMargin, {"None", "Small", "Medium", "Large"}}, + {"Calibre Web URL", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, }; } // namespace @@ -130,7 +134,24 @@ void SettingsActivity::toggleCurrentSetting() { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); } else if (setting.type == SettingType::ACTION) { - if (std::string(setting.name) == "Check for updates") { + if (std::string(setting.name) == "Calibre Web URL") { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, 127, false, + [this](const std::string& url) { + strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); + SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (std::string(setting.name) == "Check for updates") { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { diff --git a/src/main.cpp b/src/main.cpp index b9e33426..b378a5e0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,7 +5,9 @@ #include #include #include +#include #include +#include #include "Battery.h" #include "CrossPointSettings.h" @@ -13,11 +15,14 @@ #include "MappedInputManager.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" +#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/home/HomeActivity.h" #include "activities/network/CrossPointWebServerActivity.h" +#include "activities/network/WifiSelectionActivity.h" #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "fontIds.h" #define SPI_FQ 40000000 @@ -222,10 +227,49 @@ void onGoToSettings() { enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); } +// Helper to launch browser after WiFi is connected +void launchBrowserWithUrlCheck() { + // If no server URL configured, prompt for one first + if (strlen(SETTINGS.opdsServerUrl) == 0) { + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInputManager, "Calibre Web URL", "", 10, 127, false, + [](const std::string& url) { + strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); + SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); + }, + [] { + exitActivity(); + onGoHome(); + })); + } else { + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); + } +} + +void onGoToBrowser() { + exitActivity(); + // Check WiFi connectivity first + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInputManager, [](bool connected) { + exitActivity(); + if (connected) { + launchBrowserWithUrlCheck(); + } else { + onGoHome(); + } + })); + } else { + launchBrowserWithUrlCheck(); + } +} + void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings, - onGoToFileTransfer)); + onGoToFileTransfer, onGoToBrowser)); } void setupDisplayAndFonts() { diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp new file mode 100644 index 00000000..aaa01345 --- /dev/null +++ b/src/network/HttpDownloader.cpp @@ -0,0 +1,128 @@ +#include "HttpDownloader.h" + +#include +#include +#include + +#include + +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); + + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); + http.end(); + return false; + } + + outContent = http.getString().c_str(); + http.end(); + + Serial.printf("[%lu] [HTTP] Fetched %zu bytes\n", millis(), outContent.size()); + return true; +} + +HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress) { + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); + Serial.printf("[%lu] [HTTP] Destination: %s\n", millis(), destPath.c_str()); + + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + const size_t contentLength = http.getSize(); + Serial.printf("[%lu] [HTTP] Content-Length: %zu\n", millis(), contentLength); + + // Remove existing file if present + if (SdMan.exists(destPath.c_str())) { + SdMan.remove(destPath.c_str()); + } + + // Open file for writing + FsFile file; + if (!SdMan.openFileForWrite("HTTP", destPath.c_str(), file)) { + Serial.printf("[%lu] [HTTP] Failed to open file for writing\n", millis()); + http.end(); + return FILE_ERROR; + } + + // Get the stream for chunked reading + WiFiClient* stream = http.getStreamPtr(); + if (!stream) { + Serial.printf("[%lu] [HTTP] Failed to get stream\n", millis()); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return HTTP_ERROR; + } + + // Download in chunks + uint8_t buffer[DOWNLOAD_CHUNK_SIZE]; + size_t downloaded = 0; + const size_t total = contentLength > 0 ? contentLength : 0; + + while (http.connected() && (contentLength == 0 || downloaded < contentLength)) { + const size_t available = stream->available(); + if (available == 0) { + delay(1); + continue; + } + + const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE; + const size_t bytesRead = stream->readBytes(buffer, toRead); + + if (bytesRead == 0) { + break; + } + + const size_t written = file.write(buffer, bytesRead); + if (written != bytesRead) { + Serial.printf("[%lu] [HTTP] Write failed: wrote %zu of %zu bytes\n", millis(), written, bytesRead); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return FILE_ERROR; + } + + downloaded += bytesRead; + + if (progress && total > 0) { + progress(downloaded, total); + } + } + + file.close(); + http.end(); + + Serial.printf("[%lu] [HTTP] Downloaded %zu bytes\n", millis(), downloaded); + + // Verify download size if known + if (contentLength > 0 && downloaded != contentLength) { + Serial.printf("[%lu] [HTTP] Size mismatch: got %zu, expected %zu\n", millis(), downloaded, contentLength); + SdMan.remove(destPath.c_str()); + return HTTP_ERROR; + } + + return OK; +} diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h new file mode 100644 index 00000000..e6e0f163 --- /dev/null +++ b/src/network/HttpDownloader.h @@ -0,0 +1,42 @@ +#pragma once +#include + +#include +#include + +/** + * HTTP client utility for fetching content and downloading files. + * Wraps WiFiClientSecure and HTTPClient for HTTPS requests. + */ +class HttpDownloader { + public: + using ProgressCallback = std::function; + + enum DownloadError { + OK = 0, + HTTP_ERROR, + FILE_ERROR, + ABORTED, + }; + + /** + * Fetch text content from a URL. + * @param url The URL to fetch + * @param outContent The fetched content (output) + * @return true if fetch succeeded, false on error + */ + static bool fetchUrl(const std::string& url, std::string& outContent); + + /** + * Download a file to the SD card. + * @param url The URL to download + * @param destPath The destination path on SD card + * @param progress Optional progress callback + * @return DownloadError indicating success or failure type + */ + static DownloadError downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress = nullptr); + + private: + static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024; +};