Compare commits
3 Commits
3628d8eb37
...
c09f7b4a22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c09f7b4a22
|
||
|
|
7eaced602f
|
||
|
|
f955cf2fb4
|
@@ -426,6 +426,11 @@ enum class StrId : uint16_t {
|
||||
STR_SAVE_HERE,
|
||||
STR_SELECT_FOLDER,
|
||||
STR_DOWNLOAD_PATH,
|
||||
STR_POSITION,
|
||||
STR_DOWNLOAD_COMPLETE,
|
||||
STR_OPEN_BOOK,
|
||||
STR_BACK_TO_LISTING,
|
||||
STR_AFTER_DOWNLOAD,
|
||||
// Sentinel - must be last
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS servery"
|
||||
STR_SAVE_HERE: "Uložit zde"
|
||||
STR_SELECT_FOLDER: "Vybrat složku"
|
||||
STR_DOWNLOAD_PATH: "Cesta ke stažení"
|
||||
STR_POSITION: "Pozice"
|
||||
|
||||
@@ -390,3 +390,8 @@ STR_OPDS_SERVERS: "OPDS Servers"
|
||||
STR_SAVE_HERE: "Save Here"
|
||||
STR_SELECT_FOLDER: "Select Folder"
|
||||
STR_DOWNLOAD_PATH: "Download Path"
|
||||
STR_POSITION: "Position"
|
||||
STR_DOWNLOAD_COMPLETE: "Download Complete!"
|
||||
STR_OPEN_BOOK: "Open Book"
|
||||
STR_BACK_TO_LISTING: "Back to Listing"
|
||||
STR_AFTER_DOWNLOAD: "After Download"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Serveurs OPDS"
|
||||
STR_SAVE_HERE: "Enregistrer ici"
|
||||
STR_SELECT_FOLDER: "Sélectionner un dossier"
|
||||
STR_DOWNLOAD_PATH: "Chemin de téléchargement"
|
||||
STR_POSITION: "Position"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-Server"
|
||||
STR_SAVE_HERE: "Hier speichern"
|
||||
STR_SELECT_FOLDER: "Ordner auswählen"
|
||||
STR_DOWNLOAD_PATH: "Download-Pfad"
|
||||
STR_POSITION: "Position"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_SAVE_HERE: "Salvar aqui"
|
||||
STR_SELECT_FOLDER: "Selecionar pasta"
|
||||
STR_DOWNLOAD_PATH: "Caminho de download"
|
||||
STR_POSITION: "Posição"
|
||||
|
||||
@@ -328,3 +328,4 @@ STR_OPDS_SERVERS: "Servere OPDS"
|
||||
STR_SAVE_HERE: "Salvează aici"
|
||||
STR_SELECT_FOLDER: "Selectează dosar"
|
||||
STR_DOWNLOAD_PATH: "Cale descărcare"
|
||||
STR_POSITION: "Poziție"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Серверы OPDS"
|
||||
STR_SAVE_HERE: "Сохранить здесь"
|
||||
STR_SELECT_FOLDER: "Выбрать папку"
|
||||
STR_DOWNLOAD_PATH: "Путь загрузки"
|
||||
STR_POSITION: "Позиция"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_SAVE_HERE: "Guardar aquí"
|
||||
STR_SELECT_FOLDER: "Seleccionar carpeta"
|
||||
STR_DOWNLOAD_PATH: "Ruta de descarga"
|
||||
STR_POSITION: "Posición"
|
||||
|
||||
@@ -353,3 +353,4 @@ STR_OPDS_SERVERS: "OPDS-servrar"
|
||||
STR_SAVE_HERE: "Spara här"
|
||||
STR_SELECT_FOLDER: "Välj mapp"
|
||||
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
|
||||
STR_POSITION: "Position"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <esp_mac.h>
|
||||
#include <mbedtls/base64.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -81,6 +82,8 @@ bool OpdsServerStore::saveToFile() const {
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||
obj["download_path"] = server.downloadPath;
|
||||
obj["sort_order"] = server.sortOrder;
|
||||
obj["after_download"] = server.afterDownloadAction;
|
||||
}
|
||||
|
||||
String json;
|
||||
@@ -116,13 +119,33 @@ bool OpdsServerStore::loadFromFile() {
|
||||
if (!server.password.empty()) needsResave = true;
|
||||
}
|
||||
server.downloadPath = obj["download_path"] | std::string("/");
|
||||
server.sortOrder = obj["sort_order"] | 0;
|
||||
server.afterDownloadAction = obj["after_download"] | 0;
|
||||
if (server.sortOrder == 0) needsResave = true;
|
||||
servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
// Assign sequential sort orders to servers loaded without one
|
||||
bool anyZero = false;
|
||||
for (const auto& s : servers) {
|
||||
if (s.sortOrder == 0) {
|
||||
anyZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (anyZero) {
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
if (servers[i].sortOrder == 0) {
|
||||
servers[i].sortOrder = static_cast<int>(i) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortServers();
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
return true;
|
||||
@@ -149,6 +172,7 @@ bool OpdsServerStore::migrateFromSettings() {
|
||||
server.url = SETTINGS.opdsServerUrl;
|
||||
server.username = SETTINGS.opdsUsername;
|
||||
server.password = SETTINGS.opdsPassword;
|
||||
server.sortOrder = 1;
|
||||
servers.push_back(std::move(server));
|
||||
|
||||
if (saveToFile()) {
|
||||
@@ -171,7 +195,15 @@ bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
}
|
||||
|
||||
servers.push_back(server);
|
||||
LOG_DBG("OPS", "Added server: %s", server.name.c_str());
|
||||
if (servers.back().sortOrder == 0) {
|
||||
int maxOrder = 0;
|
||||
for (size_t i = 0; i + 1 < servers.size(); i++) {
|
||||
maxOrder = std::max(maxOrder, servers[i].sortOrder);
|
||||
}
|
||||
servers.back().sortOrder = maxOrder + 1;
|
||||
}
|
||||
LOG_DBG("OPS", "Added server: %s (order=%d)", server.name.c_str(), servers.back().sortOrder);
|
||||
sortServers();
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
@@ -181,7 +213,8 @@ bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
||||
}
|
||||
|
||||
servers[index] = server;
|
||||
LOG_DBG("OPS", "Updated server: %s", server.name.c_str());
|
||||
sortServers();
|
||||
LOG_DBG("OPS", "Updated server: %s (order=%d)", server.name.c_str(), server.sortOrder);
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
@@ -201,3 +234,30 @@ const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
|
||||
bool OpdsServerStore::moveServer(size_t index, int direction) {
|
||||
if (index >= servers.size()) return false;
|
||||
|
||||
size_t target;
|
||||
if (direction < 0) {
|
||||
if (index == 0) return false;
|
||||
target = index - 1;
|
||||
} else {
|
||||
if (index + 1 >= servers.size()) return false;
|
||||
target = index + 1;
|
||||
}
|
||||
|
||||
std::swap(servers[index].sortOrder, servers[target].sortOrder);
|
||||
sortServers();
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
void OpdsServerStore::sortServers() {
|
||||
std::sort(servers.begin(), servers.end(), [](const OpdsServer& a, const OpdsServer& b) {
|
||||
if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder;
|
||||
const auto& nameA = a.name.empty() ? a.url : a.name;
|
||||
const auto& nameB = b.name.empty() ? b.url : b.name;
|
||||
return std::lexicographical_compare(nameA.begin(), nameA.end(), nameB.begin(), nameB.end(),
|
||||
[](char ca, char cb) { return tolower(ca) < tolower(cb); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ struct OpdsServer {
|
||||
std::string username;
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
std::string downloadPath = "/";
|
||||
int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name
|
||||
int afterDownloadAction = 0; // 0 = back to listing, 1 = open book
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,6 +38,7 @@ class OpdsServerStore {
|
||||
bool addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
bool removeServer(size_t index);
|
||||
bool moveServer(size_t index, int direction);
|
||||
|
||||
const std::vector<OpdsServer>& getServers() const { return servers; }
|
||||
const OpdsServer* getServer(size_t index) const;
|
||||
@@ -47,6 +50,9 @@ class OpdsServerStore {
|
||||
* Called once during first load if no opds.json exists.
|
||||
*/
|
||||
bool migrateFromSettings();
|
||||
|
||||
private:
|
||||
void sortServers();
|
||||
};
|
||||
|
||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||
|
||||
@@ -102,6 +102,45 @@ void OpdsBookBrowserActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle download complete prompt
|
||||
if (state == BrowserState::DOWNLOAD_COMPLETE) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
executePromptAction(0);
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
executePromptAction(promptSelection);
|
||||
return;
|
||||
}
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
countdownActive = false;
|
||||
if (promptSelection != 1) {
|
||||
promptSelection = 1;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
countdownActive = false;
|
||||
if (promptSelection != 0) {
|
||||
promptSelection = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
if (countdownActive) {
|
||||
const unsigned long elapsed = millis() - downloadCompleteTime;
|
||||
if (elapsed >= 5000) {
|
||||
executePromptAction(server.afterDownloadAction);
|
||||
return;
|
||||
}
|
||||
const int secondsLeft = static_cast<int>((5000 - elapsed) / 1000);
|
||||
if (secondsLeft != lastCountdownSecond) {
|
||||
lastCountdownSecond = secondsLeft;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle browsing state
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
@@ -192,6 +231,37 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::DOWNLOAD_COMPLETE) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 50, tr(STR_DOWNLOAD_COMPLETE), true, EpdFontFamily::BOLD);
|
||||
const auto maxWidth = pageWidth - 40;
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, title.c_str());
|
||||
|
||||
const int buttonY = pageHeight / 2 + 20;
|
||||
const char* backText = tr(STR_BACK_TO_LISTING);
|
||||
const char* openText = tr(STR_OPEN_BOOK);
|
||||
std::string backLabel = promptSelection == 0 ? "[" + std::string(backText) + "]" : std::string(backText);
|
||||
std::string openLabel = promptSelection == 1 ? "[" + std::string(openText) + "]" : std::string(openText);
|
||||
const int backWidth = renderer.getTextWidth(UI_10_FONT_ID, backLabel.c_str());
|
||||
const int openWidth = renderer.getTextWidth(UI_10_FONT_ID, openLabel.c_str());
|
||||
constexpr int buttonSpacing = 40;
|
||||
const int totalWidth = backWidth + buttonSpacing + openWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
renderer.drawText(UI_10_FONT_ID, startX, buttonY, backLabel.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, startX + backWidth + buttonSpacing, buttonY, openLabel.c_str());
|
||||
|
||||
if (countdownActive && lastCountdownSecond >= 0) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "(%ds)", lastCountdownSecond + 1);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 50, buf);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CONFIRM), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
return;
|
||||
}
|
||||
|
||||
// Browsing state
|
||||
// Show appropriate button hint based on selected entry type
|
||||
const char* confirmLabel = tr(STR_OPEN);
|
||||
@@ -369,7 +439,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::str
|
||||
epub.clearCache();
|
||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||
|
||||
state = BrowserState::BROWSING;
|
||||
downloadedFilePath = filename;
|
||||
promptSelection = server.afterDownloadAction;
|
||||
downloadCompleteTime = millis();
|
||||
countdownActive = true;
|
||||
lastCountdownSecond = -1;
|
||||
state = BrowserState::DOWNLOAD_COMPLETE;
|
||||
requestUpdate();
|
||||
} else {
|
||||
state = BrowserState::ERROR;
|
||||
@@ -378,6 +453,15 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::str
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::executePromptAction(int action) {
|
||||
if (action == 1 && onGoToReader) {
|
||||
onGoToReader(downloadedFilePath);
|
||||
return;
|
||||
}
|
||||
state = BrowserState::BROWSING;
|
||||
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)) {
|
||||
|
||||
@@ -23,12 +23,18 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
PICKING_DIRECTORY, // Directory picker subactivity is active
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
DOWNLOAD_COMPLETE, // Prompt: open book or go back to listing
|
||||
ERROR // Error state with message
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome, const OpdsServer& server)
|
||||
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {}
|
||||
const std::function<void()>& onGoHome,
|
||||
const std::function<void(const std::string&)>& onGoToReader,
|
||||
const OpdsServer& server)
|
||||
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput),
|
||||
onGoHome(onGoHome),
|
||||
onGoToReader(onGoToReader),
|
||||
server(server) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -46,8 +52,14 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
std::string statusMessage;
|
||||
size_t downloadProgress = 0;
|
||||
size_t downloadTotal = 0;
|
||||
std::string downloadedFilePath;
|
||||
unsigned long downloadCompleteTime = 0;
|
||||
int promptSelection = 0; // 0 = back to listing, 1 = open book
|
||||
bool countdownActive = false;
|
||||
int lastCountdownSecond = -1;
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
const std::function<void(const std::string&)> onGoToReader;
|
||||
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
||||
|
||||
void checkAndConnectWifi();
|
||||
@@ -60,6 +72,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
void onDirectorySelected(const std::string& directory);
|
||||
void onDirectoryPickerCancelled();
|
||||
void downloadBook(const OpdsEntry& book, const std::string& directory);
|
||||
void executePromptAction(int action);
|
||||
bool preventAutoSleep() override { return true; }
|
||||
|
||||
OpdsEntry pendingBook;
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "activities/util/NumericStepperActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Editable fields: Name, URL, Username, Password, Download Path.
|
||||
// Editable fields: Position, Name, URL, Username, Password, Download Path, After Download.
|
||||
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||
constexpr int BASE_ITEMS = 5;
|
||||
constexpr int BASE_ITEMS = 7;
|
||||
} // namespace
|
||||
|
||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||
@@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() {
|
||||
void OpdsSettingsActivity::saveServer() {
|
||||
if (isNewServer) {
|
||||
OPDS_STORE.addServer(editServer);
|
||||
// After the first field is saved, promote to an existing server so
|
||||
// subsequent field edits update in-place rather than creating duplicates.
|
||||
isNewServer = false;
|
||||
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
|
||||
} else {
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
||||
}
|
||||
|
||||
// Re-locate our server after add/update may have re-sorted the vector
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
|
||||
serverIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// Position (sort order)
|
||||
exitActivity();
|
||||
enterNewActivity(new NumericStepperActivity(
|
||||
renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99,
|
||||
[this](int value) {
|
||||
editServer.sortOrder = value;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// Server Name
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
} else if (selectedIndex == 2) {
|
||||
// Server URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 2) {
|
||||
} else if (selectedIndex == 3) {
|
||||
// Username
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 3) {
|
||||
} else if (selectedIndex == 4) {
|
||||
// Password
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 4) {
|
||||
} else if (selectedIndex == 5) {
|
||||
// Download Path
|
||||
exitActivity();
|
||||
enterNewActivity(new DirectoryPickerActivity(
|
||||
@@ -162,7 +185,12 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
requestUpdate();
|
||||
},
|
||||
editServer.downloadPath));
|
||||
} else if (selectedIndex == 5 && !isNewServer) {
|
||||
} else if (selectedIndex == 6) {
|
||||
// After Download — toggle between 0 (back to listing) and 1 (open book)
|
||||
editServer.afterDownloadAction = editServer.afterDownloadAction == 0 ? 1 : 0;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
} else if (selectedIndex == 7 && !isNewServer) {
|
||||
// Delete server
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||
onBack();
|
||||
@@ -184,8 +212,9 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
||||
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
||||
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
|
||||
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH,
|
||||
StrId::STR_AFTER_DOWNLOAD};
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||
@@ -198,15 +227,19 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
||||
nullptr, nullptr,
|
||||
[this](int index) {
|
||||
if (index == 0) {
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
return std::to_string(editServer.sortOrder);
|
||||
} else if (index == 1) {
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
} else if (index == 2) {
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
} else if (index == 3) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
} else if (index == 4) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 5) {
|
||||
return editServer.downloadPath;
|
||||
} else if (index == 6) {
|
||||
return std::string(editServer.afterDownloadAction == 0 ? tr(STR_BACK_TO_LISTING) : tr(STR_OPEN_BOOK));
|
||||
}
|
||||
return std::string("");
|
||||
},
|
||||
|
||||
@@ -63,6 +63,14 @@ void DirectoryPickerActivity::loadDirectories() {
|
||||
StringUtils::sortFileList(directories);
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::navigateToParent() {
|
||||
auto slash = basepath.find_last_of('/');
|
||||
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::loop() {
|
||||
// Absorb the Confirm release from the parent activity that launched us
|
||||
if (waitForConfirmRelease) {
|
||||
@@ -72,14 +80,16 @@ void DirectoryPickerActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Index 0 = "Save Here", indices 1..N = directory entries
|
||||
const int totalItems = 1 + static_cast<int>(directories.size());
|
||||
const int offset = directoryOffset();
|
||||
const int totalItems = offset + static_cast<int>(directories.size());
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex == 0) {
|
||||
onSelect(basepath);
|
||||
} else if (showGoUp() && selectorIndex == 1) {
|
||||
navigateToParent();
|
||||
} else {
|
||||
const auto& dirName = directories[selectorIndex - 1];
|
||||
const auto& dirName = directories[selectorIndex - offset];
|
||||
// Strip trailing '/'
|
||||
std::string folderName = dirName.substr(0, dirName.length() - 1);
|
||||
basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName;
|
||||
@@ -94,11 +104,7 @@ void DirectoryPickerActivity::loop() {
|
||||
if (basepath == "/") {
|
||||
onCancel();
|
||||
} else {
|
||||
auto slash = basepath.find_last_of('/');
|
||||
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
navigateToParent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -139,21 +145,24 @@ void DirectoryPickerActivity::render(Activity::RenderLock&&) {
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
const int totalItems = 1 + static_cast<int>(directories.size());
|
||||
const int offset = directoryOffset();
|
||||
const int totalItems = offset + static_cast<int>(directories.size());
|
||||
const bool goUp = showGoUp();
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex,
|
||||
[this](int index) -> std::string {
|
||||
[this, offset, goUp](int index) -> std::string {
|
||||
if (index == 0) {
|
||||
std::string label = std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
|
||||
return label;
|
||||
return std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
|
||||
}
|
||||
// Strip trailing '/' for display
|
||||
const auto& dir = directories[index - 1];
|
||||
if (goUp && index == 1) {
|
||||
return "..";
|
||||
}
|
||||
const auto& dir = directories[index - offset];
|
||||
return dir.substr(0, dir.length() - 1);
|
||||
},
|
||||
nullptr,
|
||||
[this](int index) -> UIIcon {
|
||||
[](int index) -> UIIcon {
|
||||
return (index == 0) ? UIIcon::File : UIIcon::Folder;
|
||||
});
|
||||
|
||||
|
||||
@@ -41,4 +41,7 @@ class DirectoryPickerActivity final : public Activity {
|
||||
std::function<void()> onCancel;
|
||||
|
||||
void loadDirectories();
|
||||
void navigateToParent();
|
||||
[[nodiscard]] bool showGoUp() const { return basepath != "/"; }
|
||||
[[nodiscard]] int directoryOffset() const { return showGoUp() ? 2 : 1; }
|
||||
};
|
||||
|
||||
99
src/activities/util/NumericStepperActivity.cpp
Normal file
99
src/activities/util/NumericStepperActivity.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "NumericStepperActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void NumericStepperActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void NumericStepperActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void NumericStepperActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
if (onCancel) onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (onComplete) onComplete(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Side buttons: ±1
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (value < maxValue) {
|
||||
value++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (value > minValue) {
|
||||
value--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Front face buttons: ±10
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
value = std::min(value + 10, maxValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
value = std::max(value - 10, minValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void NumericStepperActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str());
|
||||
|
||||
char valueStr[16];
|
||||
snprintf(valueStr, sizeof(valueStr), "%d", value);
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = metrics.topPadding + metrics.headerHeight + 60;
|
||||
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true);
|
||||
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawSideButtonHints(renderer, "+1", "-1");
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
42
src/activities/util/NumericStepperActivity.h
Normal file
42
src/activities/util/NumericStepperActivity.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* Reusable numeric stepper for integer value entry.
|
||||
* Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10.
|
||||
* Value is clamped within [min, max].
|
||||
*/
|
||||
class NumericStepperActivity final : public Activity {
|
||||
public:
|
||||
using OnCompleteCallback = std::function<void(int)>;
|
||||
using OnCancelCallback = std::function<void()>;
|
||||
|
||||
explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title,
|
||||
int initialValue, int minValue, int maxValue, OnCompleteCallback onComplete,
|
||||
OnCancelCallback onCancel)
|
||||
: Activity("NumericStepper", renderer, mappedInput),
|
||||
title(std::move(title)),
|
||||
value(initialValue),
|
||||
minValue(minValue),
|
||||
maxValue(maxValue),
|
||||
onComplete(std::move(onComplete)),
|
||||
onCancel(std::move(onCancel)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string title;
|
||||
int value;
|
||||
int minValue;
|
||||
int maxValue;
|
||||
OnCompleteCallback onComplete;
|
||||
OnCancelCallback onCancel;
|
||||
};
|
||||
@@ -265,13 +265,13 @@ void onGoToBrowser() {
|
||||
exitActivity();
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
if (servers.size() == 1) {
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0]));
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, onGoToReader, servers[0]));
|
||||
} else {
|
||||
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
|
||||
const auto* server = OPDS_STORE.getServer(serverIndex);
|
||||
if (server) {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server));
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, onGoToReader, *server));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
server->on("/api/opds/reorder", HTTP_POST, [this] { handleReorderOpdsServer(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
@@ -1182,6 +1183,7 @@ void CrossPointWebServer::handleGetOpdsServers() const {
|
||||
doc["url"] = servers[i].url;
|
||||
doc["username"] = servers[i].username;
|
||||
doc["hasPassword"] = !servers[i].password.empty();
|
||||
doc["sortOrder"] = servers[i].sortOrder;
|
||||
|
||||
const size_t written = serializeJson(doc, output, outputSize);
|
||||
if (written >= outputSize) continue;
|
||||
@@ -1223,9 +1225,11 @@ void CrossPointWebServer::handlePostOpdsServer() {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
if (!hasPasswordField) {
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) {
|
||||
if (!hasPasswordField) password = existing->password;
|
||||
opdsServer.downloadPath = existing->downloadPath;
|
||||
opdsServer.sortOrder = existing->sortOrder;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
|
||||
@@ -1273,6 +1277,46 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleReorderOpdsServer() {
|
||||
if (!server->hasArg("plain")) {
|
||||
server->send(400, "text/plain", "Missing JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
const String body = server->arg("plain");
|
||||
JsonDocument doc;
|
||||
const DeserializationError err = deserializeJson(doc, body);
|
||||
if (err) {
|
||||
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc["index"].is<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
const int idx = doc["index"].as<int>();
|
||||
const String direction = doc["direction"] | "";
|
||||
int dir = 0;
|
||||
if (direction == "up") {
|
||||
dir = -1;
|
||||
} else if (direction == "down") {
|
||||
dir = 1;
|
||||
} else {
|
||||
server->send(400, "text/plain", "Invalid direction (use \"up\" or \"down\")");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OPDS_STORE.moveServer(static_cast<size_t>(idx), dir)) {
|
||||
server->send(400, "text/plain", "Cannot move server");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Reordered OPDS server at index %d (%s)", idx, direction.c_str());
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@@ -110,4 +110,5 @@ class CrossPointWebServer {
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
void handleReorderOpdsServer();
|
||||
};
|
||||
|
||||
@@ -457,9 +457,11 @@
|
||||
// --- OPDS Server Management ---
|
||||
let opdsServers = [];
|
||||
|
||||
function renderOpdsServer(srv, idx) {
|
||||
function renderOpdsServer(srv, idx, total) {
|
||||
const isNew = idx === -1;
|
||||
const id = isNew ? 'new' : idx;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === total - 1;
|
||||
return '<div class="opds-server" id="opds-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Server Name</span>' +
|
||||
@@ -480,6 +482,8 @@
|
||||
'<div class="opds-actions">' +
|
||||
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
||||
(!isNew && !isFirst ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'up\')" title="Move up">\u25B2</button>' : '') +
|
||||
(!isNew && !isLast ? '<button class="btn-small" onclick="reorderOpdsServer(' + idx + ',\'down\')" title="Move down">\u25BC</button>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -491,8 +495,9 @@
|
||||
if (opdsServers.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||
} else {
|
||||
var total = opdsServers.length;
|
||||
opdsServers.forEach(function(srv, idx) {
|
||||
html += renderOpdsServer(srv, idx);
|
||||
html += renderOpdsServer(srv, idx, total);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -518,7 +523,7 @@
|
||||
const card = container.querySelector('.card');
|
||||
const addBtn = card.querySelector('.btn-add').parentElement;
|
||||
if (document.getElementById('opds-new')) return;
|
||||
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1));
|
||||
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1, 0));
|
||||
}
|
||||
|
||||
async function saveOpdsServer(idx) {
|
||||
@@ -562,6 +567,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderOpdsServer(idx, direction) {
|
||||
try {
|
||||
const resp = await fetch('/api/opds/reorder', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({index: idx, direction: direction})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
await loadOpdsServers();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadOpdsServers();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user