Files
Crosspoint/src/activities/browser/OpdsBookBrowserActivity.cpp
T

423 lines
15 KiB
C++

#include "OpdsBookBrowserActivity.h"
#include <Arduino.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <OpdsStream.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
#include "util/BookCacheUtils.h"
#include "util/OpdsFilename.h"
#include "util/StringUtils.h"
#include "util/UrlUtils.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
} // namespace
void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter();
state = BrowserState::CHECK_WIFI;
entries.clear();
navigationHistory.clear();
searchTemplate = "";
currentPath = "";
selectorIndex = 0;
consumeConfirm = false;
consumeBack = false;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
requestUpdate();
checkAndConnectWifi();
}
void OpdsBookBrowserActivity::onExit() {
Activity::onExit();
entries.clear();
navigationHistory.clear();
if (WiFi.getMode() != WIFI_MODE_NULL) {
WiFi.disconnect(false);
delay(30);
silentRestart();
}
}
void OpdsBookBrowserActivity::loop() {
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;
}
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
requestUpdate();
fetchFeed(currentPath);
} else {
launchWifiSelection();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
}
return;
}
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack();
}
return;
}
if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
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();
}
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();
});
}
}
}
void OpdsBookBrowserActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Show server name in header if available, otherwise generic title
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
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);
renderer.displayBuffer();
return;
}
if (state == BrowserState::ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
if (state == BrowserState::DOWNLOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING));
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) {
GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress,
downloadTotal);
}
renderer.displayBuffer();
return;
}
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));
} else {
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];
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));
}
}
renderer.displayBuffer();
}
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
if (server.url.empty()) {
state = BrowserState::ERROR;
errorMessage = tr(STR_NO_SERVER_URL);
requestUpdate();
return;
}
std::string url = UrlUtils::buildUrl(server.url, path);
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser;
{
OpdsParserStream stream{parser};
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
state = BrowserState::ERROR;
errorMessage = tr(STR_FETCH_FEED_FAILED);
requestUpdate();
return;
}
}
if (!parser) {
state = BrowserState::ERROR;
errorMessage = tr(STR_PARSE_FEED_FAILED);
requestUpdate();
return;
}
searchTemplate = parser.getSearchTemplate();
const auto& nextUrl = parser.getNextPageUrl();
const auto& prevUrl = parser.getPrevPageUrl();
const bool feedTruncated = parser.truncated();
entries = std::move(parser).getEntries();
entries.reserve(entries.size() + (prevUrl.empty() ? 0 : 1) + (nextUrl.empty() ? 0 : 1));
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, ""});
}
if (feedTruncated) {
LOG_INF("OPDS", "Feed truncated to fit memory");
}
selectorIndex = 0;
state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING;
if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES);
requestUpdate();
}
void OpdsBookBrowserActivity::releaseEntries() { std::vector<OpdsEntry>().swap(entries); }
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
navigationHistory.push_back(currentPath);
// Resolve to a full URL so sub-sub-navigation retains parent path context
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
currentPath = UrlUtils::buildUrl(feedUrl, entry.href);
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
releaseEntries();
selectorIndex = 0;
requestUpdate(true);
fetchFeed(currentPath);
}
void OpdsBookBrowserActivity::navigateBack() {
if (navigationHistory.empty()) {
onGoHome();
} else {
currentPath = navigationHistory.back();
navigationHistory.pop_back();
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
releaseEntries();
selectorIndex = 0;
requestUpdate();
fetchFeed(currentPath);
}
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
state = BrowserState::DOWNLOADING;
statusMessage = book.title;
downloadProgress = downloadTotal = 0;
requestUpdate(true);
// Build full download URL relative to the current feed, not the root server URL
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
// opdsDownloadFolder is already a null-terminated char[64]; use it directly —
// no std::string copy. exists()/mkdir() take const char*.
const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root
bool haveFolder = folder[0] != '\0';
if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) {
// exists()-guard first: mkdir's return-on-existing is unconfirmed, and every
// existing caller checks exists() before mkdir. On real failure, fall back
// to SD root so the download is never lost.
LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder);
haveFolder = false;
}
// downloadToFile() needs a std::string, and titles are unbounded (a fixed
// char[] would truncate). Cold path (a multi-second download follows), so one
// reserve'd, in-place-appended owning string is the right call.
std::string filename;
filename.reserve(96);
if (haveFolder) filename += folder;
filename += '/';
filename += opdsBookFilename(book.author, book.title, static_cast<OpdsFilenameFormat>(SETTINGS.opdsFilenameFormat));
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
int lastRenderedPercent = -1;
unsigned long lastProgressUpdateMs = 0;
const auto result = HttpDownloader::downloadToFile(
downloadUrl, filename,
[this, &lastRenderedPercent, &lastProgressUpdateMs](const size_t downloaded, const size_t total) {
downloadProgress = downloaded;
downloadTotal = total;
const int percent = total > 0 ? static_cast<int>(static_cast<uint64_t>(downloaded) * 100 / total) : 0;
const unsigned long now = millis();
if (percent >= 100 || lastRenderedPercent < 0 ||
percent >= lastRenderedPercent + DOWNLOAD_PROGRESS_STEP_PERCENT ||
now - lastProgressUpdateMs >= DOWNLOAD_PROGRESS_MIN_UPDATE_MS) {
lastRenderedPercent = percent;
lastProgressUpdateMs = now;
requestUpdate(true);
}
},
nullptr, server.username, server.password);
if (result == HttpDownloader::OK) {
clearBookCache(filename);
state = BrowserState::BROWSING;
} else {
LOG_ERR("OPDS", "Download failed: %d", static_cast<int>(result));
state = BrowserState::ERROR;
errorMessage = tr(STR_DOWNLOAD_FAILED);
}
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);
releaseEntries();
selectorIndex = 0;
requestUpdate(true);
fetchFeed(url);
}
void OpdsBookBrowserActivity::checkAndConnectWifi() {
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
requestUpdate();
fetchFeed(currentPath);
return;
}
launchWifiSelection();
}
void OpdsBookBrowserActivity::launchWifiSelection() {
state = BrowserState::WIFI_SELECTION;
requestUpdate();
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
}
void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
if (connected) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
requestUpdate(true);
fetchFeed(currentPath);
} else {
// Leave WiFi up; onExit's silent reboot handles teardown without fragmenting.
state = BrowserState::ERROR;
errorMessage = tr(STR_WIFI_CONN_FAILED);
requestUpdate();
}
}