feat: add post-download prompt with open book / back to listing options
After an OPDS download completes, show a prompt screen instead of immediately returning to the catalog. The user can choose to open the book for reading or go back to the listing. A live countdown (5s, fast refresh) auto-selects the configured default; any button press cancels the timer. A per-server "After Download" setting controls the default action (back to listing for backward compatibility). Made-with: Cursor
This commit is contained in:
@@ -427,6 +427,10 @@ enum class StrId : uint16_t {
|
|||||||
STR_SELECT_FOLDER,
|
STR_SELECT_FOLDER,
|
||||||
STR_DOWNLOAD_PATH,
|
STR_DOWNLOAD_PATH,
|
||||||
STR_POSITION,
|
STR_POSITION,
|
||||||
|
STR_DOWNLOAD_COMPLETE,
|
||||||
|
STR_OPEN_BOOK,
|
||||||
|
STR_BACK_TO_LISTING,
|
||||||
|
STR_AFTER_DOWNLOAD,
|
||||||
// Sentinel - must be last
|
// Sentinel - must be last
|
||||||
_COUNT
|
_COUNT
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -391,3 +391,7 @@ STR_SAVE_HERE: "Save Here"
|
|||||||
STR_SELECT_FOLDER: "Select Folder"
|
STR_SELECT_FOLDER: "Select Folder"
|
||||||
STR_DOWNLOAD_PATH: "Download Path"
|
STR_DOWNLOAD_PATH: "Download Path"
|
||||||
STR_POSITION: "Position"
|
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"
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ bool OpdsServerStore::saveToFile() const {
|
|||||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||||
obj["download_path"] = server.downloadPath;
|
obj["download_path"] = server.downloadPath;
|
||||||
obj["sort_order"] = server.sortOrder;
|
obj["sort_order"] = server.sortOrder;
|
||||||
|
obj["after_download"] = server.afterDownloadAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
String json;
|
String json;
|
||||||
@@ -119,6 +120,7 @@ bool OpdsServerStore::loadFromFile() {
|
|||||||
}
|
}
|
||||||
server.downloadPath = obj["download_path"] | std::string("/");
|
server.downloadPath = obj["download_path"] | std::string("/");
|
||||||
server.sortOrder = obj["sort_order"] | 0;
|
server.sortOrder = obj["sort_order"] | 0;
|
||||||
|
server.afterDownloadAction = obj["after_download"] | 0;
|
||||||
if (server.sortOrder == 0) needsResave = true;
|
if (server.sortOrder == 0) needsResave = true;
|
||||||
servers.push_back(std::move(server));
|
servers.push_back(std::move(server));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct OpdsServer {
|
|||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||||
std::string downloadPath = "/";
|
std::string downloadPath = "/";
|
||||||
int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name
|
int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name
|
||||||
|
int afterDownloadAction = 0; // 0 = back to listing, 1 = open book
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -102,6 +102,45 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
return;
|
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
|
// Handle browsing state
|
||||||
if (state == BrowserState::BROWSING) {
|
if (state == BrowserState::BROWSING) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
@@ -192,6 +231,37 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
|
|||||||
return;
|
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
|
// Browsing state
|
||||||
// Show appropriate button hint based on selected entry type
|
// Show appropriate button hint based on selected entry type
|
||||||
const char* confirmLabel = tr(STR_OPEN);
|
const char* confirmLabel = tr(STR_OPEN);
|
||||||
@@ -369,7 +439,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::str
|
|||||||
epub.clearCache();
|
epub.clearCache();
|
||||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
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();
|
requestUpdate();
|
||||||
} else {
|
} else {
|
||||||
state = BrowserState::ERROR;
|
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() {
|
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||||
// Already connected? Verify connection is valid by checking IP
|
// Already connected? Verify connection is valid by checking IP
|
||||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
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)
|
BROWSING, // Displaying entries (navigation or books)
|
||||||
PICKING_DIRECTORY, // Directory picker subactivity is active
|
PICKING_DIRECTORY, // Directory picker subactivity is active
|
||||||
DOWNLOADING, // Downloading selected EPUB
|
DOWNLOADING, // Downloading selected EPUB
|
||||||
|
DOWNLOAD_COMPLETE, // Prompt: open book or go back to listing
|
||||||
ERROR // Error state with message
|
ERROR // Error state with message
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::function<void()>& onGoHome, const OpdsServer& server)
|
const std::function<void()>& onGoHome,
|
||||||
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {}
|
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 onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -46,8 +52,14 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
|||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
size_t downloadProgress = 0;
|
size_t downloadProgress = 0;
|
||||||
size_t downloadTotal = 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()> onGoHome;
|
||||||
|
const std::function<void(const std::string&)> onGoToReader;
|
||||||
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
||||||
|
|
||||||
void checkAndConnectWifi();
|
void checkAndConnectWifi();
|
||||||
@@ -60,6 +72,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
|||||||
void onDirectorySelected(const std::string& directory);
|
void onDirectorySelected(const std::string& directory);
|
||||||
void onDirectoryPickerCancelled();
|
void onDirectoryPickerCancelled();
|
||||||
void downloadBook(const OpdsEntry& book, const std::string& directory);
|
void downloadBook(const OpdsEntry& book, const std::string& directory);
|
||||||
|
void executePromptAction(int action);
|
||||||
bool preventAutoSleep() override { return true; }
|
bool preventAutoSleep() override { return true; }
|
||||||
|
|
||||||
OpdsEntry pendingBook;
|
OpdsEntry pendingBook;
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Editable fields: Position, 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).
|
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||||
constexpr int BASE_ITEMS = 6;
|
constexpr int BASE_ITEMS = 7;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||||
@@ -185,7 +185,12 @@ void OpdsSettingsActivity::handleSelection() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
},
|
},
|
||||||
editServer.downloadPath));
|
editServer.downloadPath));
|
||||||
} else if (selectedIndex == 6 && !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
|
// Delete server
|
||||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||||
onBack();
|
onBack();
|
||||||
@@ -208,7 +213,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
|||||||
const int menuItems = getMenuItemCount();
|
const int menuItems = getMenuItemCount();
|
||||||
|
|
||||||
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
|
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_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH,
|
||||||
|
StrId::STR_AFTER_DOWNLOAD};
|
||||||
|
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||||
@@ -232,6 +238,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
|||||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||||
} else if (index == 5) {
|
} else if (index == 5) {
|
||||||
return editServer.downloadPath;
|
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("");
|
return std::string("");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -265,13 +265,13 @@ void onGoToBrowser() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
const auto& servers = OPDS_STORE.getServers();
|
const auto& servers = OPDS_STORE.getServers();
|
||||||
if (servers.size() == 1) {
|
if (servers.size() == 1) {
|
||||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0]));
|
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, onGoToReader, servers[0]));
|
||||||
} else {
|
} else {
|
||||||
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
|
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
|
||||||
const auto* server = OPDS_STORE.getServer(serverIndex);
|
const auto* server = OPDS_STORE.getServer(serverIndex);
|
||||||
if (server) {
|
if (server) {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server));
|
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, onGoToReader, *server));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user