#include "HomeActivity.h" #include #include #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" namespace { 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 (hasOpdsUrl) { 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}); } 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(); // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; selectorIndex = 0; 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; } // Trigger first update requestUpdate(); } void HomeActivity::onExit() { Activity::onExit(); // Free the stored cover buffer if any freeCoverBuffer(); } 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::loop() { rebuildMenuEntries(); 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 >= 0 && 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); 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) { activityManager.pushReader(path); } void HomeActivity::dispatchMenuAction(MenuAction action) { 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; } }