diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 56cb0281..d21e6c24 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -437,3 +437,6 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак" STR_LINK: "[спасылка]" STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: " STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 01c0d705..ef17dc7d 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -283,3 +283,6 @@ STR_LINK: "[enllaç]" STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla" STR_AUTO_TURN_ENABLED: "Passar automàtic activat: " STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 5da37a60..a7d6833c 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -254,3 +254,6 @@ STR_BOOK_S_STYLE: "Styl knihy" STR_EMBEDDED_STYLE: "Vložený styl" STR_OPDS_SERVER_URL: "URL serveru OPDS" STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 9e540dca..c87dfb3f 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -283,3 +283,6 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Tag skærmbillede" STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index db303031..2674c235 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Houd DEL ingedrukt om alle tekst te wissen" STR_KB_HINT_SECONDARY_CHAR: "Houd SELECT ingedrukt voor secundair teken" STR_KB_HINT_UPPER_SECONDARY: "Houd SELECT ingedrukt voor HOOFDLETTERS of secundair teken" STR_KB_HINT_LOWER_SECONDARY: "Houd SELECT ingedrukt voor kleine letters of secundair teken" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index f7eae047..51eca17f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -661,4 +661,6 @@ STR_FIRMWARE_FILE_OPEN_FAILED: "Cannot open file" STR_FIRMWARE_WRITE_FAILED: "Firmware write failed" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Do not power off!" STR_RECOVERY_MODE: "Recovery Mode" -STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it" \ No newline at end of file +STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it" +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" \ No newline at end of file diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 50923526..3acbc0ec 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -253,3 +253,6 @@ STR_BOOK_S_STYLE: "Kirjan tyyli" STR_EMBEDDED_STYLE: "Upotettu tyyli" STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite" STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 3c7bf107..8c8766c6 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -648,3 +648,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Basculer la lecture bionique" STR_BTN_ACT_CYCLE_FONT_SIZE: "Changer la taille de police" STR_BTN_ACT_CYCLE_ORIENTATION: "Changer l'orientation" STR_RESTART_DEVICE: "Redémarrer l'appareil" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index e77b7caa..cd3aa3a5 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -648,3 +648,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Bionisches Lesen umschalten" STR_BTN_ACT_CYCLE_FONT_SIZE: "Schriftgröße ändern" STR_BTN_ACT_CYCLE_ORIENTATION: "Ändere Ausrichtung" STR_RESTART_DEVICE: "Neustart des Geräts" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index a3090ed3..a65c6270 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -283,3 +283,6 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Képernyőkép készítése" STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index a09428a1..bfe3a412 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -648,3 +648,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Attiva/disattiva lettura bionica" STR_BTN_ACT_CYCLE_FONT_SIZE: "Modificare dimensione carattere" STR_BTN_ACT_CYCLE_ORIENTATION: "Cambia orientamento" STR_RESTART_DEVICE: "Riavvia dispositivo" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index f6d87b8a..4257680b 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -282,3 +282,6 @@ STR_LINK: "[сілтеме]" STR_SCREENSHOT_BUTTON: "Скриншот түсіру" STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 29c457db..02199537 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -283,3 +283,6 @@ STR_LINK: "[nuoroda]" STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka" STR_AUTO_TURN_ENABLED: "Automatinis puslapių vartymas: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatinis puslapių vartymas (psl./min.)" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index ee56de48..69be6d02 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -663,3 +663,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Przełącz bioniczne czytanie" STR_BTN_ACT_CYCLE_FONT_SIZE: "Zmień rozmiar czcionki" STR_BTN_ACT_CYCLE_ORIENTATION: "Zmień orientację" STR_RESTART_DEVICE: "Uruchom ponownie urządzenie" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/portuguese_br.yaml b/lib/I18n/translations/portuguese_br.yaml index aa1ceea8..dd442710 100644 --- a/lib/I18n/translations/portuguese_br.yaml +++ b/lib/I18n/translations/portuguese_br.yaml @@ -647,3 +647,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Alternar leitura biónica" STR_BTN_ACT_CYCLE_FONT_SIZE: "Alterar tamanho da fonte" STR_BTN_ACT_CYCLE_ORIENTATION: "Alterar orientação" STR_RESTART_DEVICE: "Reiniciar dispositivo" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/portuguese_pt.yaml b/lib/I18n/translations/portuguese_pt.yaml index b9862f93..243f119e 100644 --- a/lib/I18n/translations/portuguese_pt.yaml +++ b/lib/I18n/translations/portuguese_pt.yaml @@ -646,3 +646,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Alternar leitura biónica" STR_BTN_ACT_CYCLE_FONT_SIZE: "Alterar tamanho da fonte" STR_BTN_ACT_CYCLE_ORIENTATION: "Alterar orientação" STR_RESTART_DEVICE: "Reiniciar dispositivo" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 4d935023..e411b0a4 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -283,3 +283,6 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Captură ecran" STR_AUTO_TURN_ENABLED: "Răsfoire automată: " STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 57ab0c49..f36a8814 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -647,3 +647,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Переключить бионическо STR_BTN_ACT_CYCLE_FONT_SIZE: "Изменить размер шрифта" STR_BTN_ACT_CYCLE_ORIENTATION: "Изменить ориентацию" STR_RESTART_DEVICE: "Перезагрузить устройство" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 81862896..1454a0e8 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Drži DEL za izbris celotnega besedila" STR_KB_HINT_SECONDARY_CHAR: "Drži SELECT za sekundarni znak" STR_KB_HINT_UPPER_SECONDARY: "Drži SELECT za VELIKE ČRKE ali sek. znak" STR_KB_HINT_LOWER_SECONDARY: "Drži SELECT za male črke ali sek. znak" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index ee38f4a6..436f6e30 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -647,3 +647,6 @@ STR_BTN_ACT_TOGGLE_BIONIC_READING: "Alternar lectura biónica" STR_BTN_ACT_CYCLE_FONT_SIZE: "Cambiar tamaño de fuente" STR_BTN_ACT_CYCLE_ORIENTATION: "Cambiar orientación" STR_RESTART_DEVICE: "Reiniciar dispositivo" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 88819e98..4c7be7ad 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Håll DEL för att rensa all text" STR_KB_HINT_SECONDARY_CHAR: "Håll VÄLJ för sekundärt tecken" STR_KB_HINT_UPPER_SECONDARY: "Håll VÄLJ för VERSALER eller sekundärt tecken" STR_KB_HINT_LOWER_SECONDARY: "Håll VÄLJ för gemener eller sekundärt tecken" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 53036eed..d81b35eb 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Tüm metni temizlemek için DEL basılı tutun" STR_KB_HINT_SECONDARY_CHAR: "İkincil karakter için SELECT tutun" STR_KB_HINT_UPPER_SECONDARY: "BÜYÜK HARF veya ikincil karakter için SELECT tutun" STR_KB_HINT_LOWER_SECONDARY: "Küçük harf veya ikincil karakter için SELECT tutun" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 286883a6..b36ade30 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Утримуйте DEL, щоб очистити вес STR_KB_HINT_SECONDARY_CHAR: "Утримуйте SELECT для додаткового символу" STR_KB_HINT_UPPER_SECONDARY: "Утримуйте SELECT для ВЕЛИКИХ літер або додаткового символу" STR_KB_HINT_LOWER_SECONDARY: "Утримуйте SELECT для малих літер або додаткового символу" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 34178f8d..d109175d 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -646,3 +646,6 @@ STR_KB_HINT_CLEAR_TEXT: "Giữ DEL để xóa tất cả văn bản" STR_KB_HINT_SECONDARY_CHAR: "Giữ SELECT để nhập ký tự phụ" STR_KB_HINT_UPPER_SECONDARY: "Giữ SELECT để nhập CHỮ HOA hoặc ký tự phụ" STR_KB_HINT_LOWER_SECONDARY: "Giữ SELECT để nhập chữ thường hoặc ký tự phụ" + +STR_VIEW_LIST: "List" +STR_VIEW_GRID: "Grid" diff --git a/src/CrossPointState.h b/src/CrossPointState.h index cfbb50a8..868c2e6a 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -95,6 +95,7 @@ class CrossPointState { size_t lastSleepImage = SIZE_MAX; // SIZE_MAX = unset sentinel uint8_t readerActivityLoadCount = 0; bool lastSleepFromReader = false; + bool recentBooksGridView = false; // true = grid/thumbnail view, false = list view KOReaderSyncSessionState koReaderSyncSession; PendingBookmarkJumpState pendingBookmarkJump; ~CrossPointState() = default; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 0860ea51..e7ceb177 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -85,6 +85,7 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { doc["lastSleepImage"] = s.lastSleepImage; doc["readerActivityLoadCount"] = s.readerActivityLoadCount; doc["lastSleepFromReader"] = s.lastSleepFromReader; + doc["recentBooksGridView"] = s.recentBooksGridView; // Information about a pending KOReader sync session JsonObject sync = doc["koReaderSyncSession"].to(); sync["active"] = s.koReaderSyncSession.active; @@ -143,6 +144,7 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.lastSleepImage = doc["lastSleepImage"] | SIZE_MAX; s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0; s.lastSleepFromReader = doc["lastSleepFromReader"] | false; + s.recentBooksGridView = doc["recentBooksGridView"] | false; JsonObject sync = doc["koReaderSyncSession"].as(); s.koReaderSyncSession.active = sync["active"] | false; s.koReaderSyncSession.epubPath = sync["epubPath"] | std::string(""); diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index e0eba6a1..7e54bc8a 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -1,11 +1,18 @@ #include "RecentBooksActivity.h" +#include +#include #include #include #include #include +#include +#include +#include +#include #include +#include #include "../ActivityManager.h" #include "../util/ConfirmationActivity.h" @@ -17,24 +24,119 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { +// Mirror of HomeActivity::convertSidecarToBmp — converts a JPG/PNG sidecar cover to a +// 1-bit BMP at the requested WxH size and caches it under /.crosspoint/sidecar_/. +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("RBA", sidecarPath, src)) return ""; + FsFile dst; + if (!Storage.openFileForWrite("RBA", 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; +} + +// Compute the grid thumbnail width from the available content width. +int gridThumbWidth(int contentWidth) { + const int margin = RecentBooksActivity::GRID_THUMB_MARGIN; + const int cols = RecentBooksActivity::GRID_COLS; + return (contentWidth - (cols + 1) * margin) / cols; +} + +// Resolve the [HEIGHT] placeholder in a cover BMP path to the WxH thumbnail path. +std::string gridThumbPath(const std::string& coverBmpPath, int tw, int th) { + return UITheme::getCoverThumbPath(coverBmpPath, tw, th); +} +} // namespace + void RecentBooksActivity::loadRecentBooks() { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); recentBooks.reserve(books.size()); - for (const auto& book : books) { - // Skip if file no longer exists - if (!Storage.exists(book.path.c_str())) { - continue; - } + if (!Storage.exists(book.path.c_str())) continue; recentBooks.push_back(book); } } +bool RecentBooksActivity::loadNextCover() { + const Rect contentRect = UITheme::getContentRect(renderer, true, true); + const int tw = gridThumbWidth(contentRect.width); + const int th = GRID_THUMB_HEIGHT; + + for (; nextCoverIndex < recentBooks.size(); nextCoverIndex++) { + RecentBook& book = recentBooks[nextCoverIndex]; + if (book.coverBmpPath.empty()) continue; + + 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 = ""; + continue; + } + const std::string cacheBase = + "/.crosspoint/sidecar_" + std::to_string(std::hash{}(book.path)); + const std::string placeholder = cacheBase + "/[HEIGHT].bmp"; + const std::string name = std::to_string(tw) + "x" + std::to_string(th) + ".bmp"; + const std::string result = convertSidecarToBmp(book.path, book.coverBmpPath, tw, th, name); + if (!result.empty()) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, placeholder); + book.coverBmpPath = placeholder; + } + nextCoverIndex++; + return false; // yield — one conversion per loop tick + } + + const std::string thumbPath = gridThumbPath(book.coverBmpPath, tw, th); + if (!Storage.exists(thumbPath.c_str())) { + bool ok = false; + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false, true); + ok = epub.generateThumbBmp(tw, th); + } else if (FsHelpers::hasXtcExtension(book.path)) { + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) ok = xtc.generateThumbBmp(tw, th); + } + if (!ok) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } + nextCoverIndex++; + return false; // yield — one generation per loop tick + } + } + + return true; // all thumbnails are ready +} + void RecentBooksActivity::onEnter() { Activity::onEnter(); - // Load data loadRecentBooks(); selectorIndex = 0; @@ -42,6 +144,12 @@ void RecentBooksActivity::onEnter() { selectorIndex = initialFocusIndex; } initialFocusIndex = -1; + + coversLoaded = false; + coversLoading = false; + firstRenderDone = false; + nextCoverIndex = 0; + requestUpdate(); } @@ -51,15 +159,15 @@ void RecentBooksActivity::onExit() { } void RecentBooksActivity::loop() { + const bool gridView = APP_STATE.recentBooksGridView; + + // --- Consume named button events first --- ButtonEventManager::ButtonEvent ev; while (buttonEvents.consumeEvent(ev)) { + // Open book (Confirm short/long) if (ev.button == MappedInputManager::Button::Confirm && (ev.type == ButtonEventManager::PressType::Short || ev.type == ButtonEventManager::PressType::Long)) { - if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) { - return; - } - // Long-press Confirm signals "open with KOReader sync" only for EPUBs. - // Short-press is unchanged direct open. + if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) return; const bool longPress = (ev.type == ButtonEventManager::PressType::Long) && KOREADER_STORE.hasCredentials(); const std::string& selectedPath = recentBooks[selectorIndex].path; const bool isEpubBook = FsHelpers::hasEpubExtension(selectedPath); @@ -78,58 +186,130 @@ void RecentBooksActivity::loop() { return; } + // Back → home if (ev.button == MappedInputManager::Button::Back && ev.type == ButtonEventManager::PressType::Short) { onGoHome(); return; } + // Left short: switch view or (in list view) remove book if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Short) { - if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) return; - const std::string bookPath = recentBooks[selectorIndex].path; - const std::string bookTitle = recentBooks[selectorIndex].title; - - auto handler = [this, bookPath](const ActivityResult& res) { - if (!res.isCancelled) { - LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str()); - RECENT_BOOKS.removeBook(bookPath); - loadRecentBooks(); - if (recentBooks.empty()) { - selectorIndex = 0; - } else if (selectorIndex >= static_cast(recentBooks.size())) { - selectorIndex = static_cast(recentBooks.size()) - 1; - } - requestUpdate(true); - } else { - LOG_DBG("RBA", "Remove cancelled by user"); - } - }; - - std::string heading = tr(STR_REMOVE) + std::string("? "); - startActivityForResult(std::make_unique(renderer, mappedInput, heading, bookTitle), - handler); + if (!gridView) { + // List view → switch to grid view + APP_STATE.recentBooksGridView = true; + APP_STATE.saveToFile(); + coversLoaded = false; + coversLoading = false; + firstRenderDone = false; + nextCoverIndex = 0; + requestUpdate(true); + return; + } + // Grid view: Left navigates to the previous item (column nav). The event is already + // consumed from the queue, so handle it directly here. + if (recentBooks.empty()) return; + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, static_cast(recentBooks.size())); + requestUpdate(); return; } + // Right short (list view only): show book info if (ev.button == MappedInputManager::Button::Right && ev.type == ButtonEventManager::PressType::Short) { - if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) return; - const std::string& path = recentBooks[selectorIndex].path; - if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) { - startActivityForResult(std::make_unique(renderer, mappedInput, path), - [this](const ActivityResult&) { requestUpdate(); }); + if (!gridView) { + if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) return; + const std::string& path = recentBooks[selectorIndex].path; + if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) { + startActivityForResult(std::make_unique(renderer, mappedInput, path), + [this](const ActivityResult&) { requestUpdate(); }); + return; + } + } else { + // Grid view: Right → move to next item (column navigation) + if (recentBooks.empty()) return; + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, static_cast(recentBooks.size())); + requestUpdate(); + return; + } + } + + // Left long (list view only): remove book + if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Long) { + if (!gridView) { + if (recentBooks.empty() || selectorIndex >= static_cast(recentBooks.size())) return; + const std::string bookPath = recentBooks[selectorIndex].path; + const std::string bookTitle = recentBooks[selectorIndex].title; + + auto handler = [this, bookPath](const ActivityResult& res) { + if (!res.isCancelled) { + LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str()); + RECENT_BOOKS.removeBook(bookPath); + loadRecentBooks(); + if (recentBooks.empty()) { + selectorIndex = 0; + } else if (selectorIndex >= static_cast(recentBooks.size())) { + selectorIndex = static_cast(recentBooks.size()) - 1; + } + requestUpdate(true); + } + }; + std::string heading = tr(STR_REMOVE) + std::string("? "); + startActivityForResult( + std::make_unique(renderer, mappedInput, heading, bookTitle), handler); return; } } } - // Navigator is restricted to Up/Down so it cannot race the Left/Right Short - // handlers above (Left/Right actions are not in the onNextList/onPreviousList button sets). + // --- ButtonNavigator for Up/Down (both views), and list-view Remove shortcut --- const int listSize = static_cast(recentBooks.size()); - buttonNavigator.onNextList({MappedInputManager::Button::Down}, selectorIndex, listSize, [this] { requestUpdate(); }); - buttonNavigator.onPreviousList({MappedInputManager::Button::Up}, selectorIndex, listSize, - [this] { requestUpdate(); }); + + if (gridView) { + // Up/Down navigate by full row (GRID_COLS items at a time) + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + if (!recentBooks.empty()) { + selectorIndex = std::max(0, selectorIndex - GRID_COLS); + requestUpdate(); + } + } + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (!recentBooks.empty()) { + selectorIndex = std::min(listSize - 1, selectorIndex + GRID_COLS); + requestUpdate(); + } + } + + // Lazy cover loading after first render + if (firstRenderDone && !coversLoaded && !coversLoading) { + coversLoading = true; + if (loadNextCover()) { + coversLoaded = true; + } else { + requestUpdate(); + } + coversLoading = false; + } + } else { + // List view: Up/Down with double-click and long-press special behaviour + buttonNavigator.onNextList({MappedInputManager::Button::Down}, selectorIndex, listSize, + [this] { requestUpdate(); }); + buttonNavigator.onPreviousList({MappedInputManager::Button::Up}, selectorIndex, listSize, + [this] { requestUpdate(); }); + } } -void RecentBooksActivity::render(RenderLock&&) { +void RecentBooksActivity::render(RenderLock&& lock) { + if (APP_STATE.recentBooksGridView) { + renderGridView(std::move(lock)); + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } + } else { + renderListView(std::move(lock)); + } +} + +void RecentBooksActivity::renderListView(RenderLock&&) { renderer.clearScreen(); const auto& metrics = UITheme::getInstance().getMetrics(); @@ -141,13 +321,13 @@ void RecentBooksActivity::render(RenderLock&&) { const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing; - // Recent tab if (recentBooks.empty()) { renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20, tr(STR_NO_RECENT_BOOKS)); } else { GUI.drawList( - renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, recentBooks.size(), selectorIndex, + renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, + static_cast(recentBooks.size()), selectorIndex, [this](int index) { return recentBooks[index].title; }, [this](int index) { const auto& book = recentBooks[index]; @@ -158,16 +338,130 @@ void RecentBooksActivity::render(RenderLock&&) { [this](int index) { return UITheme::getFileIcon(recentBooks[index].path); }); } - // Help text - const bool hasInfo = !recentBooks.empty() && selectorIndex < recentBooks.size() && + const bool hasInfo = !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size()) && (FsHelpers::hasEpubExtension(recentBooks[selectorIndex].path) || FsHelpers::hasXtcExtension(recentBooks[selectorIndex].path)); const bool hasBooks = !recentBooks.empty(); - const auto labels = mappedInput.mapLabels(tr(STR_HOME), hasBooks ? tr(STR_OPEN) : "", hasBooks ? tr(STR_REMOVE) : "", - hasInfo ? tr(STR_INFO) : ""); + // Left short switches to grid view; label it accordingly. Long Left = remove. + const auto labels = + mappedInput.mapLabels(tr(STR_HOME), hasBooks ? tr(STR_OPEN) : "", tr(STR_VIEW_GRID), + hasInfo ? tr(STR_INFO) : ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Side buttons (Up/Down) navigate; show their hints on the side + GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + + renderer.displayBuffer(); +} + +void RecentBooksActivity::renderGridView(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, true, true); + + GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight}, + tr(STR_MENU_RECENT_BOOKS)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing; + + if (recentBooks.empty()) { + renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20, + tr(STR_NO_RECENT_BOOKS)); + const auto labels = mappedInput.mapLabels(tr(STR_HOME), "", tr(STR_VIEW_LIST), ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + const int margin = GRID_THUMB_MARGIN; + const int tw = gridThumbWidth(contentRect.width); + const int th = GRID_THUMB_HEIGHT; + const int cellHeight = th + GRID_LABEL_HEIGHT + margin; + + const int visibleRows = std::max(1, contentHeight / cellHeight); + const int totalRows = (static_cast(recentBooks.size()) + GRID_COLS - 1) / GRID_COLS; + + const int selectedRow = selectorIndex / GRID_COLS; + const int pageStartRow = (selectedRow / visibleRows) * visibleRows; + + const int startIndex = pageStartRow * GRID_COLS; + const int endIndex = std::min(startIndex + visibleRows * GRID_COLS, static_cast(recentBooks.size())); + + for (int i = startIndex; i < endIndex; i++) { + const int row = (i / GRID_COLS) - pageStartRow; + const int col = i % GRID_COLS; + + const int cellX = contentRect.x + margin + col * (tw + margin); + const int cellY = contentTop + row * cellHeight; + + const bool selected = (i == selectorIndex); + + // Draw thumbnail border (double rect when selected) + if (selected) { + renderer.drawRect(cellX - 2, cellY - 2, tw + 4, th + 4); + renderer.drawRect(cellX - 1, cellY - 1, tw + 2, th + 2); + } else { + renderer.drawRect(cellX, cellY, tw, th); + } + + // Draw cover BMP thumbnail + const auto& book = recentBooks[i]; + bool coverDrawn = false; + if (!book.coverBmpPath.empty()) { + const std::string thumbPath = gridThumbPath(book.coverBmpPath, tw, th); + FsFile file; + if (Storage.openFileForRead("RBA", thumbPath, file)) { + Bitmap bmp(file); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + renderer.drawBitmap1Bit(bmp, cellX, cellY, tw, th); + coverDrawn = true; + } + file.close(); + } + } + + if (!coverDrawn) { + // Empty placeholder — just a white box inside the border + renderer.fillRect(cellX + 1, cellY + 1, tw - 2, th - 2, false); + } + + // Label: title on line 1, author on line 2 + const int labelY = cellY + th + 3; + const int labelW = tw - 4; + const bool invert = !selected; + + std::string titleStr = renderer.truncatedText(SMALL_FONT_ID, book.title.c_str(), labelW); + renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY, titleStr.c_str(), invert); + + if (!book.author.empty()) { + std::string authorStr = renderer.truncatedText(SMALL_FONT_ID, book.author.c_str(), labelW); + renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY + 17, authorStr.c_str(), invert); + } + } + + // Scroll arrows when content spans multiple pages + if (totalRows > visibleRows) { + constexpr int arrowSize = 6; + const int centerX = contentRect.x + contentRect.width / 2; + + if (pageStartRow > 0) { + const int arrowY = contentTop + 2; + for (int j = 0; j < arrowSize; ++j) { + const int half = arrowSize - 1 - j; + renderer.drawLine(centerX - half, arrowY + j, centerX + half, arrowY + j); + } + } + if (pageStartRow + visibleRows < totalRows) { + const int arrowY = contentTop + contentHeight - arrowSize - 2; + for (int j = 0; j < arrowSize; ++j) { + renderer.drawLine(centerX - j, arrowY + j, centerX + j, arrowY + j); + } + } + } + + const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_VIEW_LIST), ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); renderer.displayBuffer(); diff --git a/src/activities/home/RecentBooksActivity.h b/src/activities/home/RecentBooksActivity.h index b5044d25..f11bafb6 100644 --- a/src/activities/home/RecentBooksActivity.h +++ b/src/activities/home/RecentBooksActivity.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -10,17 +11,32 @@ #include "util/ButtonNavigator.h" class RecentBooksActivity final : public Activity { + public: + static constexpr int GRID_COLS = 3; + static constexpr int GRID_THUMB_HEIGHT = 180; + static constexpr int GRID_THUMB_MARGIN = 10; + static constexpr int GRID_LABEL_HEIGHT = 36; // two small-font lines below each thumbnail + private: ButtonNavigator buttonNavigator; int selectorIndex = 0; int initialFocusIndex = -1; // applied once in onEnter(), then cleared - // Recent tab state std::vector recentBooks; - // Data loading + // Lazy cover loading state for grid view + bool coversLoaded = false; + bool coversLoading = false; + bool firstRenderDone = false; + size_t nextCoverIndex = 0; + void loadRecentBooks(); + // Generates the next missing grid thumbnail (one per call). Returns true when all done. + bool loadNextCover(); + + void renderListView(RenderLock&&); + void renderGridView(RenderLock&&); public: explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int focusIndex = -1)