Add bookinfo

This commit is contained in:
jpirnay
2026-03-07 18:07:49 +01:00
parent 3ed96a7e49
commit 10e0680f6e
4 changed files with 313 additions and 56 deletions
+4
View File
@@ -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"
+215
View File
@@ -0,0 +1,215 @@
#include "BookInfoActivity.h"
#include <Bitmap.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Xtc.h>
#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<unsigned>(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();
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <string>
#include <vector>
#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;
};
+63 -56
View File
@@ -9,6 +9,7 @@
#include <algorithm>
#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<ConfirmationActivity>(renderer, mappedInput, heading, entry), handler);
startActivityForResult(
std::make_unique<ConfirmationActivity>(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<BookInfoActivity>(renderer, mappedInput, cleanBase + entry),
[this](const ActivityResult&) { requestUpdate(); });
}
return;
}
int listSize = static_cast<int>(files.size());
buttonNavigator.onNextRelease([this, listSize] {
// Up/Down side buttons navigate the list
const int listSize = static_cast<int>(files.size());
buttonNavigator.onRelease({MappedInputManager::Button::Down}, [this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, listSize] {
buttonNavigator.onRelease({MappedInputManager::Button::Up}, [this, listSize] {
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
requestUpdate();
});
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
buttonNavigator.onContinuous({MappedInputManager::Button::Down}, [this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
buttonNavigator.onContinuous({MappedInputManager::Button::Up}, [this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(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();