Compare commits
11 Commits
a9f5149444
...
7819cf0f77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7819cf0f77
|
||
|
|
3d7340ca6f
|
||
|
|
966fbef3d1
|
||
|
|
38a87298f3
|
||
|
|
ab4540b26f
|
||
|
|
7e15c9835f
|
||
|
|
7b3de29c59
|
||
|
|
1d7971ae60
|
||
|
|
61fb11cae3
|
||
|
|
424e332c75
|
||
|
|
f21720dc79
|
10
docs/i18n.md
10
docs/i18n.md
@@ -2,8 +2,16 @@
|
||||
|
||||
This guide explains the multi-language support system in CrossPoint Reader.
|
||||
|
||||
## Supported Languages (Updating)
|
||||
## Supported Languages
|
||||
|
||||
- English
|
||||
- French
|
||||
- German
|
||||
- Portuguese
|
||||
- Spanish
|
||||
- Swedish
|
||||
- Czech
|
||||
- Russian
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <HalStorage.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
#include <PngToBmpConverter.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -518,12 +519,45 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
|
||||
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||
}
|
||||
|
||||
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
|
||||
if (isPng) {
|
||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile coverBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
coverPng.close();
|
||||
return false;
|
||||
}
|
||||
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
|
||||
coverPng.close();
|
||||
coverBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
}
|
||||
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -611,9 +645,46 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
}
|
||||
|
||||
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
|
||||
if (isPng) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
|
||||
FsFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverPng.close();
|
||||
return false;
|
||||
}
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success =
|
||||
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
||||
coverPng.close();
|
||||
thumbBmp.close();
|
||||
Storage.remove(coverPngTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
}
|
||||
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -970,7 +1041,7 @@ bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
|
||||
|
||||
std::vector<std::string> Epub::getCoverCandidates() const {
|
||||
std::vector<std::string> coverDirectories = {".", "images", "Images", "OEBPS", "OEBPS/images", "OEBPS/Images"};
|
||||
std::vector<std::string> coverExtensions = {".jpg", ".jpeg"}; // add ".png" when PNG cover support is implemented
|
||||
std::vector<std::string> coverExtensions = {".jpg", ".jpeg", ".png"};
|
||||
std::vector<std::string> coverCandidates;
|
||||
for (const auto& ext : coverExtensions) {
|
||||
for (const auto& dir : coverDirectories) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#include "ImageBlock.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
@@ -47,8 +46,8 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||
if (widthDiff > 1 || heightDiff > 1) {
|
||||
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
|
||||
expectedWidth, expectedHeight);
|
||||
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
|
||||
expectedHeight);
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -57,20 +56,20 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
expectedWidth = cachedWidth;
|
||||
expectedHeight = cachedHeight;
|
||||
|
||||
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// Read and render row by row to minimize memory usage
|
||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||
if (!rowBuffer) {
|
||||
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
|
||||
LOG_ERR("IMG", "Failed to allocate row buffer");
|
||||
cacheFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int row = 0; row < cachedHeight; row++) {
|
||||
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
|
||||
LOG_ERR("IMG", "Cache read error at row %d", row);
|
||||
free(rowBuffer);
|
||||
cacheFile.close();
|
||||
return false;
|
||||
@@ -88,22 +87,22 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
free(rowBuffer);
|
||||
cacheFile.close();
|
||||
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
|
||||
LOG_DBG("IMG", "Cache render complete");
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
|
||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
|
||||
// Bounds check render position using logical screen dimensions
|
||||
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
||||
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
|
||||
height, screenWidth, screenHeight);
|
||||
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
|
||||
screenHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,18 +116,18 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
// Check if image file exists
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("IMG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
size_t fileSize = file.size();
|
||||
file.close();
|
||||
|
||||
if (fileSize == 0) {
|
||||
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
|
||||
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
|
||||
|
||||
RenderConfig config;
|
||||
config.x = x;
|
||||
@@ -143,19 +142,19 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||
if (!decoder) {
|
||||
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
|
||||
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
|
||||
|
||||
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
|
||||
LOG_DBG("IMG", "Decode successful");
|
||||
}
|
||||
|
||||
bool ImageBlock::serialize(FsFile& file) {
|
||||
|
||||
@@ -30,7 +30,7 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||
const char* visiblePtr = w.c_str() + 3;
|
||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
|
||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83");
|
||||
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
||||
startX = wordX + prefixWidth;
|
||||
underlineWidth = visibleWidth;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "ImageDecoderFactory.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -35,7 +35,7 @@ ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& im
|
||||
return pngDecoder.get();
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
||||
if (width * height > MAX_SOURCE_PIXELS) {
|
||||
Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width,
|
||||
height, width * height, format.c_str(), MAX_SOURCE_PIXELS);
|
||||
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
|
||||
format.c_str(), MAX_SOURCE_PIXELS);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
||||
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
|
||||
millis(), feature.c_str(), imagePath.c_str());
|
||||
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
|
||||
imagePath.c_str());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
#include <picojpeg.h>
|
||||
@@ -23,7 +23,7 @@ struct JpegContext {
|
||||
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,23 +34,23 @@ bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePat
|
||||
file.close();
|
||||
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
|
||||
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = imageInfo.m_width;
|
||||
out.height = imageInfo.m_height;
|
||||
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
|
||||
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
|
||||
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
|
||||
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
|
||||
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
|
||||
LOG_ERR("JPG", "picojpeg init failed: %d", status);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -93,12 +93,11 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
destHeight = (int)(imageInfo.m_height * scale);
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
|
||||
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
|
||||
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
||||
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
|
||||
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
||||
|
||||
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
|
||||
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
|
||||
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -111,7 +110,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
bool caching = !config.cachePath.empty();
|
||||
if (caching) {
|
||||
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
||||
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
|
||||
caching = false;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +124,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
break;
|
||||
}
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
|
||||
LOG_ERR("JPG", "MCU decode failed: %d", status);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -254,7 +253,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
|
||||
LOG_DBG("JPG", "Decoding complete");
|
||||
file.close();
|
||||
|
||||
// Write cache file if caching was enabled
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <cstring>
|
||||
@@ -32,14 +31,13 @@ struct PixelCache {
|
||||
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
size_t bufferSize = (size_t)bytesPerRow * h;
|
||||
if (bufferSize > MAX_CACHE_BYTES) {
|
||||
Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h,
|
||||
MAX_CACHE_BYTES);
|
||||
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
|
||||
return false;
|
||||
}
|
||||
buffer = (uint8_t*)malloc(bufferSize);
|
||||
if (buffer) {
|
||||
memset(buffer, 0, bufferSize);
|
||||
Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
|
||||
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
|
||||
}
|
||||
return buffer != nullptr;
|
||||
}
|
||||
@@ -60,7 +58,7 @@ struct PixelCache {
|
||||
|
||||
FsFile cacheFile;
|
||||
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
|
||||
Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
|
||||
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,8 +69,7 @@ struct PixelCache {
|
||||
cacheFile.write(buffer, bytesPerRow * height);
|
||||
cacheFile.close();
|
||||
|
||||
Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
|
||||
4 + bytesPerRow * height);
|
||||
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <PNGdec.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <SdFat.h>
|
||||
@@ -216,14 +216,13 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
|
||||
MIN_FREE_HEAP_FOR_PNG);
|
||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis());
|
||||
LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -231,7 +230,7 @@ bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath
|
||||
nullptr);
|
||||
|
||||
if (rc != 0) {
|
||||
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
|
||||
LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
@@ -246,19 +245,18 @@ bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath
|
||||
|
||||
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||
const RenderConfig& config) {
|
||||
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
|
||||
LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str());
|
||||
|
||||
size_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
||||
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
|
||||
MIN_FREE_HEAP_FOR_PNG);
|
||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
|
||||
PNG* png = new (std::nothrow) PNG();
|
||||
if (!png) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis());
|
||||
LOG_ERR("PNG", "Failed to allocate PNG decoder");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -271,7 +269,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||
pngDrawCallback);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
|
||||
LOG_ERR("PNG", "Failed to open PNG: %d", rc);
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
@@ -303,8 +301,8 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
}
|
||||
ctx.lastDstY = -1; // Reset row tracking
|
||||
|
||||
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight,
|
||||
ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp());
|
||||
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
|
||||
ctx.scale, png->getBpp());
|
||||
|
||||
if (png->getBpp() != 8) {
|
||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||
@@ -314,7 +312,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
|
||||
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
|
||||
if (!ctx.grayLineBuffer) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis());
|
||||
LOG_ERR("PNG", "Failed to allocate gray line buffer");
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
@@ -324,7 +322,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
ctx.caching = !config.cachePath.empty();
|
||||
if (ctx.caching) {
|
||||
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
|
||||
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
|
||||
ctx.caching = false;
|
||||
}
|
||||
}
|
||||
@@ -337,7 +335,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
ctx.grayLineBuffer = nullptr;
|
||||
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
|
||||
LOG_ERR("PNG", "Decode failed: %d", rc);
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
@@ -345,7 +343,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
|
||||
png->close();
|
||||
delete png;
|
||||
Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
|
||||
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
|
||||
|
||||
// Write cache file if caching was enabled and buffer was allocated
|
||||
if (ctx.caching) {
|
||||
|
||||
@@ -279,7 +279,7 @@ void GfxRenderer::drawPixelDither<Color::LightGray>(const int x, const int y) co
|
||||
|
||||
template <>
|
||||
void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) const {
|
||||
drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern?
|
||||
drawPixel(x, y, (x + y) % 2 == 0);
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
|
||||
|
||||
@@ -119,6 +119,7 @@ enum class StrId : uint16_t {
|
||||
STR_CAT_READER,
|
||||
STR_CAT_CONTROLS,
|
||||
STR_CAT_SYSTEM,
|
||||
STR_CAT_CLOCK,
|
||||
STR_SLEEP_SCREEN,
|
||||
STR_SLEEP_COVER_MODE,
|
||||
STR_STATUS_BAR,
|
||||
@@ -365,6 +366,30 @@ enum class StrId : uint16_t {
|
||||
STR_DICT_CACHE_DELETED,
|
||||
STR_NO_CACHE_TO_DELETE,
|
||||
STR_TABLE_OF_CONTENTS,
|
||||
STR_TOGGLE_ORIENTATION,
|
||||
STR_TOGGLE_FONT_SIZE,
|
||||
STR_OVERRIDE_LETTERBOX_FILL,
|
||||
STR_PREFERRED_PORTRAIT,
|
||||
STR_PREFERRED_LANDSCAPE,
|
||||
STR_CHOOSE_SOMETHING,
|
||||
STR_CLOCK,
|
||||
STR_CLOCK_AMPM,
|
||||
STR_CLOCK_24H,
|
||||
STR_SET_TIME,
|
||||
STR_CLOCK_SIZE,
|
||||
STR_CLOCK_SIZE_SMALL,
|
||||
STR_CLOCK_SIZE_MEDIUM,
|
||||
STR_CLOCK_SIZE_LARGE,
|
||||
STR_TIMEZONE,
|
||||
STR_TZ_UTC,
|
||||
STR_TZ_EASTERN,
|
||||
STR_TZ_CENTRAL,
|
||||
STR_TZ_MOUNTAIN,
|
||||
STR_TZ_PACIFIC,
|
||||
STR_TZ_ALASKA,
|
||||
STR_TZ_HAWAII,
|
||||
STR_TZ_CUSTOM,
|
||||
STR_SET_UTC_OFFSET,
|
||||
// Sentinel - must be last
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čtečka"
|
||||
STR_CAT_CONTROLS: "Ovládací prvky"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_CAT_CLOCK: "Hodiny"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||
STR_STATUS_BAR: "Stavový řádek"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Nahrát"
|
||||
STR_BOOK_S_STYLE: "Styl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Vyberte si něco ke čtení"
|
||||
STR_CLOCK: "Hodiny"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 hodin"
|
||||
STR_SET_TIME: "Nastavit čas"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Display"
|
||||
STR_CAT_READER: "Reader"
|
||||
STR_CAT_CONTROLS: "Controls"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Clock"
|
||||
STR_SLEEP_SCREEN: "Sleep Screen"
|
||||
STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
|
||||
STR_STATUS_BAR: "Status Bar"
|
||||
@@ -281,7 +282,7 @@ STR_HW_LEFT_LABEL: "Left (3rd button)"
|
||||
STR_HW_RIGHT_LABEL: "Right (4th button)"
|
||||
STR_GO_TO_PERCENT: "Go to %"
|
||||
STR_GO_HOME_BUTTON: "Go Home"
|
||||
STR_SYNC_PROGRESS: "Sync Progress"
|
||||
STR_SYNC_PROGRESS: "Sync Reading Progress"
|
||||
STR_DELETE_CACHE: "Delete Book Cache"
|
||||
STR_CHAPTER_PREFIX: "Chapter: "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
@@ -331,3 +332,27 @@ STR_BOOKMARK_REMOVED: "Bookmark removed"
|
||||
STR_DICT_CACHE_DELETED: "Dictionary cache deleted"
|
||||
STR_NO_CACHE_TO_DELETE: "No cache to delete"
|
||||
STR_TABLE_OF_CONTENTS: "Table of Contents"
|
||||
STR_TOGGLE_ORIENTATION: "Toggle Portrait/Landscape"
|
||||
STR_TOGGLE_FONT_SIZE: "Toggle Font Size"
|
||||
STR_OVERRIDE_LETTERBOX_FILL: "Override Letterbox Fill"
|
||||
STR_PREFERRED_PORTRAIT: "Preferred Portrait"
|
||||
STR_PREFERRED_LANDSCAPE: "Preferred Landscape"
|
||||
STR_CHOOSE_SOMETHING: "Choose something to read"
|
||||
STR_CLOCK: "Clock"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Hour"
|
||||
STR_SET_TIME: "Set Time"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Affichage"
|
||||
STR_CAT_READER: "Lecteur"
|
||||
STR_CAT_CONTROLS: "Commandes"
|
||||
STR_CAT_SYSTEM: "Système"
|
||||
STR_CAT_CLOCK: "Horloge"
|
||||
STR_SLEEP_SCREEN: "Écran de veille"
|
||||
STR_SLEEP_COVER_MODE: "Mode d’image de l’écran de veille"
|
||||
STR_STATUS_BAR: "Barre d’état"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Envoi"
|
||||
STR_BOOK_S_STYLE: "Style du livre"
|
||||
STR_EMBEDDED_STYLE: "Style intégré"
|
||||
STR_OPDS_SERVER_URL: "URL du serveur OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Choisissez quelque chose à lire"
|
||||
STR_CLOCK: "Horloge"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 heures"
|
||||
STR_SET_TIME: "Régler l'heure"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Anzeige"
|
||||
STR_CAT_READER: "Lesen"
|
||||
STR_CAT_CONTROLS: "Bedienung"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Uhr"
|
||||
STR_SLEEP_SCREEN: "Standby-Bild"
|
||||
STR_SLEEP_COVER_MODE: "Standby-Bildmodus"
|
||||
STR_STATUS_BAR: "Statusleiste"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Hochladen"
|
||||
STR_BOOK_S_STYLE: "Buch-Stil"
|
||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||
STR_CHOOSE_SOMETHING: "Wähle etwas zum Lesen"
|
||||
STR_CLOCK: "Uhr"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Stunden"
|
||||
STR_SET_TIME: "Uhrzeit einstellen"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Tela"
|
||||
STR_CAT_READER: "Leitor"
|
||||
STR_CAT_CONTROLS: "Controles"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_CAT_CLOCK: "Relógio"
|
||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
||||
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||
STR_STATUS_BAR: "Barra de status"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Enviar"
|
||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Escolha algo para ler"
|
||||
STR_CLOCK: "Relógio"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Definir hora"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Экран"
|
||||
STR_CAT_READER: "Чтение"
|
||||
STR_CAT_CONTROLS: "Управление"
|
||||
STR_CAT_SYSTEM: "Система"
|
||||
STR_CAT_CLOCK: "Часы"
|
||||
STR_SLEEP_SCREEN: "Экран сна"
|
||||
STR_SLEEP_COVER_MODE: "Режим обложки сна"
|
||||
STR_STATUS_BAR: "Строка состояния"
|
||||
@@ -109,7 +110,7 @@ STR_COLOR_MODE: "Цветовой режим"
|
||||
STR_SCREEN_MARGIN: "Поля экрана"
|
||||
STR_PARA_ALIGNMENT: "Выравнивание абзаца"
|
||||
STR_HYPHENATION: "Перенос слов"
|
||||
STR_TIME_TO_SLEEP: "Сон Через"
|
||||
STR_TIME_TO_SLEEP: "Сон через"
|
||||
STR_REFRESH_FREQ: "Частота обновления"
|
||||
STR_CALIBRE_SETTINGS: "Настройки Calibre"
|
||||
STR_KOREADER_SYNC: "Синхронизация KOReader"
|
||||
@@ -164,9 +165,9 @@ STR_PORTRAIT: "Портрет"
|
||||
STR_LANDSCAPE_CW: "Ландшафт (CW)"
|
||||
STR_INVERTED: "Инверсия"
|
||||
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
|
||||
STR_FRONT_LAYOUT_BCLR: "Наз"
|
||||
STR_FRONT_LAYOUT_LRBC: "Лев"
|
||||
STR_FRONT_LAYOUT_LBCR: "Лев"
|
||||
STR_FRONT_LAYOUT_BCLR: "Наз, Ок, Лев, Прав"
|
||||
STR_FRONT_LAYOUT_LRBC: "Лев, Прав, Наз, Ок"
|
||||
STR_FRONT_LAYOUT_LBCR: "Лев, Наз, Ок, Прав"
|
||||
STR_PREV_NEXT: "Назад/Вперёд"
|
||||
STR_NEXT_PREV: "Вперёд/Назад"
|
||||
STR_BOOKERLY: "Bookerly"
|
||||
@@ -203,7 +204,7 @@ STR_NO_UPDATE: "Обновлений нет"
|
||||
STR_UPDATE_FAILED: "Ошибка обновления"
|
||||
STR_UPDATE_COMPLETE: "Обновление завершено"
|
||||
STR_POWER_ON_HINT: "Удерживайте кнопку питания для включения"
|
||||
STR_EXTERNAL_FONT: "Внешний шрифт"
|
||||
STR_EXTERNAL_FONT: "Пользовательский шрифт"
|
||||
STR_BUILTIN_DISABLED: "Встроенный (отключён)"
|
||||
STR_NO_ENTRIES: "Записи не найдены"
|
||||
STR_DOWNLOADING: "Загрузка..."
|
||||
@@ -246,7 +247,7 @@ STR_CAPS_ON: "CAPS"
|
||||
STR_CAPS_OFF: "caps"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_ON_MARKER: "[ВКЛ]"
|
||||
STR_SLEEP_COVER_FILTER: "Фильтр обложки сна"
|
||||
STR_SLEEP_COVER_FILTER: "Фильтр экрана сна"
|
||||
STR_FILTER_CONTRAST: "Контраст"
|
||||
STR_STATUS_BAR_FULL_PERCENT: "Полная + %"
|
||||
STR_STATUS_BAR_FULL_BOOK: "Полная + шкала книги"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Отправить"
|
||||
STR_BOOK_S_STYLE: "Стиль книги"
|
||||
STR_EMBEDDED_STYLE: "Встроенный стиль"
|
||||
STR_OPDS_SERVER_URL: "URL OPDS сервера"
|
||||
STR_CHOOSE_SOMETHING: "Выберите что-нибудь для чтения"
|
||||
STR_CLOCK: "Часы"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 часа"
|
||||
STR_SET_TIME: "Установить время"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Pantalla"
|
||||
STR_CAT_READER: "Lector"
|
||||
STR_CAT_CONTROLS: "Control"
|
||||
STR_CAT_SYSTEM: "Sistema"
|
||||
STR_CAT_CLOCK: "Reloj"
|
||||
STR_SLEEP_SCREEN: "Salva Pantallas"
|
||||
STR_SLEEP_COVER_MODE: "Modo de salva pantallas"
|
||||
STR_STATUS_BAR: "Barra de estado"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Subir"
|
||||
STR_BOOK_S_STYLE: "Estilo del libro"
|
||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Elige algo para leer"
|
||||
STR_CLOCK: "Reloj"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Establecer hora"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
@@ -85,6 +85,7 @@ STR_CAT_DISPLAY: "Skärm"
|
||||
STR_CAT_READER: "Läsare"
|
||||
STR_CAT_CONTROLS: "Kontroller"
|
||||
STR_CAT_SYSTEM: "System"
|
||||
STR_CAT_CLOCK: "Klocka"
|
||||
STR_SLEEP_SCREEN: "Viloskärm"
|
||||
STR_SLEEP_COVER_MODE: "Viloskärmens omslagsläge"
|
||||
STR_STATUS_BAR: "Statusrad"
|
||||
@@ -315,3 +316,22 @@ STR_UPLOAD: "Uppladdning"
|
||||
STR_BOOK_S_STYLE: "Bokstil"
|
||||
STR_EMBEDDED_STYLE: "Inbäddad stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
||||
STR_CHOOSE_SOMETHING: "Välj något att läsa"
|
||||
STR_CLOCK: "Klocka"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 timmar"
|
||||
STR_SET_TIME: "Ställ in tid"
|
||||
STR_CLOCK_SIZE: "Clock Size"
|
||||
STR_CLOCK_SIZE_SMALL: "Small"
|
||||
STR_CLOCK_SIZE_MEDIUM: "Medium"
|
||||
STR_CLOCK_SIZE_LARGE: "Large"
|
||||
STR_TIMEZONE: "Timezone"
|
||||
STR_TZ_UTC: "UTC"
|
||||
STR_TZ_EASTERN: "Eastern"
|
||||
STR_TZ_CENTRAL: "Central"
|
||||
STR_TZ_MOUNTAIN: "Mountain"
|
||||
STR_TZ_PACIFIC: "Pacific"
|
||||
STR_TZ_ALASKA: "Alaska"
|
||||
STR_TZ_HAWAII: "Hawaii"
|
||||
STR_TZ_CUSTOM: "Custom"
|
||||
STR_SET_UTC_OFFSET: "Set UTC Offset"
|
||||
|
||||
858
lib/PngToBmpConverter/PngToBmpConverter.cpp
Normal file
858
lib/PngToBmpConverter/PngToBmpConverter.cpp
Normal file
@@ -0,0 +1,858 @@
|
||||
#include "PngToBmpConverter.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <miniz.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "BitmapHelpers.h"
|
||||
|
||||
// ============================================================================
|
||||
// IMAGE PROCESSING OPTIONS - Same as JpegToBmpConverter for consistency
|
||||
// ============================================================================
|
||||
constexpr bool USE_8BIT_OUTPUT = false;
|
||||
constexpr bool USE_ATKINSON = true;
|
||||
constexpr bool USE_FLOYD_STEINBERG = false;
|
||||
constexpr bool USE_PRESCALE = true;
|
||||
constexpr int TARGET_MAX_WIDTH = 480;
|
||||
constexpr int TARGET_MAX_HEIGHT = 800;
|
||||
// ============================================================================
|
||||
|
||||
// PNG constants
|
||||
static constexpr uint8_t PNG_SIGNATURE[8] = {137, 80, 78, 71, 13, 10, 26, 10};
|
||||
|
||||
// PNG color types
|
||||
enum PngColorType : uint8_t {
|
||||
PNG_COLOR_GRAYSCALE = 0,
|
||||
PNG_COLOR_RGB = 2,
|
||||
PNG_COLOR_PALETTE = 3,
|
||||
PNG_COLOR_GRAYSCALE_ALPHA = 4,
|
||||
PNG_COLOR_RGBA = 6,
|
||||
};
|
||||
|
||||
// PNG filter types
|
||||
enum PngFilter : uint8_t {
|
||||
PNG_FILTER_NONE = 0,
|
||||
PNG_FILTER_SUB = 1,
|
||||
PNG_FILTER_UP = 2,
|
||||
PNG_FILTER_AVERAGE = 3,
|
||||
PNG_FILTER_PAETH = 4,
|
||||
};
|
||||
|
||||
// Read a big-endian 32-bit value from file
|
||||
static bool readBE32(FsFile& file, uint32_t& value) {
|
||||
uint8_t buf[4];
|
||||
if (file.read(buf, 4) != 4) return false;
|
||||
value = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
|
||||
(static_cast<uint32_t>(buf[2]) << 8) | buf[3];
|
||||
return true;
|
||||
}
|
||||
|
||||
// BMP writing helpers (same as JpegToBmpConverter)
|
||||
inline void write16(Print& out, const uint16_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32(Print& out, const uint32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32Signed(Print& out, const int32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
static void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
|
||||
const int bytesPerRow = (width + 3) / 4 * 4;
|
||||
const int imageSize = bytesPerRow * height;
|
||||
const uint32_t paletteSize = 256 * 4;
|
||||
const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
|
||||
|
||||
bmpOut.write('B');
|
||||
bmpOut.write('M');
|
||||
write32(bmpOut, fileSize);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, 14 + 40 + paletteSize);
|
||||
|
||||
write32(bmpOut, 40);
|
||||
write32Signed(bmpOut, width);
|
||||
write32Signed(bmpOut, -height);
|
||||
write16(bmpOut, 1);
|
||||
write16(bmpOut, 8);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, imageSize);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 256);
|
||||
write32(bmpOut, 256);
|
||||
|
||||
for (int i = 0; i < 256; i++) {
|
||||
bmpOut.write(static_cast<uint8_t>(i));
|
||||
bmpOut.write(static_cast<uint8_t>(i));
|
||||
bmpOut.write(static_cast<uint8_t>(i));
|
||||
bmpOut.write(static_cast<uint8_t>(0));
|
||||
}
|
||||
}
|
||||
|
||||
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
|
||||
const int bytesPerRow = (width + 31) / 32 * 4;
|
||||
const int imageSize = bytesPerRow * height;
|
||||
const uint32_t fileSize = 62 + imageSize;
|
||||
|
||||
bmpOut.write('B');
|
||||
bmpOut.write('M');
|
||||
write32(bmpOut, fileSize);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, 62);
|
||||
|
||||
write32(bmpOut, 40);
|
||||
write32Signed(bmpOut, width);
|
||||
write32Signed(bmpOut, -height);
|
||||
write16(bmpOut, 1);
|
||||
write16(bmpOut, 1);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, imageSize);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 2);
|
||||
write32(bmpOut, 2);
|
||||
|
||||
uint8_t palette[8] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00};
|
||||
for (const uint8_t i : palette) {
|
||||
bmpOut.write(i);
|
||||
}
|
||||
}
|
||||
|
||||
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
|
||||
const int bytesPerRow = (width * 2 + 31) / 32 * 4;
|
||||
const int imageSize = bytesPerRow * height;
|
||||
const uint32_t fileSize = 70 + imageSize;
|
||||
|
||||
bmpOut.write('B');
|
||||
bmpOut.write('M');
|
||||
write32(bmpOut, fileSize);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, 70);
|
||||
|
||||
write32(bmpOut, 40);
|
||||
write32Signed(bmpOut, width);
|
||||
write32Signed(bmpOut, -height);
|
||||
write16(bmpOut, 1);
|
||||
write16(bmpOut, 2);
|
||||
write32(bmpOut, 0);
|
||||
write32(bmpOut, imageSize);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 2835);
|
||||
write32(bmpOut, 4);
|
||||
write32(bmpOut, 4);
|
||||
|
||||
uint8_t palette[16] = {0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00,
|
||||
0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00};
|
||||
for (const uint8_t i : palette) {
|
||||
bmpOut.write(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Paeth predictor function per PNG spec
|
||||
static inline uint8_t paethPredictor(uint8_t a, uint8_t b, uint8_t c) {
|
||||
int p = static_cast<int>(a) + b - c;
|
||||
int pa = p > a ? p - a : a - p;
|
||||
int pb = p > b ? p - b : b - p;
|
||||
int pc = p > c ? p - c : c - p;
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Context for streaming PNG decompression
|
||||
struct PngDecodeContext {
|
||||
FsFile& file;
|
||||
|
||||
// PNG image properties
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint8_t bitDepth;
|
||||
uint8_t colorType;
|
||||
uint8_t bytesPerPixel; // after expanding sub-byte depths
|
||||
uint32_t rawRowBytes; // bytes per raw row (without filter byte)
|
||||
|
||||
// Scanline buffers
|
||||
uint8_t* currentRow; // current defiltered scanline
|
||||
uint8_t* previousRow; // previous defiltered scanline
|
||||
|
||||
// zlib decompression state
|
||||
mz_stream zstream;
|
||||
bool zstreamInitialized;
|
||||
|
||||
// Chunk reading state
|
||||
uint32_t chunkBytesRemaining; // bytes left in current IDAT chunk
|
||||
bool idatFinished; // no more IDAT chunks
|
||||
|
||||
// File read buffer for feeding zlib
|
||||
uint8_t readBuf[2048];
|
||||
|
||||
// Palette for indexed color (type 3)
|
||||
uint8_t palette[256 * 3];
|
||||
int paletteSize;
|
||||
};
|
||||
|
||||
// Read the next IDAT chunk header, skipping non-IDAT chunks
|
||||
// Returns true if an IDAT chunk was found
|
||||
static bool findNextIdatChunk(PngDecodeContext& ctx) {
|
||||
while (true) {
|
||||
uint32_t chunkLen;
|
||||
if (!readBE32(ctx.file, chunkLen)) return false;
|
||||
|
||||
uint8_t chunkType[4];
|
||||
if (ctx.file.read(chunkType, 4) != 4) return false;
|
||||
|
||||
if (memcmp(chunkType, "IDAT", 4) == 0) {
|
||||
ctx.chunkBytesRemaining = chunkLen;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip this chunk's data + 4-byte CRC
|
||||
// Use seek to skip efficiently
|
||||
if (!ctx.file.seekCur(chunkLen + 4)) return false;
|
||||
|
||||
// If we hit IEND, there are no more chunks
|
||||
if (memcmp(chunkType, "IEND", 4) == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feed compressed data to zlib from IDAT chunks
|
||||
// Returns number of bytes made available in zstream, or -1 on error
|
||||
static int feedZlibInput(PngDecodeContext& ctx) {
|
||||
if (ctx.idatFinished) return 0;
|
||||
|
||||
// If current IDAT chunk is exhausted, skip its CRC and find next
|
||||
while (ctx.chunkBytesRemaining == 0) {
|
||||
// Skip 4-byte CRC of previous IDAT
|
||||
if (!ctx.file.seekCur(4)) return -1;
|
||||
|
||||
if (!findNextIdatChunk(ctx)) {
|
||||
ctx.idatFinished = true;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Read from current IDAT chunk
|
||||
size_t toRead = sizeof(ctx.readBuf);
|
||||
if (toRead > ctx.chunkBytesRemaining) toRead = ctx.chunkBytesRemaining;
|
||||
|
||||
int bytesRead = ctx.file.read(ctx.readBuf, toRead);
|
||||
if (bytesRead <= 0) return -1;
|
||||
|
||||
ctx.chunkBytesRemaining -= bytesRead;
|
||||
ctx.zstream.next_in = ctx.readBuf;
|
||||
ctx.zstream.avail_in = bytesRead;
|
||||
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
// Decompress exactly 'needed' bytes into 'dest'
|
||||
static bool decompressBytes(PngDecodeContext& ctx, uint8_t* dest, size_t needed) {
|
||||
ctx.zstream.next_out = dest;
|
||||
ctx.zstream.avail_out = needed;
|
||||
|
||||
while (ctx.zstream.avail_out > 0) {
|
||||
if (ctx.zstream.avail_in == 0) {
|
||||
int fed = feedZlibInput(ctx);
|
||||
if (fed < 0) return false;
|
||||
if (fed == 0) {
|
||||
// Try one more inflate to flush
|
||||
int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH);
|
||||
if (ctx.zstream.avail_out == 0) break;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH);
|
||||
if (ret != MZ_OK && ret != MZ_STREAM_END && ret != MZ_BUF_ERROR) {
|
||||
LOG_ERR("PNG", "zlib inflate error: %d", ret);
|
||||
return false;
|
||||
}
|
||||
if (ret == MZ_STREAM_END) break;
|
||||
}
|
||||
|
||||
return ctx.zstream.avail_out == 0;
|
||||
}
|
||||
|
||||
// Decode one scanline: decompress filter byte + raw bytes, then unfilter
|
||||
static bool decodeScanline(PngDecodeContext& ctx) {
|
||||
// Decompress filter byte
|
||||
uint8_t filterType;
|
||||
if (!decompressBytes(ctx, &filterType, 1)) return false;
|
||||
|
||||
// Decompress raw row data into currentRow
|
||||
if (!decompressBytes(ctx, ctx.currentRow, ctx.rawRowBytes)) return false;
|
||||
|
||||
// Apply reverse filter
|
||||
const int bpp = ctx.bytesPerPixel;
|
||||
|
||||
switch (filterType) {
|
||||
case PNG_FILTER_NONE:
|
||||
break;
|
||||
|
||||
case PNG_FILTER_SUB:
|
||||
for (uint32_t i = bpp; i < ctx.rawRowBytes; i++) {
|
||||
ctx.currentRow[i] += ctx.currentRow[i - bpp];
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_FILTER_UP:
|
||||
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
|
||||
ctx.currentRow[i] += ctx.previousRow[i];
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_FILTER_AVERAGE:
|
||||
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
|
||||
uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0;
|
||||
uint8_t b = ctx.previousRow[i];
|
||||
ctx.currentRow[i] += (a + b) / 2;
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_FILTER_PAETH:
|
||||
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
|
||||
uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0;
|
||||
uint8_t b = ctx.previousRow[i];
|
||||
uint8_t c = (i >= static_cast<uint32_t>(bpp)) ? ctx.previousRow[i - bpp] : 0;
|
||||
ctx.currentRow[i] += paethPredictor(a, b, c);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
LOG_ERR("PNG", "Unknown filter type: %d", filterType);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Batch-convert an entire scanline to grayscale.
|
||||
// Branches once on colorType/bitDepth, then runs a tight loop for the whole row.
|
||||
static void convertScanlineToGray(const PngDecodeContext& ctx, uint8_t* grayRow) {
|
||||
const uint8_t* src = ctx.currentRow;
|
||||
const uint32_t w = ctx.width;
|
||||
|
||||
switch (ctx.colorType) {
|
||||
case PNG_COLOR_GRAYSCALE:
|
||||
if (ctx.bitDepth == 8) {
|
||||
memcpy(grayRow, src, w);
|
||||
} else if (ctx.bitDepth == 16) {
|
||||
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2];
|
||||
} else {
|
||||
const int ppb = 8 / ctx.bitDepth;
|
||||
const uint8_t mask = (1 << ctx.bitDepth) - 1;
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth;
|
||||
grayRow[x] = (src[x / ppb] >> shift & mask) * 255 / mask;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_COLOR_RGB:
|
||||
if (ctx.bitDepth == 8) {
|
||||
// Fast path: most common EPUB cover format
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
const uint8_t* p = src + x * 3;
|
||||
grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100;
|
||||
}
|
||||
} else {
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
grayRow[x] = (src[x * 6] * 25 + src[x * 6 + 2] * 50 + src[x * 6 + 4] * 25) / 100;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_COLOR_PALETTE: {
|
||||
const int ppb = 8 / ctx.bitDepth;
|
||||
const uint8_t mask = (1 << ctx.bitDepth) - 1;
|
||||
const uint8_t* pal = ctx.palette;
|
||||
const int palSize = ctx.paletteSize;
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth;
|
||||
uint8_t idx = (src[x / ppb] >> shift) & mask;
|
||||
if (idx >= palSize) idx = 0;
|
||||
grayRow[x] = (pal[idx * 3] * 25 + pal[idx * 3 + 1] * 50 + pal[idx * 3 + 2] * 25) / 100;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PNG_COLOR_GRAYSCALE_ALPHA:
|
||||
if (ctx.bitDepth == 8) {
|
||||
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2];
|
||||
} else {
|
||||
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 4];
|
||||
}
|
||||
break;
|
||||
|
||||
case PNG_COLOR_RGBA:
|
||||
if (ctx.bitDepth == 8) {
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
const uint8_t* p = src + x * 4;
|
||||
grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100;
|
||||
}
|
||||
} else {
|
||||
for (uint32_t x = 0; x < w; x++) {
|
||||
grayRow[x] = (src[x * 8] * 25 + src[x * 8 + 2] * 50 + src[x * 8 + 4] * 25) / 100;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
memset(grayRow, 128, w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool PngToBmpConverter::pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight,
|
||||
bool oneBit, bool crop) {
|
||||
LOG_DBG("PNG", "Converting PNG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
|
||||
|
||||
// Verify PNG signature
|
||||
uint8_t sig[8];
|
||||
if (pngFile.read(sig, 8) != 8 || memcmp(sig, PNG_SIGNATURE, 8) != 0) {
|
||||
LOG_ERR("PNG", "Invalid PNG signature");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read IHDR chunk
|
||||
uint32_t ihdrLen;
|
||||
if (!readBE32(pngFile, ihdrLen)) return false;
|
||||
|
||||
uint8_t ihdrType[4];
|
||||
if (pngFile.read(ihdrType, 4) != 4 || memcmp(ihdrType, "IHDR", 4) != 0) {
|
||||
LOG_ERR("PNG", "Missing IHDR chunk");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t width, height;
|
||||
if (!readBE32(pngFile, width) || !readBE32(pngFile, height)) return false;
|
||||
|
||||
uint8_t ihdrRest[5];
|
||||
if (pngFile.read(ihdrRest, 5) != 5) return false;
|
||||
|
||||
uint8_t bitDepth = ihdrRest[0];
|
||||
uint8_t colorType = ihdrRest[1];
|
||||
uint8_t compression = ihdrRest[2];
|
||||
uint8_t filter = ihdrRest[3];
|
||||
uint8_t interlace = ihdrRest[4];
|
||||
|
||||
// Skip IHDR CRC
|
||||
pngFile.seekCur(4);
|
||||
|
||||
LOG_DBG("PNG", "Image: %ux%u, depth=%u, color=%u, interlace=%u", width, height, bitDepth, colorType, interlace);
|
||||
|
||||
if (compression != 0 || filter != 0) {
|
||||
LOG_ERR("PNG", "Unsupported compression/filter method");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (interlace != 0) {
|
||||
LOG_ERR("PNG", "Interlaced PNGs not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Safety limits
|
||||
constexpr int MAX_IMAGE_WIDTH = 2048;
|
||||
constexpr int MAX_IMAGE_HEIGHT = 3072;
|
||||
|
||||
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width == 0 || height == 0) {
|
||||
LOG_ERR("PNG", "Image too large or zero (%ux%u)", width, height);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate bytes per pixel and raw row bytes
|
||||
uint8_t bytesPerPixel;
|
||||
uint32_t rawRowBytes;
|
||||
|
||||
switch (colorType) {
|
||||
case PNG_COLOR_GRAYSCALE:
|
||||
if (bitDepth == 16) {
|
||||
bytesPerPixel = 2;
|
||||
rawRowBytes = width * 2;
|
||||
} else if (bitDepth == 8) {
|
||||
bytesPerPixel = 1;
|
||||
rawRowBytes = width;
|
||||
} else {
|
||||
// Sub-byte: 1, 2, or 4 bits
|
||||
bytesPerPixel = 1;
|
||||
rawRowBytes = (width * bitDepth + 7) / 8;
|
||||
}
|
||||
break;
|
||||
case PNG_COLOR_RGB:
|
||||
bytesPerPixel = (bitDepth == 16) ? 6 : 3;
|
||||
rawRowBytes = width * bytesPerPixel;
|
||||
break;
|
||||
case PNG_COLOR_PALETTE:
|
||||
bytesPerPixel = 1;
|
||||
rawRowBytes = (width * bitDepth + 7) / 8;
|
||||
break;
|
||||
case PNG_COLOR_GRAYSCALE_ALPHA:
|
||||
bytesPerPixel = (bitDepth == 16) ? 4 : 2;
|
||||
rawRowBytes = width * bytesPerPixel;
|
||||
break;
|
||||
case PNG_COLOR_RGBA:
|
||||
bytesPerPixel = (bitDepth == 16) ? 8 : 4;
|
||||
rawRowBytes = width * bytesPerPixel;
|
||||
break;
|
||||
default:
|
||||
LOG_ERR("PNG", "Unsupported color type: %d", colorType);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate raw row bytes won't cause memory issues
|
||||
if (rawRowBytes > 16384) {
|
||||
LOG_ERR("PNG", "Row too large: %u bytes", rawRowBytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize decode context
|
||||
PngDecodeContext ctx = {.file = pngFile,
|
||||
.width = width,
|
||||
.height = height,
|
||||
.bitDepth = bitDepth,
|
||||
.colorType = colorType,
|
||||
.bytesPerPixel = bytesPerPixel,
|
||||
.rawRowBytes = rawRowBytes,
|
||||
.currentRow = nullptr,
|
||||
.previousRow = nullptr,
|
||||
.zstream = {},
|
||||
.zstreamInitialized = false,
|
||||
.chunkBytesRemaining = 0,
|
||||
.idatFinished = false,
|
||||
.readBuf = {},
|
||||
.palette = {},
|
||||
.paletteSize = 0};
|
||||
|
||||
// Allocate scanline buffers
|
||||
ctx.currentRow = static_cast<uint8_t*>(malloc(rawRowBytes));
|
||||
ctx.previousRow = static_cast<uint8_t*>(calloc(rawRowBytes, 1));
|
||||
if (!ctx.currentRow || !ctx.previousRow) {
|
||||
LOG_ERR("PNG", "Failed to allocate scanline buffers (%u bytes each)", rawRowBytes);
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan for PLTE chunk (palette) and first IDAT chunk
|
||||
// We need to read chunks until we find IDAT, collecting PLTE along the way
|
||||
bool foundIdat = false;
|
||||
while (!foundIdat) {
|
||||
uint32_t chunkLen;
|
||||
if (!readBE32(pngFile, chunkLen)) break;
|
||||
|
||||
uint8_t chunkType[4];
|
||||
if (pngFile.read(chunkType, 4) != 4) break;
|
||||
|
||||
if (memcmp(chunkType, "PLTE", 4) == 0) {
|
||||
int entries = chunkLen / 3;
|
||||
if (entries > 256) entries = 256;
|
||||
ctx.paletteSize = entries;
|
||||
size_t palBytes = entries * 3;
|
||||
pngFile.read(ctx.palette, palBytes);
|
||||
// Skip any remaining palette data
|
||||
if (chunkLen > palBytes) pngFile.seekCur(chunkLen - palBytes);
|
||||
pngFile.seekCur(4); // CRC
|
||||
} else if (memcmp(chunkType, "IDAT", 4) == 0) {
|
||||
ctx.chunkBytesRemaining = chunkLen;
|
||||
foundIdat = true;
|
||||
} else if (memcmp(chunkType, "IEND", 4) == 0) {
|
||||
break;
|
||||
} else {
|
||||
// Skip unknown chunk
|
||||
pngFile.seekCur(chunkLen + 4);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundIdat) {
|
||||
LOG_ERR("PNG", "No IDAT chunk found");
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize zlib decompression
|
||||
memset(&ctx.zstream, 0, sizeof(ctx.zstream));
|
||||
if (mz_inflateInit(&ctx.zstream) != MZ_OK) {
|
||||
LOG_ERR("PNG", "Failed to initialize zlib");
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
return false;
|
||||
}
|
||||
ctx.zstreamInitialized = true;
|
||||
|
||||
// Calculate output dimensions (same logic as JpegToBmpConverter)
|
||||
int outWidth = width;
|
||||
int outHeight = height;
|
||||
uint32_t scaleX_fp = 65536;
|
||||
uint32_t scaleY_fp = 65536;
|
||||
bool needsScaling = false;
|
||||
|
||||
if (targetWidth > 0 && targetHeight > 0 &&
|
||||
(static_cast<int>(width) > targetWidth || static_cast<int>(height) > targetHeight)) {
|
||||
const float scaleToFitWidth = static_cast<float>(targetWidth) / width;
|
||||
const float scaleToFitHeight = static_cast<float>(targetHeight) / height;
|
||||
float scale = 1.0;
|
||||
if (crop) {
|
||||
scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
|
||||
} else {
|
||||
scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
|
||||
}
|
||||
|
||||
outWidth = static_cast<int>(width * scale);
|
||||
outHeight = static_cast<int>(height * scale);
|
||||
if (outWidth < 1) outWidth = 1;
|
||||
if (outHeight < 1) outHeight = 1;
|
||||
|
||||
scaleX_fp = (static_cast<uint32_t>(width) << 16) / outWidth;
|
||||
scaleY_fp = (static_cast<uint32_t>(height) << 16) / outHeight;
|
||||
needsScaling = true;
|
||||
|
||||
LOG_DBG("PNG", "Pre-scaling %ux%u -> %dx%d (fit to %dx%d)", width, height, outWidth, outHeight, targetWidth,
|
||||
targetHeight);
|
||||
}
|
||||
|
||||
// Write BMP header
|
||||
int bytesPerRow;
|
||||
if (USE_8BIT_OUTPUT && !oneBit) {
|
||||
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
|
||||
bytesPerRow = (outWidth + 3) / 4 * 4;
|
||||
} else if (oneBit) {
|
||||
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
|
||||
bytesPerRow = (outWidth + 31) / 32 * 4;
|
||||
} else {
|
||||
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
|
||||
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
|
||||
}
|
||||
|
||||
// Allocate BMP row buffer
|
||||
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
|
||||
if (!rowBuffer) {
|
||||
LOG_ERR("PNG", "Failed to allocate row buffer");
|
||||
mz_inflateEnd(&ctx.zstream);
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create ditherers (same as JpegToBmpConverter)
|
||||
AtkinsonDitherer* atkinsonDitherer = nullptr;
|
||||
FloydSteinbergDitherer* fsDitherer = nullptr;
|
||||
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
|
||||
|
||||
if (oneBit) {
|
||||
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
|
||||
} else if (!USE_8BIT_OUTPUT) {
|
||||
if (USE_ATKINSON) {
|
||||
atkinsonDitherer = new AtkinsonDitherer(outWidth);
|
||||
} else if (USE_FLOYD_STEINBERG) {
|
||||
fsDitherer = new FloydSteinbergDitherer(outWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Scaling accumulators
|
||||
uint32_t* rowAccum = nullptr;
|
||||
uint16_t* rowCount = nullptr;
|
||||
int currentOutY = 0;
|
||||
uint32_t nextOutY_srcStart = 0;
|
||||
|
||||
if (needsScaling) {
|
||||
rowAccum = new uint32_t[outWidth]();
|
||||
rowCount = new uint16_t[outWidth]();
|
||||
nextOutY_srcStart = scaleY_fp;
|
||||
}
|
||||
|
||||
// Allocate grayscale row buffer - batch-convert each scanline to avoid
|
||||
// per-pixel getPixelGray() switch overhead in the hot loops
|
||||
auto* grayRow = static_cast<uint8_t*>(malloc(width));
|
||||
if (!grayRow) {
|
||||
LOG_ERR("PNG", "Failed to allocate grayscale row buffer");
|
||||
delete[] rowAccum;
|
||||
delete[] rowCount;
|
||||
delete atkinsonDitherer;
|
||||
delete fsDitherer;
|
||||
delete atkinson1BitDitherer;
|
||||
free(rowBuffer);
|
||||
mz_inflateEnd(&ctx.zstream);
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
|
||||
// Process each scanline
|
||||
for (uint32_t y = 0; y < height; y++) {
|
||||
// Decode one scanline
|
||||
if (!decodeScanline(ctx)) {
|
||||
LOG_ERR("PNG", "Failed to decode scanline %u", y);
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Batch-convert entire scanline to grayscale (one branch, tight loop)
|
||||
convertScanlineToGray(ctx, grayRow);
|
||||
|
||||
if (!needsScaling) {
|
||||
// Direct output (no scaling)
|
||||
memset(rowBuffer, 0, bytesPerRow);
|
||||
|
||||
if (USE_8BIT_OUTPUT && !oneBit) {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
rowBuffer[x] = adjustPixel(grayRow[x]);
|
||||
}
|
||||
} else if (oneBit) {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
const uint8_t bit =
|
||||
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(grayRow[x], x) : quantize1bit(grayRow[x], x, y);
|
||||
const int byteIndex = x / 8;
|
||||
const int bitOffset = 7 - (x % 8);
|
||||
rowBuffer[byteIndex] |= (bit << bitOffset);
|
||||
}
|
||||
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
|
||||
} else {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
const uint8_t gray = adjustPixel(grayRow[x]);
|
||||
uint8_t twoBit;
|
||||
if (atkinsonDitherer) {
|
||||
twoBit = atkinsonDitherer->processPixel(gray, x);
|
||||
} else if (fsDitherer) {
|
||||
twoBit = fsDitherer->processPixel(gray, x);
|
||||
} else {
|
||||
twoBit = quantize(gray, x, y);
|
||||
}
|
||||
const int byteIndex = (x * 2) / 8;
|
||||
const int bitOffset = 6 - ((x * 2) % 8);
|
||||
rowBuffer[byteIndex] |= (twoBit << bitOffset);
|
||||
}
|
||||
if (atkinsonDitherer)
|
||||
atkinsonDitherer->nextRow();
|
||||
else if (fsDitherer)
|
||||
fsDitherer->nextRow();
|
||||
}
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
} else {
|
||||
// Area-averaging scaling (same as JpegToBmpConverter)
|
||||
for (int outX = 0; outX < outWidth; outX++) {
|
||||
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
|
||||
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
|
||||
|
||||
int sum = 0;
|
||||
int count = 0;
|
||||
for (int srcX = srcXStart; srcX < srcXEnd && srcX < static_cast<int>(width); srcX++) {
|
||||
sum += grayRow[srcX];
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count == 0 && srcXStart < static_cast<int>(width)) {
|
||||
sum = grayRow[srcXStart];
|
||||
count = 1;
|
||||
}
|
||||
|
||||
rowAccum[outX] += sum;
|
||||
rowCount[outX] += count;
|
||||
}
|
||||
|
||||
// Check if we've crossed into the next output row
|
||||
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
|
||||
|
||||
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
|
||||
memset(rowBuffer, 0, bytesPerRow);
|
||||
|
||||
if (USE_8BIT_OUTPUT && !oneBit) {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
|
||||
rowBuffer[x] = adjustPixel(gray);
|
||||
}
|
||||
} else if (oneBit) {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
|
||||
const uint8_t bit =
|
||||
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, currentOutY);
|
||||
const int byteIndex = x / 8;
|
||||
const int bitOffset = 7 - (x % 8);
|
||||
rowBuffer[byteIndex] |= (bit << bitOffset);
|
||||
}
|
||||
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
|
||||
} else {
|
||||
for (int x = 0; x < outWidth; x++) {
|
||||
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
|
||||
uint8_t twoBit;
|
||||
if (atkinsonDitherer) {
|
||||
twoBit = atkinsonDitherer->processPixel(gray, x);
|
||||
} else if (fsDitherer) {
|
||||
twoBit = fsDitherer->processPixel(gray, x);
|
||||
} else {
|
||||
twoBit = quantize(gray, x, currentOutY);
|
||||
}
|
||||
const int byteIndex = (x * 2) / 8;
|
||||
const int bitOffset = 6 - ((x * 2) % 8);
|
||||
rowBuffer[byteIndex] |= (twoBit << bitOffset);
|
||||
}
|
||||
if (atkinsonDitherer)
|
||||
atkinsonDitherer->nextRow();
|
||||
else if (fsDitherer)
|
||||
fsDitherer->nextRow();
|
||||
}
|
||||
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
currentOutY++;
|
||||
|
||||
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
|
||||
memset(rowCount, 0, outWidth * sizeof(uint16_t));
|
||||
|
||||
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap current/previous row buffers
|
||||
uint8_t* temp = ctx.previousRow;
|
||||
ctx.previousRow = ctx.currentRow;
|
||||
ctx.currentRow = temp;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
free(grayRow);
|
||||
delete[] rowAccum;
|
||||
delete[] rowCount;
|
||||
delete atkinsonDitherer;
|
||||
delete fsDitherer;
|
||||
delete atkinson1BitDitherer;
|
||||
free(rowBuffer);
|
||||
mz_inflateEnd(&ctx.zstream);
|
||||
free(ctx.currentRow);
|
||||
free(ctx.previousRow);
|
||||
|
||||
if (success) {
|
||||
LOG_DBG("PNG", "Successfully converted PNG to BMP");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) {
|
||||
return pngFileToBmpStreamInternal(pngFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop);
|
||||
}
|
||||
|
||||
bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,
|
||||
int targetMaxHeight) {
|
||||
return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
|
||||
}
|
||||
|
||||
bool PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,
|
||||
int targetMaxHeight) {
|
||||
return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true);
|
||||
}
|
||||
14
lib/PngToBmpConverter/PngToBmpConverter.h
Normal file
14
lib/PngToBmpConverter/PngToBmpConverter.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
class FsFile;
|
||||
class Print;
|
||||
|
||||
class PngToBmpConverter {
|
||||
static bool pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit,
|
||||
bool crop = true);
|
||||
|
||||
public:
|
||||
static bool pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop = true);
|
||||
static bool pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
|
||||
static bool pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
@@ -135,7 +136,13 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
|
||||
writer.writeItem(file, fadingFix);
|
||||
writer.writeItem(file, embeddedStyle);
|
||||
writer.writeItem(file, sleepScreenLetterboxFill);
|
||||
// New fields need to be added at end for backward compatibility
|
||||
// New fields added at end for backward compatibility
|
||||
writer.writeItem(file, preferredPortrait);
|
||||
writer.writeItem(file, preferredLandscape);
|
||||
writer.writeItem(file, clockFormat);
|
||||
writer.writeItem(file, clockSize);
|
||||
writer.writeItem(file, timezone);
|
||||
writer.writeItem(file, timezoneOffsetHours);
|
||||
|
||||
return writer.item_count;
|
||||
}
|
||||
@@ -264,9 +271,20 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{ uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
readAndValidate(inputFile, preferredPortrait, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, preferredLandscape, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, clockFormat, CLOCK_FORMAT_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, clockSize, CLOCK_SIZE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, timezone, TZ_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, timezoneOffsetHours);
|
||||
if (timezoneOffsetHours < -12 || timezoneOffsetHours > 14) timezoneOffsetHours = 0;
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
if (frontButtonMappingRead) {
|
||||
@@ -433,3 +451,31 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
const char* CrossPointSettings::getTimezonePosixStr() const {
|
||||
switch (timezone) {
|
||||
case TZ_EASTERN:
|
||||
return "EST5EDT,M3.2.0,M11.1.0";
|
||||
case TZ_CENTRAL:
|
||||
return "CST6CDT,M3.2.0,M11.1.0";
|
||||
case TZ_MOUNTAIN:
|
||||
return "MST7MDT,M3.2.0,M11.1.0";
|
||||
case TZ_PACIFIC:
|
||||
return "PST8PDT,M3.2.0,M11.1.0";
|
||||
case TZ_ALASKA:
|
||||
return "AKST9AKDT,M3.2.0,M11.1.0";
|
||||
case TZ_HAWAII:
|
||||
return "HST10";
|
||||
case TZ_CUSTOM: {
|
||||
// Build "UTC<offset>" string where offset sign is inverted per POSIX convention
|
||||
// POSIX TZ: positive = west of UTC, so we negate the user-facing offset
|
||||
static char buf[16];
|
||||
int posixOffset = -timezoneOffsetHours;
|
||||
snprintf(buf, sizeof(buf), "UTC%d", posixOffset);
|
||||
return buf;
|
||||
}
|
||||
case TZ_UTC:
|
||||
default:
|
||||
return "UTC0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,25 @@ class CrossPointSettings {
|
||||
// UI Theme
|
||||
enum UI_THEME { CLASSIC = 0, LYRA = 1 };
|
||||
|
||||
// Home screen clock format
|
||||
enum CLOCK_FORMAT { CLOCK_OFF = 0, CLOCK_AMPM = 1, CLOCK_24H = 2, CLOCK_FORMAT_COUNT };
|
||||
|
||||
// Clock size
|
||||
enum CLOCK_SIZE { CLOCK_SIZE_SMALL = 0, CLOCK_SIZE_MEDIUM = 1, CLOCK_SIZE_LARGE = 2, CLOCK_SIZE_COUNT };
|
||||
|
||||
// Timezone presets
|
||||
enum TIMEZONE {
|
||||
TZ_UTC = 0,
|
||||
TZ_EASTERN = 1,
|
||||
TZ_CENTRAL = 2,
|
||||
TZ_MOUNTAIN = 3,
|
||||
TZ_PACIFIC = 4,
|
||||
TZ_ALASKA = 5,
|
||||
TZ_HAWAII = 6,
|
||||
TZ_CUSTOM = 7,
|
||||
TZ_COUNT
|
||||
};
|
||||
|
||||
// Sleep screen settings
|
||||
uint8_t sleepScreen = DARK;
|
||||
// Sleep screen cover mode settings
|
||||
@@ -183,6 +202,22 @@ class CrossPointSettings {
|
||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||
uint8_t embeddedStyle = 1;
|
||||
|
||||
// Preferred orientations for the portrait/landscape toggle in the reader menu.
|
||||
// preferredPortrait: PORTRAIT (0) or INVERTED (2)
|
||||
// preferredLandscape: LANDSCAPE_CW (1) or LANDSCAPE_CCW (3)
|
||||
uint8_t preferredPortrait = PORTRAIT;
|
||||
uint8_t preferredLandscape = LANDSCAPE_CW;
|
||||
|
||||
// Clock display format (OFF by default)
|
||||
uint8_t clockFormat = CLOCK_OFF;
|
||||
// Clock display size
|
||||
uint8_t clockSize = CLOCK_SIZE_SMALL;
|
||||
|
||||
// Timezone setting
|
||||
uint8_t timezone = TZ_UTC;
|
||||
// Custom timezone offset in hours from UTC (-12 to +14)
|
||||
int8_t timezoneOffsetHours = 0;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
// Get singleton instance
|
||||
@@ -202,6 +237,7 @@ class CrossPointSettings {
|
||||
float getReaderLineCompression() const;
|
||||
unsigned long getSleepTimeoutMs() const;
|
||||
int getRefreshFrequency() const;
|
||||
const char* getTimezonePosixStr() const;
|
||||
};
|
||||
|
||||
// Helper macro to access settings
|
||||
|
||||
@@ -76,6 +76,17 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
{StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA}, "uiTheme", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
// --- Clock ---
|
||||
SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::clockFormat,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_CLOCK_AMPM, StrId::STR_CLOCK_24H}, "clockFormat",
|
||||
StrId::STR_CAT_CLOCK),
|
||||
SettingInfo::Enum(StrId::STR_CLOCK_SIZE, &CrossPointSettings::clockSize,
|
||||
{StrId::STR_CLOCK_SIZE_SMALL, StrId::STR_CLOCK_SIZE_MEDIUM, StrId::STR_CLOCK_SIZE_LARGE},
|
||||
"clockSize", StrId::STR_CAT_CLOCK),
|
||||
SettingInfo::Enum(StrId::STR_TIMEZONE, &CrossPointSettings::timezone,
|
||||
{StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN,
|
||||
StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM},
|
||||
"timezone", StrId::STR_CAT_CLOCK),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::DynamicEnum(
|
||||
@@ -110,6 +121,23 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
"orientation", StrId::STR_CAT_READER),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_PREFERRED_PORTRAIT, {StrId::STR_PORTRAIT, StrId::STR_INVERTED},
|
||||
[] { return static_cast<uint8_t>(SETTINGS.preferredPortrait == CrossPointSettings::INVERTED ? 1 : 0); },
|
||||
[](uint8_t idx) {
|
||||
SETTINGS.preferredPortrait = (idx == 1) ? CrossPointSettings::INVERTED : CrossPointSettings::PORTRAIT;
|
||||
},
|
||||
"preferredPortrait", StrId::STR_CAT_READER),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_PREFERRED_LANDSCAPE, {StrId::STR_LANDSCAPE_CW, StrId::STR_LANDSCAPE_CCW},
|
||||
[] {
|
||||
return static_cast<uint8_t>(SETTINGS.preferredLandscape == CrossPointSettings::LANDSCAPE_CCW ? 1 : 0);
|
||||
},
|
||||
[](uint8_t idx) {
|
||||
SETTINGS.preferredLandscape =
|
||||
(idx == 1) ? CrossPointSettings::LANDSCAPE_CCW : CrossPointSettings::LANDSCAPE_CW;
|
||||
},
|
||||
"preferredLandscape", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing, "extraParagraphSpacing",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -196,8 +196,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
auto folderName = basepath == "/" ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1).c_str();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName);
|
||||
std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str());
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void WifiSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -243,6 +244,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
||||
connectedIP = ipStr;
|
||||
autoConnecting = false;
|
||||
|
||||
// Start NTP time sync in the background (non-blocking)
|
||||
TimeSync::startNtpSync();
|
||||
|
||||
// Save this as the last connected network - SD card operations need lock as
|
||||
// we use SPI for both
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
constexpr unsigned long skipChapterMs = 700;
|
||||
constexpr unsigned long goHomeMs = 1000;
|
||||
constexpr unsigned long longPressConfirmMs = 700;
|
||||
constexpr int statusBarMargin = 19;
|
||||
constexpr int progressBarMarginTop = 1;
|
||||
|
||||
@@ -230,12 +231,27 @@ void EpubReaderActivity::loop() {
|
||||
!mappedInput.wasReleased(MappedInputManager::Button::Back);
|
||||
if (confirmCleared && backCleared) {
|
||||
skipNextButtonCheck = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter reader menu activity.
|
||||
// Long press CONFIRM opens Table of Contents directly (skip menu)
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= longPressConfirmMs) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
if (epub && epub->getTocItemsCount() > 0) {
|
||||
openChapterSelection(true); // skip the stale release from this long-press
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press CONFIRM opens reader menu
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->pageCount : 0;
|
||||
float bookProgress = 0.0f;
|
||||
@@ -250,8 +266,8 @@ void EpubReaderActivity::loop() {
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
|
||||
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(),
|
||||
[this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
}
|
||||
|
||||
@@ -342,11 +358,13 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
||||
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation, const uint8_t fontSize) {
|
||||
exitActivity();
|
||||
// Apply the user-selected orientation when the menu is dismissed.
|
||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||
applyOrientation(orientation);
|
||||
// Apply font size change (no-op if unchanged).
|
||||
applyFontSize(fontSize);
|
||||
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||
pagesUntilFullRefresh = 1;
|
||||
requestUpdate();
|
||||
@@ -415,6 +433,39 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openChapterSelection(bool initialSkipRelease) {
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
initialSkipRelease));
|
||||
}
|
||||
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
switch (action) {
|
||||
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||
@@ -510,36 +561,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
if (bookmarks.empty()) {
|
||||
// No bookmarks: fall back to Table of Contents if available, otherwise go back
|
||||
if (epub->getTocItemsCount() > 0) {
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
openChapterSelection();
|
||||
}
|
||||
// If no TOC either, just return to reader (menu already closed by callback)
|
||||
break;
|
||||
@@ -563,60 +586,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
||||
if (Dictionary::cacheExists()) {
|
||||
Dictionary::deleteCache();
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||
// Calculate values BEFORE we start destroying things
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
|
||||
// 1. Close the menu
|
||||
exitActivity();
|
||||
|
||||
// 2. Open the Chapter Selector
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
|
||||
openChapterSelection();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
|
||||
@@ -706,7 +678,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
exitActivity();
|
||||
enterNewActivity(new LookedUpWordsActivity(
|
||||
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
|
||||
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
|
||||
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; },
|
||||
true)); // initialSkipRelease: consumed the long-press that triggered this
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
||||
@@ -766,6 +739,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}
|
||||
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
|
||||
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
|
||||
case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE:
|
||||
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
|
||||
break;
|
||||
}
|
||||
@@ -798,6 +772,28 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyFontSize(const uint8_t fontSize) {
|
||||
if (SETTINGS.fontSize == fontSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve current reading position so we can restore after reflow.
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
if (section) {
|
||||
cachedSpineIndex = currentSpineIndex;
|
||||
cachedChapterTotalPageCount = section->pageCount;
|
||||
nextPageNumber = section->currentPage;
|
||||
}
|
||||
|
||||
SETTINGS.fontSize = fontSize;
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Reset section to force re-layout with the new font size.
|
||||
section.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Failure handling
|
||||
void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
if (!epub) {
|
||||
|
||||
@@ -22,8 +22,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
float pendingSpineProgress = 0.0f;
|
||||
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
|
||||
bool pendingGoHome = false; // Defer go home to avoid race condition with display task
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm
|
||||
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
@@ -33,9 +34,13 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
void saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
void jumpToPercent(int percent);
|
||||
void onReaderMenuBack(uint8_t orientation);
|
||||
// Open the Table of Contents (chapter selection) as a subactivity.
|
||||
// Pass initialSkipRelease=true when triggered by long-press to consume the stale release.
|
||||
void openChapterSelection(bool initialSkipRelease = false);
|
||||
void onReaderMenuBack(uint8_t orientation, uint8_t fontSize);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void applyFontSize(uint8_t fontSize);
|
||||
|
||||
public:
|
||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
||||
|
||||
@@ -53,11 +53,15 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
||||
if (newSpineIndex == -1) {
|
||||
onGoBack();
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
onSelectSpineIndex(newSpineIndex);
|
||||
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
||||
if (newSpineIndex == -1) {
|
||||
onGoBack();
|
||||
} else {
|
||||
onSelectSpineIndex(newSpineIndex);
|
||||
}
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
|
||||
@@ -14,6 +14,7 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
|
||||
int currentPage = 0;
|
||||
int totalPagesInSpine = 0;
|
||||
int selectorIndex = 0;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
|
||||
@@ -32,13 +33,15 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
|
||||
const int currentSpineIndex, const int currentPage,
|
||||
const int totalPagesInSpine, const std::function<void()>& onGoBack,
|
||||
const std::function<void(int newSpineIndex)>& onSelectSpineIndex,
|
||||
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition)
|
||||
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition,
|
||||
bool initialSkipRelease = false)
|
||||
: ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput),
|
||||
epub(epub),
|
||||
epubPath(epubPath),
|
||||
currentSpineIndex(currentSpineIndex),
|
||||
currentPage(currentPage),
|
||||
totalPagesInSpine(totalPagesInSpine),
|
||||
ignoreNextConfirmRelease(initialSkipRelease),
|
||||
onGoBack(onGoBack),
|
||||
onSelectSpineIndex(onSelectSpineIndex),
|
||||
onSyncPosition(onSyncPosition) {}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -20,6 +21,55 @@ void EpubReaderMenuActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Orientation sub-menu mode ---
|
||||
if (orientationSelectMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
pendingOrientation = static_cast<uint8_t>(orientationSelectIndex);
|
||||
orientationSelectMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
orientationSelectMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
buttonNavigator.onNext([this] {
|
||||
orientationSelectIndex = ButtonNavigator::nextIndex(orientationSelectIndex,
|
||||
static_cast<int>(orientationLabels.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onPrevious([this] {
|
||||
orientationSelectIndex = ButtonNavigator::previousIndex(orientationSelectIndex,
|
||||
static_cast<int>(orientationLabels.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Long-press detection (before release checks) ---
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::LOOKUP) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
auto cb = onAction;
|
||||
cb(MenuAction::LOOKED_UP_WORDS);
|
||||
return;
|
||||
}
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
orientationSelectMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
orientationSelectIndex = pendingOrientation;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
@@ -31,12 +81,29 @@ void EpubReaderMenuActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
// Use local variables for items we need to check after potential deletion
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
||||
// Toggle between the two preferred orientations.
|
||||
// If currently in a portrait-category orientation (Portrait/Inverted), switch to preferredLandscape.
|
||||
// If currently in a landscape-category orientation (CW/CCW), switch to preferredPortrait.
|
||||
const bool isCurrentlyPortrait = (pendingOrientation == CrossPointSettings::PORTRAIT ||
|
||||
pendingOrientation == CrossPointSettings::INVERTED);
|
||||
if (isCurrentlyPortrait) {
|
||||
pendingOrientation = SETTINGS.preferredLandscape;
|
||||
} else {
|
||||
pendingOrientation = SETTINGS.preferredPortrait;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (selectedAction == MenuAction::TOGGLE_FONT_SIZE) {
|
||||
pendingFontSize = (pendingFontSize + 1) % CrossPointSettings::FONT_SIZE_COUNT;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -58,9 +125,9 @@ void EpubReaderMenuActivity::loop() {
|
||||
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Return the pending orientation to the parent so it can apply on exit.
|
||||
onBack(pendingOrientation);
|
||||
return; // Also return here just in case
|
||||
// Return the pending orientation and font size to the parent so it can apply on exit.
|
||||
onBack(pendingOrientation, pendingFontSize);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +187,11 @@ void EpubReaderMenuActivity::render(Activity::RenderLock&&) {
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||
}
|
||||
if (menuItems[i].action == MenuAction::TOGGLE_FONT_SIZE) {
|
||||
const char* value = I18N.get(fontSizeLabels[pendingFontSize]);
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||
}
|
||||
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
|
||||
// Render current letterbox fill value on the right edge of the content area.
|
||||
const char* value = I18N.get(letterboxFillLabels[letterboxFillToIndex()]);
|
||||
@@ -128,6 +200,30 @@ void EpubReaderMenuActivity::render(Activity::RenderLock&&) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Orientation sub-menu overlay ---
|
||||
if (orientationSelectMode) {
|
||||
constexpr int popupMargin = 15;
|
||||
constexpr int popupLineHeight = 28;
|
||||
const int optionCount = static_cast<int>(orientationLabels.size());
|
||||
const int popupH = popupMargin * 2 + popupLineHeight * optionCount;
|
||||
const int popupW = contentWidth - 60;
|
||||
const int popupX = contentX + (contentWidth - popupW) / 2;
|
||||
const int popupY = 180 + hintGutterHeight;
|
||||
|
||||
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
|
||||
renderer.fillRect(popupX, popupY, popupW, popupH, false);
|
||||
|
||||
for (int i = 0; i < optionCount; ++i) {
|
||||
const int optY = popupY + popupMargin + i * popupLineHeight;
|
||||
const bool isSel = (i == orientationSelectIndex);
|
||||
if (isSel) {
|
||||
renderer.fillRect(popupX + 2, optY, popupW - 4, popupLineHeight, true);
|
||||
}
|
||||
const char* label = I18N.get(orientationLabels[i]);
|
||||
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, optY, label, !isSel);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer / Hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/BookSettings.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
@@ -19,6 +20,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
LOOKUP,
|
||||
LOOKED_UP_WORDS,
|
||||
ROTATE_SCREEN,
|
||||
TOGGLE_FONT_SIZE,
|
||||
LETTERBOX_FILL,
|
||||
SELECT_CHAPTER,
|
||||
GO_TO_BOOKMARK,
|
||||
@@ -26,19 +28,20 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
DELETE_CACHE,
|
||||
DELETE_DICT_CACHE
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const bool hasDictionary,
|
||||
const bool isBookmarked, const std::string& bookCachePath,
|
||||
const std::function<void(uint8_t)>& onBack,
|
||||
const uint8_t currentOrientation, const uint8_t currentFontSize,
|
||||
const bool hasDictionary, const bool isBookmarked,
|
||||
const std::string& bookCachePath,
|
||||
const std::function<void(uint8_t, uint8_t)>& onBack,
|
||||
const std::function<void(MenuAction)>& onAction)
|
||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
|
||||
title(title),
|
||||
pendingOrientation(currentOrientation),
|
||||
pendingFontSize(currentFontSize),
|
||||
bookCachePath(bookCachePath),
|
||||
currentPage(currentPage),
|
||||
totalPages(totalPages),
|
||||
@@ -68,8 +71,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
std::string title = "Reader Menu";
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t pendingFontSize = 0;
|
||||
const std::vector<StrId> orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED,
|
||||
StrId::STR_LANDSCAPE_CCW};
|
||||
const std::vector<StrId> fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE,
|
||||
StrId::STR_X_LARGE};
|
||||
std::string bookCachePath;
|
||||
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
|
||||
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
|
||||
@@ -80,7 +86,15 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
int totalPages = 0;
|
||||
int bookProgressPercent = 0;
|
||||
|
||||
const std::function<void(uint8_t)> onBack;
|
||||
// Long-press state
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
// Orientation sub-menu state (entered via long-press on Toggle Portrait/Landscape)
|
||||
bool orientationSelectMode = false;
|
||||
int orientationSelectIndex = 0;
|
||||
|
||||
const std::function<void(uint8_t, uint8_t)> onBack;
|
||||
const std::function<void(MenuAction)> onAction;
|
||||
|
||||
// Map the internal override value to an index into letterboxFillLabels.
|
||||
@@ -111,19 +125,16 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
}
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP_WORD});
|
||||
items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKUP_HISTORY});
|
||||
}
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||
items.push_back({MenuAction::LETTERBOX_FILL, StrId::STR_LETTERBOX_FILL});
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_TOGGLE_ORIENTATION});
|
||||
items.push_back({MenuAction::TOGGLE_FONT_SIZE, StrId::STR_TOGGLE_FONT_SIZE});
|
||||
items.push_back({MenuAction::LETTERBOX_FILL, StrId::STR_OVERRIDE_LETTERBOX_FILL});
|
||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_TABLE_OF_CONTENTS});
|
||||
items.push_back({MenuAction::GO_TO_BOOKMARK, StrId::STR_GO_TO_BOOKMARK});
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
|
||||
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
||||
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::DELETE_DICT_CACHE, StrId::STR_DELETE_DICT_CACHE});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sntp.h>
|
||||
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderDocumentId.h"
|
||||
@@ -12,34 +11,7 @@
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
void syncTimeWithNTP() {
|
||||
// Stop SNTP if already running (can't reconfigure while running)
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
}
|
||||
|
||||
// Configure SNTP
|
||||
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||
esp_sntp_setservername(0, "pool.ntp.org");
|
||||
esp_sntp_init();
|
||||
|
||||
// Wait for time to sync (with timeout)
|
||||
int retry = 0;
|
||||
const int maxRetries = 50; // 5 seconds max
|
||||
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (retry < maxRetries) {
|
||||
LOG_DBG("KOSync", "NTP time synced");
|
||||
} else {
|
||||
LOG_DBG("KOSync", "NTP sync timeout, using fallback");
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
exitActivity();
|
||||
@@ -59,8 +31,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
// Sync time with NTP before making API requests
|
||||
syncTimeWithNTP();
|
||||
// Wait for NTP sync before making API requests (blocks up to 5s)
|
||||
TimeSync::waitForNtpSync();
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -199,8 +171,8 @@ void KOReaderSyncActivity::onEnter() {
|
||||
xTaskCreate(
|
||||
[](void* param) {
|
||||
auto* self = static_cast<KOReaderSyncActivity*>(param);
|
||||
// Sync time first
|
||||
syncTimeWithNTP();
|
||||
// Wait for NTP sync before making API requests
|
||||
TimeSync::waitForNtpSync();
|
||||
{
|
||||
RenderLock lock(*self);
|
||||
self->statusMessage = tr(STR_CALC_HASH);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -16,6 +17,9 @@ void LookedUpWordsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
words = LookupHistory::load(cachePath);
|
||||
std::reverse(words.begin(), words.end());
|
||||
// Append the "Delete Dictionary Cache" sentinel entry
|
||||
words.push_back("\xE2\x80\x94 " + std::string(tr(STR_DELETE_DICT_CACHE)));
|
||||
deleteDictCacheIndex = static_cast<int>(words.size()) - 1;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -39,6 +43,7 @@ void LookedUpWordsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty list has only the sentinel entry; if even that's gone, just go back.
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
@@ -57,6 +62,10 @@ void LookedUpWordsActivity::loop() {
|
||||
// Confirm delete
|
||||
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
|
||||
words.erase(words.begin() + pendingDeleteIndex);
|
||||
// Adjust sentinel index since we removed an item before it
|
||||
if (deleteDictCacheIndex > pendingDeleteIndex) {
|
||||
deleteDictCacheIndex--;
|
||||
}
|
||||
if (selectedIndex >= static_cast<int>(words.size())) {
|
||||
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
|
||||
}
|
||||
@@ -72,9 +81,10 @@ void LookedUpWordsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect long press on Confirm to trigger delete
|
||||
// Detect long press on Confirm to trigger delete (only for real word entries, not sentinel)
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
if (selectedIndex != deleteDictCacheIndex &&
|
||||
mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectedIndex;
|
||||
@@ -106,6 +116,33 @@ void LookedUpWordsActivity::loop() {
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Consume stale release from long-press navigation into this activity
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the "Delete Dictionary Cache" sentinel entry
|
||||
if (selectedIndex == deleteDictCacheIndex) {
|
||||
if (Dictionary::cacheExists()) {
|
||||
Dictionary::deleteCache();
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
} else {
|
||||
{
|
||||
Activity::RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string& headword = words[selectedIndex];
|
||||
|
||||
Rect popupLayout;
|
||||
@@ -197,6 +234,9 @@ void LookedUpWordsActivity::render(Activity::RenderLock&&) {
|
||||
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// The list always has at least the sentinel entry
|
||||
const bool hasRealWords = (deleteDictCacheIndex > 0);
|
||||
|
||||
if (words.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
|
||||
} else {
|
||||
@@ -234,8 +274,8 @@ void LookedUpWordsActivity::render(Activity::RenderLock&&) {
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
// "Hold select to delete" hint above button hints
|
||||
if (!words.empty()) {
|
||||
// "Hold select to delete" hint above button hints (only when real words exist)
|
||||
if (hasRealWords) {
|
||||
const char* deleteHint = "Hold select to delete";
|
||||
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
|
||||
|
||||
@@ -10,13 +10,14 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& onDone)
|
||||
const std::function<void()>& onDone, bool initialSkipRelease = false)
|
||||
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
|
||||
cachePath(cachePath),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onDone(onDone) {}
|
||||
onDone(onDone),
|
||||
ignoreNextConfirmRelease(initialSkipRelease) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -41,5 +42,9 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
|
||||
// Sentinel index: the "Delete Dictionary Cache" entry at the end of the list.
|
||||
// -1 if not present (shouldn't happen when dictionary exists).
|
||||
int deleteDictCacheIndex = -1;
|
||||
|
||||
int getPageItems() const;
|
||||
};
|
||||
|
||||
157
src/activities/settings/SetTimeActivity.cpp
Normal file
157
src/activities/settings/SetTimeActivity.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "SetTimeActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Initialize from current system time if it's been set (year > 2000)
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
hour = t->tm_hour;
|
||||
minute = t->tm_min;
|
||||
} else {
|
||||
hour = 12;
|
||||
minute = 0;
|
||||
}
|
||||
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimeActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimeActivity::loop() {
|
||||
// Back button: discard and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm button: apply time and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
applyTime();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left/Right: switch between hour and minute fields
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedField = 1;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down: increment/decrement the selected field
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 1) % 24;
|
||||
} else {
|
||||
minute = (minute + 1) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 23) % 24;
|
||||
} else {
|
||||
minute = (minute + 59) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimeActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_TIME), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format hour and minute strings
|
||||
char hourStr[4];
|
||||
char minuteStr[4];
|
||||
snprintf(hourStr, sizeof(hourStr), "%02d", hour);
|
||||
snprintf(minuteStr, sizeof(minuteStr), "%02d", minute);
|
||||
|
||||
const int colonWidth = renderer.getTextWidth(UI_12_FONT_ID, " : ");
|
||||
const int digitWidth = renderer.getTextWidth(UI_12_FONT_ID, "00");
|
||||
const int totalWidth = digitWidth * 2 + colonWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
const int timeY = 80;
|
||||
|
||||
// Draw selection highlight behind the selected field
|
||||
constexpr int highlightPad = 6;
|
||||
if (selectedField == 0) {
|
||||
renderer.fillRoundedRect(startX - highlightPad, timeY - 4, digitWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
} else {
|
||||
renderer.fillRoundedRect(startX + digitWidth + colonWidth - highlightPad, timeY - 4,
|
||||
digitWidth + highlightPad * 2, lineHeight12 + 8, 6, Color::LightGray);
|
||||
}
|
||||
|
||||
// Draw the time digits and colon
|
||||
renderer.drawText(UI_12_FONT_ID, startX, timeY, hourStr, true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth, timeY, " : ", true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth + colonWidth, timeY, minuteStr, true);
|
||||
|
||||
// Draw up/down arrows above and below the selected field
|
||||
const int arrowX = (selectedField == 0) ? startX + digitWidth / 2 : startX + digitWidth + colonWidth + digitWidth / 2;
|
||||
const int arrowUpY = timeY - 20;
|
||||
const int arrowDownY = timeY + lineHeight12 + 12;
|
||||
// Up arrow (simple triangle using lines)
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
// Down arrow
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void SetTimeActivity::applyTime() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm newTime = {};
|
||||
struct tm* current = localtime(&now);
|
||||
if (current != nullptr && current->tm_year > 100) {
|
||||
newTime = *current;
|
||||
} else {
|
||||
// If time was never set, use a reasonable date (2025-01-01)
|
||||
newTime.tm_year = 125; // years since 1900
|
||||
newTime.tm_mon = 0;
|
||||
newTime.tm_mday = 1;
|
||||
}
|
||||
newTime.tm_hour = hour;
|
||||
newTime.tm_min = minute;
|
||||
newTime.tm_sec = 0;
|
||||
time_t newEpoch = mktime(&newTime);
|
||||
struct timeval tv = {.tv_sec = newEpoch, .tv_usec = 0};
|
||||
settimeofday(&tv, nullptr);
|
||||
}
|
||||
27
src/activities/settings/SetTimeActivity.h
Normal file
27
src/activities/settings/SetTimeActivity.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimeActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: Activity("SetTime", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
const std::function<void()> onBack;
|
||||
|
||||
// 0 = editing hours, 1 = editing minutes
|
||||
uint8_t selectedField = 0;
|
||||
int hour = 12;
|
||||
int minute = 0;
|
||||
|
||||
void applyTime();
|
||||
};
|
||||
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimezoneOffsetActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
offsetHours = SETTINGS.timezoneOffsetHours;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimezoneOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
SETTINGS.timezoneOffsetHours = offsetHours;
|
||||
SETTINGS.saveToFile();
|
||||
// Apply timezone immediately
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (offsetHours < 14) {
|
||||
offsetHours++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (offsetHours > -12) {
|
||||
offsetHours--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_UTC_OFFSET), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format the offset string
|
||||
char offsetStr[16];
|
||||
if (offsetHours >= 0) {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC+%d", offsetHours);
|
||||
} else {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC%d", offsetHours);
|
||||
}
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, offsetStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = 80;
|
||||
|
||||
// Draw selection highlight
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
|
||||
// Draw the offset text
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, offsetStr, true);
|
||||
|
||||
// Draw up/down arrows
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
21
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
21
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimezoneOffsetActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimezoneOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: Activity("SetTZOffset", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
const std::function<void()> onBack;
|
||||
int8_t offsetHours = 0;
|
||||
};
|
||||
@@ -3,6 +3,9 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
@@ -11,19 +14,23 @@
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SetTimeActivity.h"
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#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();
|
||||
@@ -32,6 +39,8 @@ void SettingsActivity::onEnter() {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_CLOCK) {
|
||||
clockSettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_READER) {
|
||||
readerSettings.push_back(std::move(setting));
|
||||
} else if (setting.category == StrId::STR_CAT_CONTROLS) {
|
||||
@@ -43,6 +52,7 @@ void SettingsActivity::onEnter() {
|
||||
}
|
||||
|
||||
// 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));
|
||||
@@ -134,6 +144,9 @@ void SettingsActivity::loop() {
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
case 4:
|
||||
currentSettings = &clockSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
}
|
||||
@@ -202,6 +215,12 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::Language:
|
||||
enterSubActivity(new LanguageSelectActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SetTime:
|
||||
enterSubActivity(new SetTimeActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SetTimezoneOffset:
|
||||
enterSubActivity(new SetTimezoneOffsetActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::None:
|
||||
// Do nothing
|
||||
break;
|
||||
@@ -211,6 +230,37 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
}
|
||||
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Apply timezone whenever settings change (idempotent, cheap)
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
// Rebuild clock actions (show/hide "Set UTC Offset" based on timezone selection)
|
||||
rebuildClockActions();
|
||||
}
|
||||
|
||||
void SettingsActivity::rebuildClockActions() {
|
||||
// Remove any existing ACTION items from clockSettings (keep enum settings from getSettingsList)
|
||||
clockSettings.erase(std::remove_if(clockSettings.begin(), clockSettings.end(),
|
||||
[](const SettingInfo& s) { return s.type == SettingType::ACTION; }),
|
||||
clockSettings.end());
|
||||
|
||||
// Always add Set Time
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
|
||||
|
||||
// Only add Set UTC Offset when timezone is set to Custom
|
||||
if (SETTINGS.timezone == CrossPointSettings::TZ_CUSTOM) {
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_UTC_OFFSET, SettingAction::SetTimezoneOffset));
|
||||
}
|
||||
|
||||
// Update settingsCount if we're currently viewing the clock category
|
||||
if (currentSettings == &clockSettings) {
|
||||
settingsCount = static_cast<int>(clockSettings.size());
|
||||
// Clamp selection to avoid pointing past the end of the list
|
||||
if (selectedSettingIndex > settingsCount) {
|
||||
selectedSettingIndex = settingsCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsActivity::render(Activity::RenderLock&&) {
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class SettingAction {
|
||||
ClearCache,
|
||||
CheckForUpdates,
|
||||
Language,
|
||||
SetTime,
|
||||
SetTimezoneOffset,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
@@ -142,6 +144,7 @@ class SettingsActivity final : public ActivityWithSubactivity {
|
||||
|
||||
// Per-category settings derived from shared list + device-only actions
|
||||
std::vector<SettingInfo> displaySettings;
|
||||
std::vector<SettingInfo> clockSettings;
|
||||
std::vector<SettingInfo> readerSettings;
|
||||
std::vector<SettingInfo> controlsSettings;
|
||||
std::vector<SettingInfo> systemSettings;
|
||||
@@ -149,11 +152,12 @@ class SettingsActivity final : public ActivityWithSubactivity {
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
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,
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "I18n.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -260,6 +262,26 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
|
||||
Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
|
||||
// Draw clock on the left side (symmetric with battery on the right)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[16];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
int clockFont = SMALL_FONT_ID;
|
||||
if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM) clockFont = UI_10_FONT_ID;
|
||||
else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE) clockFont = UI_12_FONT_ID;
|
||||
renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth;
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -110,6 +115,26 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
|
||||
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
|
||||
// Draw clock on the left side (symmetric with battery on the right)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[16];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
int clockFont = SMALL_FONT_ID;
|
||||
if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM) clockFont = UI_10_FONT_ID;
|
||||
else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE) clockFont = UI_12_FONT_ID;
|
||||
renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
auto truncatedTitle = renderer.truncatedText(
|
||||
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
|
||||
@@ -300,69 +325,214 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
|
||||
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
||||
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
||||
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3;
|
||||
const int bookCount = std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
const int tileHeight = rect.height;
|
||||
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
|
||||
const int tileY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
const int coverHeight = LyraMetrics::values.homeCoverHeight;
|
||||
|
||||
// Draw book card regardless, fill with message based on `hasContinueReading`
|
||||
// Draw cover image as background if available (inside the box)
|
||||
// Only load from SD on first render, then use stored buffer
|
||||
if (hasContinueReading) {
|
||||
if (!coverRendered) {
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
i++) {
|
||||
std::string coverPath = recentBooks[i].coverBmpPath;
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
||||
if (!coverPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
||||
if (bookCount == 0) {
|
||||
const int centerY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, centerY, tr(STR_CHOOSE_SOMETHING), true);
|
||||
return;
|
||||
}
|
||||
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
float coverHeight = static_cast<float>(bitmap.getHeight());
|
||||
float coverWidth = static_cast<float>(bitmap.getWidth());
|
||||
float ratio = coverWidth / coverHeight;
|
||||
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
||||
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
||||
float cropX = 1.0f - (tileRatio / ratio);
|
||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
// Word-wrap helper: splits text into lines fitting maxWidth, capped at maxLines with ellipsis
|
||||
auto wrapText = [&renderer](int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) -> std::vector<std::string> {
|
||||
std::vector<std::string> words;
|
||||
words.reserve(8);
|
||||
size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
while (pos < text.size() && text[pos] == ' ') ++pos;
|
||||
if (pos >= text.size()) break;
|
||||
const size_t start = pos;
|
||||
while (pos < text.size() && text[pos] != ' ') ++pos;
|
||||
words.emplace_back(text.substr(start, pos - start));
|
||||
}
|
||||
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
std::vector<std::string> lines;
|
||||
std::string currentLine;
|
||||
for (auto& word : words) {
|
||||
if (static_cast<int>(lines.size()) >= maxLines) {
|
||||
lines.back().append("...");
|
||||
while (!lines.back().empty() && renderer.getTextWidth(fontId, lines.back().c_str()) > maxWidth) {
|
||||
lines.back().resize(lines.back().size() - 3);
|
||||
utf8RemoveLastChar(lines.back());
|
||||
lines.back().append("...");
|
||||
}
|
||||
break;
|
||||
}
|
||||
int wordWidth = renderer.getTextWidth(fontId, word.c_str());
|
||||
while (wordWidth > maxWidth && !word.empty()) {
|
||||
utf8RemoveLastChar(word);
|
||||
std::string withEllipsis = word + "...";
|
||||
wordWidth = renderer.getTextWidth(fontId, withEllipsis.c_str());
|
||||
if (wordWidth <= maxWidth) {
|
||||
word = withEllipsis;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int newLineWidth = renderer.getTextWidth(fontId, currentLine.c_str());
|
||||
if (newLineWidth > 0) newLineWidth += spaceWidth;
|
||||
newLineWidth += wordWidth;
|
||||
if (newLineWidth > maxWidth && !currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
if (!currentLine.empty()) currentLine.append(" ");
|
||||
currentLine.append(word);
|
||||
}
|
||||
}
|
||||
if (!currentLine.empty() && static_cast<int>(lines.size()) < maxLines) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Cover rendering helper: draws bitmap maintaining aspect ratio within a slot.
|
||||
// Crops if wider than slot, centers if narrower. Returns actual rendered width.
|
||||
auto& storage = HalStorage::getInstance();
|
||||
auto renderCoverBitmap = [&renderer, &storage, coverHeight](const std::string& coverBmpPath, int slotX, int slotY,
|
||||
int slotWidth) {
|
||||
FsFile file;
|
||||
if (storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
float bmpW = static_cast<float>(bitmap.getWidth());
|
||||
float bmpH = static_cast<float>(bitmap.getHeight());
|
||||
float ratio = bmpW / bmpH;
|
||||
int naturalWidth = static_cast<int>(coverHeight * ratio);
|
||||
|
||||
if (naturalWidth >= slotWidth) {
|
||||
float slotRatio = static_cast<float>(slotWidth) / static_cast<float>(coverHeight);
|
||||
float cropX = 1.0f - (slotRatio / ratio);
|
||||
renderer.drawBitmap(bitmap, slotX, slotY, slotWidth, coverHeight, cropX);
|
||||
} else {
|
||||
int offsetX = (slotWidth - naturalWidth) / 2;
|
||||
renderer.drawBitmap(bitmap, slotX + offsetX, slotY, naturalWidth, coverHeight, 0.0f);
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
};
|
||||
|
||||
if (bookCount == 1) {
|
||||
// ===== SINGLE BOOK: HORIZONTAL LAYOUT (cover left, text right) =====
|
||||
const bool bookSelected = (selectorIndex == 0);
|
||||
const int cardX = LyraMetrics::values.contentSidePadding;
|
||||
const int cardWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
|
||||
// Fixed cover slot width based on typical book aspect ratio (~0.65)
|
||||
const int coverSlotWidth = static_cast<int>(coverHeight * 0.65f);
|
||||
const int textGap = hPaddingInSelection * 2;
|
||||
const int textAreaX = cardX + hPaddingInSelection + coverSlotWidth + textGap;
|
||||
const int textAreaWidth = cardWidth - hPaddingInSelection * 2 - coverSlotWidth - textGap;
|
||||
|
||||
if (!coverRendered) {
|
||||
renderer.drawRect(cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth, coverHeight);
|
||||
if (!recentBooks[0].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath =
|
||||
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, coverHeight);
|
||||
renderCoverBitmap(coverBmpPath, cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth);
|
||||
}
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) {
|
||||
bool bookSelected = (selectorIndex == i);
|
||||
// Selection highlight: border strips around the cover, fill the text area
|
||||
if (bookSelected) {
|
||||
// Top strip
|
||||
renderer.fillRoundedRect(cardX, tileY, cardWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
// Left strip (alongside cover)
|
||||
renderer.fillRectDither(cardX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
|
||||
// Right strip
|
||||
renderer.fillRectDither(cardX + cardWidth - hPaddingInSelection, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
coverHeight, Color::LightGray);
|
||||
// Text area background (right of cover, alongside cover height)
|
||||
renderer.fillRectDither(cardX + hPaddingInSelection + coverSlotWidth, tileY + hPaddingInSelection,
|
||||
cardWidth - hPaddingInSelection * 2 - coverSlotWidth, coverHeight, Color::LightGray);
|
||||
// Bottom strip (below cover, full width)
|
||||
const int bottomY = tileY + hPaddingInSelection + coverHeight;
|
||||
const int bottomH = tileHeight - hPaddingInSelection - coverHeight;
|
||||
if (bottomH > 0) {
|
||||
renderer.fillRoundedRect(cardX, bottomY, cardWidth, bottomH, cornerRadius, false, false, true, true,
|
||||
Color::LightGray);
|
||||
}
|
||||
}
|
||||
|
||||
// Title: UI_12 font, wrap generously (up to 5 lines)
|
||||
auto titleLines = wrapText(UI_12_FONT_ID, recentBooks[0].title, textAreaWidth, 5);
|
||||
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
int textY = tileY + hPaddingInSelection + 3;
|
||||
for (const auto& line : titleLines) {
|
||||
renderer.drawText(UI_12_FONT_ID, textAreaX, textY, line.c_str(), true);
|
||||
textY += titleLineHeight;
|
||||
}
|
||||
|
||||
// Author: UI_10 font
|
||||
if (!recentBooks[0].author.empty()) {
|
||||
textY += 4;
|
||||
auto author = renderer.truncatedText(UI_10_FONT_ID, recentBooks[0].author.c_str(), textAreaWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textAreaX, textY, author.c_str(), true);
|
||||
}
|
||||
|
||||
} else {
|
||||
// ===== MULTI BOOK: TILE LAYOUT (2-3 books) =====
|
||||
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / bookCount;
|
||||
// Bottom section height: everything below cover + top padding
|
||||
const int bottomSectionHeight = tileHeight - coverHeight - hPaddingInSelection;
|
||||
|
||||
// Render covers (first render only)
|
||||
if (!coverRendered) {
|
||||
for (int i = 0; i < bookCount; i++) {
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
int drawWidth = tileWidth - 2 * hPaddingInSelection;
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth, coverHeight);
|
||||
if (!recentBooks[i].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[i].coverBmpPath, coverHeight);
|
||||
renderCoverBitmap(coverBmpPath, tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth);
|
||||
}
|
||||
}
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
}
|
||||
|
||||
// Draw selection and text for each book tile
|
||||
for (int i = 0; i < bookCount; i++) {
|
||||
bool bookSelected = (selectorIndex == i);
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
auto title =
|
||||
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
|
||||
const int maxTextWidth = tileWidth - 2 * hPaddingInSelection;
|
||||
|
||||
if (bookSelected) {
|
||||
// Draw selection box
|
||||
// Top strip
|
||||
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
// Left/right strips alongside cover
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
|
||||
bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
|
||||
hPaddingInSelection, coverHeight, Color::LightGray);
|
||||
// Bottom section: spans from below cover to the card bottom
|
||||
renderer.fillRoundedRect(tileX, tileY + coverHeight + hPaddingInSelection, tileWidth, bottomSectionHeight,
|
||||
cornerRadius, false, false, true, true, Color::LightGray);
|
||||
}
|
||||
|
||||
// Word-wrap title to 2 lines (UI_10)
|
||||
auto titleLines = wrapText(UI_10_FONT_ID, recentBooks[i].title, maxTextWidth, 2);
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
|
||||
int textY = tileY + coverHeight + hPaddingInSelection + 4;
|
||||
for (const auto& line : titleLines) {
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, textY, line.c_str(), true);
|
||||
textY += lineHeight;
|
||||
}
|
||||
|
||||
// Author below title
|
||||
if (!recentBooks[i].author.empty()) {
|
||||
auto author = renderer.truncatedText(SMALL_FONT_ID, recentBooks[i].author.c_str(), maxTextWidth);
|
||||
renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, textY + 2, author.c_str(), true);
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
|
||||
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 56,
|
||||
.homeCoverHeight = 226,
|
||||
.homeCoverTileHeight = 287,
|
||||
.homeCoverTileHeight = 318,
|
||||
.homeRecentBooksCount = 3,
|
||||
.buttonHintsHeight = 40,
|
||||
.sideButtonHintsWidth = 30,
|
||||
|
||||
33
src/main.cpp
33
src/main.cpp
@@ -10,7 +10,9 @@
|
||||
#include <SPI.h>
|
||||
#include <builtinFonts/all.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -324,6 +326,11 @@ void setup() {
|
||||
}
|
||||
|
||||
SETTINGS.loadFromFile();
|
||||
|
||||
// Apply saved timezone setting on boot
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
@@ -350,6 +357,18 @@ void setup() {
|
||||
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||
LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
|
||||
|
||||
// Log RTC time to verify persistence across deep sleep
|
||||
{
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
LOG_DBG("MAIN", "RTC time: %04d-%02d-%02d %02d:%02d:%02d", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
|
||||
t->tm_hour, t->tm_min, t->tm_sec);
|
||||
} else {
|
||||
LOG_DBG("MAIN", "RTC time not set (epoch)");
|
||||
}
|
||||
}
|
||||
|
||||
setupDisplayAndFonts();
|
||||
|
||||
exitActivity();
|
||||
@@ -428,6 +447,20 @@ void loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh screen when the displayed minute changes (clock in header)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) {
|
||||
static int lastRenderedMinute = -1;
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
const int currentMinute = t->tm_hour * 60 + t->tm_min;
|
||||
if (lastRenderedMinute >= 0 && currentMinute != lastRenderedMinute) {
|
||||
currentActivity->requestUpdate();
|
||||
}
|
||||
lastRenderedMinute = currentMinute;
|
||||
}
|
||||
}
|
||||
|
||||
const unsigned long activityStartTime = millis();
|
||||
if (currentActivity) {
|
||||
currentActivity->loop();
|
||||
|
||||
58
src/util/TimeSync.cpp
Normal file
58
src/util/TimeSync.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "TimeSync.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <esp_sntp.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
namespace TimeSync {
|
||||
|
||||
void startNtpSync() {
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
}
|
||||
|
||||
// Apply timezone so NTP-synced time is displayed correctly
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||
esp_sntp_setservername(0, "pool.ntp.org");
|
||||
esp_sntp_init();
|
||||
|
||||
LOG_DBG("NTP", "SNTP service started");
|
||||
}
|
||||
|
||||
bool waitForNtpSync(int timeoutMs) {
|
||||
startNtpSync();
|
||||
|
||||
const int intervalMs = 100;
|
||||
const int maxRetries = timeoutMs / intervalMs;
|
||||
int retry = 0;
|
||||
|
||||
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
|
||||
vTaskDelay(intervalMs / portTICK_PERIOD_MS);
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (retry < maxRetries) {
|
||||
LOG_DBG("NTP", "Time synced after %d ms", retry * intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_DBG("NTP", "Sync timeout after %d ms", timeoutMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
void stopNtpSync() {
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
LOG_DBG("NTP", "SNTP service stopped");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace TimeSync
|
||||
17
src/util/TimeSync.h
Normal file
17
src/util/TimeSync.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
namespace TimeSync {
|
||||
|
||||
// Start NTP time synchronization (non-blocking).
|
||||
// Configures and starts the SNTP service; time will be updated
|
||||
// automatically when the NTP response arrives.
|
||||
void startNtpSync();
|
||||
|
||||
// Start NTP sync and block until complete or timeout.
|
||||
// Returns true if time was synced, false on timeout.
|
||||
bool waitForNtpSync(int timeoutMs = 5000);
|
||||
|
||||
// Stop the SNTP service. Call before disconnecting WiFi.
|
||||
void stopNtpSync();
|
||||
|
||||
} // namespace TimeSync
|
||||
Reference in New Issue
Block a user