feat(opds): Allow selecting the download format
When downloading a book via OPDS, the last acquisition link is chosen when there are multiple. This allows choosing which format to download when there are many. Having only one format will automatically download that.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
#include <Logging.h>
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
#include <Xtc.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <cctype>
|
||||
@@ -15,6 +16,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsFormatLabel.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -25,7 +27,20 @@
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int FORMAT_ITEM_HEIGHT = 30;
|
||||
constexpr int FORMAT_LIST_TOP_OFFSET = 20;
|
||||
constexpr int FORMAT_LIST_BOTTOM_PADDING = 20;
|
||||
|
||||
int formatItemsPerPage(const Rect& contentRect) {
|
||||
const int midY = contentRect.y + contentRect.height / 2;
|
||||
const int listTop = midY + FORMAT_LIST_TOP_OFFSET;
|
||||
const int listBottom = contentRect.y + contentRect.height - FORMAT_LIST_BOTTOM_PADDING;
|
||||
const int rawAvailableHeight = listBottom - listTop;
|
||||
const int availableHeight = rawAvailableHeight > FORMAT_ITEM_HEIGHT ? rawAvailableHeight : FORMAT_ITEM_HEIGHT;
|
||||
const int itemsPerPage = availableHeight / FORMAT_ITEM_HEIGHT;
|
||||
return itemsPerPage > 0 ? itemsPerPage : 1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -36,6 +51,9 @@ void OpdsBookBrowserActivity::onEnter() {
|
||||
currentPath = ""; // Root path - user provides full URL in settings
|
||||
searchTemplate.clear();
|
||||
selectorIndex = 0;
|
||||
selectedBookIndex = -1;
|
||||
formatSelectorIndex = 0;
|
||||
formatSelectionLabels.clear();
|
||||
consumeConfirm = false;
|
||||
consumeBack = false;
|
||||
errorMessage.clear();
|
||||
@@ -52,6 +70,7 @@ void OpdsBookBrowserActivity::onExit() {
|
||||
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
formatSelectionLabels.clear();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::loop() {
|
||||
@@ -93,11 +112,51 @@ void OpdsBookBrowserActivity::loop() {
|
||||
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
if (state == BrowserState::FORMAT_SELECTION) {
|
||||
if (selectedBookIndex < 0 || selectedBookIndex >= static_cast<int>(entries.size())) {
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& entry = entries[selectedBookIndex];
|
||||
if (entry.acquisitionLinks.empty()) {
|
||||
state = BrowserState::BROWSING;
|
||||
selectedBookIndex = -1;
|
||||
formatSelectionLabels.clear();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
state = BrowserState::BROWSING;
|
||||
selectedBookIndex = -1;
|
||||
formatSelectionLabels.clear();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
downloadBook(entry, entry.acquisitionLinks[formatSelectorIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, &entry] {
|
||||
formatSelectorIndex = ButtonNavigator::nextIndex(formatSelectorIndex, entry.acquisitionLinks.size());
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onPreviousRelease([this, &entry] {
|
||||
formatSelectorIndex = ButtonNavigator::previousIndex(formatSelectorIndex, entry.acquisitionLinks.size());
|
||||
requestUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
entry.type == OpdsEntryType::BOOK ? chooseBookFormat(entry) : navigateToEntry(entry);
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
@@ -175,6 +234,34 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::FORMAT_SELECTION) {
|
||||
const auto& entry = entries[selectedBookIndex];
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, entry.title.c_str(), contentRect.width - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY - 40, title.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (!entry.author.empty()) {
|
||||
auto author = renderer.truncatedText(UI_10_FONT_ID, entry.author.c_str(), contentRect.width - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY - 10, author.c_str());
|
||||
}
|
||||
|
||||
const int listTop = midY + 20;
|
||||
const int itemsPerPage = formatItemsPerPage(contentRect);
|
||||
const int pageStartIndex = formatSelectorIndex / itemsPerPage * itemsPerPage;
|
||||
renderer.fillRect(contentRect.x, listTop + (formatSelectorIndex - pageStartIndex) * FORMAT_ITEM_HEIGHT - 2,
|
||||
contentRect.width - 1, FORMAT_ITEM_HEIGHT);
|
||||
for (int i = pageStartIndex;
|
||||
i < static_cast<int>(entry.acquisitionLinks.size()) && i < pageStartIndex + itemsPerPage; i++) {
|
||||
const char* label = i < static_cast<int>(formatSelectionLabels.size()) ? formatSelectionLabels[i].c_str() : "";
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, label, contentRect.width - 40);
|
||||
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, listTop + (i - pageStartIndex) * FORMAT_ITEM_HEIGHT,
|
||||
item.c_str(), i != formatSelectorIndex);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Browsing state
|
||||
// Show appropriate button hint based on selected entry type
|
||||
const char* confirmLabel =
|
||||
@@ -292,17 +379,43 @@ void OpdsBookBrowserActivity::navigateBack() {
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
// Opens a screen to allow the user to choose which format they want to download
|
||||
// if multiple formats are available. If only one format is available, the
|
||||
// download is started immediately.
|
||||
void OpdsBookBrowserActivity::chooseBookFormat(const OpdsEntry& book) {
|
||||
if (book.acquisitionLinks.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (book.acquisitionLinks.size() == 1) {
|
||||
downloadBook(book, book.acquisitionLinks[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedBookIndex = selectorIndex;
|
||||
formatSelectorIndex = 0;
|
||||
formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, SETTINGS.opdsServerUrl);
|
||||
state = BrowserState::FORMAT_SELECTION;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// Downloads the selected book's acquisition link and saves it to the SD card.
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition) {
|
||||
state = BrowserState::DOWNLOADING;
|
||||
statusMessage = book.title;
|
||||
downloadProgress = 0;
|
||||
downloadTotal = 0;
|
||||
requestUpdate(true);
|
||||
|
||||
std::string downloadUrl =
|
||||
(book.href.rfind("http", 0) == 0) ? book.href : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
std::string filename =
|
||||
"/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub";
|
||||
std::string downloadUrl = (acquisition.href.rfind("http", 0) == 0)
|
||||
? acquisition.href
|
||||
: UrlUtils::buildUrl(SETTINGS.opdsServerUrl, acquisition.href);
|
||||
std::string filename = "/" +
|
||||
StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) +
|
||||
acquisition.fileExtension;
|
||||
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
@@ -330,10 +443,20 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
|
||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
||||
|
||||
Epub(filename, "/.crosspoint").clearCache();
|
||||
// Clear any existing cache for this book just in case it's a redownload of
|
||||
// a previously opened book.
|
||||
if (acquisition.mimeType == "application/epub+zip") {
|
||||
Epub(filename, "/.crosspoint").clearCache();
|
||||
} else if (acquisition.formatKey == "xtc" || acquisition.formatKey == "xtch") {
|
||||
Xtc(filename, "/.crosspoint").clearCache();
|
||||
}
|
||||
selectedBookIndex = -1;
|
||||
formatSelectionLabels.clear();
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
} else {
|
||||
selectedBookIndex = -1;
|
||||
formatSelectionLabels.clear();
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
||||
requestUpdate();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
#include <OpdsParser.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -14,7 +13,16 @@
|
||||
*/
|
||||
class OpdsBookBrowserActivity final : public Activity {
|
||||
public:
|
||||
enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT };
|
||||
enum class BrowserState {
|
||||
CHECK_WIFI,
|
||||
WIFI_SELECTION,
|
||||
LOADING,
|
||||
BROWSING,
|
||||
FORMAT_SELECTION,
|
||||
DOWNLOADING,
|
||||
ERROR,
|
||||
SEARCH_INPUT
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {}
|
||||
@@ -34,6 +42,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
bool consumeConfirm = false;
|
||||
bool consumeBack = false; // Added missing member
|
||||
int selectorIndex = 0;
|
||||
int selectedBookIndex = -1;
|
||||
int formatSelectorIndex = 0;
|
||||
std::vector<std::string> formatSelectionLabels;
|
||||
std::string errorMessage;
|
||||
std::string statusMessage;
|
||||
size_t downloadProgress = 0;
|
||||
@@ -45,7 +56,8 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
void fetchFeed(const std::string& path);
|
||||
void navigateToEntry(const OpdsEntry& entry);
|
||||
void navigateBack();
|
||||
void downloadBook(const OpdsEntry& book);
|
||||
void downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition);
|
||||
void chooseBookFormat(const OpdsEntry& book);
|
||||
void fetchOsdTemplate(const std::string& osdUrl);
|
||||
void launchSearch();
|
||||
void performSearch(const std::string& query);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "OpdsFormatLabel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
namespace {
|
||||
bool hasDuplicateFormatKey(const OpdsAcquisitionLink& acquisition,
|
||||
const std::vector<OpdsAcquisitionLink>& acquisitionLinks) {
|
||||
size_t sameFormatCount = 0;
|
||||
for (const auto& link : acquisitionLinks) {
|
||||
if (link.formatKey == acquisition.formatKey) {
|
||||
sameFormatCount++;
|
||||
if (sameFormatCount > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string resolvedHostname(const OpdsAcquisitionLink& acquisition, const std::string& serverUrl) {
|
||||
const std::string resolvedUrl =
|
||||
acquisition.href.rfind("http", 0) == 0 ? acquisition.href : UrlUtils::buildUrl(serverUrl, acquisition.href);
|
||||
return UrlUtils::extractHostname(resolvedUrl);
|
||||
}
|
||||
|
||||
std::vector<std::string> resolvedHostnames(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::string& serverUrl) {
|
||||
std::vector<std::string> hostnames;
|
||||
hostnames.reserve(acquisitionLinks.size());
|
||||
std::transform(acquisitionLinks.begin(), acquisitionLinks.end(), std::back_inserter(hostnames),
|
||||
[&serverUrl](const OpdsAcquisitionLink& link) { return resolvedHostname(link, serverUrl); });
|
||||
return hostnames;
|
||||
}
|
||||
|
||||
std::string buildDuplicateAwareLabel(const OpdsAcquisitionLink& acquisition,
|
||||
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::vector<std::string>& hostnames, const size_t currentIndex) {
|
||||
std::string label = opdsBaseFormatLabel(acquisition);
|
||||
if (!hasDuplicateFormatKey(acquisition, acquisitionLinks)) {
|
||||
return label;
|
||||
}
|
||||
|
||||
const std::string& hostname = hostnames[currentIndex];
|
||||
if (hostname.empty()) {
|
||||
return label;
|
||||
}
|
||||
|
||||
label.reserve(label.size() + hostname.size() + 8);
|
||||
label += " - ";
|
||||
label += hostname;
|
||||
|
||||
size_t duplicateCount = 0;
|
||||
size_t duplicateIndex = 0;
|
||||
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
|
||||
if (acquisitionLinks[i].formatKey == acquisition.formatKey && hostnames[i] == hostname) {
|
||||
duplicateCount++;
|
||||
if (i <= currentIndex) {
|
||||
duplicateIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateCount > 1) {
|
||||
label += " (";
|
||||
label += std::to_string(duplicateIndex);
|
||||
label += ")";
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition) {
|
||||
if (acquisition.formatKey == "kepub") {
|
||||
return "KEPUB";
|
||||
}
|
||||
if (acquisition.formatKey == "epub") {
|
||||
return "EPUB";
|
||||
}
|
||||
if (acquisition.formatKey == "txt") {
|
||||
return "TXT";
|
||||
}
|
||||
if (acquisition.formatKey == "md") {
|
||||
return "MD";
|
||||
}
|
||||
if (acquisition.formatKey == "xtc") {
|
||||
return "XTC";
|
||||
}
|
||||
if (acquisition.formatKey == "xtch") {
|
||||
return "XTCH";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition,
|
||||
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::string& serverUrl) {
|
||||
const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl);
|
||||
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
|
||||
if (acquisitionLinks[i].href == acquisition.href && acquisitionLinks[i].formatKey == acquisition.formatKey) {
|
||||
return buildDuplicateAwareLabel(acquisition, acquisitionLinks, hostnames, i);
|
||||
}
|
||||
}
|
||||
|
||||
return opdsBaseFormatLabel(acquisition);
|
||||
}
|
||||
|
||||
std::vector<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::string& serverUrl) {
|
||||
const auto hostnames = resolvedHostnames(acquisitionLinks, serverUrl);
|
||||
std::vector<std::string> labels;
|
||||
labels.reserve(acquisitionLinks.size());
|
||||
for (size_t i = 0; i < acquisitionLinks.size(); i++) {
|
||||
labels.push_back(buildDuplicateAwareLabel(acquisitionLinks[i], acquisitionLinks, hostnames, i));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <OpdsParser.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
const char* opdsBaseFormatLabel(const OpdsAcquisitionLink& acquisition);
|
||||
std::string opdsFormatSelectionLabel(const OpdsAcquisitionLink& acquisition,
|
||||
const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::string& serverUrl);
|
||||
std::vector<std::string> buildOpdsFormatSelectionLabels(const std::vector<OpdsAcquisitionLink>& acquisitionLinks,
|
||||
const std::string& serverUrl);
|
||||
Reference in New Issue
Block a user