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
This commit is contained in:
Justin Mitchell 2026-01-02 19:39:09 -05:00
parent 062d69dc2a
commit 55c38150cb
13 changed files with 964 additions and 41 deletions

View File

@ -0,0 +1,198 @@
#include "OpdsParser.h"
#include <HardwareSerial.h>
#include <cstring>
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<int>(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<OpdsParser*>(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<OpdsParser*>(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<OpdsParser*>(userData);
// Only accumulate text when in a text element
if (self->inTitle || self->inAuthorName || self->inId) {
self->currentText.append(s, len);
}
}

View File

@ -0,0 +1,77 @@
#pragma once
#include <expat.h>
#include <string>
#include <vector>
/**
* 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<OpdsBook>& 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<OpdsBook> books;
OpdsBook currentBook;
std::string currentText;
// Parser state
bool inEntry = false;
bool inTitle = false;
bool inAuthor = false;
bool inAuthorName = false;
bool inId = false;
};

View File

@ -3,6 +3,7 @@
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <cstring>
#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;
}
}

View File

@ -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

View File

@ -0,0 +1,305 @@
#include "OpdsBookBrowserActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#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<OpdsBookBrowserActivity*>(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<size_t>(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<size_t>(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;
}

View File

@ -0,0 +1,56 @@
#pragma once
#include <OpdsParser.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#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<void()>& 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<OpdsBook> books;
int selectorIndex = 0;
std::string errorMessage;
std::string statusMessage;
size_t downloadProgress = 0;
size_t downloadTotal = 0;
const std::function<void()> 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;
};

View File

@ -3,7 +3,10 @@
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <cstring>
#include <vector>
#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<const char*> 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<int>(menuItems.size()) * menuTileHeight +
(static_cast<int>(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<int>(i) + (hasContinueReading ? 1 : 0);
constexpr int tileX = margin;
const int tileY = menuStartY + i * (menuTileHeight + menuSpacing);
const int tileY = menuStartY + static_cast<int>(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);

View File

@ -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<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
const std::function<void()> 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<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
const std::function<void()>& 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;

View File

@ -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) {

View File

@ -1,15 +1,17 @@
#include "SettingsActivity.h"
#include <GfxRenderer.h>
#include <cstring>
#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<uint8_t>(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] {

View File

@ -5,7 +5,9 @@
#include <InputManager.h>
#include <SDCardManager.h>
#include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/all.h>
#include <cstring>
#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() {

View File

@ -0,0 +1,128 @@
#include "HttpDownloader.h"
#include <HardwareSerial.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <memory>
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
const std::unique_ptr<WiFiClientSecure> 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<WiFiClientSecure> 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;
}

View File

@ -0,0 +1,42 @@
#pragma once
#include <SDCardManager.h>
#include <functional>
#include <string>
/**
* HTTP client utility for fetching content and downloading files.
* Wraps WiFiClientSecure and HTTPClient for HTTPS requests.
*/
class HttpDownloader {
public:
using ProgressCallback = std::function<void(size_t downloaded, size_t total)>;
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;
};