2026-01-07 03:58:37 -05:00
|
|
|
#include "OpdsBookBrowserActivity.h"
|
|
|
|
|
|
2026-01-21 05:06:07 -08:00
|
|
|
#include <Epub.h>
|
2026-01-07 03:58:37 -05:00
|
|
|
#include <GfxRenderer.h>
|
|
|
|
|
#include <HardwareSerial.h>
|
2026-01-21 17:43:51 +03:00
|
|
|
#include <OpdsStream.h>
|
2026-01-07 03:58:37 -05:00
|
|
|
#include <WiFi.h>
|
|
|
|
|
|
|
|
|
|
#include "CrossPointSettings.h"
|
|
|
|
|
#include "MappedInputManager.h"
|
|
|
|
|
#include "ScreenComponents.h"
|
2026-01-12 17:49:42 +07:00
|
|
|
#include "activities/network/WifiSelectionActivity.h"
|
2026-01-07 03:58:37 -05:00
|
|
|
#include "fontIds.h"
|
|
|
|
|
#include "network/HttpDownloader.h"
|
|
|
|
|
#include "util/StringUtils.h"
|
|
|
|
|
#include "util/UrlUtils.h"
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
constexpr int PAGE_ITEMS = 23;
|
|
|
|
|
constexpr int SKIP_PAGE_MS = 700;
|
|
|
|
|
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
|
|
|
|
auto* self = static_cast<OpdsBookBrowserActivity*>(param);
|
|
|
|
|
self->displayTaskLoop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::onEnter() {
|
2026-01-12 17:49:42 +07:00
|
|
|
ActivityWithSubactivity::onEnter();
|
2026-01-07 03:58:37 -05:00
|
|
|
|
|
|
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
|
|
|
state = BrowserState::CHECK_WIFI;
|
|
|
|
|
entries.clear();
|
|
|
|
|
navigationHistory.clear();
|
|
|
|
|
currentPath = OPDS_ROOT_PATH;
|
|
|
|
|
selectorIndex = 0;
|
|
|
|
|
errorMessage.clear();
|
|
|
|
|
statusMessage = "Checking WiFi...";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
|
|
|
|
|
xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask",
|
|
|
|
|
4096, // Stack size (larger for HTTP operations)
|
|
|
|
|
this, // Parameters
|
|
|
|
|
1, // Priority
|
|
|
|
|
&displayTaskHandle // Task handle
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Check WiFi and connect if needed, then fetch feed
|
|
|
|
|
checkAndConnectWifi();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::onExit() {
|
2026-01-12 17:49:42 +07:00
|
|
|
ActivityWithSubactivity::onExit();
|
2026-01-07 03:58:37 -05:00
|
|
|
|
|
|
|
|
// Turn off WiFi when exiting
|
|
|
|
|
WiFi.mode(WIFI_OFF);
|
|
|
|
|
|
|
|
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
|
|
|
if (displayTaskHandle) {
|
|
|
|
|
vTaskDelete(displayTaskHandle);
|
|
|
|
|
displayTaskHandle = nullptr;
|
|
|
|
|
}
|
|
|
|
|
vSemaphoreDelete(renderingMutex);
|
|
|
|
|
renderingMutex = nullptr;
|
|
|
|
|
entries.clear();
|
|
|
|
|
navigationHistory.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::loop() {
|
2026-01-12 17:49:42 +07:00
|
|
|
// Handle WiFi selection subactivity
|
|
|
|
|
if (state == BrowserState::WIFI_SELECTION) {
|
|
|
|
|
ActivityWithSubactivity::loop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 03:58:37 -05:00
|
|
|
// Handle error state - Confirm retries, Back goes back or home
|
|
|
|
|
if (state == BrowserState::ERROR) {
|
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
2026-01-12 17:49:42 +07:00
|
|
|
// 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
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis());
|
|
|
|
|
state = BrowserState::LOADING;
|
|
|
|
|
statusMessage = "Loading...";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
fetchFeed(currentPath);
|
|
|
|
|
} else {
|
|
|
|
|
// WiFi not connected - launch WiFi selection
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis());
|
|
|
|
|
launchWifiSelection();
|
|
|
|
|
}
|
2026-01-07 03:58:37 -05:00
|
|
|
} 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) {
|
|
|
|
|
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
|
|
|
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
|
|
|
|
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
|
|
|
|
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
|
|
|
|
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
} else if (prevReleased && !entries.empty()) {
|
|
|
|
|
if (skipPage) {
|
|
|
|
|
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
|
|
|
|
|
} else {
|
|
|
|
|
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
|
|
|
|
|
}
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
} else if (nextReleased && !entries.empty()) {
|
|
|
|
|
if (skipPage) {
|
|
|
|
|
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
|
|
|
|
|
} else {
|
|
|
|
|
selectorIndex = (selectorIndex + 1) % entries.size();
|
|
|
|
|
}
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::displayTaskLoop() {
|
|
|
|
|
while (true) {
|
|
|
|
|
if (updateRequired) {
|
|
|
|
|
updateRequired = false;
|
|
|
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
|
|
|
render();
|
|
|
|
|
xSemaphoreGive(renderingMutex);
|
|
|
|
|
}
|
|
|
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::render() const {
|
|
|
|
|
renderer.clearScreen();
|
|
|
|
|
|
|
|
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
|
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
|
|
|
|
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD);
|
|
|
|
|
|
|
|
|
|
if (state == BrowserState::CHECK_WIFI) {
|
|
|
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
|
|
|
|
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
|
|
|
|
renderer.drawButtonHints(UI_10_FONT_ID, 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("« Back", "", "", "");
|
|
|
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
|
renderer.displayBuffer();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state == BrowserState::ERROR) {
|
|
|
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:");
|
|
|
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
|
|
|
|
const auto labels = mappedInput.mapLabels("« Back", "Retry", "", "");
|
|
|
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
|
renderer.displayBuffer();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state == BrowserState::DOWNLOADING) {
|
|
|
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, "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;
|
|
|
|
|
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal);
|
|
|
|
|
}
|
|
|
|
|
renderer.displayBuffer();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Browsing state
|
|
|
|
|
// Show appropriate button hint based on selected entry type
|
|
|
|
|
const char* confirmLabel = "Open";
|
|
|
|
|
if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) {
|
|
|
|
|
confirmLabel = "Download";
|
|
|
|
|
}
|
|
|
|
|
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
|
|
|
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
|
|
|
|
|
|
if (entries.empty()) {
|
|
|
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
|
|
|
|
|
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<size_t>(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<size_t>(selectorIndex));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer.displayBuffer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
|
|
|
|
const char* serverUrl = SETTINGS.opdsServerUrl;
|
|
|
|
|
if (strlen(serverUrl) == 0) {
|
|
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "No server URL configured";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
|
|
|
|
|
2026-01-21 17:43:51 +03:00
|
|
|
OpdsParser parser;
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
OpdsParserStream stream{parser};
|
|
|
|
|
if (!HttpDownloader::fetchUrl(url, stream)) {
|
|
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "Failed to fetch feed";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 03:58:37 -05:00
|
|
|
}
|
|
|
|
|
|
2026-01-21 17:43:51 +03:00
|
|
|
if (!parser) {
|
2026-01-07 03:58:37 -05:00
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "Failed to parse feed";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 17:43:51 +03:00
|
|
|
entries = std::move(parser).getEntries();
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Found %d entries\n", millis(), entries.size());
|
2026-01-07 03:58:37 -05:00
|
|
|
selectorIndex = 0;
|
|
|
|
|
|
|
|
|
|
if (entries.empty()) {
|
|
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "No entries found";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = BrowserState::BROWSING;
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
|
|
|
|
|
// Push current path to history before navigating
|
|
|
|
|
navigationHistory.push_back(currentPath);
|
|
|
|
|
currentPath = entry.href;
|
|
|
|
|
|
|
|
|
|
state = BrowserState::LOADING;
|
|
|
|
|
statusMessage = "Loading...";
|
|
|
|
|
entries.clear();
|
|
|
|
|
selectorIndex = 0;
|
|
|
|
|
updateRequired = 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 = "Loading...";
|
|
|
|
|
entries.clear();
|
|
|
|
|
selectorIndex = 0;
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
|
|
|
|
|
fetchFeed(currentPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
|
|
|
|
state = BrowserState::DOWNLOADING;
|
|
|
|
|
statusMessage = book.title;
|
|
|
|
|
downloadProgress = 0;
|
|
|
|
|
downloadTotal = 0;
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
|
|
|
|
|
// 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";
|
|
|
|
|
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), 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;
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result == HttpDownloader::OK) {
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
|
2026-01-21 05:06:07 -08:00
|
|
|
|
|
|
|
|
// Invalidate any existing cache for this file to prevent stale metadata issues
|
|
|
|
|
Epub epub(filename, "/.crosspoint");
|
|
|
|
|
epub.clearCache();
|
|
|
|
|
Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str());
|
|
|
|
|
|
2026-01-07 03:58:37 -05:00
|
|
|
state = BrowserState::BROWSING;
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
} else {
|
|
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "Download failed";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
2026-01-12 17:49:42 +07:00
|
|
|
// Already connected? Verify connection is valid by checking IP
|
|
|
|
|
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
2026-01-07 03:58:37 -05:00
|
|
|
state = BrowserState::LOADING;
|
|
|
|
|
statusMessage = "Loading...";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
fetchFeed(currentPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 17:49:42 +07:00
|
|
|
// Not connected - launch WiFi selection screen directly
|
|
|
|
|
launchWifiSelection();
|
|
|
|
|
}
|
2026-01-07 03:58:37 -05:00
|
|
|
|
2026-01-12 17:49:42 +07:00
|
|
|
void OpdsBookBrowserActivity::launchWifiSelection() {
|
|
|
|
|
state = BrowserState::WIFI_SELECTION;
|
|
|
|
|
updateRequired = true;
|
2026-01-07 03:58:37 -05:00
|
|
|
|
2026-01-12 17:49:42 +07:00
|
|
|
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
|
|
|
|
|
[this](const bool connected) { onWifiSelectionComplete(connected); }));
|
|
|
|
|
}
|
2026-01-07 03:58:37 -05:00
|
|
|
|
2026-01-12 17:49:42 +07:00
|
|
|
void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
|
|
|
|
|
exitActivity();
|
2026-01-07 03:58:37 -05:00
|
|
|
|
2026-01-12 17:49:42 +07:00
|
|
|
if (connected) {
|
|
|
|
|
Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis());
|
2026-01-07 03:58:37 -05:00
|
|
|
state = BrowserState::LOADING;
|
|
|
|
|
statusMessage = "Loading...";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
fetchFeed(currentPath);
|
|
|
|
|
} else {
|
2026-01-12 17:49:42 +07:00
|
|
|
Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis());
|
|
|
|
|
// Force disconnect to ensure clean state for next retry
|
|
|
|
|
// This prevents stale connection status from interfering
|
|
|
|
|
WiFi.disconnect();
|
|
|
|
|
WiFi.mode(WIFI_OFF);
|
2026-01-07 03:58:37 -05:00
|
|
|
state = BrowserState::ERROR;
|
|
|
|
|
errorMessage = "WiFi connection failed";
|
|
|
|
|
updateRequired = true;
|
|
|
|
|
}
|
|
|
|
|
}
|