Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
26 kB · 694 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695#include "OpdsBookBrowserActivity.h"
#include <Arduino.h>#include <FontCacheManager.h>#include <FreeInkUIIcon.h>#include <GfxRenderer.h>#include <HalStorage.h>#include <I18n.h>#include <LibraryBuilder.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/UIScale.h"#include "components/UITheme.h"#include "components/icons/headerIcons.h"#include "components/icons/search32.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 fui = freeink::ui;
namespace {constexpr fui::ActionId ACTION_ROW = 1;constexpr fui::ActionId ACTION_SEARCH = 2;constexpr fui::ActionId ACTION_CANCEL = 3;constexpr fui::ActionId ACTION_BACK = 4;constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
} // namespace
OpdsBookBrowserActivity::OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, OpdsServer server) : Activity("OpdsBookBrowser", renderer, mappedInput), UiAppHost(renderer), buttonNavigator(), server(std::move(server)) {}
void OpdsBookBrowserActivity::onEnter() { Activity::onEnter();
state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); searchTemplate = ""; currentPath = ""; selectorIndex = 0; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI);
listNav.reset(); resetUi(); app.on(ACTION_ROW, &OpdsBookBrowserActivity::onRowEvent, this); app.on(ACTION_SEARCH, &OpdsBookBrowserActivity::onSearchEvent, this); app.on(ACTION_CANCEL, &OpdsBookBrowserActivity::onCancelEvent, this); app.on(ACTION_BACK, &OpdsBookBrowserActivity::onBackEvent, this); app.setScreen(&OpdsBookBrowserActivity::rootScreen, this); 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::activateSelected() { if (entries.empty() || selectorIndex < 0 || selectorIndex >= static_cast<int>(entries.size())) return; const auto& entry = entries[selectorIndex]; entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);}
void OpdsBookBrowserActivity::onRowEvent(const fui::ActionEvent& event, void* user) { auto* self = static_cast<OpdsBookBrowserActivity*>(user); if (self->state != BrowserState::BROWSING) return; if (event.value < 0 || event.value >= static_cast<int16_t>(self->entries.size())) return; self->selectorIndex = event.value; // The tapped row leaves the screen either way (new feed or download view); // a lingering tap flash would gray an unrelated row on the next list. self->app.clearTapFlash(); self->activateSelected();}
void OpdsBookBrowserActivity::onSearchEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast<OpdsBookBrowserActivity*>(user); if (self->state != BrowserState::BROWSING) return; self->app.clearTapFlash(); self->launchSearch();}
void OpdsBookBrowserActivity::onBackEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast<OpdsBookBrowserActivity*>(user); if (self->state != BrowserState::BROWSING) return; self->app.clearTapFlash(); self->navigateBack();}
void OpdsBookBrowserActivity::onCancelEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast<OpdsBookBrowserActivity*>(user); if (self->state != BrowserState::DOWNLOADING) return; self->app.clearTapFlash(); self->cancelDownload = true;}
void OpdsBookBrowserActivity::loop() { if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { return; }
if (state == BrowserState::ERROR) { int tx = 0; int ty = 0; if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) { 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)) { activateSelected(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); }
// Touch goes through the FreeInkApp: render() registered every tap target // (rows, header search button); route the snapshot and let the registered // handlers dispatch. const auto route = routeTouch(mappedInput); if (route.routed) { // No pressed-state repaint: the render it triggers would drop a slow // tap's release inside the uiReady window (tap-to-activate needed two // taps), and it costs a second e-ink refresh per tap. if (app.invalidated()) requestUpdate(); if (route) return; // dispatched to onRowEvent/onSearchEvent if (state != BrowserState::BROWSING) return; }
if (!entries.empty()) { // Swipes scroll the viewport; the selection stays put (it may scroll // off-screen) and button navigation pulls the view back to it. const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { const int delta = swipe == MappedInputManager::SwipeDir::Up ? listNav.visibleRows : -listNav.visibleRows; if (listNav.scrollBy(delta, static_cast<int>(entries.size()))) requestUpdate(); return; }
const auto moveSelection = [this](const int index) { selectorIndex = index; listNav.selected = index; listNav.follow(static_cast<int>(entries.size())); requestUpdate(); }; buttonNavigator.onNextRelease( [this, &moveSelection] { moveSelection(ButtonNavigator::nextIndex(selectorIndex, entries.size())); }); buttonNavigator.onPreviousRelease( [this, &moveSelection] { moveSelection(ButtonNavigator::previousIndex(selectorIndex, entries.size())); }); buttonNavigator.onNextContinuous([this, &moveSelection] { moveSelection(ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), listNav.visibleRows)); }); buttonNavigator.onPreviousContinuous([this, &moveSelection] { moveSelection(ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), listNav.visibleRows)); }); } }}
bool OpdsBookBrowserActivity::preventAutoSleep() { switch (state) { case BrowserState::CHECK_WIFI: case BrowserState::WIFI_SELECTION: case BrowserState::LOADING: case BrowserState::DOWNLOADING: case BrowserState::SEARCH_INPUT: return true; case BrowserState::BROWSING: case BrowserState::ERROR: return false; } return false;}
void OpdsBookBrowserActivity::rootScreen(UiScreen& screen, void* user) { auto* self = static_cast<OpdsBookBrowserActivity*>(user); switch (self->state) { case BrowserState::BROWSING: self->buildBrowsingScreen(screen); break; case BrowserState::DOWNLOADING: self->buildDownloadScreen(screen); break; default: self->buildStatusScreen(screen); break; }}
// Shared chrome for every state: reserve the firmware's button-hint band and// draw the themed header (padding, centering, and rule come from the theme).void OpdsBookBrowserActivity::screenHeader(UiScreen& screen, const bool withSearch) { const auto& metrics = UITheme::getInstance().getMetrics(); const auto& theme = screen.theme(); fui::HeaderProps header; header.title = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); header.borderEdges = fui::EdgeBottom; // Same battery/clock band as every GUI.drawHeader screen; the header // heights are unified across themes, so the buttons derive from the band. GUI.applyHeaderStatus(renderer, header); // Only the browsing state routes header taps (and only there does back mean // anything); the loading/downloading/status headers stay passive. if (state == BrowserState::BROWSING && mappedInput.hasTouch()) { header.leadingIcon = fui::bitmapFromIcon(icon_header_back_32); header.leadingAction = ACTION_BACK; } if (withSearch && !searchTemplate.empty()) { header.trailingIcon = fui::bitmapFromIcon(icon_search_32); header.trailingAction = ACTION_SEARCH; // Vertical placement comes from applyHeaderStatus: buttons center on the // unified band. } header.titleText = theme.titleText; header.titleText.align = theme.headerTitleAlign; header.styles = theme.popup; if (header.styles.normal.border.kind == fui::PaintKind::None && theme.headerUnderline > 0) { header.styles.normal.border = fui::Paint::solid(fui::Color::Black); header.styles.normal.borderWidth = theme.headerUnderline; } header.trailingStyles = fui::plainStyles(fui::Paint::solid(fui::Color::Black)); header.sidePadding = theme.headerSidePadding; header.minTouchSize = theme.minTouchSize; const auto frameRect = screen.frame().screen(); fui::header(screen.frame(), fui::Rect{frameRect.x, static_cast<int16_t>(metrics.topPadding), frameRect.width, static_cast<int16_t>(metrics.headerHeight)}, header); screen.setContentMarginFromScreen( fui::Insets{static_cast<int16_t>(metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing), 0, static_cast<int16_t>(metrics.buttonHintsHeight), 0});}
void OpdsBookBrowserActivity::buildBrowsingScreen(UiScreen& screen) { screenHeader(screen, true);
if (entries.empty()) { screen.centeredText(tr(STR_NO_ENTRIES), screen.theme().bodyText); return; }
// Transient per-render: sized once via reserve, points into `entries` // strings, freed on scope exit. // rowItems is built whenever entries changes (see rebuildRowItems(), called // from fetchFeed()/releaseEntries()) and reused here on every repaint. fui::ListProps props; props.items = rowItems.data(); props.count = static_cast<uint16_t>(rowItems.size()); props.action = ACTION_ROW; props.inputMask = fui::InputTouch; // physical buttons stay in loop() props.valueInset = 8; // air between the nav chevron and the row edge listNav.selected = selectorIndex; props.partialTrailingRow = true; screen.syncListViewport(listNav, props, static_cast<int>(entries.size())); screen.list(props);}
void OpdsBookBrowserActivity::buildDownloadScreen(UiScreen& screen) { screenHeader(screen, false);
// Centered block: status line, book title, progress bar, cancel button. const auto& theme = screen.theme(); fui::TextStyle centered = theme.bodyText; centered.align = fui::TextAlign::Center; const int16_t lh = screen.target().lineHeight(centered.font); const int16_t gap = theme.spaceMd; const int16_t barH = 16; const int16_t btnH = theme.rowHeight; const int16_t blockH = static_cast<int16_t>(lh * 2 + barH + btnH + gap * 3); const fui::Rect body = screen.body(); if (body.height > blockH) screen.spacer(static_cast<int16_t>((body.height - blockH) / 2));
screen.target().text(screen.takeTop(lh, gap), tr(STR_DOWNLOADING), centered); screen.target().text(screen.takeTop(lh, gap), statusMessage.c_str(), centered);
const fui::Rect bar = screen.takeTop(barH, gap).inset(fui::Insets{0, 50, 0, 50}); if (downloadTotal > 0) { fui::ProgressBarProps progress; progress.value = static_cast<int32_t>(downloadProgress); progress.max = static_cast<int32_t>(downloadTotal); progress.border = fui::Paint::solid(fui::Color::Black); progress.borderWidth = 1; fui::progressBar(screen.frame(), bar, progress); }
const fui::Rect btnArea = screen.takeTop(btnH); const int16_t btnW = static_cast<int16_t>(btnArea.width / 3); fui::ButtonProps cancel; cancel.label = tr(STR_CANCEL); cancel.action = ACTION_CANCEL; screen.button(cancel, fui::Rect{static_cast<int16_t>(btnArea.x + (btnArea.width - btnW) / 2), btnArea.y, btnW, btnH});}
void OpdsBookBrowserActivity::buildStatusScreen(UiScreen& screen) { screenHeader(screen, false);
fui::TextStyle centered = screen.theme().bodyText; centered.align = fui::TextAlign::Center; if (state == BrowserState::ERROR) { const int16_t lh = screen.target().lineHeight(centered.font); const int16_t gap = screen.theme().spaceMd; const bool showTapHint = mappedInput.hasTouch(); const int16_t blockH = static_cast<int16_t>(lh * (showTapHint ? 3 : 2) + gap * (showTapHint ? 2 : 1)); const fui::Rect body = screen.body(); if (body.height > blockH) screen.spacer(static_cast<int16_t>((body.height - blockH) / 2)); screen.target().text(screen.takeTop(lh, gap), tr(STR_ERROR_MSG), centered); screen.target().text(screen.takeTop(lh, gap), errorMessage.c_str(), centered); if (showTapHint) screen.target().text(screen.takeTop(lh), tr(STR_TAP_TO_RETRY), centered); return; } // CHECK_WIFI / LOADING (and the brief child-activity handoff states). screen.centeredText(statusMessage.c_str(), centered);}
void OpdsBookBrowserActivity::render(RenderLock&&) { renderer.clearScreen();
MappedInputManager::Labels labels; switch (state) { case BrowserState::BROWSING: { 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); labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); break; } case BrowserState::DOWNLOADING: labels = mappedInput.mapLabels(tr(STR_CANCEL), "", "", ""); break; case BrowserState::ERROR: labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", ""); break; default: labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); break; } GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderUi(); 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(); // Reset the selection before the swap: the render task reads // entries[selectorIndex] under only an empty() guard, and the new feed can // be shorter than the old selection. selectorIndex = 0; listNav.reset(); 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"); }
state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); rebuildRowItems(); requestUpdate();}
// Derives rowItems from entries. Called whenever entries changes// (fetchFeed()/releaseEntries()) so buildBrowsingScreen() reuses the cached// rows on every repaint instead of rebuilding them per render.void OpdsBookBrowserActivity::rebuildRowItems() { rowItems.clear(); rowItems.reserve(entries.size()); for (const auto& entry : entries) { fui::ListItem item; item.label = entry.title.c_str(); if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) item.subtitle = entry.author.c_str(); if (entry.type == OpdsEntryType::NAVIGATION) item.value = ">"; item.actionValue = static_cast<int16_t>(rowItems.size()); rowItems.push_back(item); }}
void OpdsBookBrowserActivity::releaseEntries() { // The app's interaction table holds row indices (and hit rects) for the old // entries; stop routing touches against it until the next render. closeRouting(); std::vector<OpdsEntry>().swap(entries); std::vector<fui::ListItem>().swap(rowItems);}
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; cancelDownload = false; goHomeAfterCancel = false; 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());
// The selected book data is now copied into the download URL, filename, and // status line. Reclaim the catalog while TLS owns its record buffers; reload // the current feed when the transfer finishes. releaseEntries();
// Rebuildable SD-font caches can hold tens of KB the TLS session needs for // a multi-MB book; release them up front (they repopulate on demand) and // refuse to start below the floor — a doomed transfer otherwise dies // mid-stream with MEMORY_E, or abort()s on an interior allocation. if (auto* fcm = renderer.getFontCacheManager()) { fcm->releaseSdFontCaches(); } LOG_DBG("OPDS", "Download heap: %u free, %u max block", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); if (ESP.getFreeHeap() < HttpDownloader::MIN_TLS_FREE_HEAP || ESP.getMaxAllocHeap() < HttpDownloader::MIN_TLS_MAX_ALLOC) { LOG_ERR("OPDS", "Low heap for download (%u free, %u max block)", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); requestUpdate(); return; }
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; // The activity loop is blocked for the whole download; pump input here // so the Cancel button or a Back press can abort mid-transfer. mappedInput.update(true); if (mappedInput.wasReleased(MappedInputManager::Button::Back)) cancelDownload = true; // Home cancels immediately; other configured actions are deferred to // the next main-loop pass by the transfer input pump. if (mappedInput.wasHomeGesture()) { cancelDownload = true; goHomeAfterCancel = true; } routeTouch(mappedInput); 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); } }, &cancelDownload, server.username, server.password);
if (result == HttpDownloader::OK) { clearBookCache(filename); library::markLibraryIndexDirty(); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); fetchFeed(currentPath); return; } else if (result == HttpDownloader::ABORTED) { // The partial file is already removed. Reload the released catalog unless // the cancel came from the home gesture. LOG_INF("OPDS", "Download cancelled"); if (goHomeAfterCancel) { onGoHome(); return; } state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); fetchFeed(currentPath); return; } else { LOG_ERR("OPDS", "Download failed: %d", static_cast<int>(result)); state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); } requestUpdate();}
void OpdsBookBrowserActivity::launchSearch() { 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); 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(); }}