feat: add OPDS search support & next/prev page navigation (#1462)
## Summary **What is the goal of this PR?** Adds OPDS search support, allowing users to search a catalog directly from the book browser when the server exposes an OpenSearch template. **What changes are included?** - `OpdsParser`: parses the OpenSearch template URL from feed-level `<link rel="search">` elements and exposes it via `getSearchTemplate()` - `OpdsBookBrowserActivity`: fetches and stores the search template after each feed load; shows a Search hint on the Left button when a template is available; launches the existing `KeyboardEntryActivity` for query input; URL-encodes the query and fetches the result feed - Absolute search result URLs are handled correctly in `fetchFeed` (skips prepending the server base URL) - A `consumeConfirm` guard prevents the Confirm release that submits the keyboard from immediately triggering a book download on the first browsing frame after search results load ## Additional Context - Search is silently unavailable if the server does not advertise an OpenSearch template — no UI change in that case - Tested against a Calibre-Web OPDS endpoint which exposes `<link rel="search" type="application/opensearchdescription+xml">` - The inline URL encoder in `performSearch` was necessary as `StringUtils` has no such utility; worth considering extracting to `StringUtils` in a follow-up - No new dependencies introduced --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --------- Co-authored-by: kira <rammah@tuta.io> Co-authored-by: Justin Mitchell <justin@jmitch.com>
This commit is contained in:
co-authored by
kira
Justin Mitchell
parent
5c12f2f01e
commit
fa2a3d2539
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Адкрыйце гэты адрас у браўзеры"
|
||||
STR_OR_HTTP_PREFIX: "або http://"
|
||||
STR_SCAN_QR_HINT: "або адсканіруйце QR-код:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre па Wi-Fi"
|
||||
STR_CALIBRE_WEB_URL: "Вэб-адрас Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Абаронена | + = Захавана"
|
||||
STR_MAC_ADDRESS: "MAC-адрас:"
|
||||
STR_CHECKING_WIFI: "Праверка Wi-Fi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Obriu aquest URL al navegador"
|
||||
STR_OR_HTTP_PREFIX: "o http://"
|
||||
STR_SCAN_QR_HINT: "o escanegeu el codi QR amb el telèfon:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre sense fils"
|
||||
STR_CALIBRE_WEB_URL: "URL web del Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Encriptat | + = Desat"
|
||||
STR_MAC_ADDRESS: "Adreça MAC:"
|
||||
STR_CHECKING_WIFI: "S'està comprovant el WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otevřete tuto URL ve svém prohlížeči"
|
||||
STR_OR_HTTP_PREFIX: "nebo http://"
|
||||
STR_SCAN_QR_HINT: "nebo naskenujte QR kód telefonem:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "URL webu Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Šifrováno | + = Uloženo"
|
||||
STR_MAC_ADDRESS: "MAC adresa:"
|
||||
STR_CHECKING_WIFI: "Kontrola WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Åbn denne URL i din browser"
|
||||
STR_OR_HTTP_PREFIX: "eller http://"
|
||||
STR_SCAN_QR_HINT: "eller scan QR-kode med din telefon:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Krypteret | + = Gemt"
|
||||
STR_MAC_ADDRESS: "MAC-adresse:"
|
||||
STR_CHECKING_WIFI: "Tjekker WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open deze URL in je browser"
|
||||
STR_OR_HTTP_PREFIX: "of http://"
|
||||
STR_SCAN_QR_HINT: "of scan de QR-code met je telefoon:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Beveiligd | + = Opgeslagen"
|
||||
STR_MAC_ADDRESS: "MAC-adres:"
|
||||
STR_CHECKING_WIFI: "Wifi controleren..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Open this URL in your browser"
|
||||
STR_OR_HTTP_PREFIX: "or http://"
|
||||
STR_SCAN_QR_HINT: "or scan QR code with your phone:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Encrypted | + = Saved"
|
||||
STR_MAC_ADDRESS: "MAC address:"
|
||||
STR_CHECKING_WIFI: "Checking WiFi..."
|
||||
@@ -175,6 +175,8 @@ STR_UNNAMED: "Unnamed"
|
||||
STR_NO_SERVER_URL: "No server URL configured"
|
||||
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
||||
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
||||
STR_NEXT_PAGE: "Next Page »"
|
||||
STR_PREV_PAGE: "« Previous Page"
|
||||
STR_NETWORK_PREFIX: "Network: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP Address: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Error: General failure"
|
||||
@@ -229,6 +231,7 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
|
||||
STR_OPDS_BROWSER: "OPDS Browser"
|
||||
STR_SEARCH: "Search"
|
||||
STR_COVER_CUSTOM: "Cover + Custom"
|
||||
STR_MENU_RECENT_BOOKS: "Recent Books"
|
||||
STR_NO_RECENT_BOOKS: "No recent books"
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Avaa tämä osoite selaimessasi"
|
||||
STR_OR_HTTP_PREFIX: "tai http://"
|
||||
STR_SCAN_QR_HINT: "tai skannaa QR-koodi puhelimellasi:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre langaton"
|
||||
STR_CALIBRE_WEB_URL: "Calibre-verkko-osoite"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Salattu | + = Tallennettu"
|
||||
STR_MAC_ADDRESS: "MAC-osoite:"
|
||||
STR_CHECKING_WIFI: "Tarkistetaan WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Ouvrez cette URL dans un navigateur"
|
||||
STR_OR_HTTP_PREFIX: "ou http://"
|
||||
STR_SCAN_QR_HINT: "ou scannez le QR code :"
|
||||
STR_CALIBRE_WIRELESS: "Connexion Calibre sans fil"
|
||||
STR_CALIBRE_WEB_URL: "URL Web Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Sécurisé | + = Sauvegardé"
|
||||
STR_MAC_ADDRESS: "Adresse MAC :"
|
||||
STR_CHECKING_WIFI: "Vérification du WiFi…"
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Diese URL im Browser öffnen"
|
||||
STR_OR_HTTP_PREFIX: "oder http://"
|
||||
STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre-Web-URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert"
|
||||
STR_MAC_ADDRESS: "MAC-Adresse:"
|
||||
STR_CHECKING_WIFI: "WLAN prüfen…"
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Nyisd meg ezt az URL-t a böngésződben"
|
||||
STR_OR_HTTP_PREFIX: "vagy http://"
|
||||
STR_SCAN_QR_HINT: "vagy olvasd be a QR-kódot a telefonoddal:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Titkosított | + = Mentett"
|
||||
STR_MAC_ADDRESS: "MAC-cím:"
|
||||
STR_CHECKING_WIFI: "WiFi ellenőrzése..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Apri questo URL nel tuo browser"
|
||||
STR_OR_HTTP_PREFIX: "o http://"
|
||||
STR_SCAN_QR_HINT: "o scansiona il codice QR con il tuo telefono:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "URL Web Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Criptata | + = Salvata"
|
||||
STR_MAC_ADDRESS: "Indirizzo MAC:"
|
||||
STR_CHECKING_WIFI: "Controllo WiFi in corso..."
|
||||
|
||||
@@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Браузерде осы URL мекенжайын ашың
|
||||
STR_OR_HTTP_PREFIX: "немесе http://"
|
||||
STR_SCAN_QR_HINT: "немесе телефонмен QR кодын сканерлеңіз:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre сымсыз"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Шифрланған | + = Сақталған"
|
||||
STR_MAC_ADDRESS: "MAC мекенжайы:"
|
||||
STR_CHECKING_WIFI: "WiFi тексерілуде..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Atidarykite šį adresą naršyklėje"
|
||||
STR_OR_HTTP_PREFIX: "arba http://"
|
||||
STR_SCAN_QR_HINT: "arba nuskaitykite QR kodą:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre belaidis"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Užšifruota | + = Išsaugota"
|
||||
STR_MAC_ADDRESS: "MAC adresas:"
|
||||
STR_CHECKING_WIFI: "Tikrinamas WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Otwórz ten URL w przeglądarce"
|
||||
STR_OR_HTTP_PREFIX: "albo http://"
|
||||
STR_SCAN_QR_HINT: "albo zeskanuj kod QR telefonem:"
|
||||
STR_CALIBRE_WIRELESS: "Bezprzewodowe połączenie z Calibre"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Zaszyfrowane | + = Zapisane"
|
||||
STR_MAC_ADDRESS: "Adres MAC:"
|
||||
STR_CHECKING_WIFI: "Sprawdzanie WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra este URL seu navegador"
|
||||
STR_OR_HTTP_PREFIX: "ou http://"
|
||||
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
||||
STR_CALIBRE_WEB_URL: "URL do Calibre Web"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Criptografada | + = Salva"
|
||||
STR_MAC_ADDRESS: "Endereço MAC:"
|
||||
STR_CHECKING_WIFI: "Verificando Wi‑Fi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs."
|
||||
STR_OR_HTTP_PREFIX: "sau http://"
|
||||
STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre URL"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Criptat | + = Salvat"
|
||||
STR_MAC_ADDRESS: "Adresă MAC:"
|
||||
STR_CHECKING_WIFI: "Verificare WiFi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Откройте этот адрес в браузере"
|
||||
STR_OR_HTTP_PREFIX: "или http://"
|
||||
STR_SCAN_QR_HINT: "или отсканируйте QR-код:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre по Wi-Fi"
|
||||
STR_CALIBRE_WEB_URL: "Web-адрес Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Защищена | + = Сохранена"
|
||||
STR_MAC_ADDRESS: "MAC-адрес:"
|
||||
STR_CHECKING_WIFI: "Проверка Wi-Fi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Abra esta dirección en su navegador"
|
||||
STR_OR_HTTP_PREFIX: "o http://"
|
||||
STR_SCAN_QR_HINT: "o escanee el código QR con su móvil:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre inalámbrico"
|
||||
STR_CALIBRE_WEB_URL: "URL del sitio web de Calibre"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* (Cifrado) | + (Guardado)"
|
||||
STR_MAC_ADDRESS: "MAC Address:"
|
||||
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Öppna denna adress i din browser"
|
||||
STR_OR_HTTP_PREFIX: "eller http://"
|
||||
STR_SCAN_QR_HINT: "eller skanna QR-kod med din telefon:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Trådlöst"
|
||||
STR_CALIBRE_WEB_URL: "Calibre webbadress"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad"
|
||||
STR_MAC_ADDRESS: "MAC-adress:"
|
||||
STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…"
|
||||
|
||||
@@ -44,7 +44,7 @@ STR_OPEN_URL_HINT: "Tarayıcınızda bu adresi açın"
|
||||
STR_OR_HTTP_PREFIX: "veya http://"
|
||||
STR_SCAN_QR_HINT: "veya telefonunuzla QR kodu tarayın:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Kablosuz"
|
||||
STR_CALIBRE_WEB_URL: "Calibre Web Adresi"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Şifreli | + = Kayıtlı"
|
||||
STR_MAC_ADDRESS: "MAC adresi:"
|
||||
STR_CHECKING_WIFI: "WiFi kontrol ediliyor..."
|
||||
|
||||
@@ -45,7 +45,7 @@ STR_OPEN_URL_HINT: "Відкрийте цю URL-адресу у вашому б
|
||||
STR_OR_HTTP_PREFIX: "або http://"
|
||||
STR_SCAN_QR_HINT: "або відскануйте QR-код телефоном:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre бездротовий"
|
||||
STR_CALIBRE_WEB_URL: "URL Calibre Web"
|
||||
STR_CALIBRE_WEB_URL: "OPDS URL"
|
||||
STR_NETWORK_LEGEND: "* = Зашифровано | + = Збережено"
|
||||
STR_MAC_ADDRESS: "MAC адреса:"
|
||||
STR_CHECKING_WIFI: "Перевірка WiFi..."
|
||||
|
||||
+53
-102
@@ -25,15 +25,12 @@ OpdsParser::~OpdsParser() {
|
||||
size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
||||
|
||||
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
if (errorOccured) {
|
||||
return length;
|
||||
}
|
||||
if (errorOccured) return length;
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
|
||||
// Parse in chunks to avoid large buffer allocations
|
||||
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
||||
size_t remaining = length;
|
||||
constexpr size_t chunkSize = 1024;
|
||||
@@ -42,9 +39,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
void* const buf = XML_GetBuffer(parser, chunkSize);
|
||||
if (!buf) {
|
||||
errorOccured = true;
|
||||
LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return length;
|
||||
}
|
||||
|
||||
@@ -53,13 +48,9 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
|
||||
errorOccured = true;
|
||||
LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return length;
|
||||
}
|
||||
|
||||
currentPos += toRead;
|
||||
remaining -= toRead;
|
||||
}
|
||||
@@ -78,30 +69,25 @@ bool OpdsParser::error() const { return errorOccured; }
|
||||
|
||||
void OpdsParser::clear() {
|
||||
entries.clear();
|
||||
searchTemplate.clear();
|
||||
nextPageUrl.clear();
|
||||
prevPageUrl.clear();
|
||||
currentEntry = OpdsEntry{};
|
||||
currentText.clear();
|
||||
inEntry = false;
|
||||
inTitle = false;
|
||||
inAuthor = false;
|
||||
inAuthorName = false;
|
||||
inId = false;
|
||||
inEntry = inTitle = inAuthor = inAuthorName = 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);
|
||||
}
|
||||
if (entry.type == OpdsEntryType::BOOK) books.push_back(entry);
|
||||
}
|
||||
return books;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
if (strcmp(atts[i], name) == 0) return atts[i + 1];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -109,7 +95,38 @@ const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
|
||||
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, "link") == 0 || strstr(name, ":link") != nullptr) {
|
||||
const char* href = findAttribute(atts, "href");
|
||||
if (href) {
|
||||
const char* rel = findAttribute(atts, "rel");
|
||||
const char* type = findAttribute(atts, "type");
|
||||
|
||||
if (rel && strcmp(rel, "search") == 0) {
|
||||
std::string sHref(href);
|
||||
if (sHref.find("{searchTerms}") != std::string::npos) {
|
||||
self->searchTemplate = sHref;
|
||||
}
|
||||
} else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) {
|
||||
self->nextPageUrl = href;
|
||||
} else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) {
|
||||
self->prevPageUrl = href;
|
||||
}
|
||||
|
||||
if (self->inEntry) {
|
||||
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
|
||||
strcmp(type, "application/epub+zip") == 0) {
|
||||
self->currentEntry.type = OpdsEntryType::BOOK;
|
||||
self->currentEntry.href = href;
|
||||
} else if (type && strstr(type, "application/atom+xml") != nullptr) {
|
||||
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
||||
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
||||
self->currentEntry.href = href;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
||||
self->inEntry = true;
|
||||
self->currentEntry = OpdsEntry{};
|
||||
@@ -118,112 +135,46 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
|
||||
|
||||
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) {
|
||||
} else 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)) {
|
||||
} else 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) {
|
||||
} else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||
self->inId = true;
|
||||
self->currentText.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for link element
|
||||
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");
|
||||
|
||||
if (href) {
|
||||
// Check for acquisition link with epub type (this is a downloadable book)
|
||||
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 entry if it has required fields (title and href)
|
||||
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
|
||||
self->entries.push_back(self->currentEntry);
|
||||
}
|
||||
self->inEntry = false;
|
||||
self->currentEntry = OpdsEntry{};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self->inEntry) return;
|
||||
|
||||
// Check for title end
|
||||
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
||||
if (self->inTitle) {
|
||||
self->currentEntry.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) {
|
||||
} else if (self->inEntry) {
|
||||
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
||||
if (self->inTitle) self->currentEntry.title = self->currentText;
|
||||
self->inTitle = false;
|
||||
} else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
|
||||
self->inAuthor = false;
|
||||
} else if (self->inAuthorName && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
||||
self->currentEntry.author = self->currentText;
|
||||
self->inAuthorName = false;
|
||||
} else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||
if (self->inId) self->currentEntry.id = self->currentText;
|
||||
self->inId = false;
|
||||
}
|
||||
self->inAuthorName = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for id end
|
||||
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||
if (self->inId) {
|
||||
self->currentEntry.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);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ class OpdsParser final : public Print {
|
||||
~OpdsParser();
|
||||
|
||||
// Disable copy
|
||||
const std::string& getSearchTemplate() const { return searchTemplate; }
|
||||
const std::string& getNextPageUrl() const { return nextPageUrl; }
|
||||
const std::string& getPrevPageUrl() const { return prevPageUrl; }
|
||||
OpdsParser(const OpdsParser&) = delete;
|
||||
OpdsParser& operator=(const OpdsParser&) = delete;
|
||||
|
||||
@@ -85,6 +88,9 @@ class OpdsParser final : public Print {
|
||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
|
||||
|
||||
std::string searchTemplate;
|
||||
std::string nextPageUrl;
|
||||
std::string prevPageUrl;
|
||||
// Helper to find attribute value
|
||||
static const char* findAttribute(const XML_Char** atts, const char* name);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
@@ -18,7 +19,7 @@
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
} // namespace
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -26,47 +27,47 @@ void OpdsBookBrowserActivity::onEnter() {
|
||||
state = BrowserState::CHECK_WIFI;
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
currentPath = ""; // Root path - user provides full URL in settings
|
||||
searchTemplate = "";
|
||||
currentPath = "";
|
||||
selectorIndex = 0;
|
||||
consumeConfirm = false;
|
||||
consumeBack = false;
|
||||
errorMessage.clear();
|
||||
statusMessage = tr(STR_CHECKING_WIFI);
|
||||
requestUpdate();
|
||||
|
||||
// Check WiFi and connect if needed, then fetch feed
|
||||
checkAndConnectWifi();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Turn off WiFi when exiting
|
||||
WiFi.mode(WIFI_OFF);
|
||||
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::loop() {
|
||||
// Handle WiFi selection subactivity
|
||||
if (state == BrowserState::WIFI_SELECTION) {
|
||||
// Should already handled by the WifiSelectionActivity
|
||||
if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
consumeConfirm = false;
|
||||
return;
|
||||
}
|
||||
if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
consumeBack = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle error state - Confirm retries, Back goes back or home
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Check if WiFi is still connected
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
// WiFi connected - just retry fetching the feed
|
||||
LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch");
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
requestUpdate();
|
||||
fetchFeed(currentPath);
|
||||
} else {
|
||||
// WiFi not connected - launch WiFi selection
|
||||
LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection");
|
||||
launchWifiSelection();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -75,59 +76,40 @@ void OpdsBookBrowserActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle WiFi check state - only Back works
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoHome();
|
||||
state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle loading state - only Back works
|
||||
if (state == BrowserState::LOADING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
// Handle downloading state - no input allowed
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle browsing state
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
if (entry.type == OpdsEntryType::BOOK) {
|
||||
downloadBook(entry);
|
||||
} else {
|
||||
navigateToEntry(entry);
|
||||
}
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
if (!entries.empty()) {
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
@@ -138,21 +120,12 @@ void OpdsBookBrowserActivity::loop() {
|
||||
|
||||
void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD);
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::LOADING) {
|
||||
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
@@ -171,76 +144,50 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING));
|
||||
const auto maxWidth = pageWidth - 40;
|
||||
// Trim long titles to keep them within the screen bounds.
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), pageWidth - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str());
|
||||
if (downloadTotal > 0) {
|
||||
const int barWidth = pageWidth - 100;
|
||||
constexpr int barHeight = 20;
|
||||
constexpr int barX = 50;
|
||||
const int barY = pageHeight / 2 + 20;
|
||||
GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal);
|
||||
GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress,
|
||||
downloadTotal);
|
||||
}
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Browsing state
|
||||
// Show appropriate button hint based on selected entry type
|
||||
const char* confirmLabel = tr(STR_OPEN);
|
||||
if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) {
|
||||
confirmLabel = tr(STR_DOWNLOAD);
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
const char* confirmLabel =
|
||||
(!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN);
|
||||
const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP);
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
if (entries.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES));
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
|
||||
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
|
||||
|
||||
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 < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
|
||||
const auto& entry = entries[i];
|
||||
|
||||
// Format display text with type indicator
|
||||
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;
|
||||
}
|
||||
for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
|
||||
const auto& entry = entries[i];
|
||||
std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title;
|
||||
if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author;
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
|
||||
i != static_cast<size_t>(selectorIndex));
|
||||
}
|
||||
|
||||
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::fetchFeed(const std::string& path) {
|
||||
const char* serverUrl = SETTINGS.opdsServerUrl;
|
||||
if (strlen(serverUrl) == 0) {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_NO_SERVER_URL);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
||||
|
||||
std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path);
|
||||
OpdsParser parser;
|
||||
|
||||
{
|
||||
OpdsParserStream stream{parser};
|
||||
if (!HttpDownloader::fetchUrl(url, stream)) {
|
||||
@@ -258,50 +205,46 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchTemplate = parser.getSearchTemplate();
|
||||
const auto& nextUrl = parser.getNextPageUrl();
|
||||
const auto& prevUrl = parser.getPrevPageUrl();
|
||||
entries = std::move(parser).getEntries();
|
||||
LOG_DBG("OPDS", "Found %d entries", entries.size());
|
||||
selectorIndex = 0;
|
||||
|
||||
if (entries.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_NO_ENTRIES);
|
||||
requestUpdate();
|
||||
return;
|
||||
if (!prevUrl.empty()) {
|
||||
entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""});
|
||||
}
|
||||
if (!nextUrl.empty()) {
|
||||
entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""});
|
||||
}
|
||||
|
||||
state = BrowserState::BROWSING;
|
||||
selectorIndex = 0;
|
||||
state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING;
|
||||
if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES);
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
|
||||
// Push current path to history before navigating
|
||||
navigationHistory.push_back(currentPath);
|
||||
currentPath = entry.href;
|
||||
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
entries.clear();
|
||||
selectorIndex = 0;
|
||||
requestUpdate(true); // Force update to show loading state immediately before fetch
|
||||
|
||||
requestUpdate(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 = tr(STR_LOADING);
|
||||
entries.clear();
|
||||
selectorIndex = 0;
|
||||
requestUpdate();
|
||||
|
||||
fetchFeed(currentPath);
|
||||
}
|
||||
}
|
||||
@@ -309,48 +252,84 @@ void OpdsBookBrowserActivity::navigateBack() {
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
state = BrowserState::DOWNLOADING;
|
||||
statusMessage = book.title;
|
||||
downloadProgress = 0;
|
||||
downloadTotal = 0;
|
||||
downloadProgress = downloadTotal = 0;
|
||||
requestUpdate(true);
|
||||
|
||||
// Build full download URL
|
||||
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
|
||||
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
|
||||
std::string baseName = book.title;
|
||||
if (!book.author.empty()) {
|
||||
baseName += " - " + book.author;
|
||||
}
|
||||
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
std::string downloadUrl =
|
||||
(book.href.find("http") == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
std::string filename =
|
||||
"/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub";
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
downloadProgress = downloaded;
|
||||
downloadTotal = total;
|
||||
requestUpdate(true); // Force update to refresh progress bar
|
||||
requestUpdate(true);
|
||||
});
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
||||
|
||||
// Invalidate any existing cache for this file to prevent stale metadata issues
|
||||
Epub epub(filename, "/.crosspoint");
|
||||
epub.clearCache();
|
||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||
|
||||
Epub(filename, "/.crosspoint").clearCache();
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
} else {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
||||
requestUpdate();
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::launchSearch() {
|
||||
consumeConfirm = true;
|
||||
state = BrowserState::SEARCH_INPUT;
|
||||
requestUpdate();
|
||||
|
||||
auto keyboard = std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SEARCH));
|
||||
startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) {
|
||||
state = BrowserState::BROWSING;
|
||||
if (!result.isCancelled) {
|
||||
performSearch(std::get<KeyboardResult>(result.data).text);
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::performSearch(const std::string& query) {
|
||||
if (query.empty() || searchTemplate.empty()) {
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
auto urlEncode = [](const std::string& s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s) {
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
|
||||
out += static_cast<char>(c);
|
||||
else {
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%%%02X", c);
|
||||
out += buf;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
std::string url = searchTemplate;
|
||||
const std::string placeholder = "{searchTerms}";
|
||||
const size_t pos = url.find(placeholder);
|
||||
if (pos != std::string::npos) url.replace(pos, placeholder.length(), urlEncode(query));
|
||||
|
||||
navigationHistory.push_back(currentPath); // <-- add this
|
||||
currentPath = url; // <-- add this
|
||||
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
requestUpdate(true);
|
||||
fetchFeed(url);
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
// Already connected? Verify connection is valid by checking IP
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
@@ -358,12 +337,11 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
fetchFeed(currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not connected - launch WiFi selection screen directly
|
||||
launchWifiSelection();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::launchWifiSelection() {
|
||||
consumeBack = consumeConfirm = true;
|
||||
state = BrowserState::WIFI_SELECTION;
|
||||
requestUpdate();
|
||||
|
||||
@@ -373,15 +351,11 @@ void OpdsBookBrowserActivity::launchWifiSelection() {
|
||||
|
||||
void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
|
||||
if (connected) {
|
||||
LOG_DBG("OPDS", "WiFi connected via selection, fetching feed");
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
requestUpdate(true); // Force update to show loading state immediately before fetch
|
||||
requestUpdate(true);
|
||||
fetchFeed(currentPath);
|
||||
} else {
|
||||
LOG_DBG("OPDS", "WiFi selection cancelled/failed");
|
||||
// Force disconnect to ensure clean state for next retry
|
||||
// This prevents stale connection status from interfering
|
||||
WiFi.disconnect();
|
||||
WiFi.mode(WIFI_OFF);
|
||||
state = BrowserState::ERROR;
|
||||
|
||||
@@ -11,21 +11,13 @@
|
||||
/**
|
||||
* Activity for browsing and downloading books from an OPDS server.
|
||||
* Supports navigation through catalog hierarchy and downloading EPUBs.
|
||||
* When WiFi connection fails, launches WiFi selection to let user connect.
|
||||
*/
|
||||
class OpdsBookBrowserActivity final : public Activity {
|
||||
public:
|
||||
enum class BrowserState {
|
||||
CHECK_WIFI, // Checking WiFi connection
|
||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||
LOADING, // Fetching OPDS feed
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
ERROR // Error state with message
|
||||
};
|
||||
enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT };
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput) {}
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -36,8 +28,11 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
BrowserState state = BrowserState::LOADING;
|
||||
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
|
||||
std::vector<std::string> navigationHistory;
|
||||
std::string currentPath;
|
||||
std::string searchTemplate;
|
||||
bool consumeConfirm = false;
|
||||
bool consumeBack = false; // Added missing member
|
||||
int selectorIndex = 0;
|
||||
std::string errorMessage;
|
||||
std::string statusMessage;
|
||||
@@ -51,5 +46,7 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
void navigateToEntry(const OpdsEntry& entry);
|
||||
void navigateBack();
|
||||
void downloadBook(const OpdsEntry& book);
|
||||
void launchSearch();
|
||||
void performSearch(const std::string& query);
|
||||
bool preventAutoSleep() override { return true; }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user