Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
22 kB · 561 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562#include "HomeActivity.h"
#include <Bitmap.h>#include <Epub.h>#include <FsHelpers.h>#include <GfxRenderer.h>#include <HalDisplay.h>#include <HalStorage.h>#include <I18n.h>#include <LibraryBuilder.h>#include <LibraryIndexFile.h>#include <Memory.h>#include <Utf8.h>#include <Xtc.h>
#include <algorithm>#include <cstring>#include <vector>
#include "CrossPointSettings.h"#include "CrossPointState.h"#include "MappedInputManager.h"#include "OpdsServerStore.h"#include "RecentBooksStore.h"#include "components/UITheme.h"#include "fontIds.h"
int HomeActivity::getMenuItemCount() const { int count = 4; // File Browser, Library, File transfer, Settings if (!recentBooks.empty()) { count += recentBooks.size(); } if (hasOpdsServers) { count++; } return count;}
void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); recentBooks.reserve(coverGridUi ? maxBooks : std::min(static_cast<int>(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 (RecentBooksStore::isMissing(book)) { continue; }
recentBooks.push_back(book); }}
void HomeActivity::fillCoverGridFromLibrary() { if (recentBooks.size() >= CoverGridHomeUi::MAX_BOOKS) return; // Keep the index and record together off the task stack; reuse for every row. struct LibraryReader { library::LibraryIndexFile index; library::ClixRecord record; }; auto reader = makeUniqueNoThrow<LibraryReader>(); if (!reader) { LOG_ERR("HOME", "OOM: library index"); return; } auto& index = reader->index; auto& record = reader->record; if (!index.open(library::libraryIndexPath())) { index.close(); GUI.drawPopup(renderer, tr(STR_LIBRARY_REBUILDING)); library::BuildStats stats; if (!library::buildLibraryIndex("/", stats, SETTINGS.libraryUseMetadata != 0) || !index.open(library::libraryIndexPath())) { LOG_ERR("HOME", "Cannot populate cover grid from library"); return; } } for (uint16_t row = 0; row < index.bookCount() && recentBooks.size() < CoverGridHomeUi::MAX_BOOKS; ++row) { RecentBook book; if (!index.readRecord(index.ordinalForRow(library::SortOrder::RecentDesc, row), record) || !index.readPath(record, book.path)) continue; if (std::any_of(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& existing) { return existing.path == book.path; }) || RecentBooksStore::isMissing(book)) continue; if (!index.readTitle(record, book.title) && !index.readName(record, book.title)) continue; index.readAuthor(record, book.author); if (index.ioFailed()) break; recentBooks.push_back(std::move(book)); }}
void HomeActivity::resolveGridCoverPaths() { for (auto& book : recentBooks) { if (!book.coverBmpPath.empty()) continue; // Constructors only derive cache paths; no metadata parsing or image generation. // Keep these large objects off the task stack and release each before the next book. if (FsHelpers::hasEpubExtension(book.path)) { auto epub = makeUniqueNoThrow<Epub>(book.path, "/.crosspoint"); if (!epub) { LOG_ERR("HOME", "OOM: EPUB thumbnail path"); continue; } book.coverBmpPath = epub->getThumbBmpPath(); } else if (FsHelpers::hasXtcExtension(book.path)) { auto xtc = makeUniqueNoThrow<Xtc>(book.path, "/.crosspoint"); if (!xtc) { LOG_ERR("HOME", "OOM: XTC thumbnail path"); continue; } book.coverBmpPath = xtc->getThumbBmpPath(); } }}
void HomeActivity::loadGridCover(RecentBook& book, int height, bool& showingLoading, Rect& popupRect) { if (!book.coverBmpPath.empty() && Storage.exists(UITheme::getCoverThumbPath(book.coverBmpPath, height).c_str())) return; // Only one parser lives at a time; EPUB/XTC objects exceed the stack budget. if (FsHelpers::hasEpubExtension(book.path)) { auto epub = makeUniqueNoThrow<Epub>(book.path, "/.crosspoint"); if (!epub) { LOG_ERR("HOME", "OOM: cover EPUB"); return; } book.coverBmpPath = epub->getThumbBmpPath(); if (Storage.exists(epub->getThumbBmpPath(height).c_str())) return; if (!showingLoading) { showingLoading = true; popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); GUI.fillPopupProgress(renderer, popupRect, 0); } if (epub->generateThumbBmpFromSource(height)) { return; } } else if (FsHelpers::hasXtcExtension(book.path)) { auto xtc = makeUniqueNoThrow<Xtc>(book.path, "/.crosspoint"); if (!xtc) { LOG_ERR("HOME", "OOM: cover XTC"); return; } book.coverBmpPath = xtc->getThumbBmpPath(); if (Storage.exists(xtc->getThumbBmpPath(height).c_str())) return; if (!showingLoading) { showingLoading = true; popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); GUI.fillPopupProgress(renderer, popupRect, 0); } if (xtc->load() && xtc->generateThumbBmp(height)) { return; } } book.coverBmpPath.clear();}
void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = true; bool showingLoading = false; Rect popupRect;
int progress = 0; for (RecentBook& book : recentBooks) { // The cover grid shares one slot size; generating at any other height // would rescale the dithered thumb at draw time and alias badly. const int thumbHeight = coverGridUi ? coverGridUi->thumbHeightFor() : coverHeight; if (coverGridUi) { loadGridCover(book, thumbHeight, showingLoading, popupRect); ++progress; if (showingLoading) GUI.fillPopupProgress(renderer, popupRect, progress * 100 / recentBooks.size()); continue; } if (!book.coverBmpPath.empty()) { std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, thumbHeight); 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 if (!showingLoading) { showingLoading = true; popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); } GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); bool success = epub.generateThumbBmp(thumbHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); book.coverBmpPath = ""; } coverRendered = false; requestUpdate(); } 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 if (!showingLoading) { showingLoading = true; popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); } GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); bool success = xtc.generateThumbBmp(thumbHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); book.coverBmpPath = ""; } coverRendered = false; requestUpdate(); } } } } progress++; }
recentsLoaded = true; recentsLoading = false;}
void HomeActivity::onEnter() { Activity::onEnter();
hasOpdsServers = OPDS_STORE.hasServers();
const auto& metrics = UITheme::getInstance().getMetrics(); if (UITheme::getInstance().hasCoverGridHome()) { // Screen-lifetime interaction tables and component properties exceed the stack budget. coverGridUi = makeUniqueNoThrow<CoverGridHomeUi>(renderer); if (!coverGridUi) LOG_ERR("HOME", "OOM: cover grid UI; using standard home"); } loadRecentBooks(coverGridUi ? CoverGridHomeUi::MAX_BOOKS : metrics.homeRecentBooksCount); hasContinueReading = !recentBooks.empty(); if (coverGridUi) { fillCoverGridFromLibrary(); resolveGridCoverPaths(); coverGridUi->begin(recentBooks, hasOpdsServers, hasContinueReading); }
const auto base = static_cast<int>(recentBooks.size()); selectorIndex = initialMenuItem == HomeMenuItem::NONE ? 0 : base + menuItemToIndex(initialMenuItem, hasOpdsServers);
// Trigger first update requestUpdate();}
void HomeActivity::onExit() { Activity::onExit();
coverGridUi.reset();
// Free the stored cover buffer if any freeCoverBuffer();}
bool HomeActivity::storeCoverBuffer() { // render() must have already set the cover rect; without it we'd be back to // cloning the whole framebuffer. if (coverRectW <= 0 || coverRectH <= 0) return false; freeCoverBuffer(); const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH); if (needed == 0) return false; coverBuffer = static_cast<uint8_t*>(malloc(needed)); if (!coverBuffer) { LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed); return false; } coverBufferSize = needed; if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize)) { free(coverBuffer); coverBuffer = nullptr; coverBufferSize = 0; return false; } return true;}
bool HomeActivity::restoreCoverBuffer() { if (!coverBuffer || coverRectW <= 0 || coverRectH <= 0) return false; return renderer.copyBufferToRegion(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize);}
void HomeActivity::freeCoverBuffer() { if (coverBuffer) { free(coverBuffer); coverBuffer = nullptr; } coverBufferSize = 0; coverBufferStored = false;}
void HomeActivity::loop() { const int menuCount = getMenuItemCount(); const auto& metrics = UITheme::getInstance().getMetrics();
auto activateSelection = [this] { if (selectorIndex < recentBooks.size()) { onSelectBook(recentBooks[selectorIndex].path); return; } const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size()); switch (indexToMenuItem(menuIndex, hasOpdsServers)) { case HomeMenuItem::FILE_BROWSER: onFileBrowserOpen(); break; case HomeMenuItem::LIBRARY: onLibraryOpen(); break; case HomeMenuItem::OPDS_BROWSER: onOpdsBrowserOpen(); break; case HomeMenuItem::FILE_TRANSFER: onFileTransferOpen(); break; case HomeMenuItem::SETTINGS_MENU: onSettingsOpen(); break; default: break; } };
// Cover grid home splits navigation by button group (see below); the flat // next/previous cycle is for the classic list home only. if (!coverGridUi) { buttonNavigator.onNext([this, menuCount] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); requestUpdate(); });
buttonNavigator.onPrevious([this, menuCount] { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); requestUpdate(); }); }
const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up) { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); requestUpdate(); return; } if (swipe == MappedInputManager::SwipeDir::Down) { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); requestUpdate(); return; }
// Back is otherwise unused on the home menu: open the most recently read // book directly (recentBooks is most-recent-first and already pruned of // files missing from the SD card). if (mappedInput.wasReleased(MappedInputManager::Button::Back) && hasContinueReading && !recentBooks.empty()) { onSelectBook(recentBooks[0].path); return; }
if (coverGridUi) { const int touched = coverGridUi->selectedAction(mappedInput); if (touched >= 0 && touched < menuCount) { selectorIndex = touched; activateSelection(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { activateSelection(); return; } // Side page buttons walk the covers, front Left/Right walk the tabs // (selectorIndex is flat: books first, then the tab items). A press while // selection sits in the other band jumps into this band first. const int bookCount = static_cast<int>(recentBooks.size()); const auto cycleBand = [this](const int base, const int count, const int dir) { if (count <= 0) return; int idx = selectorIndex - base; if (idx < 0 || idx >= count) { idx = dir > 0 ? 0 : count - 1; } else { idx = (idx + count + dir) % count; } selectorIndex = base + idx; requestUpdate(); }; buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [&cycleBand, bookCount] { cycleBand(0, bookCount, -1); }); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [&cycleBand, bookCount] { cycleBand(0, bookCount, +1); }); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [&cycleBand, bookCount, menuCount] { cycleBand(bookCount, menuCount - bookCount, -1); }); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [&cycleBand, bookCount, menuCount] { cycleBand(bookCount, menuCount - bookCount, +1); }); return; }
const int coverColumnCount = std::max(1, metrics.homeRecentBooksCount); const int recentCount = std::min(static_cast<int>(recentBooks.size()), coverColumnCount); const int coverColumnWidth = (renderer.getScreenWidth() - 2 * metrics.contentSidePadding) / coverColumnCount; int touchedBook = -1; const auto coverTouch = mappedInput.colTouch(touchedBook, metrics.contentSidePadding, coverColumnWidth, recentCount, metrics.homeTopPadding, metrics.homeTopPadding + metrics.homeCoverTileHeight, coverColumnWidth); if (coverTouch != MappedInputManager::RowTouch::None) { if (coverTouch == MappedInputManager::RowTouch::Down) { if (selectorIndex != touchedBook) { selectorIndex = touchedBook; requestUpdate(); } } else { selectorIndex = touchedBook; activateSelection(); } return; }
const int menuTop = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset; const int renderedMenuCount = menuCount - (metrics.homeContinueReadingInMenu ? 0 : static_cast<int>(recentBooks.size())); int menuRow = -1; // Row height from the theme, not the metrics table: RoundedRaff draws // font-derived rows and the touch grid must match the visuals exactly. const int menuRowHeight = GUI.getMenuRowHeight(renderer); const auto menuTouch = mappedInput.rowTouch(menuRow, menuTop, menuRowHeight + metrics.menuSpacing, renderedMenuCount, 0, INT32_MAX, menuRowHeight); if (menuTouch != MappedInputManager::RowTouch::None) { const int touchedIndex = metrics.homeContinueReadingInMenu ? menuRow : menuRow + static_cast<int>(recentBooks.size()); if (menuTouch == MappedInputManager::RowTouch::Down) { if (selectorIndex != touchedIndex) { selectorIndex = touchedIndex; requestUpdate(); } } else { selectorIndex = touchedIndex; activateSelection(); } return; }
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { activateSelection(); }}
void HomeActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen(); if (coverGridUi) { coverGridUi->setSelection(selectorIndex); UITheme::getInstance().drawCoverGridHome(*coverGridUi); // Front Left/Right walk the tabs, so their hints read Left/Right; the // side page buttons (unhinted) walk the covers. const auto labels = mappedInput.mapLabels(hasContinueReading ? tr(STR_RESUME) : "", tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(cleanInitialRefresh && !firstRenderDone ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH); // Slot heights are recorded during the draw above; a change (first layout // pass, orientation switch) means the paths must point at those sizes and // any missing thumbs must be generated. Refreshing the paths right away // lets the next pass draw already-cached thumbs before generation runs. const bool coverSpecChanged = coverGridUi->takeThumbHeightChanged(); if (coverSpecChanged) { coverGridUi->refreshCoverPaths(); recentsLoaded = false; } if (!firstRenderDone) { firstRenderDone = true; requestUpdate(); } else if (!recentsLoaded && !recentsLoading) { loadRecentCovers(CoverGridHomeUi::THUMB_HEIGHT); coverGridUi->refreshCoverPaths(); requestUpdate(); } return; } bool bufferRestored = coverBufferStored && restoreCoverBuffer();
// Band spans topPadding..homeTopPadding: the cover tile starts at the fixed // homeTopPadding, so the height must shrink by topPadding or the band (and a // centered title, e.g. RoundedRaff's book title) sinks into the tile. // Home is the stack root: no back button in its header. GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding - metrics.topPadding}, metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr, nullptr, false);
// Record the tile rect so storeCoverBuffer (called from the theme) knows // which sub-region of the framebuffer to snapshot. ~16 KB in Portrait // instead of the 48 KB full framebuffer the previous bind captured. coverRectX = 0; coverRectY = metrics.homeTopPadding; coverRectW = pageWidth; coverRectH = metrics.homeCoverTileHeight;
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, std::bind(&HomeActivity::storeCoverBuffer, this));
// Build menu items dynamically std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_LIBRARY), tr(STR_FILE_TRANSFER), tr(STR_SETTINGS_TITLE)}; std::vector<UIIcon> menuIcons = {Folder, Library, Transfer, Settings};
if (hasOpdsServers) { menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); menuIcons.insert(menuIcons.begin() + 2, Blocks); }
if (metrics.homeContinueReadingInMenu && !recentBooks.empty()) { // Insert Continue Reading at the top if enabled in theme menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING)); menuIcons.insert(menuIcons.begin(), Book); }
GUI.drawButtonMenu( renderer, Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth, pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing + metrics.homeMenuTopOffset + metrics.buttonHintsHeight)}, static_cast<int>(menuItems.size()), metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), [&menuItems](int index) { return std::string(menuItems[index]); }, [&menuIcons](int index) { return menuIcons[index]; });
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(cleanInitialRefresh && !firstRenderDone ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
if (!firstRenderDone) { firstRenderDone = true; requestUpdate(); } else if (!recentsLoaded && !recentsLoading) { recentsLoading = true; const int themeThumbHeight = GUI.homeCoverThumbHeight(renderer); loadRecentCovers(themeThumbHeight > 0 ? themeThumbHeight : metrics.homeCoverHeight); }}
void HomeActivity::onSelectBook(const std::string& path) { activityManager.goToReader(path); }
void HomeActivity::onFileBrowserOpen() { activityManager.goToFileBrowser(); }
void HomeActivity::onLibraryOpen() { activityManager.goToLibrary(); }
void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); }
void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); }
void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); }