add OPDS search support & next/prev page navigation (upstream #1462)

This commit is contained in:
jpirnay
2026-04-12 22:49:33 +02:00
parent 52a754b5e4
commit cf3ab19c77
20 changed files with 186 additions and 211 deletions
@@ -8,9 +8,14 @@
#include <OpdsStream.h>
#include <WiFi.h>
#include <cctype>
#include <cstdio>
#include <memory>
#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"
@@ -19,7 +24,7 @@
namespace {
constexpr int PAGE_ITEMS = 23;
} // namespace
}
void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter();
@@ -28,12 +33,14 @@ void OpdsBookBrowserActivity::onEnter() {
entries.clear();
navigationHistory.clear();
currentPath = ""; // Root path - user provides full URL in settings
searchTemplate.clear();
selectorIndex = 0;
consumeConfirm = false;
consumeBack = false;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
requestUpdate();
// Check WiFi and connect if needed, then fetch feed
checkAndConnectWifi();
}
@@ -47,26 +54,27 @@ void OpdsBookBrowserActivity::onExit() {
}
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 +83,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();
@@ -187,11 +176,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
// 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()) {
@@ -227,15 +214,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
}
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);
std::string url = (path.rfind("http", 0) == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path);
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser;
@@ -257,50 +243,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);
}
}
@@ -312,15 +294,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
downloadTotal = 0;
requestUpdate(true);
// Build full download URL
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
// Create sanitized filename: "Author - Title.epub" or just "Title.epub" if no author
std::string baseName = book.title;
if (!book.author.empty()) {
baseName = book.author + " - " + baseName;
}
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
std::string downloadUrl = (book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
std::string filename = "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub";
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
@@ -328,7 +303,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
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) {
@@ -348,11 +323,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
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 {
@@ -362,6 +333,58 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}
}
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);
currentPath = url;
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)) {
@@ -371,12 +394,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();
@@ -386,15 +408,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;