A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

rxmmah
kira
Justin Mitchell
and committed by
GitHub
fa2a3d25 5c12f2f0

+207 -276
+1 -1
lib/I18n/translations/belarusian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "або http://" 46 46 STR_SCAN_QR_HINT: "або адсканіруйце QR-код:" 47 47 STR_CALIBRE_WIRELESS: "Calibre па Wi-Fi" 48 - STR_CALIBRE_WEB_URL: "Вэб-адрас Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Абаронена | + = Захавана" 50 50 STR_MAC_ADDRESS: "MAC-адрас:" 51 51 STR_CHECKING_WIFI: "Праверка Wi-Fi..."
+1 -1
lib/I18n/translations/catalan.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "o http://" 46 46 STR_SCAN_QR_HINT: "o escanegeu el codi QR amb el telèfon:" 47 47 STR_CALIBRE_WIRELESS: "Calibre sense fils" 48 - STR_CALIBRE_WEB_URL: "URL web del Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Encriptat | + = Desat" 50 50 STR_MAC_ADDRESS: "Adreça MAC:" 51 51 STR_CHECKING_WIFI: "S'està comprovant el WiFi..."
+1 -1
lib/I18n/translations/czech.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "nebo http://" 46 46 STR_SCAN_QR_HINT: "nebo naskenujte QR kód telefonem:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "URL webu Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Šifrováno | + = Uloženo" 50 50 STR_MAC_ADDRESS: "MAC adresa:" 51 51 STR_CHECKING_WIFI: "Kontrola WiFi..."
+1 -1
lib/I18n/translations/danish.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "eller http://" 46 46 STR_SCAN_QR_HINT: "eller scan QR-kode med din telefon:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Krypteret | + = Gemt" 50 50 STR_MAC_ADDRESS: "MAC-adresse:" 51 51 STR_CHECKING_WIFI: "Tjekker WiFi..."
+1 -1
lib/I18n/translations/dutch.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "of http://" 46 46 STR_SCAN_QR_HINT: "of scan de QR-code met je telefoon:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Beveiligd | + = Opgeslagen" 50 50 STR_MAC_ADDRESS: "MAC-adres:" 51 51 STR_CHECKING_WIFI: "Wifi controleren..."
+4 -1
lib/I18n/translations/english.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "or http://" 46 46 STR_SCAN_QR_HINT: "or scan QR code with your phone:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Encrypted | + = Saved" 50 50 STR_MAC_ADDRESS: "MAC address:" 51 51 STR_CHECKING_WIFI: "Checking WiFi..." ··· 175 175 STR_NO_SERVER_URL: "No server URL configured" 176 176 STR_FETCH_FEED_FAILED: "Failed to fetch feed" 177 177 STR_PARSE_FEED_FAILED: "Failed to parse feed" 178 + STR_NEXT_PAGE: "Next Page »" 179 + STR_PREV_PAGE: "« Previous Page" 178 180 STR_NETWORK_PREFIX: "Network: " 179 181 STR_IP_ADDRESS_PREFIX: "IP Address: " 180 182 STR_ERROR_GENERAL_FAILURE: "Error: General failure" ··· 229 231 STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" 230 232 STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" 231 233 STR_OPDS_BROWSER: "OPDS Browser" 234 + STR_SEARCH: "Search" 232 235 STR_COVER_CUSTOM: "Cover + Custom" 233 236 STR_MENU_RECENT_BOOKS: "Recent Books" 234 237 STR_NO_RECENT_BOOKS: "No recent books"
+1 -1
lib/I18n/translations/finnish.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "tai http://" 46 46 STR_SCAN_QR_HINT: "tai skannaa QR-koodi puhelimellasi:" 47 47 STR_CALIBRE_WIRELESS: "Calibre langaton" 48 - STR_CALIBRE_WEB_URL: "Calibre-verkko-osoite" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Salattu | + = Tallennettu" 50 50 STR_MAC_ADDRESS: "MAC-osoite:" 51 51 STR_CHECKING_WIFI: "Tarkistetaan WiFi..."
+1 -1
lib/I18n/translations/french.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "ou http://" 46 46 STR_SCAN_QR_HINT: "ou scannez le QR code :" 47 47 STR_CALIBRE_WIRELESS: "Connexion Calibre sans fil" 48 - STR_CALIBRE_WEB_URL: "URL Web Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Sécurisé | + = Sauvegardé" 50 50 STR_MAC_ADDRESS: "Adresse MAC :" 51 51 STR_CHECKING_WIFI: "Vérification du WiFi…"
+1 -1
lib/I18n/translations/german.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "oder http://" 46 46 STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre-Web-URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert" 50 50 STR_MAC_ADDRESS: "MAC-Adresse:" 51 51 STR_CHECKING_WIFI: "WLAN prüfen…"
+1 -1
lib/I18n/translations/hungarian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "vagy http://" 46 46 STR_SCAN_QR_HINT: "vagy olvasd be a QR-kódot a telefonoddal:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Titkosított | + = Mentett" 50 50 STR_MAC_ADDRESS: "MAC-cím:" 51 51 STR_CHECKING_WIFI: "WiFi ellenőrzése..."
+1 -1
lib/I18n/translations/italian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "o http://" 46 46 STR_SCAN_QR_HINT: "o scansiona il codice QR con il tuo telefono:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "URL Web Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Criptata | + = Salvata" 50 50 STR_MAC_ADDRESS: "Indirizzo MAC:" 51 51 STR_CHECKING_WIFI: "Controllo WiFi in corso..."
+1 -1
lib/I18n/translations/kazakh.yaml
··· 44 44 STR_OR_HTTP_PREFIX: "немесе http://" 45 45 STR_SCAN_QR_HINT: "немесе телефонмен QR кодын сканерлеңіз:" 46 46 STR_CALIBRE_WIRELESS: "Calibre сымсыз" 47 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 47 + STR_CALIBRE_WEB_URL: "OPDS URL" 48 48 STR_NETWORK_LEGEND: "* = Шифрланған | + = Сақталған" 49 49 STR_MAC_ADDRESS: "MAC мекенжайы:" 50 50 STR_CHECKING_WIFI: "WiFi тексерілуде..."
+1 -1
lib/I18n/translations/lithuanian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "arba http://" 46 46 STR_SCAN_QR_HINT: "arba nuskaitykite QR kodą:" 47 47 STR_CALIBRE_WIRELESS: "Calibre belaidis" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Užšifruota | + = Išsaugota" 50 50 STR_MAC_ADDRESS: "MAC adresas:" 51 51 STR_CHECKING_WIFI: "Tikrinamas WiFi..."
+1 -1
lib/I18n/translations/polish.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "albo http://" 46 46 STR_SCAN_QR_HINT: "albo zeskanuj kod QR telefonem:" 47 47 STR_CALIBRE_WIRELESS: "Bezprzewodowe połączenie z Calibre" 48 - STR_CALIBRE_WEB_URL: "Calibre Web URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Zaszyfrowane | + = Zapisane" 50 50 STR_MAC_ADDRESS: "Adres MAC:" 51 51 STR_CHECKING_WIFI: "Sprawdzanie WiFi..."
+1 -1
lib/I18n/translations/portuguese.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "ou http://" 46 46 STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:" 47 47 STR_CALIBRE_WIRELESS: "Calibre sem fio" 48 - STR_CALIBRE_WEB_URL: "URL do Calibre Web" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Criptografada | + = Salva" 50 50 STR_MAC_ADDRESS: "Endereço MAC:" 51 51 STR_CHECKING_WIFI: "Verificando Wi‑Fi..."
+1 -1
lib/I18n/translations/romanian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "sau http://" 46 46 STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Wireless" 48 - STR_CALIBRE_WEB_URL: "Calibre URL" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Criptat | + = Salvat" 50 50 STR_MAC_ADDRESS: "Adresă MAC:" 51 51 STR_CHECKING_WIFI: "Verificare WiFi..."
+1 -1
lib/I18n/translations/russian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "или http://" 46 46 STR_SCAN_QR_HINT: "или отсканируйте QR-код:" 47 47 STR_CALIBRE_WIRELESS: "Calibre по Wi-Fi" 48 - STR_CALIBRE_WEB_URL: "Web-адрес Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Защищена | + = Сохранена" 50 50 STR_MAC_ADDRESS: "MAC-адрес:" 51 51 STR_CHECKING_WIFI: "Проверка Wi-Fi..."
+1 -1
lib/I18n/translations/spanish.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "o http://" 46 46 STR_SCAN_QR_HINT: "o escanee el código QR con su móvil:" 47 47 STR_CALIBRE_WIRELESS: "Calibre inalámbrico" 48 - STR_CALIBRE_WEB_URL: "URL del sitio web de Calibre" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* (Cifrado) | + (Guardado)" 50 50 STR_MAC_ADDRESS: "MAC Address:" 51 51 STR_CHECKING_WIFI: "Verificando Wi-Fi..."
+1 -1
lib/I18n/translations/swedish.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "eller http://" 46 46 STR_SCAN_QR_HINT: "eller skanna QR-kod med din telefon:" 47 47 STR_CALIBRE_WIRELESS: "Calibre Trådlöst" 48 - STR_CALIBRE_WEB_URL: "Calibre webbadress" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad" 50 50 STR_MAC_ADDRESS: "MAC-adress:" 51 51 STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…"
+1 -1
lib/I18n/translations/turkish.yaml
··· 44 44 STR_OR_HTTP_PREFIX: "veya http://" 45 45 STR_SCAN_QR_HINT: "veya telefonunuzla QR kodu tarayın:" 46 46 STR_CALIBRE_WIRELESS: "Calibre Kablosuz" 47 - STR_CALIBRE_WEB_URL: "Calibre Web Adresi" 47 + STR_CALIBRE_WEB_URL: "OPDS URL" 48 48 STR_NETWORK_LEGEND: "* = Şifreli | + = Kayıtlı" 49 49 STR_MAC_ADDRESS: "MAC adresi:" 50 50 STR_CHECKING_WIFI: "WiFi kontrol ediliyor..."
+1 -1
lib/I18n/translations/ukrainian.yaml
··· 45 45 STR_OR_HTTP_PREFIX: "або http://" 46 46 STR_SCAN_QR_HINT: "або відскануйте QR-код телефоном:" 47 47 STR_CALIBRE_WIRELESS: "Calibre бездротовий" 48 - STR_CALIBRE_WEB_URL: "URL Calibre Web" 48 + STR_CALIBRE_WEB_URL: "OPDS URL" 49 49 STR_NETWORK_LEGEND: "* = Зашифровано | + = Збережено" 50 50 STR_MAC_ADDRESS: "MAC адреса:" 51 51 STR_CHECKING_WIFI: "Перевірка WiFi..."
+53 -102
lib/OpdsParser/OpdsParser.cpp
··· 25 25 size_t OpdsParser::write(uint8_t c) { return write(&c, 1); } 26 26 27 27 size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { 28 - if (errorOccured) { 29 - return length; 30 - } 28 + if (errorOccured) return length; 31 29 32 30 XML_SetUserData(parser, this); 33 31 XML_SetElementHandler(parser, startElement, endElement); 34 32 XML_SetCharacterDataHandler(parser, characterData); 35 33 36 - // Parse in chunks to avoid large buffer allocations 37 34 const char* currentPos = reinterpret_cast<const char*>(xmlData); 38 35 size_t remaining = length; 39 36 constexpr size_t chunkSize = 1024; ··· 42 39 void* const buf = XML_GetBuffer(parser, chunkSize); 43 40 if (!buf) { 44 41 errorOccured = true; 45 - LOG_DBG("OPDS", "Couldn't allocate memory for buffer"); 46 42 XML_ParserFree(parser); 47 - parser = nullptr; 48 43 return length; 49 44 } 50 45 ··· 53 48 54 49 if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) { 55 50 errorOccured = true; 56 - LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser), 57 - XML_ErrorString(XML_GetErrorCode(parser))); 58 51 XML_ParserFree(parser); 59 - parser = nullptr; 60 52 return length; 61 53 } 62 - 63 54 currentPos += toRead; 64 55 remaining -= toRead; 65 56 } ··· 78 69 79 70 void OpdsParser::clear() { 80 71 entries.clear(); 72 + searchTemplate.clear(); 73 + nextPageUrl.clear(); 74 + prevPageUrl.clear(); 81 75 currentEntry = OpdsEntry{}; 82 76 currentText.clear(); 83 - inEntry = false; 84 - inTitle = false; 85 - inAuthor = false; 86 - inAuthorName = false; 87 - inId = false; 77 + inEntry = inTitle = inAuthor = inAuthorName = inId = false; 88 78 } 89 79 90 80 std::vector<OpdsEntry> OpdsParser::getBooks() const { 91 81 std::vector<OpdsEntry> books; 92 82 for (const auto& entry : entries) { 93 - if (entry.type == OpdsEntryType::BOOK) { 94 - books.push_back(entry); 95 - } 83 + if (entry.type == OpdsEntryType::BOOK) books.push_back(entry); 96 84 } 97 85 return books; 98 86 } 99 87 100 88 const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { 101 89 for (int i = 0; atts[i]; i += 2) { 102 - if (strcmp(atts[i], name) == 0) { 103 - return atts[i + 1]; 104 - } 90 + if (strcmp(atts[i], name) == 0) return atts[i + 1]; 105 91 } 106 92 return nullptr; 107 93 } ··· 109 95 void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { 110 96 auto* self = static_cast<OpdsParser*>(userData); 111 97 112 - // Check for entry element (with or without namespace prefix) 98 + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { 99 + const char* href = findAttribute(atts, "href"); 100 + if (href) { 101 + const char* rel = findAttribute(atts, "rel"); 102 + const char* type = findAttribute(atts, "type"); 103 + 104 + if (rel && strcmp(rel, "search") == 0) { 105 + std::string sHref(href); 106 + if (sHref.find("{searchTerms}") != std::string::npos) { 107 + self->searchTemplate = sHref; 108 + } 109 + } else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) { 110 + self->nextPageUrl = href; 111 + } else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) { 112 + self->prevPageUrl = href; 113 + } 114 + 115 + if (self->inEntry) { 116 + if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && 117 + strcmp(type, "application/epub+zip") == 0) { 118 + self->currentEntry.type = OpdsEntryType::BOOK; 119 + self->currentEntry.href = href; 120 + } else if (type && strstr(type, "application/atom+xml") != nullptr) { 121 + if (self->currentEntry.type != OpdsEntryType::BOOK) { 122 + self->currentEntry.type = OpdsEntryType::NAVIGATION; 123 + self->currentEntry.href = href; 124 + } 125 + } 126 + } 127 + } 128 + } 129 + 113 130 if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { 114 131 self->inEntry = true; 115 132 self->currentEntry = OpdsEntry{}; ··· 118 135 119 136 if (!self->inEntry) return; 120 137 121 - // Check for title element 122 138 if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { 123 139 self->inTitle = true; 124 140 self->currentText.clear(); 125 - return; 126 - } 127 - 128 - // Check for author element 129 - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { 141 + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { 130 142 self->inAuthor = true; 131 - return; 132 - } 133 - 134 - // Check for author name element 135 - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { 143 + } else if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { 136 144 self->inAuthorName = true; 137 145 self->currentText.clear(); 138 - return; 139 - } 140 - 141 - // Check for id element 142 - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { 146 + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { 143 147 self->inId = true; 144 148 self->currentText.clear(); 145 - return; 146 - } 147 - 148 - // Check for link element 149 - if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { 150 - const char* rel = findAttribute(atts, "rel"); 151 - const char* type = findAttribute(atts, "type"); 152 - const char* href = findAttribute(atts, "href"); 153 - 154 - if (href) { 155 - // Check for acquisition link with epub type (this is a downloadable book) 156 - if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && 157 - strcmp(type, "application/epub+zip") == 0) { 158 - self->currentEntry.type = OpdsEntryType::BOOK; 159 - self->currentEntry.href = href; 160 - } 161 - // Check for navigation link (subsection or no rel specified with atom+xml type) 162 - else if (type && strstr(type, "application/atom+xml") != nullptr) { 163 - // Only set navigation link if we don't already have an epub link 164 - if (self->currentEntry.type != OpdsEntryType::BOOK) { 165 - self->currentEntry.type = OpdsEntryType::NAVIGATION; 166 - self->currentEntry.href = href; 167 - } 168 - } 169 - } 170 149 } 171 150 } 172 151 173 152 void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { 174 153 auto* self = static_cast<OpdsParser*>(userData); 175 154 176 - // Check for entry end 177 155 if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { 178 - // Only add entry if it has required fields (title and href) 179 156 if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { 180 157 self->entries.push_back(self->currentEntry); 181 158 } 182 159 self->inEntry = false; 183 - self->currentEntry = OpdsEntry{}; 184 - return; 185 - } 186 - 187 - if (!self->inEntry) return; 188 - 189 - // Check for title end 190 - if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { 191 - if (self->inTitle) { 192 - self->currentEntry.title = self->currentText; 193 - } 194 - self->inTitle = false; 195 - return; 196 - } 197 - 198 - // Check for author end 199 - if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { 200 - self->inAuthor = false; 201 - return; 202 - } 203 - 204 - // Check for author name end 205 - if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { 206 - if (self->inAuthorName) { 160 + } else if (self->inEntry) { 161 + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { 162 + if (self->inTitle) self->currentEntry.title = self->currentText; 163 + self->inTitle = false; 164 + } else if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { 165 + self->inAuthor = false; 166 + } else if (self->inAuthorName && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { 207 167 self->currentEntry.author = self->currentText; 168 + self->inAuthorName = false; 169 + } else if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { 170 + if (self->inId) self->currentEntry.id = self->currentText; 171 + self->inId = false; 208 172 } 209 - self->inAuthorName = false; 210 - return; 211 - } 212 - 213 - // Check for id end 214 - if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { 215 - if (self->inId) { 216 - self->currentEntry.id = self->currentText; 217 - } 218 - self->inId = false; 219 - return; 220 173 } 221 174 } 222 175 223 176 void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { 224 177 auto* self = static_cast<OpdsParser*>(userData); 225 - 226 - // Only accumulate text when in a text element 227 178 if (self->inTitle || self->inAuthorName || self->inId) { 228 179 self->currentText.append(s, len); 229 180 }
+6
lib/OpdsParser/OpdsParser.h
··· 49 49 ~OpdsParser(); 50 50 51 51 // Disable copy 52 + const std::string& getSearchTemplate() const { return searchTemplate; } 53 + const std::string& getNextPageUrl() const { return nextPageUrl; } 54 + const std::string& getPrevPageUrl() const { return prevPageUrl; } 52 55 OpdsParser(const OpdsParser&) = delete; 53 56 OpdsParser& operator=(const OpdsParser&) = delete; 54 57 ··· 85 88 static void XMLCALL endElement(void* userData, const XML_Char* name); 86 89 static void XMLCALL characterData(void* userData, const XML_Char* s, int len); 87 90 91 + std::string searchTemplate; 92 + std::string nextPageUrl; 93 + std::string prevPageUrl; 88 94 // Helper to find attribute value 89 95 static const char* findAttribute(const XML_Char** atts, const char* name); 90 96
+115 -141
src/activities/browser/OpdsBookBrowserActivity.cpp
··· 10 10 #include "CrossPointSettings.h" 11 11 #include "MappedInputManager.h" 12 12 #include "activities/network/WifiSelectionActivity.h" 13 + #include "activities/util/KeyboardEntryActivity.h" 13 14 #include "components/UITheme.h" 14 15 #include "fontIds.h" 15 16 #include "network/HttpDownloader.h" ··· 18 19 19 20 namespace { 20 21 constexpr int PAGE_ITEMS = 23; 21 - } // namespace 22 + } 22 23 23 24 void OpdsBookBrowserActivity::onEnter() { 24 25 Activity::onEnter(); ··· 26 27 state = BrowserState::CHECK_WIFI; 27 28 entries.clear(); 28 29 navigationHistory.clear(); 29 - currentPath = ""; // Root path - user provides full URL in settings 30 + searchTemplate = ""; 31 + currentPath = ""; 30 32 selectorIndex = 0; 33 + consumeConfirm = false; 34 + consumeBack = false; 31 35 errorMessage.clear(); 32 36 statusMessage = tr(STR_CHECKING_WIFI); 33 37 requestUpdate(); 34 38 35 - // Check WiFi and connect if needed, then fetch feed 36 39 checkAndConnectWifi(); 37 40 } 38 41 39 42 void OpdsBookBrowserActivity::onExit() { 40 43 Activity::onExit(); 41 - 42 - // Turn off WiFi when exiting 43 44 WiFi.mode(WIFI_OFF); 44 - 45 45 entries.clear(); 46 46 navigationHistory.clear(); 47 47 } 48 48 49 49 void OpdsBookBrowserActivity::loop() { 50 - // Handle WiFi selection subactivity 51 - if (state == BrowserState::WIFI_SELECTION) { 52 - // Should already handled by the WifiSelectionActivity 50 + if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { 53 51 return; 54 52 } 55 53 56 - // Handle error state - Confirm retries, Back goes back or home 54 + if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 55 + consumeConfirm = false; 56 + return; 57 + } 58 + if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { 59 + consumeBack = false; 60 + return; 61 + } 62 + 57 63 if (state == BrowserState::ERROR) { 58 64 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 59 - // Check if WiFi is still connected 60 65 if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { 61 - // WiFi connected - just retry fetching the feed 62 - LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch"); 63 66 state = BrowserState::LOADING; 64 67 statusMessage = tr(STR_LOADING); 65 68 requestUpdate(); 66 69 fetchFeed(currentPath); 67 70 } else { 68 - // WiFi not connected - launch WiFi selection 69 - LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection"); 70 71 launchWifiSelection(); 71 72 } 72 73 } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ··· 75 76 return; 76 77 } 77 78 78 - // Handle WiFi check state - only Back works 79 - if (state == BrowserState::CHECK_WIFI) { 80 - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 81 - onGoHome(); 82 - } 83 - return; 84 - } 85 - 86 - // Handle loading state - only Back works 87 - if (state == BrowserState::LOADING) { 79 + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { 88 80 if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 89 - navigateBack(); 81 + state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack(); 90 82 } 91 83 return; 92 84 } 93 85 94 - // Handle downloading state - no input allowed 95 - if (state == BrowserState::DOWNLOADING) { 96 - return; 97 - } 86 + if (state == BrowserState::DOWNLOADING) return; 98 87 99 - // Handle browsing state 100 88 if (state == BrowserState::BROWSING) { 101 89 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 102 90 if (!entries.empty()) { 103 91 const auto& entry = entries[selectorIndex]; 104 - if (entry.type == OpdsEntryType::BOOK) { 105 - downloadBook(entry); 106 - } else { 107 - navigateToEntry(entry); 108 - } 92 + entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); 109 93 } 110 94 } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 111 95 navigateBack(); 96 + } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { 97 + if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); 112 98 } 113 99 114 - // Handle navigation 115 100 if (!entries.empty()) { 116 101 buttonNavigator.onNextRelease([this] { 117 102 selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); 118 103 requestUpdate(); 119 104 }); 120 - 121 105 buttonNavigator.onPreviousRelease([this] { 122 106 selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size()); 123 107 requestUpdate(); 124 108 }); 125 - 126 109 buttonNavigator.onNextContinuous([this] { 127 110 selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); 128 111 requestUpdate(); 129 112 }); 130 - 131 113 buttonNavigator.onPreviousContinuous([this] { 132 114 selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); 133 115 requestUpdate(); ··· 138 120 139 121 void OpdsBookBrowserActivity::render(RenderLock&&) { 140 122 renderer.clearScreen(); 141 - 142 123 const auto pageWidth = renderer.getScreenWidth(); 143 124 const auto pageHeight = renderer.getScreenHeight(); 144 125 145 126 renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); 146 127 147 - if (state == BrowserState::CHECK_WIFI) { 148 - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); 149 - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); 150 - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 151 - renderer.displayBuffer(); 152 - return; 153 - } 154 - 155 - if (state == BrowserState::LOADING) { 128 + if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { 156 129 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); 157 130 const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); 158 131 GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); ··· 171 144 172 145 if (state == BrowserState::DOWNLOADING) { 173 146 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); 174 - const auto maxWidth = pageWidth - 40; 175 - // Trim long titles to keep them within the screen bounds. 176 - auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth); 147 + auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), pageWidth - 40); 177 148 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); 178 149 if (downloadTotal > 0) { 179 - const int barWidth = pageWidth - 100; 180 - constexpr int barHeight = 20; 181 - constexpr int barX = 50; 182 - const int barY = pageHeight / 2 + 20; 183 - GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); 150 + GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress, 151 + downloadTotal); 184 152 } 185 153 renderer.displayBuffer(); 186 154 return; 187 155 } 188 156 189 - // Browsing state 190 - // Show appropriate button hint based on selected entry type 191 - const char* confirmLabel = tr(STR_OPEN); 192 - if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { 193 - confirmLabel = tr(STR_DOWNLOAD); 194 - } 195 - const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); 157 + const char* confirmLabel = 158 + (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); 159 + const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP); 160 + const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); 196 161 GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 197 162 198 163 if (entries.empty()) { 199 164 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES)); 200 - renderer.displayBuffer(); 201 - return; 202 - } 203 - 204 - const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; 205 - renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); 206 - 207 - for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) { 208 - const auto& entry = entries[i]; 165 + } else { 166 + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; 167 + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); 209 168 210 - // Format display text with type indicator 211 - std::string displayText; 212 - if (entry.type == OpdsEntryType::NAVIGATION) { 213 - displayText = "> " + entry.title; // Folder/navigation indicator 214 - } else { 215 - // Book: "Title - Author" or just "Title" 216 - displayText = entry.title; 217 - if (!entry.author.empty()) { 218 - displayText += " - " + entry.author; 219 - } 169 + for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) { 170 + const auto& entry = entries[i]; 171 + std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title; 172 + if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author; 173 + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40); 174 + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), 175 + i != static_cast<size_t>(selectorIndex)); 220 176 } 221 - 222 - auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); 223 - renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), 224 - i != static_cast<size_t>(selectorIndex)); 225 177 } 226 - 227 178 renderer.displayBuffer(); 228 179 } 229 180 230 181 void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { 231 - const char* serverUrl = SETTINGS.opdsServerUrl; 232 - if (strlen(serverUrl) == 0) { 182 + if (strlen(SETTINGS.opdsServerUrl) == 0) { 233 183 state = BrowserState::ERROR; 234 184 errorMessage = tr(STR_NO_SERVER_URL); 235 185 requestUpdate(); 236 186 return; 237 187 } 238 188 239 - std::string url = UrlUtils::buildUrl(serverUrl, path); 240 - LOG_DBG("OPDS", "Fetching: %s", url.c_str()); 241 - 189 + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); 242 190 OpdsParser parser; 243 - 244 191 { 245 192 OpdsParserStream stream{parser}; 246 193 if (!HttpDownloader::fetchUrl(url, stream)) { ··· 258 205 return; 259 206 } 260 207 208 + searchTemplate = parser.getSearchTemplate(); 209 + const auto& nextUrl = parser.getNextPageUrl(); 210 + const auto& prevUrl = parser.getPrevPageUrl(); 261 211 entries = std::move(parser).getEntries(); 262 - LOG_DBG("OPDS", "Found %d entries", entries.size()); 263 - selectorIndex = 0; 264 212 265 - if (entries.empty()) { 266 - state = BrowserState::ERROR; 267 - errorMessage = tr(STR_NO_ENTRIES); 268 - requestUpdate(); 269 - return; 213 + if (!prevUrl.empty()) { 214 + entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}); 215 + } 216 + if (!nextUrl.empty()) { 217 + entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}); 270 218 } 271 219 272 - state = BrowserState::BROWSING; 220 + selectorIndex = 0; 221 + state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; 222 + if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); 273 223 requestUpdate(); 274 224 } 275 225 276 226 void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { 277 - // Push current path to history before navigating 278 227 navigationHistory.push_back(currentPath); 279 228 currentPath = entry.href; 280 - 281 229 state = BrowserState::LOADING; 282 230 statusMessage = tr(STR_LOADING); 283 231 entries.clear(); 284 232 selectorIndex = 0; 285 - requestUpdate(true); // Force update to show loading state immediately before fetch 286 - 233 + requestUpdate(true); 287 234 fetchFeed(currentPath); 288 235 } 289 236 290 237 void OpdsBookBrowserActivity::navigateBack() { 291 238 if (navigationHistory.empty()) { 292 - // At root, go home 293 239 onGoHome(); 294 240 } else { 295 - // Go back to previous catalog 296 241 currentPath = navigationHistory.back(); 297 242 navigationHistory.pop_back(); 298 - 299 243 state = BrowserState::LOADING; 300 244 statusMessage = tr(STR_LOADING); 301 245 entries.clear(); 302 246 selectorIndex = 0; 303 247 requestUpdate(); 304 - 305 248 fetchFeed(currentPath); 306 249 } 307 250 } ··· 309 252 void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { 310 253 state = BrowserState::DOWNLOADING; 311 254 statusMessage = book.title; 312 - downloadProgress = 0; 313 - downloadTotal = 0; 255 + downloadProgress = downloadTotal = 0; 314 256 requestUpdate(true); 315 257 316 - // Build full download URL 317 - std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); 318 - 319 - // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author 320 - std::string baseName = book.title; 321 - if (!book.author.empty()) { 322 - baseName += " - " + book.author; 323 - } 324 - std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; 325 - 326 - LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); 258 + std::string downloadUrl = 259 + (book.href.find("http") == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); 260 + std::string filename = 261 + "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; 327 262 328 263 const auto result = 329 264 HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { 330 265 downloadProgress = downloaded; 331 266 downloadTotal = total; 332 - requestUpdate(true); // Force update to refresh progress bar 267 + requestUpdate(true); 333 268 }); 334 269 335 270 if (result == HttpDownloader::OK) { 336 - LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); 337 - 338 - // Invalidate any existing cache for this file to prevent stale metadata issues 339 - Epub epub(filename, "/.crosspoint"); 340 - epub.clearCache(); 341 - LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); 342 - 271 + Epub(filename, "/.crosspoint").clearCache(); 343 272 state = BrowserState::BROWSING; 344 - requestUpdate(); 345 273 } else { 346 274 state = BrowserState::ERROR; 347 275 errorMessage = tr(STR_DOWNLOAD_FAILED); 276 + } 277 + requestUpdate(); 278 + } 279 + 280 + void OpdsBookBrowserActivity::launchSearch() { 281 + consumeConfirm = true; 282 + state = BrowserState::SEARCH_INPUT; 283 + requestUpdate(); 284 + 285 + auto keyboard = std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SEARCH)); 286 + startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { 287 + state = BrowserState::BROWSING; 288 + if (!result.isCancelled) { 289 + performSearch(std::get<KeyboardResult>(result.data).text); 290 + } else { 291 + requestUpdate(); 292 + } 293 + }); 294 + } 295 + 296 + void OpdsBookBrowserActivity::performSearch(const std::string& query) { 297 + if (query.empty() || searchTemplate.empty()) { 298 + state = BrowserState::BROWSING; 348 299 requestUpdate(); 300 + return; 349 301 } 302 + 303 + auto urlEncode = [](const std::string& s) { 304 + std::string out; 305 + out.reserve(s.size() * 3); 306 + for (unsigned char c : s) { 307 + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') 308 + out += static_cast<char>(c); 309 + else { 310 + char buf[4]; 311 + snprintf(buf, sizeof(buf), "%%%02X", c); 312 + out += buf; 313 + } 314 + } 315 + return out; 316 + }; 317 + 318 + std::string url = searchTemplate; 319 + const std::string placeholder = "{searchTerms}"; 320 + const size_t pos = url.find(placeholder); 321 + if (pos != std::string::npos) url.replace(pos, placeholder.length(), urlEncode(query)); 322 + 323 + navigationHistory.push_back(currentPath); // <-- add this 324 + currentPath = url; // <-- add this 325 + 326 + state = BrowserState::LOADING; 327 + statusMessage = tr(STR_LOADING); 328 + requestUpdate(true); 329 + fetchFeed(url); 350 330 } 351 331 352 332 void OpdsBookBrowserActivity::checkAndConnectWifi() { 353 - // Already connected? Verify connection is valid by checking IP 354 333 if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { 355 334 state = BrowserState::LOADING; 356 335 statusMessage = tr(STR_LOADING); ··· 358 337 fetchFeed(currentPath); 359 338 return; 360 339 } 361 - 362 - // Not connected - launch WiFi selection screen directly 363 340 launchWifiSelection(); 364 341 } 365 342 366 343 void OpdsBookBrowserActivity::launchWifiSelection() { 344 + consumeBack = consumeConfirm = true; 367 345 state = BrowserState::WIFI_SELECTION; 368 346 requestUpdate(); 369 347 ··· 373 351 374 352 void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { 375 353 if (connected) { 376 - LOG_DBG("OPDS", "WiFi connected via selection, fetching feed"); 377 354 state = BrowserState::LOADING; 378 355 statusMessage = tr(STR_LOADING); 379 - requestUpdate(true); // Force update to show loading state immediately before fetch 356 + requestUpdate(true); 380 357 fetchFeed(currentPath); 381 358 } else { 382 - LOG_DBG("OPDS", "WiFi selection cancelled/failed"); 383 - // Force disconnect to ensure clean state for next retry 384 - // This prevents stale connection status from interfering 385 359 WiFi.disconnect(); 386 360 WiFi.mode(WIFI_OFF); 387 361 state = BrowserState::ERROR;
+9 -12
src/activities/browser/OpdsBookBrowserActivity.h
··· 11 11 /** 12 12 * Activity for browsing and downloading books from an OPDS server. 13 13 * Supports navigation through catalog hierarchy and downloading EPUBs. 14 - * When WiFi connection fails, launches WiFi selection to let user connect. 15 14 */ 16 15 class OpdsBookBrowserActivity final : public Activity { 17 16 public: 18 - enum class BrowserState { 19 - CHECK_WIFI, // Checking WiFi connection 20 - WIFI_SELECTION, // WiFi selection subactivity is active 21 - LOADING, // Fetching OPDS feed 22 - BROWSING, // Displaying entries (navigation or books) 23 - DOWNLOADING, // Downloading selected EPUB 24 - ERROR // Error state with message 25 - }; 17 + enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; 26 18 27 19 explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) 28 - : Activity("OpdsBookBrowser", renderer, mappedInput) {} 20 + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} 29 21 30 22 void onEnter() override; 31 23 void onExit() override; ··· 36 28 ButtonNavigator buttonNavigator; 37 29 BrowserState state = BrowserState::LOADING; 38 30 std::vector<OpdsEntry> entries; 39 - std::vector<std::string> navigationHistory; // Stack of previous feed paths for back navigation 40 - std::string currentPath; // Current feed path being displayed 31 + std::vector<std::string> navigationHistory; 32 + std::string currentPath; 33 + std::string searchTemplate; 34 + bool consumeConfirm = false; 35 + bool consumeBack = false; // Added missing member 41 36 int selectorIndex = 0; 42 37 std::string errorMessage; 43 38 std::string statusMessage; ··· 51 46 void navigateToEntry(const OpdsEntry& entry); 52 47 void navigateBack(); 53 48 void downloadBook(const OpdsEntry& book); 49 + void launchSearch(); 50 + void performSearch(const std::string& query); 54 51 bool preventAutoSleep() override { return true; } 55 52 };