337 lines
12 KiB
C++
337 lines
12 KiB
C++
#include "BookInfoActivity.h"
|
|
|
|
#include <Bitmap.h>
|
|
#include <Epub.h>
|
|
#include <FsHelpers.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <Logging.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& metrics = UITheme::getInstance().getMetrics();
|
|
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
|
|
|
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
|
tr(STR_INFO));
|
|
|
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
|
renderer.drawText(UI_12_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop, tr(STR_LOADING));
|
|
|
|
renderer.displayBuffer();
|
|
}
|
|
|
|
void BookInfoActivity::loadData() {
|
|
loadSucceeded = false;
|
|
loadError.clear();
|
|
|
|
// 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");
|
|
if (!epub.load(true, true)) {
|
|
loadError = tr(STR_LOAD_EPUB_FAILED);
|
|
LOG_ERR("BookInfo", "Failed to load EPUB metadata: %s", filePath.c_str());
|
|
return;
|
|
}
|
|
|
|
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 : "";
|
|
loadSucceeded = true;
|
|
} else if (FsHelpers::hasXtcExtension(filePath)) {
|
|
Xtc xtc(filePath, "/.crosspoint");
|
|
if (!xtc.load()) {
|
|
loadError = tr(STR_LOAD_XTC_FAILED);
|
|
LOG_ERR("BookInfo", "Failed to load XTC metadata: %s", filePath.c_str());
|
|
return;
|
|
}
|
|
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 : "";
|
|
loadSucceeded = true;
|
|
} else {
|
|
loadError = tr(STR_ERROR_GENERAL_FAILURE);
|
|
LOG_ERR("BookInfo", "Unsupported file type for book info: %s", filePath.c_str());
|
|
}
|
|
}
|
|
|
|
void BookInfoActivity::onEnter() {
|
|
Activity::onEnter();
|
|
fullRenderDone = false;
|
|
{
|
|
RenderLock lock(*this);
|
|
renderLoading();
|
|
}
|
|
loadData();
|
|
requestUpdate(true);
|
|
}
|
|
|
|
void BookInfoActivity::loop() {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
finish();
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
|
if (descPage > 0) {
|
|
descPage--;
|
|
requestUpdate(true);
|
|
}
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
|
if (descPage + 1 < descTotalPages) {
|
|
descPage++;
|
|
requestUpdate(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
void BookInfoActivity::render(RenderLock&&) {
|
|
// Fast path: only the description page / button hints changed. Reuse the
|
|
// already-rendered header/cover/meta by clearing just those two bands. Cap
|
|
// consecutive partial renders to bound e-ink ghosting buildup.
|
|
//
|
|
// Requires Portrait orientation: the hints-gutter location is derived below
|
|
// assuming the gutter sits at the bottom, which is only true in Portrait
|
|
// (PortraitInverted puts it at the top; landscape orientations put it on a
|
|
// side). In non-Portrait we fall through to a full render to avoid leaving
|
|
// stale button labels in the real gutter.
|
|
if (fullRenderDone && loadSucceeded && !description.empty() && partialRenderCount < MAX_PARTIAL_RENDERS &&
|
|
renderer.getOrientation() == GfxRenderer::Portrait) {
|
|
renderer.fillRect(descBandX, descBandY, descBandWidth, descBandHeight, false);
|
|
renderer.fillRect(0, hintsBandY, renderer.getScreenWidth(), hintsBandHeight, false);
|
|
renderDescriptionAndHints();
|
|
++partialRenderCount;
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
partialRenderCount = 0;
|
|
renderer.clearScreen();
|
|
|
|
const auto& metrics = UITheme::getInstance().getMetrics();
|
|
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
|
|
|
// Header
|
|
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
|
tr(STR_INFO));
|
|
|
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
|
const int contentBottom = contentRect.y + contentRect.height - metrics.verticalSpacing;
|
|
const int textX = contentRect.x + metrics.contentSidePadding;
|
|
const int textWidth = contentRect.width - metrics.contentSidePadding * 2;
|
|
const int lineHeightSmall = renderer.getLineHeight(UI_10_FONT_ID);
|
|
const int lineHeightLarge = renderer.getLineHeight(UI_12_FONT_ID);
|
|
|
|
if (!loadSucceeded) {
|
|
const auto lastSlash = filePath.find_last_of('/');
|
|
const std::string fileName = (lastSlash == std::string::npos) ? filePath : filePath.substr(lastSlash + 1);
|
|
const auto titleLines = renderer.wrappedText(UI_12_FONT_ID, fileName.c_str(), textWidth, 2);
|
|
const auto errorLines = renderer.wrappedText(
|
|
UI_10_FONT_ID, loadError.empty() ? tr(STR_ERROR_GENERAL_FAILURE) : loadError.c_str(), textWidth, 3);
|
|
|
|
int y = contentTop + metrics.verticalSpacing;
|
|
for (const auto& line : titleLines) {
|
|
if (y + lineHeightLarge > contentBottom) break;
|
|
renderer.drawText(UI_12_FONT_ID, textX, y, line.c_str(), true, EpdFontFamily::BOLD);
|
|
y += lineHeightLarge;
|
|
}
|
|
y += metrics.verticalSpacing;
|
|
for (const auto& line : errorLines) {
|
|
if (y + lineHeightSmall > contentBottom) break;
|
|
renderer.drawText(UI_10_FONT_ID, textX, y, line.c_str());
|
|
y += lineHeightSmall;
|
|
}
|
|
|
|
if (fileSizeBytes > 0 && y + lineHeightSmall <= contentBottom) {
|
|
y += metrics.verticalSpacing;
|
|
const std::string sizeLine = std::string(tr(STR_FILE_SIZE)) + ": " + formatFileSize(fileSizeBytes);
|
|
renderer.drawText(UI_10_FONT_ID, textX, y, sizeLine.c_str());
|
|
}
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
// 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, paged via Left/Right ---
|
|
descTotalPages = 0;
|
|
descLinesPerPage = 0;
|
|
descBandX = textX;
|
|
descBandWidth = textWidth;
|
|
descBandY = contentBottom;
|
|
descBandHeight = 0;
|
|
if (!description.empty()) {
|
|
int y = topSectionBottom + metrics.verticalSpacing;
|
|
if (y + lineHeightSmall + 4 < contentBottom) {
|
|
renderer.drawLine(textX, y, contentRect.x + contentRect.width - metrics.contentSidePadding, y);
|
|
y += 4;
|
|
|
|
// Record the description band (below the separator) for partial redraws.
|
|
descBandY = y;
|
|
descBandHeight = contentBottom - y;
|
|
}
|
|
}
|
|
|
|
// Hints band spans from the bottom of the content area to the screen bottom.
|
|
hintsBandY = contentRect.y + contentRect.height;
|
|
hintsBandHeight = renderer.getScreenHeight() - hintsBandY;
|
|
|
|
renderDescriptionAndHints();
|
|
|
|
fullRenderDone = true;
|
|
renderer.displayBuffer();
|
|
}
|
|
|
|
void BookInfoActivity::renderDescriptionAndHints() {
|
|
const int lineHeightSmall = renderer.getLineHeight(UI_10_FONT_ID);
|
|
|
|
descTotalPages = 0;
|
|
descLinesPerPage = 0;
|
|
|
|
if (!description.empty() && descBandHeight > 0) {
|
|
descLinesPerPage = descBandHeight / lineHeightSmall;
|
|
if (descLinesPerPage > 0) {
|
|
if (descLines.empty() || descWrappedWidth != descBandWidth) {
|
|
descLines = renderer.wrappedText(UI_10_FONT_ID, description.c_str(), descBandWidth, 1000);
|
|
descWrappedWidth = descBandWidth;
|
|
}
|
|
const int totalLines = static_cast<int>(descLines.size());
|
|
descTotalPages = (totalLines + descLinesPerPage - 1) / descLinesPerPage;
|
|
if (descPage >= descTotalPages) descPage = std::max(0, descTotalPages - 1);
|
|
|
|
const int startLine = descPage * descLinesPerPage;
|
|
const int endLine = std::min(totalLines, startLine + descLinesPerPage);
|
|
int y = descBandY;
|
|
const int bandBottom = descBandY + descBandHeight;
|
|
for (int i = startLine; i < endLine; ++i) {
|
|
if (y + lineHeightSmall > bandBottom) break;
|
|
renderer.drawText(UI_10_FONT_ID, descBandX, y, descLines[i].c_str());
|
|
y += lineHeightSmall;
|
|
}
|
|
}
|
|
}
|
|
|
|
const char* prevLabel = (descPage > 0) ? tr(STR_PREV) : "";
|
|
const char* nextLabel = (descPage + 1 < descTotalPages) ? tr(STR_NEXT) : "";
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", prevLabel, nextLabel);
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|