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:
cottongin
2026-03-02 15:27:53 -05:00
parent f955cf2fb4
commit 7eaced602f
8 changed files with 127 additions and 11 deletions

View File

@@ -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
}; };

View File

@@ -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"

View File

@@ -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));
} }

View File

@@ -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
}; };
/** /**

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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("");
}, },

View File

@@ -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));
} }
})); }));
} }