#include "OpdsBookBrowserActivity.h" #include #include #include #include #include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/StringUtils.h" #include "util/UrlUtils.h" namespace { constexpr int PAGE_ITEMS = 23; } // namespace void OpdsBookBrowserActivity::onEnter() { ActivityWithSubactivity::onEnter(); state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); // Check WiFi and connect if needed, then fetch feed checkAndConnectWifi(); } void OpdsBookBrowserActivity::onExit() { ActivityWithSubactivity::onExit(); // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); entries.clear(); navigationHistory.clear(); } void OpdsBookBrowserActivity::loop() { // Handle WiFi selection subactivity if (state == BrowserState::WIFI_SELECTION) { ActivityWithSubactivity::loop(); 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)) { navigateBack(); } return; } // Handle WiFi check state - only Back works if (state == BrowserState::CHECK_WIFI) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { onGoHome(); } return; } // Handle loading state - only Back works if (state == BrowserState::LOADING) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } 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); } } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } // 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(); }); } } } void OpdsBookBrowserActivity::render(Activity::RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == BrowserState::LOADING) { 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)); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str()); if (downloadTotal > 0) { const int barWidth = pageWidth - 100; constexpr int barHeight = 20; constexpr int barX = 50; const int barY = pageHeight / 2 + 20; GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); } renderer.displayBuffer(); return; } // Browsing state // Show appropriate button hint based on selected entry type const char* confirmLabel = tr(STR_OPEN); if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { confirmLabel = tr(STR_DOWNLOAD); } const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (entries.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES)); renderer.displayBuffer(); return; } 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(pageStartIndex + PAGE_ITEMS); i++) { const auto& entry = entries[i]; // Format display text with type indicator std::string displayText; if (entry.type == OpdsEntryType::NAVIGATION) { displayText = "> " + entry.title; // Folder/navigation indicator } else { // Book: "Title - Author" or just "Title" displayText = entry.title; if (!entry.author.empty()) { displayText += " - " + entry.author; } } auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != static_cast(selectorIndex)); } renderer.displayBuffer(); } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { const char* serverUrl = SETTINGS.opdsServerUrl; if (strlen(serverUrl) == 0) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } std::string url = UrlUtils::buildUrl(serverUrl, path); LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; { OpdsParserStream stream{parser}; if (!HttpDownloader::fetchUrl(url, stream)) { state = BrowserState::ERROR; errorMessage = tr(STR_FETCH_FEED_FAILED); requestUpdate(); return; } } if (!parser) { state = BrowserState::ERROR; errorMessage = tr(STR_PARSE_FEED_FAILED); requestUpdate(); return; } 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; } state = BrowserState::BROWSING; 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(); 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); } } void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = 0; downloadTotal = 0; requestUpdate(); // Build full download URL std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author std::string baseName = book.title; if (!book.author.empty()) { baseName += " - " + book.author; } std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); const auto result = HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; requestUpdate(); }); if (result == HttpDownloader::OK) { LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); // Invalidate any existing cache for this file to prevent stale metadata issues Epub epub(filename, "/.crosspoint"); epub.clearCache(); LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str()); state = BrowserState::BROWSING; requestUpdate(); } else { state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); requestUpdate(); } } void OpdsBookBrowserActivity::checkAndConnectWifi() { // Already connected? Verify connection is valid by checking IP if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); fetchFeed(currentPath); return; } // Not connected - launch WiFi selection screen directly launchWifiSelection(); } void OpdsBookBrowserActivity::launchWifiSelection() { state = BrowserState::WIFI_SELECTION; requestUpdate(); enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); })); } void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { exitActivity(); if (connected) { LOG_DBG("OPDS", "WiFi connected via selection, fetching feed"); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); 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; errorMessage = tr(STR_WIFI_CONN_FAILED); requestUpdate(); } }