diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 9fa1bee9..d7abbab9 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -17,6 +17,16 @@ STR_SELECT_CHAPTER: "Select Chapter" STR_NO_CHAPTERS: "No chapters" STR_END_OF_BOOK: "End of book" STR_EMPTY_CHAPTER: "Empty chapter" +STR_FINISHED_BOOK_HEADER: "Book finished!" +STR_FINISHED_BOOK_HEADER_LINE1: "Congratulations, you finished this book." +STR_FINISHED_BOOK_HEADER_LINE2: "What do you want to do now?" +STR_NEXT_BOOK_HEADER: "Next book to read:" +STR_GO_BACK_TO_HOME: "Go back to Home" +STR_GO_BACK_TO_HOME_DESC: "Return to the home screen" +STR_OPEN_NEXT_BOOK: "Open the next book:" +STR_MOVE_FINISHED_TO_COMPLETED: "Move finished book to /COMPLETED" +STR_FORGET_BOOK: "Forget book" +STR_FORGET_BOOK_DESC: "Remove book from Recent Books" STR_INDEXING: "Indexing" STR_MEMORY_ERROR: "Memory error" STR_PAGE_LOAD_ERROR: "Page load error" @@ -374,6 +384,7 @@ STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE: "Delete" STR_REMOVE: "Remove" STR_DISPLAY_QR: "Show page as QR" +STR_MARK_AS_READ: "Mark as read" STR_CHAPTER_PREFIX: "Chapter: " STR_PAGES_SEPARATOR: " pages | " STR_BOOK_PREFIX: "Book: " diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index a7e99f31..ca297cc2 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -324,6 +324,7 @@ STR_CHAPTER: "Rozdział" STR_EXAMPLE_CHAPTER: "Rozdział 21" STR_EXAMPLE_BOOK: "Tytuł książki" STR_PREVIEW: "Podgląd" +STR_NEXT_BOOK_HEADER: "Następna książka do przeczytania:" STR_TITLE: "Tytuł" STR_BATTERY: "Bateria" STR_XTC_STATUS_BAR: "Pasek statusu XTC" diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 81c4cb3c..787cf73b 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -3,6 +3,7 @@ #include #include #include +#include Txt::Txt(std::string path, std::string cacheBasePath) : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { @@ -156,10 +157,30 @@ bool Txt::generateCoverBmp() const { LOG_DBG("TXT", "Generated BMP from JPG cover image"); } return success; + } else if (FsHelpers::hasPngExtension(coverImagePath)) { + LOG_DBG("TXT", "Generating BMP from PNG cover image"); + FsFile coverPng, coverBmp; + if (!Storage.openFileForRead("TXT", coverImagePath, coverPng)) { + return false; + } + if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { + coverPng.close(); + return false; + } + const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp); + coverPng.close(); + coverBmp.close(); + + if (!success) { + LOG_ERR("TXT", "Failed to generate BMP from PNG cover image"); + Storage.remove(getCoverBmpPath().c_str()); + } else { + LOG_DBG("TXT", "Generated BMP from PNG cover image"); + } + return success; } - // PNG files are not supported (would need a PNG decoder) - LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG)"); + LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG/PNG)"); return false; } diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index f2045831..41271fac 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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) diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 16ff9987..0860ea51 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -211,6 +211,8 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) if (s.sdFontFamilyName[0] != '\0') { doc["sdFontFamilyName"] = s.sdFontFamilyName; } + doc["moveFinishedBooksToCompleted"] = s.moveFinishedBooksToCompleted; + doc["removeFinishedBooksFromRecents"] = s.removeFinishedBooksFromRecents; String json; serializeJson(doc, json); @@ -325,6 +327,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"); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 887636eb..a1acffc7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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,53 @@ 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(renderer, mappedInput, epub->getPath(), nextBookPath), + [this, nextBookPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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 +698,64 @@ 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(renderer, mappedInput, epub->getPath(), nextBookPath), + [this, nextBookPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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 +1412,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 +1469,50 @@ 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(renderer, mappedInput, epub->getPath(), nextBookPath), + [this, nextBookPath](const ActivityResult& result) { + finishedBookActivityStarted_ = false; + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index ecae6077..40f8604e 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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 diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index beedd5ee..1f315db4 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -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: diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 0a2f0bfc..0002285c 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -28,6 +28,7 @@ class EpubReaderMenuActivity final : public MenuListActivity { PUSH_LOCAL, STARRED_PAGES, STAR_PAGE, + MARK_AS_READ, DELETE_CACHE, RENDER_BENCHMARK }; diff --git a/src/activities/reader/FinishedBookActivity.cpp b/src/activities/reader/FinishedBookActivity.cpp new file mode 100644 index 00000000..6e20d07a --- /dev/null +++ b/src/activities/reader/FinishedBookActivity.cpp @@ -0,0 +1,707 @@ +#include "FinishedBookActivity.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 findUniquePathWithSuffix(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 findUniqueCompletedSidecarPath(const std::string& basePath) { return findUniquePathWithSuffix(basePath); } + +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{}(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(left[i])) != std::tolower(static_cast(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(*s1)) && std::isdigit(static_cast(*s2))) { + while (*s1 == '0') s1++; + while (*s2 == '0') s2++; + int len1 = 0; + int len2 = 0; + while (std::isdigit(static_cast(s1[len1]))) len1++; + while (std::isdigit(static_cast(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(*s1)); + const char c2 = std::tolower(static_cast(*s2)); + if (c1 != c2) return c1 < c2; + s1++; + s2++; + } + } + return *s1 == '\0' && *s2 != '\0'; +} + +std::optional 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 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::infinity(); + for (const auto& fileName : files) { + const std::string candidatePath = pathWithFilename(directory, fileName); + auto epub = std::unique_ptr(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 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) { return findUniquePathWithSuffix(basePath); } +} // 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) + : Activity("FinishedBook", renderer, mappedInput), + currentBookPath_(std::move(currentBookPath)), + nextBookPath_(std::move(nextBookPath)), + nextBookAvailable_(!nextBookPath_.empty()) { + 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; + selectedIndex_ = std::clamp(selectedIndex_, 0, 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(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(BookFinished::FinishedBookAction::GoHome); + ActivityResult result(menuResult); + setResult(std::move(result)); + finish(); + return; + } + + if (selectedIndex_ == 1 && nextBookAvailable_) { + MenuResult menuResult; + menuResult.action = static_cast(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, tr(STR_NEXT_BOOK_HEADER), 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; + + if (nextBookAvailable_) { + int actualCoverWidth = 0; + int previewTextX = previewX; + + 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) { + int 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; + } + + 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 rowTitles; + std::vector rowSubtitles; + std::vector 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(); +} diff --git a/src/activities/reader/FinishedBookActivity.h b/src/activities/reader/FinishedBookActivity.h new file mode 100644 index 00000000..9a91c2c6 --- /dev/null +++ b/src/activities/reader/FinishedBookActivity.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#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); + + 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; +}; diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 81118c93..4be66cd7 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -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,43 @@ 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(renderer, mappedInput, currentBookPath, nextBookPath), + [this, currentBookPath, nextBookPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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; } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 54c4bdf0..5a7c6718 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -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,7 @@ void TxtReaderActivity::loop() { currentPage++; requestUpdate(); } else { - finish(); + launchFinishedBookFlow(); } return; } @@ -218,11 +219,50 @@ void TxtReaderActivity::loop() { currentPage++; requestUpdate(); } else { - finish(); + launchFinishedBookFlow(); } } } +void TxtReaderActivity::launchFinishedBookFlow() { + saveProgress(); + const std::string currentBookPath = txt->getPath(); + const std::string nextBookPath = BookFinished::findNextBookInDirectory(currentBookPath, std::string(), std::string()); + + startActivityForResult(std::make_unique(renderer, mappedInput, currentBookPath, nextBookPath), + [this, currentBookPath, nextBookPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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(); + }); +} + void TxtReaderActivity::initializeReader() { if (initialized) { return; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 4230a75f..6d54c09a 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -50,6 +50,7 @@ class TxtReaderActivity final : public Activity { // Consume a persisted bookmark-jump request (from GlobalBookmarksActivity) for // this TXT file. Rewrites progress.bin before initializeReader() reads it. void applyPendingBookmarkJump(); + void launchFinishedBookFlow(); public: explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt) diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d9e339c6..527bdffb 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -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,46 @@ 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(renderer, mappedInput, currentBookPath, nextBookPath), + [this, nextBookPath, currentBookPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + if (menuResult.action == static_cast(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(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();