#include "HomeActivity.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" #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; constexpr int CLASSIC_MIN_RECENT_TO_MENU_GAP = 2; constexpr int LYRA_MIN_RECENT_TO_MENU_GAP = 4; struct HomeScreenLayout { int recentTileHeight; int recentToMenuGap; int menuHeight; }; bool isLyraFamilyTheme() { const auto theme = static_cast(SETTINGS.uiTheme); return theme == CrossPointSettings::UI_THEME::LYRA || theme == CrossPointSettings::UI_THEME::LYRA_3_COVERS; } bool isLyraExtendedTheme() { return static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_3_COVERS; } int getMinRecentTileHeight() { if (isLyraExtendedTheme()) { return LYRA_3_COVERS_MIN_RECENT_TILE_HEIGHT; } if (isLyraFamilyTheme()) { return LYRA_MIN_RECENT_TILE_HEIGHT; } return CLASSIC_MIN_RECENT_TILE_HEIGHT; } int getMinRecentToMenuGap() { return isLyraFamilyTheme() ? LYRA_MIN_RECENT_TO_MENU_GAP : CLASSIC_MIN_RECENT_TO_MENU_GAP; } HomeScreenLayout computeHomeScreenLayout(const ThemeMetrics& metrics, int contentHeight, int menuItemCount) { HomeScreenLayout layout{metrics.homeCoverTileHeight, metrics.verticalSpacing, 0}; const int menuRequiredHeight = menuItemCount * metrics.menuRowHeight + std::max(0, menuItemCount - 1) * metrics.menuSpacing; auto computeMenuHeight = [&]() { return contentHeight - (metrics.homeTopPadding + layout.recentTileHeight + layout.recentToMenuGap); }; layout.menuHeight = computeMenuHeight(); if (layout.menuHeight >= menuRequiredHeight) { return layout; } const int gapReduction = std::min(layout.recentToMenuGap - getMinRecentToMenuGap(), menuRequiredHeight - layout.menuHeight); if (gapReduction > 0) { layout.recentToMenuGap -= gapReduction; layout.menuHeight = computeMenuHeight(); } if (layout.menuHeight >= menuRequiredHeight) { return layout; } const int tileReduction = std::min(layout.recentTileHeight - getMinRecentTileHeight(), menuRequiredHeight - layout.menuHeight); if (tileReduction > 0) { layout.recentTileHeight -= tileReduction; layout.menuHeight = computeMenuHeight(); } layout.menuHeight = std::max(0, layout.menuHeight); return layout; } int getHomeCoverRenderHeight(const HomeScreenLayout& layout) { return isLyraExtendedTheme() ? std::max(120, layout.recentTileHeight - 58) : std::max(120, layout.recentTileHeight - (isLyraFamilyTheme() ? 16 : 0)); } } // namespace // Builds the menu entry list in display order. Single source of truth for both loop() (which // dispatches Confirm based on action) and render() (which draws labels/icons). void HomeActivity::rebuildMenuEntries() { menuEntries.clear(); menuEntries.reserve(7); menuEntries.push_back({MenuAction::FileBrowser, StrId::STR_BROWSE_FILES, Folder}); menuEntries.push_back({MenuAction::Recents, StrId::STR_MENU_RECENT_BOOKS, Recent}); if (!GLOBAL_BOOKMARKS.isEmpty()) { menuEntries.push_back({MenuAction::GlobalBookmarks, StrId::STR_GLOBAL_BOOKMARKS, Book}); } if (hasOpdsServers) { menuEntries.push_back({MenuAction::OpdsBrowser, StrId::STR_OPDS_BROWSER, Library}); } menuEntries.push_back({MenuAction::FileTransfer, StrId::STR_FILE_TRANSFER, Transfer}); if (SETTINGS.useWeather) { menuEntries.push_back({MenuAction::Weather, StrId::STR_WEATHER, Weather}); } menuEntries.push_back({MenuAction::Settings, StrId::STR_SETTINGS_TITLE, Settings}); menuEntriesDirty = false; } void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); for (const RecentBook& book : books) { // Limit to maximum number of recent books if (recentBooks.size() >= maxBooks) { break; } // Skip if file no longer exists if (!Storage.exists(book.path.c_str())) { continue; } recentBooks.push_back(book); } } void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = true; 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); // Try to generate thumbnail image for Continue Reading card bool success = epub.generateThumbBmp(coverHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; } coverRendered = false; nextRecentCoverIndex++; 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); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; } coverRendered = false; nextRecentCoverIndex++; recentsLoading = false; requestUpdate(); return; } } } } } recentsLoaded = true; recentsLoading = false; } void HomeActivity::onEnter() { Activity::onEnter(); hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; carouselFramesReady = false; recentsLoading = false; recentsLoaded = false; firstRenderDone = false; nextRecentCoverIndex = 0; coverRendered = false; freeCoverBuffer(); const auto& metrics = UITheme::getInstance().getMetrics(); loadRecentBooks(metrics.homeRecentBooksCount); if (recentBooks.empty()) { 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; if (!focusBookPath.empty()) { for (size_t i = 0; i < recentBooks.size(); ++i) { if (recentBooks[i].path == focusBookPath) { selectorIndex = static_cast(i); focused = true; break; } } focusBookPath.clear(); } if (!focused && focusSelectorIndex >= 0) { rebuildMenuEntries(); // need menu count to clamp; rebuild is idempotent const int combinedSize = static_cast(recentBooks.size() + menuEntries.size()); if (combinedSize > 0) { selectorIndex = std::min(focusSelectorIndex, combinedSize - 1); } } focusSelectorIndex = -1; // Trigger first update menuEntriesDirty = true; requestUpdate(); } void HomeActivity::onExit() { Activity::onExit(); freeCoverBuffer(); invalidateCarouselCache(); freeCarouselFrames(); } bool HomeActivity::storeCoverBuffer() { uint8_t* frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } // Free any existing buffer first freeCoverBuffer(); const size_t bufferSize = renderer.getBufferSize(); coverBuffer = static_cast(malloc(bufferSize)); if (!coverBuffer) { return false; } memcpy(coverBuffer, frameBuffer, bufferSize); return true; } bool HomeActivity::restoreCoverBuffer() { if (!coverBuffer) { return false; } uint8_t* frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } const size_t bufferSize = renderer.getBufferSize(); memcpy(frameBuffer, coverBuffer, bufferSize); return true; } void HomeActivity::freeCoverBuffer() { if (coverBuffer) { free(coverBuffer); coverBuffer = nullptr; } 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; if (isCarousel) { const int bookCount = static_cast(recentBooks.size()); const int menuItemCount = static_cast(menuEntries.size()); const bool inCarouselRow = (selectorIndex < bookCount); const int menuIdx = inCarouselRow ? 0 : (selectorIndex - bookCount); if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { if (inCarouselRow && bookCount > 0) selectorIndex = (selectorIndex + 1) % bookCount; else if (!inCarouselRow) selectorIndex = bookCount + (menuIdx + 1) % menuItemCount; requestUpdate(); } if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { if (inCarouselRow && bookCount > 0) selectorIndex = (selectorIndex + bookCount - 1) % bookCount; else if (!inCarouselRow) selectorIndex = bookCount + (menuIdx + menuItemCount - 1) % menuItemCount; requestUpdate(); } if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { if (inCarouselRow) { lastCarouselBookIndex = selectorIndex; selectorIndex = bookCount; } else { selectorIndex = lastCarouselBookIndex; } requestUpdate(); } if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { if (inCarouselRow) { lastCarouselBookIndex = selectorIndex; selectorIndex = bookCount; } else { selectorIndex = lastCarouselBookIndex; } requestUpdate(); } } else { const int totalItems = static_cast(recentBooks.size() + menuEntries.size()); if (firstRenderDone && !recentsLoaded && !recentsLoading) { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); const HomeScreenLayout layout = computeHomeScreenLayout(metrics, contentRect.height, static_cast(menuEntries.size())); loadRecentCovers(getHomeCoverRenderHeight(layout)); return; } buttonNavigator.onNext([this, totalItems] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); requestUpdate(); }); buttonNavigator.onPrevious([this, totalItems] { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); requestUpdate(); }); } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const int recentsCount = static_cast(recentBooks.size()); if (selectorIndex < recentsCount) { onSelectBook(recentBooks[selectorIndex].path); } else { const int menuIdx = selectorIndex - recentsCount; if (menuIdx < static_cast(menuEntries.size())) { dispatchMenuAction(menuEntries[menuIdx].action); } } } } void HomeActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); 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, Rect{contentRect.x, metrics.homeTopPadding, contentRect.width, layout.recentTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, std::bind(&HomeActivity::storeCoverBuffer, this)); GUI.drawButtonMenu( renderer, Rect{contentRect.x, metrics.homeTopPadding + layout.recentTileHeight + layout.recentToMenuGap, contentRect.width, layout.menuHeight}, menuCount, selectorIndex - static_cast(recentBooks.size()), [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)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); if (!firstRenderDone) { firstRenderDone = true; requestUpdate(); } } void HomeActivity::onSelectBook(const std::string& path) { ReturnHint hint; hint.target = ReturnTo::Home; hint.selectName = path; // used to re-focus the book in the recents strip after return activityManager.replaceWithReader(path, std::move(hint)); } void HomeActivity::dispatchMenuAction(MenuAction action) { // Record where the menu entry was focused so that when the launched activity exits // (via returnFromChild() or an empty-stack finish()), we come back to the same row. ReturnHint hint; hint.target = ReturnTo::Home; hint.selectIndex = selectorIndex; activityManager.setReturnHint(std::move(hint)); switch (action) { case MenuAction::FileBrowser: activityManager.goToFileBrowser(); break; case MenuAction::Recents: activityManager.goToRecentBooks(); break; case MenuAction::GlobalBookmarks: activityManager.goToGlobalBookmarks(); break; case MenuAction::OpdsBrowser: activityManager.goToBrowser(); break; case MenuAction::FileTransfer: activityManager.goToFileTransfer(); break; case MenuAction::Weather: activityManager.goToWeather(); break; case MenuAction::Settings: activityManager.goToSettings(); break; default: LOG_ERR("HOME", "Unexpected menu action: %d", static_cast(action)); break; } }