diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index afd064c6..e2e28fe8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -180,6 +180,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { } } +void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const { + if (renderMode == BW && val2bit < 3) { + drawPixel(x, y); + } else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) { + drawPixel(x, y, false); + } else if (renderMode == GRAYSCALE_LSB && val2bit == 1) { + drawPixel(x, y, false); + } +} + int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { const auto fontIt = fontMap.find(fontId); if (fontIt == fontMap.end()) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 53a7065d..f0a295ef 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -83,6 +83,7 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) const; + void drawPixelGray(int x, int y, uint8_t val2bit) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const; void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; diff --git a/platformio.ini b/platformio.ini index 4ba1c750..a86b8ca2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -72,6 +72,22 @@ build_flags = -DLOG_LEVEL=2 ; Set log level to debug for development builds +[env:mod] +extends = base +extra_scripts = + ${base.extra_scripts} + pre:scripts/inject_mod_version.py +build_flags = + ${base.build_flags} + -DOMIT_OPENDYSLEXIC + -DOMIT_HYPH_DE + -DOMIT_HYPH_ES + -DOMIT_HYPH_FR + -DOMIT_HYPH_IT + -DOMIT_HYPH_RU + -DENABLE_SERIAL_LOG + -DLOG_LEVEL=2 ; Set log level to debug for mod builds + [env:gh_release] extends = base build_flags = diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 0b2c1825..c9928529 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -5,14 +5,251 @@ #include #include #include +#include +#include +#include #include #include +#include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" +#include "util/BookSettings.h" + +#define USE_SLEEP_DOUBLE_FAST_REFRESH 1 + +namespace { + +constexpr int EDGE_SAMPLE_DEPTH = 20; +constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; } + +struct LetterboxFillData { + uint8_t avgA = 128; + uint8_t avgB = 128; + int letterboxA = 0; + int letterboxB = 0; + bool horizontal = false; + bool valid = false; +}; + +uint8_t snapToEinkLevel(uint8_t gray) { + if (gray < 43) return 0; + if (gray < 128) return 85; + if (gray < 213) return 170; + return 255; +} + +// clang-format off +constexpr uint8_t BAYER_4X4[4][4] = { + { 0, 128, 32, 160}, + {192, 64, 224, 96}, + { 48, 176, 16, 144}, + {240, 112, 208, 80} +}; +// clang-format on + +uint8_t quantizeBayerDither(int gray, int x, int y) { + const int threshold = BAYER_4X4[y & 3][x & 3]; + const int scaled = gray * 3; + if (scaled < 255) return (scaled + threshold >= 255) ? 1 : 0; + if (scaled < 510) return ((scaled - 255) + threshold >= 255) ? 2 : 1; + return ((scaled - 510) + threshold >= 255) ? 3 : 2; +} + +constexpr uint8_t EDGE_CACHE_VERSION = 2; + +bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) { + FsFile file; + if (!Storage.openFileForRead("SLP", path, file)) return false; + + uint8_t version; + serialization::readPod(file, version); + if (version != EDGE_CACHE_VERSION) { + file.close(); + return false; + } + + uint16_t cachedW, cachedH; + serialization::readPod(file, cachedW); + serialization::readPod(file, cachedH); + if (cachedW != static_cast(screenWidth) || cachedH != static_cast(screenHeight)) { + file.close(); + return false; + } + + uint8_t horizontal; + serialization::readPod(file, horizontal); + data.horizontal = (horizontal != 0); + serialization::readPod(file, data.avgA); + serialization::readPod(file, data.avgB); + + int16_t lbA, lbB; + serialization::readPod(file, lbA); + serialization::readPod(file, lbB); + data.letterboxA = lbA; + data.letterboxB = lbB; + + file.close(); + data.valid = true; + return true; +} + +bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) { + if (!data.valid) return false; + FsFile file; + if (!Storage.openFileForWrite("SLP", path, file)) return false; + + serialization::writePod(file, EDGE_CACHE_VERSION); + serialization::writePod(file, static_cast(screenWidth)); + serialization::writePod(file, static_cast(screenHeight)); + serialization::writePod(file, static_cast(data.horizontal ? 1 : 0)); + serialization::writePod(file, data.avgA); + serialization::writePod(file, data.avgB); + serialization::writePod(file, static_cast(data.letterboxA)); + serialization::writePod(file, static_cast(data.letterboxB)); + file.close(); + return true; +} + +LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight, + float scale, float cropX, float cropY) { + LetterboxFillData data; + + const int cropPixX = static_cast(std::floor(bitmap.getWidth() * cropX / 2.0f)); + const int cropPixY = static_cast(std::floor(bitmap.getHeight() * cropY / 2.0f)); + const int visibleWidth = bitmap.getWidth() - 2 * cropPixX; + const int visibleHeight = bitmap.getHeight() - 2 * cropPixY; + + if (visibleWidth <= 0 || visibleHeight <= 0) return data; + + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + if (!outputRow || !rowBytes) { + free(outputRow); + free(rowBytes); + return data; + } + + if (imgY > 0) { + data.horizontal = true; + const int scaledHeight = static_cast(std::round(static_cast(visibleHeight) * scale)); + data.letterboxA = imgY; + data.letterboxB = pageHeight - imgY - scaledHeight; + if (data.letterboxB < 0) data.letterboxB = 0; + + const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight); + uint64_t sumTop = 0, sumBot = 0; + int countTop = 0, countBot = 0; + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; + const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; + const int outY = logicalY - cropPixY; + + const bool inTop = (outY < sampleRows); + const bool inBot = (outY >= visibleHeight - sampleRows); + if (!inTop && !inBot) continue; + + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + const uint8_t gray = val2bitToGray(val); + if (inTop) { sumTop += gray; countTop++; } + if (inBot) { sumBot += gray; countBot++; } + } + } + + data.avgA = countTop > 0 ? static_cast(sumTop / countTop) : 128; + data.avgB = countBot > 0 ? static_cast(sumBot / countBot) : 128; + data.valid = true; + } else if (imgX > 0) { + data.horizontal = false; + const int scaledWidth = static_cast(std::round(static_cast(visibleWidth) * scale)); + data.letterboxA = imgX; + data.letterboxB = pageWidth - imgX - scaledWidth; + if (data.letterboxB < 0) data.letterboxB = 0; + + const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth); + uint64_t sumLeft = 0, sumRight = 0; + int countLeft = 0, countRight = 0; + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; + const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; + + for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) { + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + sumLeft += val2bitToGray(val); + countLeft++; + } + for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + sumRight += val2bitToGray(val); + countRight++; + } + } + + data.avgA = countLeft > 0 ? static_cast(sumLeft / countLeft) : 128; + data.avgB = countRight > 0 ? static_cast(sumRight / countRight) : 128; + data.valid = true; + } + + bitmap.rewindToData(); + free(outputRow); + free(rowBytes); + return data; +} + +void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) { + if (!data.valid) return; + + const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); + const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0; + const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0; + + if (data.horizontal) { + if (data.letterboxA > 0) { + for (int y = 0; y < data.letterboxA; y++) + for (int x = 0; x < renderer.getScreenWidth(); x++) { + const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y); + renderer.drawPixelGray(x, y, lv); + } + } + if (data.letterboxB > 0) { + const int start = renderer.getScreenHeight() - data.letterboxB; + for (int y = start; y < renderer.getScreenHeight(); y++) + for (int x = 0; x < renderer.getScreenWidth(); x++) { + const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y); + renderer.drawPixelGray(x, y, lv); + } + } + } else { + if (data.letterboxA > 0) { + for (int x = 0; x < data.letterboxA; x++) + for (int y = 0; y < renderer.getScreenHeight(); y++) { + const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y); + renderer.drawPixelGray(x, y, lv); + } + } + if (data.letterboxB > 0) { + const int start = renderer.getScreenWidth() - data.letterboxB; + for (int x = start; x < renderer.getScreenWidth(); x++) + for (int y = 0; y < renderer.getScreenHeight(); y++) { + const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y); + renderer.drawPixelGray(x, y, lv); + } + } + } +} + +} // namespace void SleepActivity::onEnter() { Activity::onEnter(); @@ -137,7 +374,8 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } -void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath, + uint8_t fillModeOverride) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -145,62 +383,99 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { - // image will scale, make sure placement is right float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio); if (ratio > screenRatio) { - // image wider than viewport ratio, scaled down image needs to be centered vertically if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { cropX = 1.0f - (screenRatio / ratio); - LOG_DBG("SLP", "Cropping bitmap x: %f", cropX); ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); } x = 0; y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); - LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y); } else { - // image taller than viewport ratio, scaled down image needs to be centered horizontally if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { cropY = 1.0f - (ratio / screenRatio); - LOG_DBG("SLP", "Cropping bitmap y: %f", cropY); ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); } x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); y = 0; - LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x); } } else { - // center the image x = (pageWidth - bitmap.getWidth()) / 2; y = (pageHeight - bitmap.getHeight()) / 2; } LOG_DBG("SLP", "drawing to %d x %d", x, y); + + const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); + const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight(); + const float scale = + std::min(static_cast(pageWidth) / effectiveWidth, static_cast(pageHeight) / effectiveHeight); + + const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL && + fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) + ? fillModeOverride + : SETTINGS.sleepScreenLetterboxFill; + const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE); + + LetterboxFillData fillData; + const bool hasLetterbox = (x > 0 || y > 0); + if (hasLetterbox && wantFill) { + bool cacheLoaded = false; + if (!edgeCachePath.empty()) { + cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData); + } + if (!cacheLoaded) { + fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY); + if (fillData.valid && !edgeCachePath.empty()) { + saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData); + } + } + } + renderer.clearScreen(); const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; + const bool isInverted = + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE; - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); +#if USE_SLEEP_DOUBLE_FAST_REFRESH + const bool useDoubleFast = + fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED; +#else + const bool useDoubleFast = false; +#endif - if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { - renderer.invertScreen(); + if (useDoubleFast) { + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + + if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); + if (isInverted) renderer.invertScreen(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + } else { + if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); + if (isInverted) renderer.invertScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - if (hasGreyscale) { bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); @@ -225,11 +500,10 @@ void SleepActivity::renderCoverSleepScreen() const { } std::string coverBmpPath; + std::string bookCachePath; bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; - // Check if the current book is XTC, TXT, or EPUB if (FsHelpers::hasXtcExtension(APP_STATE.openEpubPath)) { - // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { LOG_ERR("SLP", "Failed to load last XTC"); @@ -237,13 +511,18 @@ void SleepActivity::renderCoverSleepScreen() const { } if (!lastXtc.generateCoverBmp()) { + LOG_DBG("SLP", "XTC cover generation failed, trying placeholder"); + PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800); + } + + if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastXtc.getCoverBmpPath(); + bookCachePath = lastXtc.getCachePath(); } else if (FsHelpers::hasTxtExtension(APP_STATE.openEpubPath)) { - // Handle TXT file - looks for cover image in the same folder Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastTxt.load()) { LOG_ERR("SLP", "Failed to load last TXT"); @@ -251,36 +530,58 @@ void SleepActivity::renderCoverSleepScreen() const { } if (!lastTxt.generateCoverBmp()) { + LOG_DBG("SLP", "TXT cover generation failed, trying placeholder"); + PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800); + } + + if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) { LOG_ERR("SLP", "No cover image found for TXT file"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastTxt.getCoverBmpPath(); + bookCachePath = lastTxt.getCachePath(); } else if (FsHelpers::hasEpubExtension(APP_STATE.openEpubPath)) { - // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); - // Skip loading css since we only need metadata here if (!lastEpub.load(true, true)) { LOG_ERR("SLP", "Failed to load last epub"); return (this->*renderNoCoverSleepScreen)(); } if (!lastEpub.generateCoverBmp(cropped)) { + LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder"); + PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(), + lastEpub.getAuthor(), 480, 800); + } + + if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) { LOG_ERR("SLP", "Failed to generate cover bmp"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastEpub.getCoverBmpPath(cropped); + bookCachePath = lastEpub.getCachePath(); } else { return (this->*renderNoCoverSleepScreen)(); } + uint8_t fillModeOverride = BookSettings::USE_GLOBAL; + if (!bookCachePath.empty()) { + auto bookSettings = BookSettings::load(bookCachePath); + fillModeOverride = bookSettings.letterboxFillOverride; + } + FsFile file; if (Storage.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str()); - renderBitmapSleepScreen(bitmap); + std::string edgeCachePath; + const auto dotPos = coverBmpPath.rfind(".bmp"); + if (dotPos != std::string::npos) { + edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin"; + } + renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride); file.close(); return; } diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 87df8ba1..47d918bc 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,4 +1,7 @@ #pragma once + +#include + #include "../Activity.h" class Bitmap; @@ -13,6 +16,7 @@ class SleepActivity final : public Activity { void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; - void renderBitmapSleepScreen(const Bitmap& bitmap) const; + void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "", + uint8_t fillModeOverride = 0xFF) const; void renderBlankSleepScreen() const; }; diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index 6ba8f8cf..fb42fa43 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -6,10 +6,13 @@ #include +#include "BookManageMenuActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "activities/ActivityResult.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/BookManager.h" namespace { constexpr unsigned long GO_HOME_MS = 1000; @@ -44,11 +47,64 @@ void RecentBooksActivity::onExit() { recentBooks.clear(); } +void RecentBooksActivity::openManageMenu(const std::string& bookPath) { + const bool isArchived = BookManager::isArchived(bookPath); + const std::string capturedPath = bookPath; + startActivityForResult( + std::make_unique(renderer, mappedInput, capturedPath, isArchived, false), + [this, capturedPath](const ActivityResult& result) { + if (result.isCancelled) { + requestUpdate(); + return; + } + const auto& menuResult = std::get(result.data); + auto action = static_cast(menuResult.action); + bool success = false; + switch (action) { + case BookManageMenuActivity::Action::ARCHIVE: + success = BookManager::archiveBook(capturedPath); + break; + case BookManageMenuActivity::Action::UNARCHIVE: + success = BookManager::unarchiveBook(capturedPath); + break; + case BookManageMenuActivity::Action::DELETE: + success = BookManager::deleteBook(capturedPath); + break; + case BookManageMenuActivity::Action::DELETE_CACHE: + success = BookManager::deleteBookCache(capturedPath); + break; + case BookManageMenuActivity::Action::REINDEX: + success = BookManager::reindexBook(capturedPath, false); + break; + case BookManageMenuActivity::Action::REINDEX_FULL: + success = BookManager::reindexBook(capturedPath, true); + break; + } + { + RenderLock lock(*this); + GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED)); + } + loadRecentBooks(); + selectorIndex = 0; + requestUpdate(); + }); +} + void RecentBooksActivity::loop() { const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) { if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { + ignoreNextConfirmRelease = true; + openManageMenu(recentBooks[selectorIndex].path); + return; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (ignoreNextConfirmRelease) { + ignoreNextConfirmRelease = false; + } else if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str()); onSelectBook(recentBooks[selectorIndex].path); return; diff --git a/src/activities/home/RecentBooksActivity.h b/src/activities/home/RecentBooksActivity.h index b1103f04..d138391b 100644 --- a/src/activities/home/RecentBooksActivity.h +++ b/src/activities/home/RecentBooksActivity.h @@ -18,8 +18,11 @@ class RecentBooksActivity final : public Activity { // Recent tab state std::vector recentBooks; - // Data loading + bool ignoreNextConfirmRelease = false; + static constexpr unsigned long LONG_PRESS_MS = 700; + void loadRecentBooks(); + void openManageMenu(const std::string& bookPath); public: explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 659fd3e5..d6eaa684 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -3,14 +3,21 @@ #include #include +#include +#include +#include + #include "ButtonRemapActivity.h" -#include "CalibreSettingsActivity.h" #include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "LanguageSelectActivity.h" #include "MappedInputManager.h" +#include "NtpSyncActivity.h" +#include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" +#include "SetTimeActivity.h" +#include "SetTimezoneOffsetActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" #include "activities/network/WifiSelectionActivity.h" @@ -18,13 +25,15 @@ #include "fontIds.h" const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER, - StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM}; + StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM, + StrId::STR_CAT_CLOCK}; void SettingsActivity::onEnter() { Activity::onEnter(); // Build per-category vectors from the shared settings list displaySettings.clear(); + clockSettings.clear(); readerSettings.clear(); controlsSettings.clear(); systemSettings.clear(); @@ -33,6 +42,8 @@ void SettingsActivity::onEnter() { if (setting.category == StrId::STR_NONE_OPT) continue; if (setting.category == StrId::STR_CAT_DISPLAY) { displaySettings.push_back(setting); + } else if (setting.category == StrId::STR_CAT_CLOCK) { + clockSettings.push_back(setting); } else if (setting.category == StrId::STR_CAT_READER) { readerSettings.push_back(setting); } else if (setting.category == StrId::STR_CAT_CONTROLS) { @@ -40,10 +51,10 @@ void SettingsActivity::onEnter() { } else if (setting.category == StrId::STR_CAT_SYSTEM) { systemSettings.push_back(setting); } - // Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI } // Append device-only ACTION items + rebuildClockActions(); controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); @@ -132,6 +143,9 @@ void SettingsActivity::loop() { case 3: currentSettings = &systemSettings; break; + case 4: + currentSettings = &clockSettings; + break; } settingsCount = static_cast(currentSettings->size()); } @@ -173,7 +187,7 @@ void SettingsActivity::toggleCurrentSetting() { startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; case SettingAction::OPDSBrowser: - startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + startActivityForResult(std::make_unique(renderer, mappedInput, false), resultHandler); break; case SettingAction::Network: startActivityForResult(std::make_unique(renderer, mappedInput, false), resultHandler); @@ -187,16 +201,51 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::Language: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::SetTime: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; + case SettingAction::SetTimezoneOffset: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; + case SettingAction::SyncClock: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::None: - // Do nothing break; } - return; // Results will be handled in the result handler, so we can return early here + return; + } else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) { + const uint8_t currentValue = setting.valueGetter(); + setting.valueSetter((currentValue + 1) % static_cast(setting.enumValues.size())); } else { return; } SETTINGS.saveToFile(); + + setenv("TZ", SETTINGS.getTimezonePosixStr(), 1); + tzset(); + rebuildClockActions(); +} + +void SettingsActivity::rebuildClockActions() { + clockSettings.erase(std::remove_if(clockSettings.begin(), clockSettings.end(), + [](const SettingInfo& s) { return s.type == SettingType::ACTION; }), + clockSettings.end()); + + clockSettings.push_back(SettingInfo::Action(StrId::STR_SYNC_CLOCK, SettingAction::SyncClock)); + clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime)); + + if (SETTINGS.timezone == CrossPointSettings::TZ_CUSTOM) { + clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_UTC_OFFSET, SettingAction::SetTimezoneOffset)); + } + + if (currentSettings == &clockSettings) { + settingsCount = static_cast(clockSettings.size()); + if (selectedSettingIndex > settingsCount) { + selectedSettingIndex = settingsCount; + } + } } void SettingsActivity::render(RenderLock&&) { @@ -235,6 +284,11 @@ void SettingsActivity::render(RenderLock&&) { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t value = SETTINGS.*(setting.valuePtr); valueText = I18N.get(setting.enumValues[value]); + } else if (setting.type == SettingType::ENUM && setting.valueGetter) { + const uint8_t value = setting.valueGetter(); + if (value < setting.enumValues.size()) { + valueText = I18N.get(setting.enumValues[value]); + } } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { valueText = std::to_string(SETTINGS.*(setting.valuePtr)); } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 9d5778a9..bcd9d5df 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -21,6 +21,9 @@ enum class SettingAction { ClearCache, CheckForUpdates, Language, + SetTime, + SetTimezoneOffset, + SyncClock, }; struct SettingInfo { @@ -148,16 +151,18 @@ class SettingsActivity final : public Activity { // Per-category settings derived from shared list + device-only actions std::vector displaySettings; + std::vector clockSettings; std::vector readerSettings; std::vector controlsSettings; std::vector systemSettings; const std::vector* currentSettings = nullptr; - static constexpr int categoryCount = 4; + static constexpr int categoryCount = 5; static const StrId categoryNames[categoryCount]; void enterCategory(int categoryIndex); void toggleCurrentSetting(); + void rebuildClockActions(); public: explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) diff --git a/src/components/UITheme.h b/src/components/UITheme.h index daa1ec45..e51a7eaa 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -30,5 +30,10 @@ class UITheme { std::unique_ptr currentTheme; }; +// Known theme thumbnail heights to prerender when opening a book for the first time. +// Correspond to homeCoverHeight values across all themes (Lyra=226, Base=400). +static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400}; +static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2; + // Helper macro to access current theme #define GUI UITheme::getInstance().getTheme()