diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8c5f0d88..cd6d2981 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -324,6 +324,7 @@ STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_EXTENDED: "Lyra Extended" +STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 70e06d4b..59d6f0b5 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -21,39 +21,9 @@ #include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "components/UITheme.h" -#include "components/themes/lyra/LyraCarouselTheme.h" #include "fontIds.h" -// --------------------------------------------------------------------------- -// Static carousel frame cache — survives HomeActivity re-creation so that -// returning to home (e.g. after settings) doesn't re-read covers from SD. -// Freed explicitly in onSelectBook() before entering the reader. -// --------------------------------------------------------------------------- namespace { -uint8_t* gCachedFrames[HomeActivity::kCarouselFrameCount] = {}; -int gCachedFrameBookIdx[HomeActivity::kCarouselFrameCount] = {-1, -1, -1}; -int gCachedFrameCount = 0; -std::string gCacheKey; - -int findFrameSlot(int bookIdx) { - for (int i = 0; i < HomeActivity::kCarouselFrameCount; ++i) { - if (gCachedFrameBookIdx[i] == bookIdx && gCachedFrames[i] != nullptr) return i; - } - return -1; -} - -void invalidateCarouselCache() { - for (int i = 0; i < HomeActivity::kCarouselFrameCount; ++i) { - if (gCachedFrames[i]) { - free(gCachedFrames[i]); - gCachedFrames[i] = nullptr; - } - gCachedFrameBookIdx[i] = -1; - } - gCachedFrameCount = 0; - gCacheKey.clear(); -} - 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; @@ -177,19 +147,40 @@ void HomeActivity::loadRecentBooks(int maxBooks) { void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = true; + const auto thumbSizes = GUI.getCoverThumbSizes(coverHeight); + for (; nextRecentCoverIndex < recentBooks.size(); nextRecentCoverIndex++) { RecentBook& book = recentBooks[nextRecentCoverIndex]; if (!book.coverBmpPath.empty()) { - std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); - if (!Storage.exists(coverPath.c_str())) { - // If epub, try to load the metadata for title/author and cover - if (FsHelpers::hasEpubExtension(book.path)) { - Epub epub(book.path, "/.crosspoint"); - // Skip loading css since we only need metadata here - epub.load(false, true); + 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; + } + } - // Try to generate thumbnail image for Continue Reading card - bool success = epub.generateThumbBmp(coverHeight); + 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 = ""; @@ -199,12 +190,14 @@ void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = false; requestUpdate(); return; - } else if (FsHelpers::hasXtcExtension(book.path)) { - // Handle XTC file - Xtc xtc(book.path, "/.crosspoint"); - if (xtc.load()) { - // Try to generate thumbnail image for Continue Reading card - 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 = ""; @@ -214,6 +207,20 @@ 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; + } } } } @@ -230,7 +237,6 @@ void HomeActivity::onEnter() { hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; - carouselFramesReady = false; recentsLoading = false; recentsLoaded = false; firstRenderDone = false; @@ -244,11 +250,6 @@ void HomeActivity::onEnter() { recentsLoaded = true; } - // Pre-render carousel frames before first display so the fast path is ready. - if (static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL) { - preRenderCarouselFrames(); - } - // Apply focus: book path takes priority, else combined selector index (covers // "return to the menu entry I was on"). bool focused = false; @@ -278,10 +279,7 @@ void HomeActivity::onEnter() { void HomeActivity::onExit() { Activity::onExit(); - freeCoverBuffer(); - invalidateCarouselCache(); - freeCarouselFrames(); } bool HomeActivity::storeCoverBuffer() { @@ -326,126 +324,12 @@ void HomeActivity::freeCoverBuffer() { coverBufferStored = false; } -void HomeActivity::freeCarouselFrames() { - // Instance pointers are aliases into the static cache — do not free here. - for (int i = 0; i < kCarouselFrameCount; ++i) carouselFrames[i] = nullptr; - carouselFramesReady = false; -} - -void HomeActivity::preRenderCarouselFrames() { - const int bookCount = static_cast(recentBooks.size()); - if (bookCount == 0) return; - - // Build cache key from book paths in order - std::string newKey; - newKey.reserve(128); - for (const auto& b : recentBooks) { - newKey += b.path; - newKey += '\0'; - } - - // Cache hit: same books in same order — reuse without any SD reads - if (newKey == gCacheKey && gCachedFrameCount > 0) { - for (int i = 0; i < gCachedFrameCount; ++i) carouselFrames[i] = gCachedFrames[i]; - carouselFramesReady = true; - coverRendered = false; - coverBufferStored = false; - return; - } - - // Cache miss: free old cache and re-render - invalidateCarouselCache(); - - if (!renderer.getFrameBuffer()) return; - - const size_t bufferSize = renderer.getBufferSize(); - freeCoverBuffer(); // reclaim 48KB before allocating frames - - const int frameCount = std::min(bookCount, kCarouselFrameCount); - for (int i = 0; i < frameCount; ++i) { - gCachedFrames[i] = static_cast(malloc(bufferSize)); - if (!gCachedFrames[i]) { - LOG_ERR("HOME", "preRenderCarouselFrames: malloc failed for frame %d", i); - invalidateCarouselCache(); - return; - } - } - - // Render only the currently-selected cover. Adjacent frames are populated - // lazily by updateSlidingWindowCache() after the first paint completes. - const int selectedBookIdx = (selectorIndex < bookCount) ? selectorIndex : lastCarouselBookIndex; - const int initialBookIdx = (selectedBookIdx >= 0 && selectedBookIdx < bookCount) ? selectedBookIdx : 0; - renderCarouselFrame(initialBookIdx, 0); - - gCachedFrameCount = frameCount; - gCacheKey = newKey; - carouselFramesReady = true; - coverRendered = false; - coverBufferStored = false; -} - -void HomeActivity::renderCarouselFrame(int bookIdx, int slotIdx) { - uint8_t* frameBuffer = renderer.getFrameBuffer(); - if (!frameBuffer || !gCachedFrames[slotIdx]) return; - - const auto& metrics = UITheme::getInstance().getMetrics(); - const int pageWidth = renderer.getScreenWidth(); - const int bookCount = static_cast(recentBooks.size()); - bool dummy1 = false, dummy2 = false, dummy3 = false; - - LyraCarouselTheme::setPreRenderIndex(bookIdx); - renderer.clearScreen(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); - GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, - recentBooks, bookCount, dummy1, dummy2, dummy3, []() { return true; }); - - memcpy(gCachedFrames[slotIdx], frameBuffer, renderer.getBufferSize()); - gCachedFrameBookIdx[slotIdx] = bookIdx; - carouselFrames[slotIdx] = gCachedFrames[slotIdx]; -} - -void HomeActivity::updateSlidingWindowCache(int centerIdx, int bookCount) { - if (bookCount <= kCarouselFrameCount || !carouselFramesReady) return; - - const int prevIdx = (centerIdx + bookCount - 1) % bookCount; - const int nextIdx = (centerIdx + 1) % bookCount; - - const bool hasPrev = findFrameSlot(prevIdx) >= 0; - const bool hasNext = findFrameSlot(nextIdx) >= 0; - if (hasPrev && hasNext) return; - - const int missingIdx = !hasPrev ? prevIdx : nextIdx; - - int evictSlot = -1; - int maxDist = -1; - for (int i = 0; i < kCarouselFrameCount; ++i) { - if (!gCachedFrames[i]) continue; - const int bookInSlot = gCachedFrameBookIdx[i]; - if (bookInSlot == centerIdx) continue; - if (hasPrev && bookInSlot == prevIdx) continue; - if (hasNext && bookInSlot == nextIdx) continue; - const int diff = std::abs(bookInSlot - centerIdx); - const int dist = std::min(diff, bookCount - diff); - if (dist > maxDist) { - maxDist = dist; - evictSlot = i; - } - } - - if (evictSlot >= 0) { - LOG_DBG("HOME", "carousel: evict slot %d (book %d) -> book %d", evictSlot, gCachedFrameBookIdx[evictSlot], - missingIdx); - renderCarouselFrame(missingIdx, evictSlot); - } -} - void HomeActivity::loop() { if (menuEntriesDirty) { rebuildMenuEntries(); } - const bool isCarousel = - static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL; + const bool isCarousel = (GUI.getHomeNavigation() == HomeNavigation::Carousel); if (isCarousel) { const int bookCount = static_cast(recentBooks.size()); @@ -525,21 +409,43 @@ void HomeActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); + if (menuEntriesDirty) { + rebuildMenuEntries(); + } + + const int menuCount = static_cast(menuEntries.size()); + const bool isCarousel = (GUI.getHomeNavigation() == HomeNavigation::Carousel); + + // Fast path: theme owns its own pre-rendered frame cache + if (isCarousel) { + const auto carouselLabels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); + const bool handled = GUI.tryFastHomeRender( + renderer, recentBooks, selectorIndex, menuCount, + [this](int index) { return std::string(I18N.get(menuEntries[index].label)); }, + [this](int index) { return menuEntries[index].icon; }, carouselLabels.btn1, carouselLabels.btn2, + carouselLabels.btn3, carouselLabels.btn4); + if (handled) { + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + recentsLoading = true; + loadRecentCovers(metrics.homeCoverHeight); + } + return; + } + } + renderer.clearScreen(); bool bufferRestored = coverBufferStored && restoreCoverBuffer(); GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.homeTopPadding}, nullptr); - if (menuEntriesDirty) { - rebuildMenuEntries(); - } - const int totalItems = static_cast(recentBooks.size() + menuEntries.size()); if (selectorIndex >= totalItems) { selectorIndex = std::max(0, totalItems - 1); } - const int menuCount = static_cast(menuEntries.size()); const HomeScreenLayout layout = computeHomeScreenLayout(metrics, contentRect.height, menuCount); GUI.drawRecentBookCover(renderer, @@ -555,7 +461,8 @@ void HomeActivity::render(RenderLock&&) { [this](int index) { return std::string(I18N.get(menuEntries[index].label)); }, [this](int index) { return menuEntries[index].icon; }); - const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const auto labels = isCarousel ? mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)) + : mappedInput.mapLabels("", 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/home/HomeActivity.h b/src/activities/home/HomeActivity.h index a33d6227..a161d4ec 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,8 +13,6 @@ struct Rect; class HomeActivity final : public Activity { public: - static constexpr int kCarouselFrameCount = 3; - enum class MenuAction { FileBrowser, Recents, @@ -39,20 +37,17 @@ class HomeActivity final : public Activity { bool recentsLoaded = false; bool firstRenderDone = false; bool hasOpdsServers = false; - bool coverRendered = false; // Track if cover has been rendered once - bool coverBufferStored = false; // Track if cover buffer is stored + bool coverRendered = false; + bool coverBufferStored = false; size_t nextRecentCoverIndex = 0; - uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image - - uint8_t* carouselFrames[kCarouselFrameCount] = {nullptr, nullptr, nullptr}; - bool carouselFramesReady = false; + uint8_t* coverBuffer = nullptr; std::vector recentBooks; std::vector menuEntries; bool menuEntriesDirty = true; - std::string focusBookPath; // book path to re-select on first render, if present in recents - int focusSelectorIndex = -1; // fallback combined-selector index when focusBookPath doesn't match + std::string focusBookPath; + int focusSelectorIndex = -1; void onSelectBook(const std::string& path); void dispatchMenuAction(MenuAction action); @@ -61,10 +56,6 @@ class HomeActivity final : public Activity { bool storeCoverBuffer(); bool restoreCoverBuffer(); void freeCoverBuffer(); - void preRenderCarouselFrames(); - void freeCarouselFrames(); - void renderCarouselFrame(int bookIdx, int slotIdx); - void updateSlidingWindowCache(int centerIdx, int bookCount); void loadRecentBooks(int maxBooks); void loadRecentCovers(int coverHeight); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5ed20603..3492b024 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -241,6 +241,7 @@ void EpubReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); section.reset(); + UITheme::getInstance().getMutableTheme().onBookWillClose(epub ? epub->getPath() : "", epub.get(), nullptr, nullptr); epub.reset(); currentPageFootnotes.clear(); currentPageFootnotes.shrink_to_fit(); diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 3aa172be..cb9d3a7d 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -219,6 +219,7 @@ void MdReaderActivity::onExit() { currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + UITheme::getInstance().getMutableTheme().onBookWillClose(txt ? txt->getPath() : "", nullptr, nullptr, txt.get()); txt.reset(); } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0222a8cc..1cbf4fbe 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -134,6 +134,7 @@ void TxtReaderActivity::onExit() { currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + UITheme::getInstance().getMutableTheme().onBookWillClose(txt ? txt->getPath() : "", nullptr, nullptr, txt.get()); txt.reset(); } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index f384339b..57f40936 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -20,6 +20,7 @@ #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" void XtcReaderActivity::onEnter() { @@ -52,6 +53,8 @@ void XtcReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + + UITheme::getInstance().getMutableTheme().onBookWillClose(xtc ? xtc->getPath() : "", nullptr, xtc.get(), nullptr); xtc.reset(); } diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 6cd03473..10b42635 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -18,6 +18,7 @@ class UITheme { const ThemeMetrics& getMetrics() const { return *currentMetrics; } const BaseTheme& getTheme() const { return *currentTheme; } + BaseTheme& getMutableTheme() { return *currentTheme; } void reload(); void setTheme(CrossPointSettings::UI_THEME type); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index dd116ad3..f42f9b3f 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -4,9 +4,13 @@ #include #include #include +#include #include class GfxRenderer; +class Epub; +class Txt; +class Xtc; struct RecentBook; struct Rect { @@ -48,6 +52,8 @@ struct ThemeMetrics { int homeCoverHeight; int homeCoverTileHeight; int homeRecentBooksCount; + bool homeContinueReadingInMenu = false; + int homeMenuTopOffset = 0; int buttonHintsHeight; int sideButtonHintsWidth; @@ -67,12 +73,15 @@ struct ThemeMetrics { int keyboardVerticalOffset; int keyboardTextFieldWidthPercent; int keyboardWidthPercent; + int keyboardKeyCornerRadius = 0; }; enum UIIcon { Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Weather }; enum class KeyboardKeyType { Normal, Shift, Mode, Reveal, Space, Del, Ok, Disabled }; +enum class HomeNavigation { Linear, Carousel }; + // Default theme implementation (Classic Theme) // Additional themes can inherit from this and override methods as needed @@ -159,9 +168,32 @@ class BaseTheme { const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, bool inactiveSelection = false) const; virtual bool showsFileIcons() const { return false; } - virtual void drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const {} - // Shared constants and helpers for battery drawing (used by all themes) + // ---- Home screen navigation / rendering contract ---- + + // Return Carousel to opt in to left/right book navigation and tryFastHomeRender(). + virtual HomeNavigation getHomeNavigation() const { return HomeNavigation::Linear; } + + // Return the cover thumbnail sizes this theme needs for its home screen. + // HomeActivity calls generateThumbBmp() for each pair. Empty = use height-only path. + virtual std::vector> getCoverThumbSizes(int coverHeight) const { return {}; } + + // Attempt a full home-screen render from pre-cached state. + // Return true if the theme handled the render (HomeActivity must not draw anything else). + // Return false to fall through to the standard slow-path render in HomeActivity. + virtual bool tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, int selectorIndex, + int menuCount, const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, const char* hintBtn2, + const char* hintBtn3, const char* hintBtn4) const { + return false; + } + + // Called by readers just before releasing the book object. Themes that cache + // cover thumbnails can generate them here while the book is still loaded. + // Only one of epub/xtc/txt will be non-null depending on the reader. + virtual void onBookWillClose(const std::string& path, Epub* epub, Xtc* xtc, Txt* txt) {} + + // ---- Shared constants and helpers for battery drawing (used by all themes) ---- static constexpr int batteryPercentSpacing = 4; static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight); static void drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY); diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp index 83052e3a..8f591623 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.cpp +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -1,9 +1,17 @@ #include "LyraCarouselTheme.h" #include +#include +#include #include #include +#include +#include +#include +#include +#include +#include #include #include @@ -70,6 +78,23 @@ const uint8_t* iconBitmapFor(UIIcon icon) { return nullptr; } } +// --------------------------------------------------------------------------- +// Static frame cache — survives HomeActivity re-creation so that returning to +// home after settings doesn't re-read covers from SD. +// Freed explicitly via invalidateFrameCache() before entering the reader. +// --------------------------------------------------------------------------- +constexpr int kFrameCount = 3; +uint8_t* gCachedFrames[kFrameCount] = {}; +int gCachedFrameBookIdx[kFrameCount] = {-1, -1, -1}; +int gCachedFrameCount = 0; +std::string gCacheKey; + +int findFrameSlot(int bookIdx) { + for (int i = 0; i < kFrameCount; ++i) { + if (gCachedFrameBookIdx[i] == bookIdx && gCachedFrames[i] != nullptr) return i; + } + return -1; +} } // namespace // --------------------------------------------------------------------------- @@ -77,13 +102,159 @@ const uint8_t* iconBitmapFor(UIIcon icon) { // --------------------------------------------------------------------------- void LyraCarouselTheme::setPreRenderIndex(int idx) { lastCarouselSelectorIndex = idx; } -void LyraCarouselTheme::drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const { - if (!inCarouselRow) return; - const int screenW = renderer.getScreenWidth(); - const int centerX = (screenW - kCenterCoverMaxW) / 2; - const int centerTileY = coverRect.y + kCoverTopPad; - renderer.drawRoundedRect(centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH, kSelectionLineW, kCornerRadius, - true); +void LyraCarouselTheme::invalidateFrameCache() const { + for (int i = 0; i < kFrameCount; ++i) { + if (gCachedFrames[i]) { + free(gCachedFrames[i]); + gCachedFrames[i] = nullptr; + } + gCachedFrameBookIdx[i] = -1; + } + gCachedFrameCount = 0; + gCacheKey.clear(); +} + +void LyraCarouselTheme::onBookWillClose(const std::string& /*path*/, Epub* epub, Xtc* xtc, Txt* /*txt*/) { + if (epub) { + epub->generateThumbBmp(kCenterCoverW, kCenterCoverH); + epub->generateThumbBmp(kSideCoverW, kSideCoverH); + } + if (xtc) { + xtc->generateThumbBmp(kCenterCoverW, kCenterCoverH); + xtc->generateThumbBmp(kSideCoverW, kSideCoverH); + } + // txt files have no cover image — nothing to generate + invalidateFrameCache(); +} + +// --------------------------------------------------------------------------- +// tryFastHomeRender — pre-renders carousel frames and composites them +// --------------------------------------------------------------------------- +namespace { +void renderOneCarouselFrame(GfxRenderer& renderer, const std::vector& recentBooks, int bookIdx, int slotIdx, + const ThemeMetrics& metrics) { + if (!gCachedFrames[slotIdx]) return; + const int pageWidth = renderer.getScreenWidth(); + const int bookCount = static_cast(recentBooks.size()); + bool d1 = false, d2 = false, d3 = false; + + lastCarouselSelectorIndex = bookIdx; + renderer.clearScreen(); + UITheme::getInstance().getTheme().drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, + nullptr); + UITheme::getInstance().getTheme().drawRecentBookCover( + renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, bookCount, d1, d2, + d3, []() { return true; }); + + memcpy(gCachedFrames[slotIdx], renderer.getFrameBuffer(), renderer.getBufferSize()); + gCachedFrameBookIdx[slotIdx] = bookIdx; +} + +void updateSlidingWindow(GfxRenderer& renderer, const std::vector& recentBooks, int centerIdx, + const ThemeMetrics& metrics) { + const int bookCount = static_cast(recentBooks.size()); + if (bookCount <= kFrameCount || gCachedFrameCount == 0) return; + + const int prevIdx = (centerIdx + bookCount - 1) % bookCount; + const int nextIdx = (centerIdx + 1) % bookCount; + const bool hasPrev = findFrameSlot(prevIdx) >= 0; + const bool hasNext = findFrameSlot(nextIdx) >= 0; + if (hasPrev && hasNext) return; + + const int missingIdx = !hasPrev ? prevIdx : nextIdx; + int evictSlot = -1, maxDist = -1; + for (int i = 0; i < kFrameCount; ++i) { + if (!gCachedFrames[i]) continue; + const int b = gCachedFrameBookIdx[i]; + if (b == centerIdx || (hasPrev && b == prevIdx) || (hasNext && b == nextIdx)) continue; + const int diff = std::abs(b - centerIdx); + const int dist = std::min(diff, bookCount - diff); + if (dist > maxDist) { + maxDist = dist; + evictSlot = i; + } + } + if (evictSlot >= 0) renderOneCarouselFrame(renderer, recentBooks, missingIdx, evictSlot, metrics); +} +} // namespace + +bool LyraCarouselTheme::tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, + int selectorIndex, int menuCount, + const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, + const char* hintBtn2, const char* hintBtn3, const char* hintBtn4) const { + const int bookCount = static_cast(recentBooks.size()); + if (bookCount == 0) return false; + + const auto& metrics = UITheme::getInstance().getMetrics(); + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const size_t bufferSize = renderer.getBufferSize(); + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) return false; + + // Build cache key from book paths + std::string newKey; + newKey.reserve(128); + for (const auto& b : recentBooks) { + newKey += b.path; + newKey += '\0'; + } + + bool framesReady = (newKey == gCacheKey && gCachedFrameCount > 0); + + if (!framesReady) { + // Free old cache and allocate fresh frames + invalidateFrameCache(); + const int frameCount = std::min(bookCount, kFrameCount); + for (int i = 0; i < frameCount; ++i) { + gCachedFrames[i] = static_cast(malloc(bufferSize)); + if (!gCachedFrames[i]) { + LOG_ERR("CAROUSEL", "tryFastHomeRender: malloc failed for frame %d", i); + invalidateFrameCache(); + return false; + } + } + // Render only the initially-selected frame; neighbours are filled lazily. + const int initialIdx = (selectorIndex < bookCount) ? selectorIndex : 0; + renderOneCarouselFrame(renderer, recentBooks, initialIdx, 0, metrics); + gCachedFrameCount = frameCount; + gCacheKey = newKey; + framesReady = true; + } + + const bool inCarouselRow = (selectorIndex < bookCount); + const int centerIdx = + inCarouselRow ? selectorIndex : (lastCarouselSelectorIndex >= 0 ? lastCarouselSelectorIndex : 0); + const int slotIdx = findFrameSlot(centerIdx); + if (slotIdx < 0 || !gCachedFrames[slotIdx]) return false; + + memcpy(frameBuffer, gCachedFrames[slotIdx], bufferSize); + + // Overlay the selection border when carousel row is active + if (inCarouselRow) { + const int screenW = renderer.getScreenWidth(); + const int centerTileY = metrics.homeTopPadding + kCoverTopPad; + const int centerX = (screenW - kCenterCoverMaxW) / 2; + renderer.drawRoundedRect(centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH, kSelectionLineW, kCornerRadius, + true); + } + + // Menu row + const int menuIdx = inCarouselRow ? -1 : (selectorIndex - bookCount); + UITheme::getInstance().getTheme().drawButtonMenu( + renderer, + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth, + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + + metrics.buttonHintsHeight)}, + menuCount, menuIdx, menuLabel, menuIcon); + + // Button hints + UITheme::getInstance().getTheme().drawButtonHints(renderer, hintBtn1, hintBtn2, hintBtn3, hintBtn4); + + renderer.displayBuffer(); + updateSlidingWindow(renderer, recentBooks, centerIdx, metrics); + return true; } // --------------------------------------------------------------------------- @@ -144,7 +315,6 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const float tileRatio = static_cast(maxW) / static_cast(maxH); const float cropX = (bmpRatio > tileRatio) ? (1.0f - tileRatio / bmpRatio) : 0.0f; renderer.drawBitmap(bitmap, x, y, maxW, maxH, cropX, 0.0f); - renderer.maskRoundedRectOutsideCorners(x, y, maxW, maxH, kCornerRadius, Color::White); hasCover = true; } file.close(); diff --git a/src/components/themes/lyra/LyraCarouselTheme.h b/src/components/themes/lyra/LyraCarouselTheme.h index 7cd60ce7..a848d949 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.h +++ b/src/components/themes/lyra/LyraCarouselTheme.h @@ -1,9 +1,15 @@ #pragma once +#include +#include + #include "components/themes/lyra/LyraTheme.h" class GfxRenderer; +class Epub; +class Txt; +class Xtc; // Lyra Carousel theme metrics (zero runtime cost) namespace LyraCarouselMetrics { @@ -56,13 +62,24 @@ class LyraCarouselTheme : public LyraTheme { static constexpr int kSideCoverH = LyraCarouselMetrics::values.homeCoverHeight - 210; // 390 static void setPreRenderIndex(int idx); + + HomeNavigation getHomeNavigation() const override { return HomeNavigation::Carousel; } + std::vector> getCoverThumbSizes(int /*coverHeight*/) const override { + return {{kCenterCoverW, kCenterCoverH}, {kSideCoverW, kSideCoverH}}; + } + bool tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, int selectorIndex, + int menuCount, const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, const char* hintBtn2, + const char* hintBtn3, const char* hintBtn4) const override; + void onBookWillClose(const std::string& path, Epub* epub, Xtc* xtc, Txt* txt) override; + void invalidateFrameCache() const; + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const override; void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, const std::function& rowIcon) const override; - void drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle,