11 Commits

Author SHA1 Message Date
cottongin
7819cf0f77 fix: correct book card highlight padding by increasing tile height
Instead of shrinking the highlight strip (which clipped author text),
increase homeCoverTileHeight from 310 to 318 for proper bottom padding.
Revert double-padding subtraction in bottomH/bottomSectionHeight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 11:32:04 -05:00
cottongin
3d7340ca6f fix: add bottom padding to home screen book card highlight
The selection highlight had uniform padding on the top, left, and right
but none on the bottom, causing descenders in the author text to clip
past the highlight edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 03:53:31 -05:00
cottongin
966fbef3d1 mod: add clock settings tab, timezone support, and clock size option
Fix clock persistence bug caused by stale legacy read in settings
deserialization. Add clock size setting (Small/Medium/Large) and
timezone selection with North American presets plus custom UTC offset.
Move all clock-related settings into a dedicated Clock tab, rename
"Home Screen Clock" to "Clock", and move minute-change detection to
main loop so the header clock updates on every screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 03:46:06 -05:00
cottongin
38a87298f3 mod: fix clock bugs, add NTP auto-sync, show clock in all headers
- Fix SetTimeActivity immediately dismissing by changing wasReleased to
  wasPressed for all button inputs (matching other subactivities)
- Extract NTP sync into shared TimeSync utility (startNtpSync,
  waitForNtpSync, stopNtpSync) and trigger non-blocking NTP sync on
  every WiFi connection
- Move clock rendering into drawHeader (BaseTheme + LyraTheme) so it
  appears on all screens with a header, positioned symmetrically with
  the battery icon (12px margin, same Y offset, SMALL_FONT_ID)
- Add per-minute auto-refresh on home screen so clock updates without
  button press
- Add RTC time debug log on boot to verify time persistence across
  deep sleep

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 02:13:10 -05:00
Uri Tauber
ab4540b26f Fix dangling pointer 2026-02-17 01:31:51 -05:00
cottongin
7e15c9835f feat: long-press Confirm to open Table of Contents directly
Skip the reader menu when long-pressing Confirm (700ms) to jump
straight to the chapter selection screen. Short press behavior
(opening the menu) is unchanged. Extracts shared openChapterSelection()
helper to eliminate duplicated construction across three call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 01:22:49 -05:00
cottongin
7b3de29c59 mod: improve home screen with adaptive layouts, clock, and set time
- 1-book view: horizontal layout with cover left, title/author right
- 2-3 book view: fix cover stretching by preserving aspect ratio
- 0-book view: show "Choose something to read" placeholder
- Selection highlight now fully contains title and author text
- Add optional clock display in home screen header (AM/PM or 24H)
- Add "Home Screen Clock" setting under Display
- Add "Set Time" activity for manual clock setting via Settings
- Increase homeCoverTileHeight to 310 for title/author breathing room

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 00:46:05 -05:00
cottongin
1d7971ae60 mod: overhaul reader menu with long-press actions and quick toggles
Consolidate dictionary items: remove "Lookup Word History" and
"Delete Dictionary Cache" from the menu. Long-press on "Lookup Word"
opens history; delete-dict-cache is now a sentinel entry at the bottom
of the history list.

Replace "Reading Orientation" with "Toggle Portrait/Landscape" that
toggles between two configurable preferred orientations (new settings:
Preferred Portrait, Preferred Landscape). Long-press opens a manual
4-option orientation sub-menu.

Add "Toggle Font Size" menu item that cycles through font sizes and
applies on menu exit (with section re-layout).

Rename "Letterbox Fill" to "Override Letterbox Fill" and
"Sync Progress" to "Sync Reading Progress" in reader menu.

All long-press flows use ignoreNextConfirmRelease to prevent the
button release from triggering actions on the subsequent screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 18:45:46 -05:00
cottongin
61fb11cae3 feat: Add PNG cover image support for EPUB books (#827)
Cherry-pick upstream PR #827 with conflict resolution for mod/master:

- Add PngToBmpConverter library for PNG cover → BMP conversion
- Add PNG thumbnail generation in generateThumbBmp()
- Fix generateCoverBmp() PNG block to use effectiveCoverImageHref
  (consistent with mod's fallback cover candidate probing)
- Add .png to getCoverCandidates() extensions
- Use LOG_ERR macro in ImageToFramebufferDecoder (mod standard)
- Upstream image converter refinements (ImageBlock, PixelCache,
  JpegToFramebufferConverter, PngToFramebufferConverter)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 17:04:33 -05:00
Егор Мартынов
424e332c75 chore: improve Russian language support (#926)
## Summary

This PR includes vocabulary and grammar fixes for Russian translation,
originally made as review comments
[here](https://github.com/crosspoint-reader/crosspoint-reader/pull/728).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-16 17:00:43 -05:00
Zach Nelson
f21720dc79 perf: Skip constructing unnecessary std::string (#932)
## Summary

**What is the goal of this PR?**

Skip constructing a `std::string` just to get the underlying `c_str()`
buffer, when a string literal gives the same end result.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-16 17:00:32 -05:00
48 changed files with 2323 additions and 283 deletions

View File

@@ -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
---

View File

@@ -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) {

View File

@@ -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) {

View 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;

View File

@@ -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;
}

View File

@@ -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());
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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
};

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 dimage 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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View 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);
}

View 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);
};

View File

@@ -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";
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -9,6 +9,7 @@
#include <PlaceholderCoverGenerator.h>
#include <Xtc.h>
#include <cstdio>
#include <cstring>
#include <vector>

View File

@@ -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;

View File

@@ -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
{

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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();

View File

@@ -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) {}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
};

View 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);
}

View 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();
};

View 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();
}

View 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;
};

View File

@@ -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&&) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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
View 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
View 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