From 98e7c14c16184d7e20bf65bdf51404779c10a9c9 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 8 May 2026 16:09:39 +0200 Subject: [PATCH] Proper sidecar cover support --- src/activities/home/HomeActivity.cpp | 168 ++++++++++++++---- src/activities/reader/EpubReaderActivity.cpp | 5 +- src/activities/reader/MdReaderActivity.cpp | 3 +- src/activities/reader/ReaderActivity.cpp | 11 ++ src/activities/reader/ReaderActivity.h | 2 + src/activities/reader/TxtReaderActivity.cpp | 3 +- src/activities/reader/XtcReaderActivity.cpp | 5 +- .../themes/lyra/LyraCarouselTheme.cpp | 36 ++-- 8 files changed, 181 insertions(+), 52 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 59d6f0b5..64ea36ad 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -6,6 +6,9 @@ #include #include #include +#include +#include +#include #include #include @@ -20,10 +23,43 @@ #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "RecentBooksStore.h" +#include "activities/reader/ReaderActivity.h" #include "components/UITheme.h" #include "fontIds.h" namespace { +// Convert a sidecar JPG/PNG cover to a 1-bit BMP in the cache and return the BMP path, or "" on failure. +// fileName is the basename of the output file (without directory), e.g. "340x540.bmp" or "400.bmp". +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)); + Storage.mkdir(cacheDir.c_str()); + const std::string bmpPath = cacheDir + "/" + fileName; + if (Storage.exists(bmpPath.c_str())) return bmpPath; + + FsFile src; + if (!Storage.openFileForRead("HOME", sidecarPath, src)) return ""; + FsFile dst; + if (!Storage.openFileForWrite("HOME", 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); + } + src.close(); + dst.close(); + if (!ok) { + Storage.remove(bmpPath.c_str()); + return ""; + } + return bmpPath; +} + constexpr int CLASSIC_MIN_RECENT_TILE_HEIGHT = 280; constexpr int LYRA_MIN_RECENT_TILE_HEIGHT = 170; constexpr int LYRA_3_COVERS_MIN_RECENT_TILE_HEIGHT = 200; @@ -140,6 +176,21 @@ void HomeActivity::loadRecentBooks(int maxBooks) { continue; } + // Check for a sidecar cover — takes priority over embedded cover. + // Also catches books registered before sidecar support (empty coverBmpPath). + const std::string sidecar = ReaderActivity::sidecarCoverPath(book.path); + if (!sidecar.empty()) { + const bool sidecarAlreadyStored = + book.coverBmpPath == sidecar || book.coverBmpPath.find("sidecar_") != std::string::npos; + if (!sidecarAlreadyStored) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, sidecar); + RecentBook updated = book; + updated.coverBmpPath = sidecar; + recentBooks.push_back(updated); + continue; + } + } + recentBooks.push_back(book); } } @@ -152,36 +203,36 @@ void HomeActivity::loadRecentCovers(int coverHeight) { for (; nextRecentCoverIndex < recentBooks.size(); nextRecentCoverIndex++) { RecentBook& book = recentBooks[nextRecentCoverIndex]; if (!book.coverBmpPath.empty()) { - if (!thumbSizes.empty()) { - // Theme uses WxH thumbnails — check which are missing and generate - bool anyMissing = false; - for (const auto& sz : thumbSizes) { - const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); - if (!Storage.exists(path.c_str())) { - anyMissing = true; - break; - } - } - - if (anyMissing) { + // Sidecar covers (JPG/PNG paths stored directly) must be converted to BMP thumbnails + // and the stored coverBmpPath updated to the cache path with [WIDTH]x[HEIGHT] placeholder. + const bool isSidecar = + FsHelpers::hasJpgExtension(book.coverBmpPath) || FsHelpers::hasPngExtension(book.coverBmpPath); + if (isSidecar) { + if (!Storage.exists(book.coverBmpPath.c_str())) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } else { + const std::string cacheBase = "/.crosspoint/sidecar_" + std::to_string(std::hash{}(book.path)); + const std::string placeholder = cacheBase + "/[HEIGHT].bmp"; bool success = true; - if (FsHelpers::hasEpubExtension(book.path)) { - Epub epub(book.path, "/.crosspoint"); - epub.load(false, true); + if (!thumbSizes.empty()) { for (const auto& sz : thumbSizes) { - const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); - if (!Storage.exists(path.c_str())) success = epub.generateThumbBmp(sz.first, sz.second) && success; - } - } else if (FsHelpers::hasXtcExtension(book.path)) { - Xtc xtc(book.path, "/.crosspoint"); - if (xtc.load()) { - for (const auto& sz : thumbSizes) { - const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); - if (!Storage.exists(path.c_str())) success = xtc.generateThumbBmp(sz.first, sz.second) && success; + const std::string name = std::to_string(sz.first) + "x" + std::to_string(sz.second) + ".bmp"; + if (convertSidecarToBmp(book.path, book.coverBmpPath, sz.first, sz.second, name).empty()) { + success = false; + break; } } + } else { + const int w = coverHeight * 6 / 10; + const std::string name = std::to_string(coverHeight) + ".bmp"; + if (convertSidecarToBmp(book.path, book.coverBmpPath, w, coverHeight, name).empty()) success = false; } - if (!success) { + if (success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, placeholder); + book.coverBmpPath = placeholder; + } else { + LOG_ERR("HOME", "Failed to convert sidecar cover for %s", book.path.c_str()); RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; } @@ -191,13 +242,38 @@ void HomeActivity::loadRecentCovers(int coverHeight) { requestUpdate(); return; } - } else { - std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); - if (!Storage.exists(coverPath.c_str())) { - if (FsHelpers::hasEpubExtension(book.path)) { - Epub epub(book.path, "/.crosspoint"); - epub.load(false, true); - bool success = epub.generateThumbBmp(coverHeight); + } + + if (!book.coverBmpPath.empty()) { + if (!thumbSizes.empty()) { + // Theme uses WxH thumbnails — check which are missing and generate + bool anyMissing = false; + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) { + anyMissing = true; + break; + } + } + + if (anyMissing) { + bool success = true; + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false, true); + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) success = epub.generateThumbBmp(sz.first, sz.second) && success; + } + } else if (FsHelpers::hasXtcExtension(book.path)) { + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) { + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) success = xtc.generateThumbBmp(sz.first, sz.second) && success; + } + } + } if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; @@ -207,10 +283,14 @@ void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = false; requestUpdate(); return; - } else if (FsHelpers::hasXtcExtension(book.path)) { - Xtc xtc(book.path, "/.crosspoint"); - if (xtc.load()) { - bool success = xtc.generateThumbBmp(coverHeight); + } + } else { + std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); + if (!Storage.exists(coverPath.c_str())) { + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false, true); + bool success = epub.generateThumbBmp(coverHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; @@ -220,10 +300,24 @@ void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = false; requestUpdate(); return; + } else if (FsHelpers::hasXtcExtension(book.path)) { + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) { + bool success = xtc.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } + coverRendered = false; + nextRecentCoverIndex++; + recentsLoading = false; + requestUpdate(); + return; + } } } } - } + } // if (!book.coverBmpPath.empty()) after sidecar check } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3492b024..4fec8af5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -29,6 +29,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "SdCardFontGlobals.h" @@ -207,7 +208,9 @@ void EpubReaderActivity::onEnter() { if (!series.empty() && !epub->getSeriesIndex().empty()) { series += " #" + epub->getSeriesIndex(); } - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), series, epub->getThumbBmpPath()); + const std::string epubSidecar = ReaderActivity::sidecarCoverPath(epub->getPath()); + const std::string epubCover = epubSidecar.empty() ? epub->getThumbBmpPath() : epubSidecar; + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), series, epubCover); const RecentBook currentBook = RECENT_BOOKS.getBookByPath(epub->getPath()); bookEmbeddedStyleOverride = currentBook.embeddedStyleOverride; bookImageRenderingOverride = currentBook.imageRenderingOverride; diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index cb9d3a7d..81118c93 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -15,6 +15,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" #include "MdReaderTocSelectionActivity.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -52,7 +53,7 @@ void MdReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); requestUpdate(); } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 0a7a045e..34ee7d17 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -56,6 +56,17 @@ bool ReaderActivity::isImageFile(const std::string& path) { return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); } +std::string ReaderActivity::sidecarCoverPath(const std::string& bookPath) { + const auto dot = bookPath.rfind('.'); + if (dot == std::string::npos) return ""; + const std::string base = bookPath.substr(0, dot); + for (const char* ext : {".jpg", ".jpeg", ".png", ".bmp"}) { + const std::string candidate = base + ext; + if (Storage.exists(candidate.c_str())) return candidate; + } + return ""; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!Storage.exists(path.c_str())) { LOG_ERR("READER", "File does not exist: %s", path.c_str()); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 0292901f..bc24c94a 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -30,6 +30,8 @@ class ReaderActivity final : public Activity { void onGoBack(); public: + static std::string sidecarCoverPath(const std::string& bookPath); + explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath) : Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {} void onEnter() override; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 1cbf4fbe..de670db3 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -13,6 +13,7 @@ #include "CrossPointState.h" #include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "StarredPagesActivity.h" @@ -112,7 +113,7 @@ void TxtReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); // Trigger first update requestUpdate(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 57f40936..43795b3e 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -17,6 +17,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" @@ -42,7 +43,9 @@ void XtcReaderActivity::onEnter() { // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), "", xtc->getThumbBmpPath()); + const std::string xtcSidecar = ReaderActivity::sidecarCoverPath(xtc->getPath()); + const std::string xtcCover = xtcSidecar.empty() ? xtc->getThumbBmpPath() : xtcSidecar; + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), "", xtcCover); // Trigger first update requestUpdate(); diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp index 3b3b49fe..e6a219b7 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.cpp +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -309,6 +309,9 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, auto drawCover = [&](int bookIdx, int x, int y, int maxW, int maxH) -> bool { if (bookIdx < 0 || bookIdx >= bookCount) return false; const RecentBook& book = recentBooks[bookIdx]; + // Side tiles may extend off-screen — only round corners that are on-screen. + const bool roundLeft = (x >= 0); + const bool roundRight = (x + maxW <= screenW); bool hasCover = false; if (!book.coverBmpPath.empty()) { const std::string thumbPath = UITheme::getCoverThumbPath(book.coverBmpPath, maxW, maxH); @@ -331,23 +334,28 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const int ex = kCornerRadius - 1 - dx; const int ey = kCornerRadius - 1 - dy; if (ex * ex + ey * ey > (kCornerRadius - 1) * (kCornerRadius - 1)) { - renderer.drawPixel(x + dx, y + dy, false); // top-left - renderer.drawPixel(x + maxW - 1 - dx, y + dy, false); // top-right - renderer.drawPixel(x + dx, y + maxH - 1 - dy, false); // bottom-left - renderer.drawPixel(x + maxW - 1 - dx, y + maxH - 1 - dy, false); // bottom-right + if (roundLeft) { + renderer.drawPixel(x + dx, y + dy, false); // top-left + renderer.drawPixel(x + dx, y + maxH - 1 - dy, false); // bottom-left + } + if (roundRight) { + renderer.drawPixel(x + maxW - 1 - dx, y + dy, false); // top-right + renderer.drawPixel(x + maxW - 1 - dx, y + maxH - 1 - dy, false); // bottom-right + } } } } - renderer.drawRoundedRect(x, y, maxW, maxH, kThinOutlineW, kCornerRadius, true); + renderer.drawRoundedRect(x, y, maxW, maxH, kThinOutlineW, kCornerRadius, roundLeft, roundRight, roundLeft, + roundRight, true); hasCover = true; } file.close(); } } if (!hasCover) { - renderer.drawRoundedRect(x, y, maxW, maxH, 1, kCornerRadius, true); + renderer.drawRoundedRect(x, y, maxW, maxH, 1, kCornerRadius, roundLeft, roundRight, roundLeft, roundRight, true); renderer.fillRoundedRect(x, y + maxH / 3, maxW, 2 * maxH / 3, kCornerRadius, /*roundTopLeft=*/false, - /*roundTopRight=*/false, /*roundBottomLeft=*/true, /*roundBottomRight=*/true, + /*roundTopRight=*/false, /*roundBottomLeft=*/roundLeft, /*roundBottomRight=*/roundRight, Color::Black); renderer.drawIcon(CoverIcon, x + maxW / 2 - 16, y + 8, 32, 32); } @@ -373,12 +381,18 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const int prevIdx = (centerIdx + bookCount - 1) % bookCount; const int nextIdx = (centerIdx + 1) % bookCount; if (bookCount >= 3) { - if (drawCover(prevIdx, leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) - renderer.drawRoundedRect(leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, true); + if (drawCover(prevIdx, leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) { + const bool rl = (leftX >= 0), rr = (leftX + kSideCoverMaxW <= screenW); + renderer.drawRoundedRect(leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, rl, rr, rl, rr, + true); + } } if (bookCount >= 2) { - if (drawCover(nextIdx, rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) - renderer.drawRoundedRect(rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, true); + if (drawCover(nextIdx, rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) { + const bool rl = (rightX >= 0), rr = (rightX + kSideCoverMaxW <= screenW); + renderer.drawRoundedRect(rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, rl, rr, rl, rr, + true); + } } // Clear a white outline ring around the centre cover, then draw the cover