Add end of book actions screen

This commit is contained in:
jpirnay
2026-05-12 18:11:55 +02:00
parent 8d2cfb17a7
commit a91c151979
13 changed files with 1116 additions and 15 deletions
+4
View File
@@ -281,6 +281,10 @@ class CrossPointSettings {
// (1 = enabled, 0 = disabled). The push only fires when credentials are configured and the
// reader session advanced at least 3 pages, and is skipped when remote progress is already ahead.
uint8_t koSyncOnBookClose = 0;
// Move finished book to /COMPLETED when the end-of-book screen action is selected.
uint8_t moveFinishedBooksToCompleted = 0;
// Remove finished book from Recent Books when the end-of-book screen action is selected.
uint8_t removeFinishedBooksFromRecents = 0;
// Show clock in the reader status bar
uint8_t statusBarClock = 0;
// Clock format: 0 = 24h (14:00), 1 = 12h (2:00pm)
+3
View File
@@ -211,6 +211,7 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName;
}
doc["moveFinishedBooksToCompleted"] = s.moveFinishedBooksToCompleted;
String json;
serializeJson(doc, json);
@@ -325,6 +326,8 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
const char* sfn = doc["sdFontFamilyName"] | "";
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
s.moveFinishedBooksToCompleted = doc["moveFinishedBooksToCompleted"] | (uint8_t)0;
s.removeFinishedBooksFromRecents = doc["removeFinishedBooksFromRecents"] | (uint8_t)0;
LOG_DBG("CPS", "Settings loaded from file");
+155 -8
View File
@@ -25,6 +25,7 @@
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "EpubRenderBenchmarkActivity.h"
#include "FinishedBookActivity.h"
#include "GlobalBookmarkIndex.h"
#include "KOReaderCredentialStore.h"
#include "MappedInputManager.h"
@@ -456,11 +457,54 @@ void EpubReaderActivity::loop() {
return;
}
// At end of the book, forward button returns to caller and back button returns to last page
// At end of the book, forward button opens the finished-book flow and back returns to last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
if (nextTriggered) {
if (tryAutoPushOnClose()) return;
finish();
const int lastSpineIndex = epub->getSpineItemsCount() - 1;
int lastPageIndex = 0;
int lastPageCount = 0;
if (section && currentSpineIndex == lastSpineIndex) {
lastPageCount = section->pageCount;
lastPageIndex = std::max(0, section->pageCount - 1);
}
writeReaderProgressCache(epub->getCachePath(), lastSpineIndex, lastPageIndex, lastPageCount, 100);
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(epub->getPath(), epub->getSeries(), epub->getSeriesIndex());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, epub->getPath(), nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
} else {
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
@@ -655,6 +699,65 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
onGoHome();
return;
}
case EpubReaderMenuActivity::MenuAction::MARK_AS_READ: {
if (!epub) {
break;
}
const int spineCount = epub->getSpineItemsCount();
if (spineCount > 0) {
const int lastSpineIndex = spineCount - 1;
int lastPageIndex = 0;
int lastPageCount = 0;
if (section && currentSpineIndex == lastSpineIndex) {
lastPageCount = section->pageCount;
lastPageIndex = std::max(0, section->pageCount - 1);
}
if (lastPageCount > 0) {
writeReaderProgressCache(epub->getCachePath(), lastSpineIndex, lastPageIndex, lastPageCount, 100);
} else {
writeReaderProgressCache(epub->getCachePath(), lastSpineIndex, 0, 0, 100);
}
}
{
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(epub->getPath(), epub->getSeries(), epub->getSeriesIndex());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, epub->getPath(), nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
}
return;
}
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
{
RenderLock lock(*this);
@@ -1311,6 +1414,11 @@ bool EpubReaderActivity::stepPageState(const bool isForwardTurn) {
nextPageNumber = 0;
currentSpineIndex++;
section.reset();
} else if (currentSpineIndex + 1 == epub->getSpineItemsCount()) {
RenderLock lock(*this);
nextPageNumber = UINT16_MAX;
currentSpineIndex++;
section.reset();
} else {
return false;
}
@@ -1363,12 +1471,51 @@ void EpubReaderActivity::render(RenderLock&& lock) {
currentSpineIndex = spineCount;
}
// Show end of book screen
// Immediately transition to finished-book flow instead of showing an end-of-book screen
if (currentSpineIndex == spineCount) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
automaticPageTurnActive = false;
if (!finishedBookActivityStarted_) {
finishedBookActivityStarted_ = true;
const int lastSpineIndex = std::max(0, spineCount - 1);
writeReaderProgressCache(epub->getCachePath(), lastSpineIndex, 0, 0, 100);
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(epub->getPath(), epub->getSeries(), epub->getSeriesIndex());
lock.unlock();
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, epub->getPath(), nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath](const ActivityResult& result) {
finishedBookActivityStarted_ = false;
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(epub->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(epub->getPath());
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
}
return;
}
@@ -135,6 +135,7 @@ class EpubReaderActivity final : public Activity {
uint16_t pendingListItemIndex = 0;
bool pendingScreenshot = false;
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool finishedBookActivityStarted_ = false;
ReaderUtils::InputDrainGuard inputDrainGuard;
bool automaticPageTurnActive = false;
// Pages turned in the current reader session. Used to gate auto-push-on-close: a brief
@@ -237,6 +237,8 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
SettingInfo::Action(StrId::STR_SCREENSHOT_BUTTON, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
menuItems.push_back(
SettingInfo::Action(StrId::STR_DISPLAY_QR, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
menuItems.push_back(
SettingInfo::Action(StrId::STR_MARK_AS_READ, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
menuItems.push_back(
SettingInfo::Action(StrId::STR_DELETE_CACHE, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_HOME_BUTTON, SettingAction::None));
@@ -274,6 +276,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
return MenuAction::SCREENSHOT;
case StrId::STR_DISPLAY_QR:
return MenuAction::DISPLAY_QR;
case StrId::STR_MARK_AS_READ:
return MenuAction::MARK_AS_READ;
case StrId::STR_DELETE_CACHE:
return MenuAction::DELETE_CACHE;
case StrId::STR_RENDER_BENCHMARK:
@@ -28,6 +28,7 @@ class EpubReaderMenuActivity final : public MenuListActivity {
PUSH_LOCAL,
STARRED_PAGES,
STAR_PAGE,
MARK_AS_READ,
DELETE_CACHE,
RENDER_BENCHMARK
};
@@ -0,0 +1,729 @@
#include "FinishedBookActivity.h"
#include <Bitmap.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <Txt.h>
#include <Xtc.h>
#include <algorithm>
#include <cerrno>
#include <cstdlib>
#include <limits>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "ReaderActivity.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
std::string extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
return "/";
}
return filePath.substr(0, lastSlash);
}
std::string getFilename(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos) {
return filePath;
}
return filePath.substr(lastSlash + 1);
}
static constexpr int kFinishedBookCoverHeight = 240;
static constexpr int kFinishedBookCoverMaxWidth = 220;
std::string findUniqueCompletedSidecarPath(const std::string& basePath) {
if (!Storage.exists(basePath.c_str())) {
return basePath;
}
const auto dotPos = basePath.find_last_of('.');
const std::string base = (dotPos == std::string::npos) ? basePath : basePath.substr(0, dotPos);
const std::string ext = (dotPos == std::string::npos) ? std::string() : basePath.substr(dotPos);
for (int suffix = 1; suffix < 1000; ++suffix) {
const std::string candidate = base + " (" + std::to_string(suffix) + ")" + ext;
if (!Storage.exists(candidate.c_str())) {
return candidate;
}
}
return {};
}
std::string convertSidecarToBmp(const std::string& bookPath, const std::string& sidecarPath, int width, int height,
const std::string& fileName) {
const std::string cacheDir = "/.crosspoint/sidecar_" + std::to_string(std::hash<std::string>{}(bookPath));
if (!Storage.exists(cacheDir.c_str())) {
Storage.mkdir(cacheDir.c_str());
}
const std::string bmpPath = cacheDir + "/" + fileName;
if (Storage.exists(bmpPath.c_str())) {
return bmpPath;
}
FsFile src;
if (!Storage.openFileForRead("FIN", sidecarPath, src)) {
return "";
}
FsFile dst;
if (!Storage.openFileForWrite("FIN", bmpPath, dst)) {
src.close();
return "";
}
bool ok = false;
if (FsHelpers::hasJpgExtension(sidecarPath)) {
ok = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(src, dst, width, height);
} else if (FsHelpers::hasPngExtension(sidecarPath)) {
ok = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(src, dst, width, height);
} else if (FsHelpers::hasBmpExtension(sidecarPath)) {
uint8_t buffer[1024];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
ok = true;
}
src.close();
dst.close();
if (!ok) {
Storage.remove(bmpPath.c_str());
return "";
}
return bmpPath;
}
std::string getSidecarCoverBmpPath(const std::string& bookPath, int width, int height) {
const std::string sidecarPath = ReaderActivity::sidecarCoverPath(bookPath);
if (sidecarPath.empty()) {
return "";
}
if (FsHelpers::hasBmpExtension(sidecarPath)) {
return sidecarPath;
}
const std::string fileName = "thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp";
return convertSidecarToBmp(bookPath, sidecarPath, width, height, fileName);
}
bool moveSidecarFilesToCompleted(const std::string& currentBookPath, const std::string& targetBookPath) {
const auto srcDot = currentBookPath.rfind('.');
const auto dstDot = targetBookPath.rfind('.');
if (srcDot == std::string::npos || dstDot == std::string::npos) {
return false;
}
const std::string srcBase = currentBookPath.substr(0, srcDot);
const std::string dstBase = targetBookPath.substr(0, dstDot);
const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"};
bool success = true;
for (const char* ext : extensions) {
const std::string srcSidecar = srcBase + ext;
if (!Storage.exists(srcSidecar.c_str())) {
continue;
}
std::string dstSidecar = dstBase + ext;
if (Storage.exists(dstSidecar.c_str())) {
dstSidecar = findUniqueCompletedSidecarPath(dstSidecar);
if (dstSidecar.empty()) {
LOG_ERR("FIN", "Failed to create unique sidecar target for %s", srcSidecar.c_str());
success = false;
continue;
}
}
if (!Storage.rename(srcSidecar.c_str(), dstSidecar.c_str())) {
LOG_ERR("FIN", "Failed to move sidecar %s -> %s", srcSidecar.c_str(), dstSidecar.c_str());
success = false;
}
}
return success;
}
struct NextBookMetadata {
std::string title;
std::string author;
std::string series;
std::string coverPath;
};
NextBookMetadata loadNextBookMetadata(const std::string& nextBookPath) {
NextBookMetadata metadata;
if (nextBookPath.empty()) {
return metadata;
}
if (FsHelpers::hasEpubExtension(nextBookPath)) {
Epub epub(nextBookPath, "/.crosspoint");
epub.setSyntheticTocFallbackEnabled(SETTINGS.syntheticTocFallback != 0);
if (epub.load(false, true)) {
metadata.title = epub.getTitle();
metadata.author = epub.getAuthor();
metadata.series = epub.getSeries();
if (!metadata.series.empty() && !epub.getSeriesIndex().empty()) {
metadata.series += " #" + epub.getSeriesIndex();
}
if (epub.generateThumbBmp(kFinishedBookCoverHeight)) {
metadata.coverPath = epub.getThumbBmpPath(kFinishedBookCoverHeight);
} else {
metadata.coverPath = getSidecarCoverBmpPath(nextBookPath, kFinishedBookCoverMaxWidth, kFinishedBookCoverHeight);
}
}
return metadata;
}
if (FsHelpers::hasXtcExtension(nextBookPath)) {
Xtc xtc(nextBookPath, "/.crosspoint");
if (xtc.load()) {
metadata.title = xtc.getTitle();
metadata.author = xtc.getAuthor();
if (xtc.generateThumbBmp(kFinishedBookCoverHeight)) {
metadata.coverPath = xtc.getThumbBmpPath(kFinishedBookCoverHeight);
} else {
metadata.coverPath = getSidecarCoverBmpPath(nextBookPath, kFinishedBookCoverMaxWidth, kFinishedBookCoverHeight);
}
}
return metadata;
}
if (FsHelpers::hasMarkdownExtension(nextBookPath) || FsHelpers::hasTxtExtension(nextBookPath)) {
Txt txt(nextBookPath, "/.crosspoint");
if (txt.load()) {
metadata.title = txt.getTitle();
if (txt.generateCoverBmp()) {
metadata.coverPath = txt.getCoverBmpPath();
} else {
metadata.coverPath = getSidecarCoverBmpPath(nextBookPath, kFinishedBookCoverMaxWidth, kFinishedBookCoverHeight);
}
}
return metadata;
}
return metadata;
}
bool isSupportedBookFile(const std::string& fileName) {
return FsHelpers::hasEpubExtension(fileName) || FsHelpers::hasXtcExtension(fileName) ||
FsHelpers::hasTxtExtension(fileName) || FsHelpers::hasMarkdownExtension(fileName);
}
bool caseInsensitiveEqual(const std::string& left, const std::string& right) {
if (left.size() != right.size()) return false;
for (size_t i = 0; i < left.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(left[i])) != std::tolower(static_cast<unsigned char>(right[i]))) {
return false;
}
}
return true;
}
bool naturalLess(const std::string& str1, const std::string& str2) {
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
while (*s1 && *s2) {
if (std::isdigit(static_cast<unsigned char>(*s1)) && std::isdigit(static_cast<unsigned char>(*s2))) {
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
int len1 = 0;
int len2 = 0;
while (std::isdigit(static_cast<unsigned char>(s1[len1]))) len1++;
while (std::isdigit(static_cast<unsigned char>(s2[len2]))) len2++;
if (len1 != len2) return len1 < len2;
for (int i = 0; i < len1; ++i) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
s1 += len1;
s2 += len2;
} else {
const char c1 = std::tolower(static_cast<unsigned char>(*s1));
const char c2 = std::tolower(static_cast<unsigned char>(*s2));
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
return *s1 == '\0' && *s2 != '\0';
}
std::optional<float> parseSeriesIndex(const std::string& rawIndex) {
const char* start = rawIndex.c_str();
char* end = nullptr;
errno = 0;
const float value = std::strtof(start, &end);
if (end == start || errno == ERANGE) {
return std::nullopt;
}
return value;
}
std::string pathWithFilename(const std::string& directory, const std::string& fileName) {
if (directory.empty() || directory == "/") {
return std::string("/") + fileName;
}
return directory + "/" + fileName;
}
std::string findSeriesSequel(const std::string& directory, const std::string& currentSeries,
const std::string& currentSeriesIndex) {
const auto currentIndex = parseSeriesIndex(currentSeriesIndex);
if (!currentIndex.has_value() || currentSeries.empty()) {
return {};
}
std::vector<std::string> files;
auto root = Storage.open(directory.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return {};
}
root.rewindDirectory();
char name[512];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (file.isDirectory()) {
file.close();
continue;
}
std::string fileName = name;
if (!FsHelpers::hasEpubExtension(fileName)) {
file.close();
continue;
}
if (fileName.empty() || fileName[0] == '.') {
file.close();
continue;
}
files.push_back(fileName);
file.close();
}
root.close();
if (files.empty()) return {};
std::sort(files.begin(), files.end(), naturalLess);
std::string bestPath;
float bestIndex = std::numeric_limits<float>::infinity();
for (const auto& fileName : files) {
const std::string candidatePath = pathWithFilename(directory, fileName);
auto epub = std::unique_ptr<Epub>(new Epub(candidatePath, "/.crosspoint"));
epub->setSyntheticTocFallbackEnabled(SETTINGS.syntheticTocFallback != 0);
if (!epub->load(true, SETTINGS.embeddedStyle == 0)) {
continue;
}
const std::string candidateSeries = epub->getSeries();
const std::string candidateSeriesIndex = epub->getSeriesIndex();
if (!caseInsensitiveEqual(candidateSeries, currentSeries)) {
continue;
}
const auto candidateIndex = parseSeriesIndex(candidateSeriesIndex);
if (!candidateIndex.has_value()) {
continue;
}
if (candidateIndex.value() <= currentIndex.value()) {
continue;
}
if (candidateIndex.value() < bestIndex) {
bestIndex = candidateIndex.value();
bestPath = candidatePath;
}
}
return bestPath;
}
std::string findNextAlphabeticalBook(const std::string& directory, const std::string& currentFilename) {
std::vector<std::string> files;
auto root = Storage.open(directory.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return {};
}
root.rewindDirectory();
char name[512];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (file.isDirectory()) {
file.close();
continue;
}
std::string fileName = name;
if (fileName.empty() || fileName[0] == '.' || !isSupportedBookFile(fileName)) {
file.close();
continue;
}
files.push_back(fileName);
file.close();
}
root.close();
if (files.empty()) return {};
std::sort(files.begin(), files.end(), naturalLess);
for (size_t i = 0; i < files.size(); ++i) {
if (files[i] == currentFilename && i + 1 < files.size()) {
return pathWithFilename(directory, files[i + 1]);
}
}
return {};
}
bool pathIsInCompleted(const std::string& bookPath) {
return bookPath.rfind("/COMPLETED/", 0) == 0 || bookPath == "/COMPLETED";
}
std::string buildCompletedTargetPath(const std::string& currentBookPath) {
const std::string fileName = getFilename(currentBookPath);
return std::string("/COMPLETED/") + fileName;
}
std::string findUniqueCompletedPath(const std::string& basePath) {
if (!Storage.exists(basePath.c_str())) {
return basePath;
}
const auto dotPos = basePath.find_last_of('.');
const std::string base = (dotPos == std::string::npos) ? basePath : basePath.substr(0, dotPos);
const std::string ext = (dotPos == std::string::npos) ? std::string() : basePath.substr(dotPos);
for (int suffix = 1; suffix < 1000; ++suffix) {
const std::string candidate = base + " (" + std::to_string(suffix) + ")" + ext;
if (!Storage.exists(candidate.c_str())) {
return candidate;
}
}
return {};
}
} // namespace
namespace BookFinished {
std::string findNextBookInDirectory(const std::string& currentBookPath, const std::string& currentBookSeries,
const std::string& currentBookSeriesIndex) {
const std::string directory = extractFolderPath(currentBookPath);
const std::string currentFilename = getFilename(currentBookPath);
if (!currentBookSeries.empty() && !currentBookSeriesIndex.empty()) {
const std::string sequel = findSeriesSequel(directory, currentBookSeries, currentBookSeriesIndex);
if (!sequel.empty() && sequel != currentBookPath) {
return sequel;
}
}
return findNextAlphabeticalBook(directory, currentFilename);
}
bool moveFinishedBookToCompleted(const std::string& currentBookPath, std::string& outMovedPath) {
if (pathIsInCompleted(currentBookPath)) {
outMovedPath = currentBookPath;
return true;
}
const std::string completedDir = "/COMPLETED";
if (!Storage.exists(completedDir.c_str()) && !Storage.mkdir(completedDir.c_str())) {
LOG_ERR("FIN", "Failed to create /COMPLETED directory");
return false;
}
std::string targetPath = buildCompletedTargetPath(currentBookPath);
if (Storage.exists(targetPath.c_str())) {
targetPath = findUniqueCompletedPath(targetPath);
if (targetPath.empty()) {
LOG_ERR("FIN", "Cannot resolve unique /COMPLETED filename");
return false;
}
}
if (!Storage.rename(currentBookPath.c_str(), targetPath.c_str())) {
LOG_ERR("FIN", "Failed to move book to /COMPLETED: %s -> %s", currentBookPath.c_str(), targetPath.c_str());
return false;
}
if (!moveSidecarFilesToCompleted(currentBookPath, targetPath)) {
LOG_ERR("FIN", "One or more sidecar files failed to move for %s", currentBookPath.c_str());
}
outMovedPath = targetPath;
return true;
}
} // namespace BookFinished
FinishedBookActivity::FinishedBookActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::string currentBookPath, std::string nextBookPath,
bool moveFinishedBooksToCompleted)
: Activity("FinishedBook", renderer, mappedInput),
currentBookPath_(std::move(currentBookPath)),
nextBookPath_(std::move(nextBookPath)),
nextBookAvailable_(!nextBookPath_.empty()),
moveFinishedBooksToCompleted_(moveFinishedBooksToCompleted) {
nextBookName_ = nextBookAvailable_ ? getFilename(nextBookPath_) : tr(STR_NOT_SET);
}
void FinishedBookActivity::onEnter() {
Activity::onEnter();
const bool canMoveToCompleted = !pathIsInCompleted(currentBookPath_);
const int optionCount = 1 + (nextBookAvailable_ ? 1 : 0) + (canMoveToCompleted ? 1 : 0) + 1;
if (selectedIndex_ < 0 || selectedIndex_ >= optionCount) {
selectedIndex_ = 0;
}
if (selectedIndex_ >= optionCount) {
selectedIndex_ = optionCount - 1;
}
moveFinishedBooksToCompleted_ = SETTINGS.moveFinishedBooksToCompleted;
removeFinishedBooksFromRecents_ = SETTINGS.removeFinishedBooksFromRecents;
if (nextBookAvailable_) {
nextBookTitle_ = nextBookName_;
nextBookAuthor_.clear();
nextBookSeries_.clear();
nextBookCoverPath_.clear();
nextBookMetadataLoaded_ = false;
} else {
nextBookTitle_.clear();
nextBookAuthor_.clear();
nextBookSeries_.clear();
nextBookCoverPath_.clear();
nextBookMetadataLoaded_ = true;
}
requestUpdate();
}
void FinishedBookActivity::loop() {
ButtonEventManager::ButtonEvent ev;
while (buttonEvents.consumeEvent(ev)) {
if (ev.type != ButtonEventManager::PressType::Short) {
continue;
}
const bool canMoveToCompleted = !pathIsInCompleted(currentBookPath_);
const int optionCount = 1 + (nextBookAvailable_ ? 1 : 0) + (canMoveToCompleted ? 1 : 0) + 1;
if (ev.button == MappedInputManager::Button::Back) {
MenuResult menuResult;
menuResult.action = static_cast<int>(BookFinished::FinishedBookAction::GoHome);
ActivityResult result(menuResult);
setResult(std::move(result));
finish();
return;
}
if (ev.button == MappedInputManager::Button::Confirm) {
if (selectedIndex_ == 0) {
MenuResult menuResult;
menuResult.action = static_cast<int>(BookFinished::FinishedBookAction::GoHome);
ActivityResult result(menuResult);
setResult(std::move(result));
finish();
return;
}
if (selectedIndex_ == 1 && nextBookAvailable_) {
MenuResult menuResult;
menuResult.action = static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook);
ActivityResult result(menuResult);
setResult(std::move(result));
finish();
return;
}
const int moveIndex = 1 + (nextBookAvailable_ ? 1 : 0);
if (selectedIndex_ == moveIndex && canMoveToCompleted) {
moveFinishedBooksToCompleted_ = !moveFinishedBooksToCompleted_;
SETTINGS.moveFinishedBooksToCompleted = moveFinishedBooksToCompleted_;
SETTINGS.saveToFile();
requestUpdate();
return;
}
const int removeIndex = 1 + (nextBookAvailable_ ? 1 : 0) + (canMoveToCompleted ? 1 : 0);
if (selectedIndex_ == removeIndex) {
removeFinishedBooksFromRecents_ = !removeFinishedBooksFromRecents_;
SETTINGS.removeFinishedBooksFromRecents = removeFinishedBooksFromRecents_;
SETTINGS.saveToFile();
requestUpdate();
return;
}
return;
}
if (ev.button == MappedInputManager::Button::Down || ev.button == MappedInputManager::Button::Right) {
selectedIndex_ = (selectedIndex_ + 1) % optionCount;
requestUpdate();
return;
}
if (ev.button == MappedInputManager::Button::Up || ev.button == MappedInputManager::Button::Left) {
selectedIndex_ = (selectedIndex_ + optionCount - 1) % optionCount;
requestUpdate();
return;
}
}
if (nextBookAvailable_ && !nextBookMetadataLoaded_) {
const auto metadata = loadNextBookMetadata(nextBookPath_);
nextBookTitle_ = metadata.title.empty() ? getFilename(nextBookPath_) : metadata.title;
nextBookAuthor_ = metadata.author;
nextBookSeries_ = metadata.series;
nextBookCoverPath_ = metadata.coverPath;
nextBookMetadataLoaded_ = true;
requestUpdate();
}
}
void FinishedBookActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics();
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
const int contentTop = contentRect.y;
const int contentBottom = contentRect.y + contentRect.height - metrics.buttonHintsHeight;
const int contentWidth = contentRect.width - 2 * metrics.contentSidePadding;
int y = contentTop;
int yGap = renderer.getLineHeight(UI_10_FONT_ID);
GUI.drawHeader(renderer, Rect{contentRect.x, contentTop, contentRect.width, metrics.headerHeight},
tr(STR_FINISHED_BOOK_HEADER), "");
y += metrics.headerHeight + metrics.verticalSpacing;
renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, y, tr(STR_FINISHED_BOOK_HEADER_LINE1),
true, EpdFontFamily::REGULAR);
y += yGap + 4;
renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, y, tr(STR_FINISHED_BOOK_HEADER_LINE2),
true, EpdFontFamily::REGULAR);
y += yGap + 4;
if (nextBookAvailable_) {
renderer.drawText(UI_12_FONT_ID, contentRect.x + metrics.contentSidePadding, y, "Next book to read:", true,
EpdFontFamily::BOLD);
y += yGap + metrics.verticalSpacing;
}
const int previewHeight =
nextBookAvailable_ ? std::max(0, std::min(kFinishedBookCoverHeight,
contentBottom - y - 3 * (renderer.getLineHeight(UI_12_FONT_ID) + 8) -
metrics.verticalSpacing))
: 0;
const int previewWidth = nextBookAvailable_ ? std::min(contentWidth / 2, kFinishedBookCoverMaxWidth) : 0;
const int previewX = contentRect.x + metrics.contentSidePadding;
const int previewY = y;
int actualCoverWidth = 0;
int actualCoverHeight = 0;
int previewTextX = previewX;
if (nextBookAvailable_) {
if (!nextBookCoverPath_.empty()) {
const std::string coverPath = UITheme::getCoverThumbPath(nextBookCoverPath_, previewWidth, previewHeight);
HalFile coverFile = Storage.open(coverPath.c_str());
if (coverFile) {
Bitmap bmp(coverFile);
if (bmp.parseHeaders() == BmpReaderError::Ok && bmp.getWidth() > 0 && bmp.getHeight() > 0) {
actualCoverHeight = previewHeight;
actualCoverWidth = bmp.getWidth() * actualCoverHeight / bmp.getHeight();
if (actualCoverWidth > previewWidth) {
actualCoverWidth = previewWidth;
actualCoverHeight = bmp.getHeight() * actualCoverWidth / bmp.getWidth();
}
renderer.drawBitmap(bmp, previewX, previewY, actualCoverWidth, actualCoverHeight);
}
coverFile.close();
}
}
if (actualCoverWidth > 0) {
previewTextX = previewX + actualCoverWidth + metrics.contentSidePadding;
} else {
previewTextX = previewX;
}
const int previewTextWidth = contentRect.x + contentRect.width - metrics.contentSidePadding - previewTextX;
int infoY = previewY;
const auto titleLines = renderer.wrappedText(UI_12_FONT_ID, nextBookTitle_.c_str(), previewTextWidth, 3);
const auto authorLines = renderer.wrappedText(UI_10_FONT_ID, nextBookAuthor_.c_str(), previewTextWidth, 3);
const auto seriesLines = renderer.wrappedText(UI_10_FONT_ID, nextBookSeries_.c_str(), previewTextWidth, 2);
if (!nextBookTitle_.empty()) {
for (const auto& line : titleLines) {
renderer.drawText(UI_12_FONT_ID, previewTextX, infoY, line.c_str(), true, EpdFontFamily::BOLD);
infoY += renderer.getLineHeight(UI_12_FONT_ID);
}
infoY += 4;
}
if (!nextBookAuthor_.empty()) {
for (const auto& line : authorLines) {
renderer.drawText(UI_10_FONT_ID, previewTextX, infoY, line.c_str(), true);
infoY += renderer.getLineHeight(UI_10_FONT_ID);
}
infoY += 4;
}
if (!nextBookSeries_.empty()) {
for (const auto& line : seriesLines) {
renderer.drawText(UI_10_FONT_ID, previewTextX, infoY, line.c_str(), true);
infoY += renderer.getLineHeight(UI_10_FONT_ID);
}
}
y = std::max(y + previewHeight, infoY) + metrics.verticalSpacing;
}
const bool currentBookIsCompleted = pathIsInCompleted(currentBookPath_);
const bool canMoveToCompleted = !currentBookIsCompleted;
const int optionCount = 1 + (nextBookAvailable_ ? 1 : 0) + (canMoveToCompleted ? 1 : 0) + 1;
std::vector<std::string> rowTitles;
std::vector<std::string> rowSubtitles;
std::vector<std::string> rowValues;
rowTitles.push_back(tr(STR_GO_BACK_TO_HOME));
rowSubtitles.push_back(tr(STR_GO_BACK_TO_HOME_DESC));
rowValues.push_back(tr(STR_HOME));
if (nextBookAvailable_) {
std::string subtitle;
if (!nextBookAuthor_.empty()) {
subtitle = nextBookAuthor_;
}
if (!nextBookSeries_.empty()) {
if (!subtitle.empty()) subtitle += "";
subtitle += nextBookSeries_;
}
if (subtitle.empty()) {
subtitle = nextBookName_;
}
rowTitles.push_back(nextBookTitle_.empty() ? tr(STR_OPEN_NEXT_BOOK) : nextBookTitle_);
rowSubtitles.push_back(subtitle);
rowValues.push_back(tr(STR_OPEN));
}
if (canMoveToCompleted) {
rowTitles.push_back(tr(STR_MOVE_FINISHED_TO_COMPLETED));
rowSubtitles.push_back("");
rowValues.push_back(moveFinishedBooksToCompleted_ ? tr(STR_STATE_ON) : tr(STR_STATE_OFF));
}
rowTitles.push_back(tr(STR_FORGET_BOOK));
rowSubtitles.push_back(tr(STR_FORGET_BOOK_DESC));
rowValues.push_back(removeFinishedBooksFromRecents_ ? tr(STR_STATE_ON) : tr(STR_STATE_OFF));
const Rect listRect{contentRect.x, y, contentRect.width, contentBottom - y};
GUI.drawList(
renderer, listRect, optionCount, selectedIndex_, [&rowTitles](int index) { return rowTitles[index]; },
[&rowSubtitles](int index) { return rowSubtitles[index]; }, nullptr,
[&rowValues](int index) { return rowValues[index]; }, true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,43 @@
#pragma once
#include <string>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
namespace BookFinished {
std::string findNextBookInDirectory(const std::string& currentBookPath, const std::string& currentBookSeries,
const std::string& currentBookSeriesIndex);
bool moveFinishedBookToCompleted(const std::string& currentBookPath, std::string& outMovedPath);
enum class FinishedBookAction {
Stay = 0,
GoHome = 1,
OpenNextBook = 2,
};
} // namespace BookFinished
class FinishedBookActivity : public Activity {
public:
FinishedBookActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string currentBookPath,
std::string nextBookPath, bool moveFinishedBooksToCompleted);
void onEnter() override;
void loop() override;
void render(RenderLock&&) override;
private:
std::string currentBookPath_;
std::string nextBookPath_;
std::string nextBookName_;
std::string nextBookTitle_;
std::string nextBookAuthor_;
std::string nextBookSeries_;
std::string nextBookCoverPath_;
bool nextBookAvailable_ = false;
bool nextBookMetadataLoaded_ = false;
bool moveFinishedBooksToCompleted_ = false;
bool removeFinishedBooksFromRecents_ = false;
int selectedIndex_ = 0;
};
+39 -1
View File
@@ -13,6 +13,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "FinishedBookActivity.h"
#include "MappedInputManager.h"
#include "MdReaderTocSelectionActivity.h"
#include "ReaderActivity.h"
@@ -276,7 +277,44 @@ void MdReaderActivity::loop() {
currentHeadingIndex = getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
} else {
onGoHome();
saveProgress();
const std::string currentBookPath = txt ? txt->getPath() : std::string();
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(currentBookPath, std::string(), std::string());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, currentBookPath, nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, currentBookPath, nextBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(currentBookPath, movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(currentBookPath);
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(currentBookPath, movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(currentBookPath);
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
}
return;
}
+64 -2
View File
@@ -11,6 +11,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "FinishedBookActivity.h"
#include "GlobalBookmarkIndex.h"
#include "MappedInputManager.h"
#include "ReaderActivity.h"
@@ -197,7 +198,43 @@ void TxtReaderActivity::loop() {
currentPage++;
requestUpdate();
} else {
finish();
saveProgress();
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(txt->getPath(), std::string(), std::string());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, txt->getPath(), nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(txt->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(txt->getPath());
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(txt->getPath(), movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(txt->getPath());
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
}
return;
}
@@ -218,7 +255,32 @@ void TxtReaderActivity::loop() {
currentPage++;
requestUpdate();
} else {
finish();
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(txt->getPath(), std::string(), std::string());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, txt->getPath(), nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(txt->getPath(), movedPath);
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
}
}
}
+40 -2
View File
@@ -16,6 +16,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "FinishedBookActivity.h"
#include "MappedInputManager.h"
#include "ReaderActivity.h"
#include "ReaderUtils.h"
@@ -131,10 +132,47 @@ void XtcReaderActivity::loop() {
return;
}
// At end of the book, forward button returns to caller and back button returns to last page
// At end of the book, forward button opens the finished-book flow and back button returns to last page
if (currentPage >= xtc->getPageCount()) {
if (nextTriggered) {
finish();
saveProgress();
const std::string currentBookPath = xtc->getPath();
const std::string nextBookPath =
BookFinished::findNextBookInDirectory(currentBookPath, std::string(), std::string());
startActivityForResult(
std::make_unique<FinishedBookActivity>(renderer, mappedInput, currentBookPath, nextBookPath,
SETTINGS.moveFinishedBooksToCompleted),
[this, nextBookPath, currentBookPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::GoHome)) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(currentBookPath, movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(currentBookPath);
}
activityManager.goHome();
return;
}
if (menuResult.action == static_cast<int>(BookFinished::FinishedBookAction::OpenNextBook) &&
!nextBookPath.empty()) {
if (SETTINGS.moveFinishedBooksToCompleted) {
std::string movedPath;
BookFinished::moveFinishedBookToCompleted(currentBookPath, movedPath);
}
if (SETTINGS.removeFinishedBooksFromRecents) {
RECENT_BOOKS.removeBook(currentBookPath);
}
activityManager.goToReader(nextBookPath);
return;
}
requestUpdate();
});
} else {
currentPage = xtc->getPageCount() - 1;
requestUpdate();