feat: Display epub metadata on Recents (#511)

* **What is the goal of this PR?** Implement a metadata viewer for the
Recents screen
* **What changes are included?**

| Recents | Files |
| --- | --- |
| <img alt="image"
src="https://github.com/user-attachments/assets/e0f2d816-ddce-4a2e-bd4a-cd431d0e6532"
/> | <img alt="image"
src="https://github.com/user-attachments/assets/3225cdce-d501-4175-bc92-73cb8bfe7a41"
/> |

For the Files screen, I have not made any changes on purpose. For the
Recents screen, we now display the Book title and author. If it is a
file with no epub metadata like txt or md, we display the file name
without the file extension.

---

Did you use AI tools to help write this code? _**< YES  >**_

Although I went trough all the code manually and made changes as well,
please be aware the majority of the code is AI generated.

---------

Co-authored-by: Eliz Kilic <elizk@google.com>
This commit is contained in:
Eliz 2026-01-27 17:25:03 +00:00 committed by Uri Tauber
parent 9dc57baf19
commit 8b8cca2d9b
7 changed files with 95 additions and 63 deletions

View File

@ -7,22 +7,23 @@
#include <algorithm>
namespace {
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 1;
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2;
constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin";
constexpr int MAX_RECENT_BOOKS = 10;
} // namespace
RecentBooksStore RecentBooksStore::instance;
void RecentBooksStore::addBook(const std::string& path) {
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) {
// Remove existing entry if present
auto it = std::find(recentBooks.begin(), recentBooks.end(), path);
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
recentBooks.erase(it);
}
// Add to front
recentBooks.insert(recentBooks.begin(), path);
recentBooks.insert(recentBooks.begin(), {path, title, author});
// Trim to max size
if (recentBooks.size() > MAX_RECENT_BOOKS) {
@ -46,7 +47,9 @@ bool RecentBooksStore::saveToFile() const {
serialization::writePod(outputFile, count);
for (const auto& book : recentBooks) {
serialization::writeString(outputFile, book);
serialization::writeString(outputFile, book.path);
serialization::writeString(outputFile, book.title);
serialization::writeString(outputFile, book.author);
}
outputFile.close();
@ -63,24 +66,41 @@ bool RecentBooksStore::loadFromFile() {
uint8_t version;
serialization::readPod(inputFile, version);
if (version != RECENT_BOOKS_FILE_VERSION) {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close();
return false;
}
if (version == 1) {
// Old version, just read paths
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
std::string path;
serialization::readString(inputFile, path);
// Title and author will be empty, they will be filled when the book is
// opened again
recentBooks.push_back({path, "", ""});
}
} else {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close();
return false;
}
} else {
uint8_t count;
serialization::readPod(inputFile, count);
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
recentBooks.clear();
recentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
std::string path;
serialization::readString(inputFile, path);
recentBooks.push_back(path);
for (uint8_t i = 0; i < count; i++) {
std::string path, title, author;
serialization::readString(inputFile, path);
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author});
}
}
inputFile.close();
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), count);
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size());
return true;
}

View File

@ -2,11 +2,19 @@
#include <string>
#include <vector>
struct RecentBook {
std::string path;
std::string title;
std::string author;
bool operator==(const RecentBook& other) const { return path == other.path; }
};
class RecentBooksStore {
// Static instance
static RecentBooksStore instance;
std::vector<std::string> recentBooks;
std::vector<RecentBook> recentBooks;
public:
~RecentBooksStore() = default;
@ -14,11 +22,11 @@ class RecentBooksStore {
// Get singleton instance
static RecentBooksStore& getInstance() { return instance; }
// Add a book path to the recent list (moves to front if already exists)
void addBook(const std::string& path);
// Add a book to the recent list (moves to front if already exists)
void addBook(const std::string& path, const std::string& title, const std::string& author);
// Get the list of recent book paths (most recent first)
const std::vector<std::string>& getBooks() const { return recentBooks; }
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
// Get the count of recent books
int getCount() const { return static_cast<int>(recentBooks.size()); }

View File

@ -16,6 +16,7 @@ namespace {
constexpr int TAB_BAR_Y = 15;
constexpr int CONTENT_START_Y = 60;
constexpr int LINE_HEIGHT = 30;
constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items
constexpr int LEFT_MARGIN = 20;
constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator
@ -47,7 +48,7 @@ int MyLibraryActivity::getPageItems() const {
int MyLibraryActivity::getCurrentItemCount() const {
if (currentTab == Tab::Recent) {
return static_cast<int>(bookTitles.size());
return static_cast<int>(recentBooks.size());
}
return static_cast<int>(files.size());
}
@ -65,34 +66,16 @@ int MyLibraryActivity::getCurrentPage() const {
}
void MyLibraryActivity::loadRecentBooks() {
constexpr size_t MAX_RECENT_BOOKS = 20;
bookTitles.clear();
bookPaths.clear();
recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks();
bookTitles.reserve(std::min(books.size(), MAX_RECENT_BOOKS));
bookPaths.reserve(std::min(books.size(), MAX_RECENT_BOOKS));
for (const auto& path : books) {
// Limit to maximum number of recent books
if (bookTitles.size() >= MAX_RECENT_BOOKS) {
break;
}
recentBooks.reserve(books.size());
for (const auto& book : books) {
// Skip if file no longer exists
if (!SdMan.exists(path.c_str())) {
if (!SdMan.exists(book.path.c_str())) {
continue;
}
// Extract filename from path for display
std::string title = path;
const size_t lastSlash = title.find_last_of('/');
if (lastSlash != std::string::npos) {
title = title.substr(lastSlash + 1);
}
bookTitles.push_back(title);
bookPaths.push_back(path);
recentBooks.push_back(book);
}
}
@ -176,8 +159,6 @@ void MyLibraryActivity::onExit() {
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
bookTitles.clear();
bookPaths.clear();
files.clear();
}
@ -207,8 +188,8 @@ void MyLibraryActivity::loop() {
// Confirm button - open selected item
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (currentTab == Tab::Recent) {
if (!bookPaths.empty() && selectorIndex < static_cast<int>(bookPaths.size())) {
onSelectBook(bookPaths[selectorIndex], currentTab);
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
onSelectBook(recentBooks[selectorIndex].path, currentTab);
}
} else {
// Files tab
@ -333,7 +314,7 @@ void MyLibraryActivity::render() const {
void MyLibraryActivity::renderRecentTab() const {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int bookCount = static_cast<int>(bookTitles.size());
const int bookCount = static_cast<int>(recentBooks.size());
if (bookCount == 0) {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books");
@ -343,14 +324,37 @@ void MyLibraryActivity::renderRecentTab() const {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN,
LINE_HEIGHT);
renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) {
auto item = renderer.truncatedText(UI_10_FONT_ID, bookTitles[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
i != selectorIndex);
const auto& book = recentBooks[i];
const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT;
// Line 1: Title
std::string title = book.title;
if (title.empty()) {
// Fallback for older entries or files without metadata
title = book.path;
const size_t lastSlash = title.find_last_of('/');
if (lastSlash != std::string::npos) {
title = title.substr(lastSlash + 1);
}
const size_t dot = title.find_last_of('.');
if (dot != std::string::npos) {
title.resize(dot);
}
}
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex);
// Line 2: Author
if (!book.author.empty()) {
auto truncatedAuthor =
renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex);
}
}
}

View File

@ -8,6 +8,7 @@
#include <vector>
#include "../Activity.h"
#include "RecentBooksStore.h"
class MyLibraryActivity final : public Activity {
public:
@ -22,8 +23,7 @@ class MyLibraryActivity final : public Activity {
bool updateRequired = false;
// Recent tab state
std::vector<std::string> bookTitles; // Display titles for each book
std::vector<std::string> bookPaths; // Paths for each visible book (excludes missing)
std::vector<RecentBook> recentBooks;
// Files tab state (from FileSelectionActivity)
std::string basepath = "/";

View File

@ -84,7 +84,7 @@ void EpubReaderActivity::onEnter() {
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(epub->getPath());
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor());
// Trigger first update
updateRequired = true;

View File

@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() {
// Save current txt as last opened file and add to recent books
APP_STATE.openEpubPath = txt->getPath();
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(txt->getPath());
RECENT_BOOKS.addBook(txt->getPath(), "", "");
// Trigger first update
updateRequired = true;

View File

@ -45,7 +45,7 @@ void XtcReaderActivity::onEnter() {
// Save current XTC as last opened book and add to recent books
APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(xtc->getPath());
RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor());
// Trigger first update
updateRequired = true;