mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 23:27:38 +03:00
lets you navigate and download books now
This commit is contained in:
parent
c99b93435f
commit
231393703c
@ -60,13 +60,13 @@ bool OpdsParser::parse(const char* xmlData, const size_t length) {
|
|||||||
XML_ParserFree(parser);
|
XML_ParserFree(parser);
|
||||||
parser = nullptr;
|
parser = nullptr;
|
||||||
|
|
||||||
Serial.printf("[%lu] [OPDS] Parsed %zu books\n", millis(), books.size());
|
Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsParser::clear() {
|
void OpdsParser::clear() {
|
||||||
books.clear();
|
entries.clear();
|
||||||
currentBook = OpdsBook{};
|
currentEntry = OpdsEntry{};
|
||||||
currentText.clear();
|
currentText.clear();
|
||||||
inEntry = false;
|
inEntry = false;
|
||||||
inTitle = false;
|
inTitle = false;
|
||||||
@ -75,6 +75,16 @@ void OpdsParser::clear() {
|
|||||||
inId = false;
|
inId = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<OpdsEntry> OpdsParser::getBooks() const {
|
||||||
|
std::vector<OpdsEntry> books;
|
||||||
|
for (const auto& entry : entries) {
|
||||||
|
if (entry.type == OpdsEntryType::BOOK) {
|
||||||
|
books.push_back(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
|
const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
|
||||||
for (int i = 0; atts[i]; i += 2) {
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
if (strcmp(atts[i], name) == 0) {
|
if (strcmp(atts[i], name) == 0) {
|
||||||
@ -90,7 +100,7 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
|
|||||||
// Check for entry element (with or without namespace prefix)
|
// Check for entry element (with or without namespace prefix)
|
||||||
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
||||||
self->inEntry = true;
|
self->inEntry = true;
|
||||||
self->currentBook = OpdsBook{};
|
self->currentEntry = OpdsEntry{};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,16 +133,26 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for link element with acquisition rel and epub type
|
// Check for link element
|
||||||
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
|
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
|
||||||
const char* rel = findAttribute(atts, "rel");
|
const char* rel = findAttribute(atts, "rel");
|
||||||
const char* type = findAttribute(atts, "type");
|
const char* type = findAttribute(atts, "type");
|
||||||
const char* href = findAttribute(atts, "href");
|
const char* href = findAttribute(atts, "href");
|
||||||
|
|
||||||
// Look for acquisition link with epub type
|
if (href) {
|
||||||
if (rel && type && href) {
|
// Check for acquisition link with epub type (this is a downloadable book)
|
||||||
if (strstr(rel, "opds-spec.org/acquisition") != nullptr && strcmp(type, "application/epub+zip") == 0) {
|
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
|
||||||
self->currentBook.epubUrl = href;
|
strcmp(type, "application/epub+zip") == 0) {
|
||||||
|
self->currentEntry.type = OpdsEntryType::BOOK;
|
||||||
|
self->currentEntry.href = href;
|
||||||
|
}
|
||||||
|
// Check for navigation link (subsection or no rel specified with atom+xml type)
|
||||||
|
else if (type && strstr(type, "application/atom+xml") != nullptr) {
|
||||||
|
// Only set navigation link if we don't already have an epub link
|
||||||
|
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
||||||
|
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
||||||
|
self->currentEntry.href = href;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,12 +163,12 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
|
|
||||||
// Check for entry end
|
// Check for entry end
|
||||||
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
||||||
// Only add book if it has required fields
|
// Only add entry if it has required fields (title and href)
|
||||||
if (!self->currentBook.title.empty() && !self->currentBook.epubUrl.empty()) {
|
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
|
||||||
self->books.push_back(self->currentBook);
|
self->entries.push_back(self->currentEntry);
|
||||||
}
|
}
|
||||||
self->inEntry = false;
|
self->inEntry = false;
|
||||||
self->currentBook = OpdsBook{};
|
self->currentEntry = OpdsEntry{};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,7 +177,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
// Check for title end
|
// Check for title end
|
||||||
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
||||||
if (self->inTitle) {
|
if (self->inTitle) {
|
||||||
self->currentBook.title = self->currentText;
|
self->currentEntry.title = self->currentText;
|
||||||
}
|
}
|
||||||
self->inTitle = false;
|
self->inTitle = false;
|
||||||
return;
|
return;
|
||||||
@ -172,7 +192,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
// Check for author name end
|
// Check for author name end
|
||||||
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
||||||
if (self->inAuthorName) {
|
if (self->inAuthorName) {
|
||||||
self->currentBook.author = self->currentText;
|
self->currentEntry.author = self->currentText;
|
||||||
}
|
}
|
||||||
self->inAuthorName = false;
|
self->inAuthorName = false;
|
||||||
return;
|
return;
|
||||||
@ -181,7 +201,7 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
|||||||
// Check for id end
|
// Check for id end
|
||||||
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||||
if (self->inId) {
|
if (self->inId) {
|
||||||
self->currentBook.id = self->currentText;
|
self->currentEntry.id = self->currentText;
|
||||||
}
|
}
|
||||||
self->inId = false;
|
self->inId = false;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -5,15 +5,27 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a book entry from an OPDS feed.
|
* Type of OPDS entry.
|
||||||
*/
|
*/
|
||||||
struct OpdsBook {
|
enum class OpdsEntryType {
|
||||||
|
NAVIGATION, // Link to another catalog
|
||||||
|
BOOK // Downloadable book
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an entry from an OPDS feed (either a navigation link or a book).
|
||||||
|
*/
|
||||||
|
struct OpdsEntry {
|
||||||
|
OpdsEntryType type = OpdsEntryType::NAVIGATION;
|
||||||
std::string title;
|
std::string title;
|
||||||
std::string author;
|
std::string author; // Only for books
|
||||||
std::string epubUrl; // Relative URL like "/books/get/epub/3/Calibre_Library"
|
std::string href; // Navigation URL or epub download URL
|
||||||
std::string id;
|
std::string id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Legacy alias for backward compatibility
|
||||||
|
using OpdsBook = OpdsEntry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parser for OPDS (Open Publication Distribution System) Atom feeds.
|
* Parser for OPDS (Open Publication Distribution System) Atom feeds.
|
||||||
* Uses the Expat XML parser to parse OPDS catalog entries.
|
* Uses the Expat XML parser to parse OPDS catalog entries.
|
||||||
@ -21,8 +33,12 @@ struct OpdsBook {
|
|||||||
* Usage:
|
* Usage:
|
||||||
* OpdsParser parser;
|
* OpdsParser parser;
|
||||||
* if (parser.parse(xmlData, xmlLength)) {
|
* if (parser.parse(xmlData, xmlLength)) {
|
||||||
* for (const auto& book : parser.getBooks()) {
|
* for (const auto& entry : parser.getEntries()) {
|
||||||
* // Process book entries
|
* if (entry.type == OpdsEntryType::BOOK) {
|
||||||
|
* // Downloadable book
|
||||||
|
* } else {
|
||||||
|
* // Navigation link to another catalog
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@ -44,13 +60,19 @@ class OpdsParser {
|
|||||||
bool parse(const char* xmlData, size_t length);
|
bool parse(const char* xmlData, size_t length);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parsed books.
|
* Get the parsed entries (both navigation and book entries).
|
||||||
* @return Vector of OpdsBook entries
|
* @return Vector of OpdsEntry entries
|
||||||
*/
|
*/
|
||||||
const std::vector<OpdsBook>& getBooks() const { return books; }
|
const std::vector<OpdsEntry>& getEntries() const { return entries; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all parsed books.
|
* Get only book entries (legacy compatibility).
|
||||||
|
* @return Vector of book entries
|
||||||
|
*/
|
||||||
|
std::vector<OpdsEntry> getBooks() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all parsed entries.
|
||||||
*/
|
*/
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
@ -64,8 +86,8 @@ class OpdsParser {
|
|||||||
static const char* findAttribute(const XML_Char** atts, const char* name);
|
static const char* findAttribute(const XML_Char** atts, const char* name);
|
||||||
|
|
||||||
XML_Parser parser = nullptr;
|
XML_Parser parser = nullptr;
|
||||||
std::vector<OpdsBook> books;
|
std::vector<OpdsEntry> entries;
|
||||||
OpdsBook currentBook;
|
OpdsEntry currentEntry;
|
||||||
std::string currentText;
|
std::string currentText;
|
||||||
|
|
||||||
// Parser state
|
// Parser state
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr int PAGE_ITEMS = 23;
|
constexpr int PAGE_ITEMS = 23;
|
||||||
constexpr int SKIP_PAGE_MS = 700;
|
constexpr int SKIP_PAGE_MS = 700;
|
||||||
constexpr char OPDS_FEED_PATH[] = "/opds";
|
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
||||||
|
|
||||||
// Prepend http:// if no protocol specified (server will redirect to https if needed)
|
// Prepend http:// if no protocol specified (server will redirect to https if needed)
|
||||||
std::string ensureProtocol(const std::string& url) {
|
std::string ensureProtocol(const std::string& url) {
|
||||||
@ -20,6 +20,39 @@ std::string ensureProtocol(const std::string& url) {
|
|||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path")
|
||||||
|
std::string extractHost(const std::string& url) {
|
||||||
|
const size_t protocolEnd = url.find("://");
|
||||||
|
if (protocolEnd == std::string::npos) {
|
||||||
|
// No protocol, find first slash
|
||||||
|
const size_t firstSlash = url.find('/');
|
||||||
|
return firstSlash == std::string::npos ? url : url.substr(0, firstSlash);
|
||||||
|
}
|
||||||
|
// Find the first slash after the protocol
|
||||||
|
const size_t hostStart = protocolEnd + 3;
|
||||||
|
const size_t pathStart = url.find('/', hostStart);
|
||||||
|
return pathStart == std::string::npos ? url : url.substr(0, pathStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full URL from server URL and path
|
||||||
|
// If path starts with /, it's an absolute path from the host root
|
||||||
|
// Otherwise, it's relative to the server URL
|
||||||
|
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
|
||||||
|
const std::string urlWithProtocol = ensureProtocol(serverUrl);
|
||||||
|
if (path.empty()) {
|
||||||
|
return urlWithProtocol;
|
||||||
|
}
|
||||||
|
if (path[0] == '/') {
|
||||||
|
// Absolute path - use just the host
|
||||||
|
return extractHost(urlWithProtocol) + path;
|
||||||
|
}
|
||||||
|
// Relative path - append to server URL
|
||||||
|
if (urlWithProtocol.back() == '/') {
|
||||||
|
return urlWithProtocol + path;
|
||||||
|
}
|
||||||
|
return urlWithProtocol + "/" + path;
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||||
@ -32,7 +65,9 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
state = BrowserState::LOADING;
|
state = BrowserState::LOADING;
|
||||||
books.clear();
|
entries.clear();
|
||||||
|
navigationHistory.clear();
|
||||||
|
currentPath = OPDS_ROOT_PATH;
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
errorMessage.clear();
|
errorMessage.clear();
|
||||||
statusMessage = "Loading...";
|
statusMessage = "Loading...";
|
||||||
@ -45,8 +80,8 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
&displayTaskHandle // Task handle
|
&displayTaskHandle // Task handle
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch books after setting up the display task
|
// Fetch feed after setting up the display task
|
||||||
fetchBooks();
|
fetchFeed(currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::onExit() {
|
void OpdsBookBrowserActivity::onExit() {
|
||||||
@ -59,27 +94,28 @@ void OpdsBookBrowserActivity::onExit() {
|
|||||||
}
|
}
|
||||||
vSemaphoreDelete(renderingMutex);
|
vSemaphoreDelete(renderingMutex);
|
||||||
renderingMutex = nullptr;
|
renderingMutex = nullptr;
|
||||||
books.clear();
|
entries.clear();
|
||||||
|
navigationHistory.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::loop() {
|
void OpdsBookBrowserActivity::loop() {
|
||||||
// Handle error state - Confirm retries, Back goes home
|
// Handle error state - Confirm retries, Back goes back or home
|
||||||
if (state == BrowserState::ERROR) {
|
if (state == BrowserState::ERROR) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
state = BrowserState::LOADING;
|
state = BrowserState::LOADING;
|
||||||
statusMessage = "Loading...";
|
statusMessage = "Loading...";
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
fetchBooks();
|
fetchFeed(currentPath);
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
onGoHome();
|
navigateBack();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle loading state - only Back goes home
|
// Handle loading state - only Back works
|
||||||
if (state == BrowserState::LOADING) {
|
if (state == BrowserState::LOADING) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
onGoHome();
|
navigateBack();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -89,8 +125,8 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle book list state
|
// Handle browsing state
|
||||||
if (state == BrowserState::BOOK_LIST) {
|
if (state == BrowserState::BROWSING) {
|
||||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
||||||
@ -98,23 +134,28 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
if (!books.empty()) {
|
if (!entries.empty()) {
|
||||||
downloadBook(books[selectorIndex]);
|
const auto& entry = entries[selectorIndex];
|
||||||
|
if (entry.type == OpdsEntryType::BOOK) {
|
||||||
|
downloadBook(entry);
|
||||||
|
} else {
|
||||||
|
navigateToEntry(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
onGoHome();
|
navigateBack();
|
||||||
} else if (prevReleased && !books.empty()) {
|
} else if (prevReleased && !entries.empty()) {
|
||||||
if (skipPage) {
|
if (skipPage) {
|
||||||
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + books.size()) % books.size();
|
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
|
||||||
} else {
|
} else {
|
||||||
selectorIndex = (selectorIndex + books.size() - 1) % books.size();
|
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
|
||||||
}
|
}
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
} else if (nextReleased && !books.empty()) {
|
} else if (nextReleased && !entries.empty()) {
|
||||||
if (skipPage) {
|
if (skipPage) {
|
||||||
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % books.size();
|
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
|
||||||
} else {
|
} else {
|
||||||
selectorIndex = (selectorIndex + 1) % books.size();
|
selectorIndex = (selectorIndex + 1) % entries.size();
|
||||||
}
|
}
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
@ -171,12 +212,17 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Book list state
|
// Browsing state
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Download", "", "");
|
// Show appropriate button hint based on selected entry type
|
||||||
|
const char* confirmLabel = "Open";
|
||||||
|
if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) {
|
||||||
|
confirmLabel = "Download";
|
||||||
|
}
|
||||||
|
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
if (books.empty()) {
|
if (entries.empty()) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No books found");
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -184,11 +230,19 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
|
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
|
||||||
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
|
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++) {
|
for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
|
||||||
// Format: "Title - Author" or just "Title" if no author
|
const auto& entry = entries[i];
|
||||||
std::string displayText = books[i].title;
|
|
||||||
if (!books[i].author.empty()) {
|
// Format display text with type indicator
|
||||||
displayText += " - " + books[i].author;
|
std::string displayText;
|
||||||
|
if (entry.type == OpdsEntryType::NAVIGATION) {
|
||||||
|
displayText = "> " + entry.title; // Folder/navigation indicator
|
||||||
|
} else {
|
||||||
|
// Book: "Title - Author" or just "Title"
|
||||||
|
displayText = entry.title;
|
||||||
|
if (!entry.author.empty()) {
|
||||||
|
displayText += " - " + entry.author;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40);
|
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40);
|
||||||
@ -198,7 +252,7 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::fetchBooks() {
|
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||||
const char* serverUrl = SETTINGS.opdsServerUrl;
|
const char* serverUrl = SETTINGS.opdsServerUrl;
|
||||||
if (strlen(serverUrl) == 0) {
|
if (strlen(serverUrl) == 0) {
|
||||||
state = BrowserState::ERROR;
|
state = BrowserState::ERROR;
|
||||||
@ -207,7 +261,7 @@ void OpdsBookBrowserActivity::fetchBooks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string url = ensureProtocol(serverUrl) + OPDS_FEED_PATH;
|
std::string url = buildUrl(serverUrl, path);
|
||||||
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
||||||
|
|
||||||
std::string content;
|
std::string content;
|
||||||
@ -226,21 +280,54 @@ void OpdsBookBrowserActivity::fetchBooks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
books = parser.getBooks();
|
entries = parser.getEntries();
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
|
|
||||||
if (books.empty()) {
|
if (entries.empty()) {
|
||||||
state = BrowserState::ERROR;
|
state = BrowserState::ERROR;
|
||||||
errorMessage = "No books found";
|
errorMessage = "No entries found";
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = BrowserState::BOOK_LIST;
|
state = BrowserState::BROWSING;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
|
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
|
||||||
|
// Push current path to history before navigating
|
||||||
|
navigationHistory.push_back(currentPath);
|
||||||
|
currentPath = entry.href;
|
||||||
|
|
||||||
|
state = BrowserState::LOADING;
|
||||||
|
statusMessage = "Loading...";
|
||||||
|
entries.clear();
|
||||||
|
selectorIndex = 0;
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
fetchFeed(currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpdsBookBrowserActivity::navigateBack() {
|
||||||
|
if (navigationHistory.empty()) {
|
||||||
|
// At root, go home
|
||||||
|
onGoHome();
|
||||||
|
} else {
|
||||||
|
// Go back to previous catalog
|
||||||
|
currentPath = navigationHistory.back();
|
||||||
|
navigationHistory.pop_back();
|
||||||
|
|
||||||
|
state = BrowserState::LOADING;
|
||||||
|
statusMessage = "Loading...";
|
||||||
|
entries.clear();
|
||||||
|
selectorIndex = 0;
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
fetchFeed(currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||||
state = BrowserState::DOWNLOADING;
|
state = BrowserState::DOWNLOADING;
|
||||||
statusMessage = book.title;
|
statusMessage = book.title;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
@ -248,7 +335,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
// Build full download URL
|
// Build full download URL
|
||||||
std::string downloadUrl = ensureProtocol(SETTINGS.opdsServerUrl) + book.epubUrl;
|
std::string downloadUrl = buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||||
|
|
||||||
// Create sanitized filename
|
// Create sanitized filename
|
||||||
std::string filename = "/" + sanitizeFilename(book.title) + ".epub";
|
std::string filename = "/" + sanitizeFilename(book.title) + ".epub";
|
||||||
@ -264,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsBook& book) {
|
|||||||
|
|
||||||
if (result == HttpDownloader::OK) {
|
if (result == HttpDownloader::OK) {
|
||||||
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
|
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
|
||||||
state = BrowserState::BOOK_LIST;
|
state = BrowserState::BROWSING;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
} else {
|
} else {
|
||||||
state = BrowserState::ERROR;
|
state = BrowserState::ERROR;
|
||||||
|
|||||||
@ -12,13 +12,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity for browsing and downloading books from an OPDS server.
|
* Activity for browsing and downloading books from an OPDS server.
|
||||||
* Displays a list of available books and allows downloading EPUBs.
|
* Supports navigation through catalog hierarchy and downloading EPUBs.
|
||||||
*/
|
*/
|
||||||
class OpdsBookBrowserActivity final : public Activity {
|
class OpdsBookBrowserActivity final : public Activity {
|
||||||
public:
|
public:
|
||||||
enum class BrowserState {
|
enum class BrowserState {
|
||||||
LOADING, // Fetching OPDS feed
|
LOADING, // Fetching OPDS feed
|
||||||
BOOK_LIST, // Displaying books
|
BROWSING, // Displaying entries (navigation or books)
|
||||||
DOWNLOADING, // Downloading selected EPUB
|
DOWNLOADING, // Downloading selected EPUB
|
||||||
ERROR // Error state with message
|
ERROR // Error state with message
|
||||||
};
|
};
|
||||||
@ -37,7 +37,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
|||||||
bool updateRequired = false;
|
bool updateRequired = false;
|
||||||
|
|
||||||
BrowserState state = BrowserState::LOADING;
|
BrowserState state = BrowserState::LOADING;
|
||||||
std::vector<OpdsBook> books;
|
std::vector<OpdsEntry> entries;
|
||||||
|
std::vector<std::string> navigationHistory; // Stack of previous feed paths for back navigation
|
||||||
|
std::string currentPath; // Current feed path being displayed
|
||||||
int selectorIndex = 0;
|
int selectorIndex = 0;
|
||||||
std::string errorMessage;
|
std::string errorMessage;
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
@ -50,7 +52,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
|||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
|
|
||||||
void fetchBooks();
|
void fetchFeed(const std::string& path);
|
||||||
void downloadBook(const OpdsBook& book);
|
void navigateToEntry(const OpdsEntry& entry);
|
||||||
|
void navigateBack();
|
||||||
|
void downloadBook(const OpdsEntry& book);
|
||||||
std::string sanitizeFilename(const std::string& title) const;
|
std::string sanitizeFilename(const std::string& title) const;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user