Add bookinfo
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user