From 10e0680f6e3a565296800cfa48c397aa4199588f Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 7 Mar 2026 18:07:49 +0100 Subject: [PATCH] Add bookinfo --- lib/I18n/translations/english.yaml | 4 + src/activities/home/BookInfoActivity.cpp | 215 ++++++++++++++++++++ src/activities/home/BookInfoActivity.h | 31 +++ src/activities/home/FileBrowserActivity.cpp | 119 ++++++----- 4 files changed, 313 insertions(+), 56 deletions(-) create mode 100644 src/activities/home/BookInfoActivity.cpp create mode 100644 src/activities/home/BookInfoActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 38192cc9..6df23eaf 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -341,3 +341,7 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" +STR_INFO: "Info" +STR_AUTHOR: "Author" +STR_SERIES: "Series" +STR_FILE_SIZE: "Size" diff --git a/src/activities/home/BookInfoActivity.cpp b/src/activities/home/BookInfoActivity.cpp new file mode 100644 index 00000000..32af62a0 --- /dev/null +++ b/src/activities/home/BookInfoActivity.cpp @@ -0,0 +1,215 @@ +#include "BookInfoActivity.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "components/UITheme.h" +#include "fontIds.h" + +std::string BookInfoActivity::formatFileSize(const size_t bytes) { + char buf[16]; + if (bytes < 1024) { + snprintf(buf, sizeof(buf), "%u B", static_cast(bytes)); + } else if (bytes < 1024 * 1024) { + snprintf(buf, sizeof(buf), "%.1f KB", bytes / 1024.0f); + } else { + snprintf(buf, sizeof(buf), "%.1f MB", bytes / (1024.0f * 1024.0f)); + } + return buf; +} + +void BookInfoActivity::renderLoading() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_INFO)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + renderer.drawText(UI_12_FONT_ID, metrics.contentSidePadding, contentTop, tr(STR_LOADING)); + + renderer.displayBuffer(); +} + +void BookInfoActivity::loadData() { + // Get file size + HalFile f = Storage.open(filePath.c_str()); + if (f) { + fileSizeBytes = f.fileSize(); + f.close(); + } + + // Load epub metadata — builds cache if missing, which also gives us cover + if (FsHelpers::hasEpubExtension(filePath)) { + Epub epub(filePath, "/.crosspoint"); + epub.load(true, true); + + title = epub.getTitle(); + author = epub.getAuthor(); + series = epub.getSeries(); + seriesIndex = epub.getSeriesIndex(); + description = epub.getDescription(); + + // Generate thumbnail if not present yet, then record its path + const auto& metrics = UITheme::getInstance().getMetrics(); + const std::string thumbPath = epub.getThumbBmpPath(metrics.homeCoverHeight); + if (!Storage.exists(thumbPath.c_str())) { + epub.generateThumbBmp(metrics.homeCoverHeight); + } + coverBmpPath = Storage.exists(thumbPath.c_str()) ? thumbPath : ""; + } else if (FsHelpers::hasXtcExtension(filePath)) { + Xtc xtc(filePath, "/.crosspoint"); + if (xtc.load()) { + title = xtc.getTitle(); + author = xtc.getAuthor(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const std::string thumbPath = xtc.getThumbBmpPath(metrics.homeCoverHeight); + if (!Storage.exists(thumbPath.c_str())) { + xtc.generateThumbBmp(metrics.homeCoverHeight); + } + coverBmpPath = Storage.exists(thumbPath.c_str()) ? thumbPath : ""; + } + } +} + +void BookInfoActivity::onEnter() { + Activity::onEnter(); + renderLoading(); + loadData(); + requestUpdate(true); +} + +void BookInfoActivity::loop() { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + finish(); + } +} + +void BookInfoActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + // Header + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_INFO)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentBottom = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing; + const int textX = metrics.contentSidePadding; + const int textWidth = pageWidth - metrics.contentSidePadding * 2; + const int lineHeightSmall = renderer.getLineHeight(UI_10_FONT_ID); + const int lineHeightLarge = renderer.getLineHeight(UI_12_FONT_ID); + + // Reserve space so description always gets at least a few lines below the cover block + const int descReserve = description.empty() ? 0 : (3 * lineHeightSmall + 2 * metrics.verticalSpacing); + const int topSectionMaxH = contentBottom - contentTop - descReserve; + + // --- Top section: cover (left) + title/author/series (right) --- + int topSectionBottom = contentTop; + int metaX = textX; + int metaWidth = textWidth; + + if (!coverBmpPath.empty()) { + HalFile coverFile = Storage.open(coverBmpPath.c_str()); + if (coverFile) { + Bitmap bmp(coverFile); + if (bmp.parseHeaders() == BmpReaderError::Ok && bmp.getWidth() > 0 && bmp.getHeight() > 0) { + // Natural aspect ratio, capped to available height + int coverDisplayH = std::min(bmp.getHeight(), topSectionMaxH); + int coverDisplayW = bmp.getWidth() * coverDisplayH / bmp.getHeight(); + // Also cap width at half the text area + if (coverDisplayW > textWidth / 2) { + coverDisplayW = textWidth / 2; + coverDisplayH = bmp.getHeight() * coverDisplayW / bmp.getWidth(); + } + renderer.drawBitmap(bmp, textX, contentTop, coverDisplayW, coverDisplayH); + topSectionBottom = contentTop + coverDisplayH; + + const int gap = metrics.contentSidePadding; + metaX = textX + coverDisplayW + gap; + metaWidth = textWidth - coverDisplayW - gap; + } + coverFile.close(); + } + } + + // Title/author/series in right column (or full width when no cover) + int metaY = contentTop; + + if (!title.empty()) { + const auto lines = renderer.wrappedText(UI_12_FONT_ID, title.c_str(), metaWidth, 4); + for (const auto& line : lines) { + if (metaY + lineHeightLarge > contentBottom) break; + renderer.drawText(UI_12_FONT_ID, metaX, metaY, line.c_str(), true, EpdFontFamily::BOLD); + metaY += lineHeightLarge; + } + metaY += 4; + } + + if (!author.empty()) { + const auto lines = renderer.wrappedText(UI_10_FONT_ID, author.c_str(), metaWidth, 2); + for (const auto& line : lines) { + if (metaY + lineHeightSmall > contentBottom) break; + renderer.drawText(UI_10_FONT_ID, metaX, metaY, line.c_str()); + metaY += lineHeightSmall; + } + metaY += 2; + } + + if (!series.empty()) { + std::string seriesLine = series; + if (!seriesIndex.empty()) seriesLine += " #" + seriesIndex; + const auto lines = renderer.wrappedText(UI_10_FONT_ID, seriesLine.c_str(), metaWidth, 2); + for (const auto& line : lines) { + if (metaY + lineHeightSmall > contentBottom) break; + renderer.drawText(UI_10_FONT_ID, metaX, metaY, line.c_str()); + metaY += lineHeightSmall; + } + metaY += 2; + } + + if (fileSizeBytes > 0) { + const std::string sizeStr = formatFileSize(fileSizeBytes); + metaY += metrics.verticalSpacing; + if (metaY + lineHeightSmall <= contentBottom) { + renderer.drawText(UI_10_FONT_ID, metaX, metaY, sizeStr.c_str()); + metaY += lineHeightSmall; + } + } + + topSectionBottom = std::max(topSectionBottom, metaY); + + // --- Description: full width below the top section --- + if (!description.empty()) { + int y = topSectionBottom + metrics.verticalSpacing; + if (y + lineHeightSmall + 4 < contentBottom) { + renderer.drawLine(textX, y, pageWidth - metrics.contentSidePadding, y); + y += 4; + + const int descMaxLines = (contentBottom - y) / lineHeightSmall; + if (descMaxLines > 0) { + const auto lines = renderer.wrappedText(UI_10_FONT_ID, description.c_str(), textWidth, descMaxLines); + for (const auto& line : lines) { + if (y + lineHeightSmall > contentBottom) break; + renderer.drawText(UI_10_FONT_ID, textX, y, line.c_str()); + y += lineHeightSmall; + } + } + } + } + + // Button hints + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/home/BookInfoActivity.h b/src/activities/home/BookInfoActivity.h new file mode 100644 index 00000000..94e022c3 --- /dev/null +++ b/src/activities/home/BookInfoActivity.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "../Activity.h" + +class BookInfoActivity final : public Activity { + const std::string filePath; + + // Metadata populated in onEnter + std::string title; + std::string author; + std::string series; + std::string seriesIndex; + std::string description; + std::string coverBmpPath; + size_t fileSizeBytes = 0; + + static std::string formatFileSize(size_t bytes); + void renderLoading(); + void loadData(); + + public: + explicit BookInfoActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string filePath) + : Activity("BookInfo", renderer, mappedInput), filePath(std::move(filePath)) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index b8b910ba..19ae65f0 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -9,6 +9,7 @@ #include #include "../util/ConfirmationActivity.h" +#include "BookInfoActivity.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -127,28 +128,39 @@ void FileBrowserActivity::clearFileMetadata(const std::string& fullPath) { } void FileBrowserActivity::loop() { - // Long press BACK (1s+) goes to root folder - if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && - basepath != "/") { - basepath = "/"; - loadFiles(); - selectorIndex = 0; + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); + + // Back always navigates to the home screen + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); return; } - const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); + // Confirm navigates up one directory (labelled "Back" when in a subdir) + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && basepath != "/") { + const std::string oldPath = basepath; + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); + if (basepath.empty()) basepath = "/"; + loadFiles(); + const auto pos = oldPath.find_last_of('/'); + const std::string dirName = oldPath.substr(pos + 1) + "/"; + selectorIndex = findEntry(dirName); + requestUpdate(); + return; + } - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Left opens the selected entry; long press deletes files + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (files.empty()) return; const std::string& entry = files[selectorIndex]; - bool isDirectory = (entry.back() == '/'); + const bool isDirectory = (entry.back() == '/'); if (mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) { - // --- LONG PRESS ACTION: DELETE FILE --- - std::string cleanBasePath = basepath; - if (cleanBasePath.back() != '/') cleanBasePath += "/"; - const std::string fullPath = cleanBasePath + entry; + // Long press: delete file + std::string cleanBase = basepath; + if (cleanBase.back() != '/') cleanBase += "/"; + const std::string fullPath = cleanBase + entry; auto handler = [this, fullPath](const ActivityResult& res) { if (!res.isCancelled) { @@ -160,10 +172,8 @@ void FileBrowserActivity::loop() { if (files.empty()) { selectorIndex = 0; } else if (selectorIndex >= files.size()) { - // Move selection to the new "last" item selectorIndex = files.size() - 1; } - requestUpdate(true); } else { LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str()); @@ -173,64 +183,56 @@ void FileBrowserActivity::loop() { } }; - std::string heading = tr(STR_DELETE) + std::string("? "); - - startActivityForResult(std::make_unique(renderer, mappedInput, heading, entry), handler); + startActivityForResult( + std::make_unique(renderer, mappedInput, tr(STR_DELETE) + std::string("? "), entry), + handler); return; - } else { - // --- SHORT PRESS ACTION: OPEN/NAVIGATE --- - if (basepath.back() != '/') basepath += "/"; + } - if (isDirectory) { - basepath += entry.substr(0, entry.length() - 1); - loadFiles(); - selectorIndex = 0; - requestUpdate(); - } else { - onSelectBook(basepath + entry); - } + // Short press: enter directory or open file + if (basepath.back() != '/') basepath += "/"; + if (isDirectory) { + basepath += entry.substr(0, entry.length() - 1); + loadFiles(); + selectorIndex = 0; + requestUpdate(); + } else { + onSelectBook(basepath + entry); } return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - // Short press: go up one directory, or go home if at root - if (mappedInput.getHeldTime() < GO_HOME_MS) { - if (basepath != "/") { - const std::string oldPath = basepath; - - basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); - if (basepath.empty()) basepath = "/"; - loadFiles(); - - const auto pos = oldPath.find_last_of('/'); - const std::string dirName = oldPath.substr(pos + 1) + "/"; - selectorIndex = findEntry(dirName); - - requestUpdate(); - } else { - onGoHome(); - } + // Right opens the info page for epub files + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + if (files.empty()) return; + const std::string& entry = files[selectorIndex]; + if (entry.back() != '/' && (FsHelpers::hasEpubExtension(entry) || FsHelpers::hasXtcExtension(entry))) { + std::string cleanBase = basepath; + if (cleanBase.back() != '/') cleanBase += "/"; + startActivityForResult(std::make_unique(renderer, mappedInput, cleanBase + entry), + [this](const ActivityResult&) { requestUpdate(); }); } + return; } - int listSize = static_cast(files.size()); - buttonNavigator.onNextRelease([this, listSize] { + // Up/Down side buttons navigate the list + const int listSize = static_cast(files.size()); + buttonNavigator.onRelease({MappedInputManager::Button::Down}, [this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); - buttonNavigator.onPreviousRelease([this, listSize] { + buttonNavigator.onRelease({MappedInputManager::Button::Up}, [this, listSize] { selectorIndex = ButtonNavigator::previousIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); - buttonNavigator.onNextContinuous([this, listSize, pageItems] { + buttonNavigator.onContinuous({MappedInputManager::Button::Down}, [this, listSize, pageItems] { selectorIndex = ButtonNavigator::nextPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); - buttonNavigator.onPreviousContinuous([this, listSize, pageItems] { + buttonNavigator.onContinuous({MappedInputManager::Button::Up}, [this, listSize, pageItems] { selectorIndex = ButtonNavigator::previousPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); @@ -265,10 +267,15 @@ void FileBrowserActivity::render(RenderLock&&) { [this](int index) { return UITheme::getFileIcon(files[index]); }); } - // Help text - const auto labels = - mappedInput.mapLabels(basepath == "/" ? tr(STR_HOME) : tr(STR_BACK), files.empty() ? "" : tr(STR_OPEN), - files.empty() ? "" : tr(STR_DIR_UP), files.empty() ? "" : tr(STR_DIR_DOWN)); + // Side buttons (Up/Down) navigate; show their hints on the side + GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + + // Front buttons: Back=Home, Confirm=Back(subdir)/empty(root), Left=Open, Right=Info(epub only) + const bool hasInfo = + !files.empty() && files[selectorIndex].back() != '/' && + (FsHelpers::hasEpubExtension(files[selectorIndex]) || FsHelpers::hasXtcExtension(files[selectorIndex])); + const auto labels = mappedInput.mapLabels(tr(STR_HOME), basepath == "/" ? "" : tr(STR_BACK), + files.empty() ? "" : tr(STR_OPEN), hasInfo ? tr(STR_INFO) : ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer();