Compare commits

...
15 changed files with 353 additions and 106 deletions

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
name: "PR Formatting"
on:
pull_request:
pull_request_target:
types:
- opened
@@ -9,7 +8,7 @@ on:
- edited
permissions:
statuses: write
pull-requests: read
jobs:
title-check:
+6 -6
View File
@@ -501,7 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
| **Transparent** | A BMP overlay drawn over the current screen. Supports alpha transparency in 32-bit BGRA images and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **Transparent** | A BMP or PNG overlay drawn over the current screen. Supports PNG and 32-bit BGRA alpha transparency, and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **None** | A blank screen. |
#### Cover settings
@@ -520,17 +520,17 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
#### Transparent overlay images
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP files on the SD card:
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP or PNG files on the SD card:
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place a file named `sleep-overlay.bmp` in the root directory. This takes priority over the `.sleep-overlay`/`sleep-overlay` directories.
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` or `.png` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place `sleep-overlay.bmp` or `sleep-overlay.png` in the root directory. A root BMP takes priority over a root PNG, and both take priority over the `.sleep-overlay`/`sleep-overlay` directories.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in these alpha images erase the content behind them.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a PNG with an alpha channel or a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in alpha images erase the content behind them.
> [!TIP]
> For best results:
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
> - For **Transparent** mode, use a regular BMP for white-as-transparent artwork or an uncompressed 32-bit BGRA BMP for per-pixel alpha.
> - For **Transparent** mode, use a PNG or uncompressed 32-bit BGRA BMP for per-pixel alpha, or a regular BMP for white-as-transparent artwork.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
+3 -3
View File
@@ -144,14 +144,14 @@ struct DirectPixelWriter {
// Write a single 2-bit dithered pixel value to the framebuffer.
// Must be called after beginRow() for the current row.
// No bounds checking — caller guarantees coordinates are valid.
inline void writePixel(int logicalX, uint8_t pixelValue) const {
inline void writePixel(int logicalX, uint8_t pixelValue, bool writeWhiteInBw = false) const {
// Determine whether to draw based on render mode
bool draw;
bool state;
switch (mode) {
case GfxRenderer::BW:
draw = (pixelValue < 3);
state = true;
draw = writeWhiteInBw || pixelValue < 3;
state = pixelValue < 3;
break;
case GfxRenderer::GRAYSCALE_MSB:
draw = (pixelValue == 1 || pixelValue == 2);
@@ -18,6 +18,9 @@ struct RenderConfig {
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
float sourceCropX = 0.0f; // Fraction cropped equally from the left and right edges
float sourceCropY = 0.0f; // Fraction cropped equally from the top and bottom edges
bool preserveAlpha = false; // Skip transparent pixels instead of compositing them against white
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
@@ -7,6 +7,7 @@
#include <Memory.h>
#include <PNGdec.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <new>
@@ -21,6 +22,7 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
PNG* decoder{nullptr};
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
@@ -30,14 +32,18 @@ struct PngContext {
float scale{1.f};
int srcWidth{0};
int srcHeight{0};
int cropLeft{0};
int cropTop{0};
int visibleWidth{0};
int visibleHeight{0};
int dstWidth{0};
int dstHeight{0};
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint8_t* alphaLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -131,19 +137,31 @@ uint8_t expandSampleToByte(uint8_t sample, int bitsPerSample) {
return static_cast<uint8_t>((sample * 255U) / maxSample);
}
// Convert entire source line to grayscale with alpha blending to white background.
uint8_t alphaThreshold4x4(const int x, const int y) {
static constexpr uint8_t BAYER_4X4[16] = {0, 128, 32, 160, 192, 64, 224, 96, 48, 176, 16, 144, 240, 112, 208, 80};
return BAYER_4X4[((y & 0x03) << 2) | (x & 0x03)];
}
// Convert an entire source line to grayscale and optionally retain alpha.
// Low-bit-depth grayscale/indexed scanlines are packed most-significant sample first.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, int bitsPerSample,
uint8_t* palette, int hasAlpha) {
uint8_t* palette, int hasAlpha, uint32_t transparentColor, uint8_t* alphaLine) {
const auto store = [grayLine, alphaLine](const int x, const uint8_t gray, const uint8_t alpha) {
grayLine[x] = alphaLine ? gray : static_cast<uint8_t>((gray * alpha + 255u * (255u - alpha)) / 255u);
if (alphaLine) alphaLine[x] = alpha;
};
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
if (bitsPerSample == 8) {
if (bitsPerSample == 8 && !hasAlpha && !alphaLine) {
memcpy(grayLine, pPixels, width);
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
const uint8_t sample = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t gray = expandSampleToByte(sample, bitsPerSample);
store(x, gray, hasAlpha && sample == static_cast<uint8_t>(transparentColor) ? 0 : 255);
}
}
break;
@@ -151,53 +169,44 @@ void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint32_t color = (static_cast<uint32_t>(p[0]) << 16) | (static_cast<uint32_t>(p[1]) << 8) | p[2];
store(x, gray, hasAlpha && color == transparentColor ? 0 : 255);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
for (int x = 0; x < width; x++) {
const uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t* p = &palette[idx * 3];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, hasAlpha ? palette[768 + idx] : 255);
}
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
store(x, expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample), 255);
}
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
store(x, pPixels[x * 2], pPixels[x * 2 + 1]);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, p[3]);
}
break;
default:
memset(grayLine, 128, width);
if (alphaLine) memset(alphaLine, 255, width);
break;
}
}
@@ -208,25 +217,29 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
if (srcY < ctx->cropTop || srcY >= ctx->cropTop + ctx->visibleHeight) return 1;
const int visibleSrcY = srcY - ctx->cropTop;
// Map source rows with the exact output-height ratio. During downscaling,
// multiple source rows can select the same output row; during upscaling, one
// source row must be repeated across every output row in its range. Emitting
// only the first row of an upscale leaves zero-filled (black) gaps in the
// streamed pixel cache.
int firstDstY = (srcY * ctx->dstHeight) / ctx->srcHeight;
int firstDstY = (visibleSrcY * ctx->dstHeight) / ctx->visibleHeight;
int endDstY = firstDstY + 1;
if (ctx->dstHeight > ctx->srcHeight) {
endDstY = ((srcY + 1) * ctx->dstHeight) / ctx->srcHeight;
if (ctx->dstHeight > ctx->visibleHeight) {
endDstY = ((visibleSrcY + 1) * ctx->dstHeight) / ctx->visibleHeight;
}
if (firstDstY <= ctx->lastDstY) firstDstY = ctx->lastDstY + 1;
if (firstDstY >= endDstY || firstDstY >= ctx->dstHeight) return 1;
if (endDstY > ctx->dstHeight) endDstY = ctx->dstHeight;
// Convert entire source line to grayscale (improves cache locality)
// PNGdec parses tRNS while decoding, after open() returns, so query the
// transparent color here rather than caching its pre-decode value.
const uint32_t transparentColor = ctx->decoder ? ctx->decoder->getTransparentColor() : 0;
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->iBpp, pDraw->pPalette,
pDraw->iHasAlpha);
pDraw->iHasAlpha, transparentColor, ctx->alphaLineBuffer);
// Render scaled rows using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
@@ -261,27 +274,30 @@ int pngDrawCallback(PNGDRAW* pDraw) {
}
}
int srcX = 0;
int srcX = ctx->cropLeft;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
const uint8_t alpha = ctx->alphaLineBuffer ? ctx->alphaLineBuffer[srcX] : 255;
if (alpha >= 8 && alpha > alphaThreshold4x4(outX, outY)) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray, ctx->alphaLineBuffer != nullptr);
if (caching) cw.writePixel(outX, ditheredGray);
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
// Bresenham-style stepping: advance srcX based on ratio visibleWidth/dstWidth
error += ctx->visibleWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
@@ -340,6 +356,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
PngContext ctx;
ctx.decoder = png.get();
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
@@ -360,28 +377,36 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
ctx.cropLeft = static_cast<int>(ctx.srcWidth * std::clamp(config.sourceCropX, 0.0f, 0.99f) / 2.0f);
ctx.cropTop = static_cast<int>(ctx.srcHeight * std::clamp(config.sourceCropY, 0.0f, 0.99f) / 2.0f);
ctx.visibleWidth = ctx.srcWidth - 2 * ctx.cropLeft;
ctx.visibleHeight = ctx.srcHeight - 2 * ctx.cropTop;
if (ctx.visibleWidth <= 0 || ctx.visibleHeight <= 0) {
LOG_ERR("PNG", "PNG crop leaves no visible pixels");
return false;
}
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
ctx.scale = (float)ctx.dstWidth / ctx.visibleWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
float scaleX = (float)config.maxWidth / ctx.visibleWidth;
float scaleY = (float)config.maxHeight / ctx.visibleHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
ctx.dstWidth = (int)(ctx.visibleWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.visibleHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
const int pixelType = png->getPixelType();
const int bitsPerSample = png->getBpp();
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth,
ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
LOG_DBG("PNG", "PNG %dx%d (visible %dx%d) -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight,
ctx.visibleWidth, ctx.visibleHeight, ctx.dstWidth, ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType, bitsPerSample);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
@@ -410,12 +435,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
auto grayLineBuffer = makeUniqueNoThrow<uint8_t[]>(grayBufSize);
if (!grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
// tRNS chunks are parsed during decode(), so hasAlpha() is not reliable yet
// for color-key and indexed transparency. Reserve the alpha line whenever
// the caller requests preservation; opaque pixels simply store alpha 255.
const bool retainAlpha = config.preserveAlpha;
const size_t lineBufferBytes = grayBufSize * (retainAlpha ? 2u : 1u);
auto lineBuffers = makeUniqueNoThrow<uint8_t[]>(lineBufferBytes);
if (!lineBuffers) {
LOG_ERR("PNG", "Failed to allocate PNG line buffers (%u bytes)", static_cast<unsigned>(lineBufferBytes));
return false;
}
ctx.grayLineBuffer = grayLineBuffer.get();
ctx.grayLineBuffer = lineBuffers.get();
ctx.alphaLineBuffer = retainAlpha ? ctx.grayLineBuffer + grayBufSize : nullptr;
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
@@ -423,7 +454,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.cachePath.empty();
ctx.caching = !config.preserveAlpha && !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
@@ -436,6 +467,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
unsigned long decodeTime = millis() - decodeStart;
ctx.grayLineBuffer = nullptr;
ctx.alphaLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
+147 -20
View File
@@ -1,6 +1,7 @@
#include "SleepActivity.h"
#include <Epub.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalDisplay.h>
@@ -8,6 +9,7 @@
#include <HalStorage.h>
#include <I18n.h>
#include <Memory.h>
#include <PNGdec.h>
#include <Txt.h>
#include <Xtc.h>
@@ -29,7 +31,8 @@
namespace {
// Kept separate from /sleep.bmp and /.sleep so alpha-overlay art does not mix with full-screen wallpapers.
constexpr char TRANSPARENT_SLEEP_ROOT[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_BMP[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_PNG[] = "/sleep-overlay.png";
constexpr char TRANSPARENT_SLEEP_DIR[] = "/.sleep-overlay";
constexpr char TRANSPARENT_SLEEP_LEGACY_DIR[] = "/sleep-overlay";
constexpr size_t MAX_SLEEP_FILE_NAME_LEN = 256;
@@ -71,6 +74,50 @@ uint32_t readLE32(HalFile& file) {
(static_cast<uint32_t>(b3) << 24);
}
uint32_t readBE32(HalFile& file) {
const int c0 = file.read();
const int c1 = file.read();
const int c2 = file.read();
const int c3 = file.read();
if (c0 < 0 || c1 < 0 || c2 < 0 || c3 < 0) return 0;
return (static_cast<uint32_t>(c0) << 24) | (static_cast<uint32_t>(c1) << 16) | (static_cast<uint32_t>(c2) << 8) |
static_cast<uint32_t>(c3);
}
bool isValidPngHeader(HalFile& file) {
static constexpr uint8_t PNG_SIGNATURE[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
static constexpr uint32_t MAX_SOURCE_PIXELS = 2048u * 1536u;
uint8_t signature[8];
if (!file.seek(0) || file.read(signature, sizeof(signature)) != static_cast<int>(sizeof(signature)) ||
!std::equal(std::begin(signature), std::end(signature), std::begin(PNG_SIGNATURE))) {
return false;
}
const uint32_t ihdrLength = readBE32(file);
char chunkType[4];
if (file.read(reinterpret_cast<uint8_t*>(chunkType), sizeof(chunkType)) != static_cast<int>(sizeof(chunkType)) ||
ihdrLength != 13 || !std::equal(std::begin(chunkType), std::end(chunkType), "IHDR")) {
return false;
}
const uint32_t width = readBE32(file);
const uint32_t height = readBE32(file);
const int bitDepth = file.read();
const int colorType = file.read();
const int compression = file.read();
const int filter = file.read();
const int interlace = file.read();
const bool supportedBitDepth =
bitDepth == 8 || ((colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_INDEXED) &&
(bitDepth == 1 || bitDepth == 2 || bitDepth == 4));
const bool supportedColorType = colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_TRUECOLOR ||
colorType == PNG_PIXEL_INDEXED || colorType == PNG_PIXEL_GRAY_ALPHA ||
colorType == PNG_PIXEL_TRUECOLOR_ALPHA;
return width > 0 && height > 0 && width <= 2048 && height <= 3072 && width * height <= MAX_SOURCE_PIXELS &&
supportedBitDepth && supportedColorType && compression == 0 && filter == 0 && interlace == 0;
}
BitmapPlacement calculateBitmapPlacement(const int bitmapWidth, const int bitmapHeight, const GfxRenderer& renderer) {
BitmapPlacement placement;
const auto pageWidth = renderer.getScreenWidth();
@@ -335,20 +382,26 @@ void pushRecentSleepIndex(const SleepRecentKind recentKind, const uint16_t idx)
}
}
bool findNextValidSleepBmp(HalFile& dir, char* name) {
bool findNextValidSleepImage(HalFile& dir, const SleepRecentKind recentKind, char* name) {
for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) {
if (dirFile.isDirectory()) continue;
dirFile.getName(name, MAX_SLEEP_FILE_NAME_LEN);
if (name[0] == '\0' || name[0] == '.') continue;
if (!FsHelpers::hasBmpExtension(name)) {
const bool isBmp = FsHelpers::hasBmpExtension(name);
const bool isPng = recentKind == SleepRecentKind::Overlay && FsHelpers::hasPngExtension(std::string_view{name});
if (!isBmp && !isPng) {
LOG_DBG("SLP", "Skipping unsupported sleep image: %s", name);
continue;
}
Bitmap bitmap(dirFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
const bool isValid = isBmp ? [&dirFile]() {
Bitmap bitmap(dirFile);
return bitmap.parseHeaders() == BmpReaderError::Ok;
}()
: isValidPngHeader(dirFile);
if (!isValid) {
LOG_DBG("SLP", "Skipping invalid sleep image: %s", name);
continue;
}
@@ -368,7 +421,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
}
uint16_t fileCount = 0;
while (fileCount < UINT16_MAX && findNextValidSleepBmp(dir, name.get())) ++fileCount;
while (fileCount < UINT16_MAX && findNextValidSleepImage(dir, recentKind, name.get())) ++fileCount;
if (fileCount == 0) return false;
// Pick a random wallpaper, excluding recently shown ones.
@@ -383,7 +436,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
dir.rewindDirectory();
for (uint16_t index = 0; index <= randomFileIndex; ++index) {
if (!findNextValidSleepBmp(dir, name.get())) return false;
if (!findNextValidSleepImage(dir, recentKind, name.get())) return false;
}
selectedPath.reserve(strlen(dirPath) + 1 + strlen(name.get()));
@@ -395,6 +448,34 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
return true;
}
bool drawSleepPopupPreservingFrame(GfxRenderer& renderer) {
const auto& metrics = UITheme::getInstance().getMetrics();
const int frameThickness = metrics.popupFrameThickness;
const int popupY = static_cast<int>(renderer.getScreenHeight() * metrics.popupTopOffsetRatio);
const int popupHeight = renderer.getLineHeight(UI_12_FONT_ID) + metrics.popupMarginY * 2;
const int bandTop = std::max(0, popupY - frameThickness);
const int bandBottom = std::min(renderer.getScreenHeight(), popupY + popupHeight + frameThickness);
const int bandHeight = bandBottom - bandTop;
const size_t bandBytes = renderer.getRegionByteSize(0, bandTop, renderer.getScreenWidth(), bandHeight);
auto savedBand = makeUniqueNoThrow<uint8_t[]>(bandBytes);
if (!savedBand) {
LOG_ERR("SLP", "OOM: sleep popup background (%u bytes)", static_cast<unsigned>(bandBytes));
return false;
}
if (!renderer.copyRegionToBuffer(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to save sleep popup background");
return false;
}
GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP));
if (!renderer.copyBufferToRegion(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to restore sleep popup background");
return false;
}
return true;
}
} // namespace
void SleepActivity::onEnter() {
@@ -421,6 +502,10 @@ void SleepActivity::onEnter() {
// output-level inversion first so the retained content keeps its visible
// polarity after the display driver returns to normal.
if (frameWasInverted) renderer.invertScreen();
if (APP_STATE.lastSleepFromReader) {
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
}
drawSleepPopupPreservingFrame(renderer);
if (APP_STATE.lastSleepFromReader) {
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
}
@@ -585,26 +670,68 @@ bool SleepActivity::renderSleepOverlayFile(HalFile& file, const char* pathForLog
return true;
}
void SleepActivity::renderTransparentCustomSleepScreen() const {
{
HalFile rootFile;
if (Storage.openFileForRead("SLP", TRANSPARENT_SLEEP_ROOT, rootFile)) {
if (renderSleepOverlayFile(rootFile, TRANSPARENT_SLEEP_ROOT)) return;
}
bool SleepActivity::renderTransparentOverlayPng(const std::string& path) const {
ImageDimensions dimensions;
if (!PngToFramebufferConverter::getDimensionsStatic(path, dimensions)) return false;
const auto placement = calculateBitmapPlacement(dimensions.width, dimensions.height, renderer);
RenderConfig config;
config.x = placement.x;
config.y = placement.y;
config.maxWidth = renderer.getScreenWidth();
config.maxHeight = renderer.getScreenHeight();
config.useDithering = false;
config.sourceCropX = placement.cropX;
config.sourceCropY = placement.cropY;
config.useExactDimensions = placement.cropX > 0.0f || placement.cropY > 0.0f;
config.preserveAlpha = true;
PngToFramebufferConverter converter;
LOG_DBG("SLP", "Rendering transparent PNG overlay: %s (%dx%d)", path.c_str(), dimensions.width, dimensions.height);
if (!converter.decodeToFramebuffer(path, renderer, config)) return false;
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
bool SleepActivity::renderSleepOverlayPath(const std::string& path) const {
if (FsHelpers::hasPngExtension(path)) {
return Storage.exists(path.c_str()) && renderTransparentOverlayPng(path);
}
HalFile file;
return Storage.openFileForRead("SLP", path, file) && renderSleepOverlayFile(file, path.c_str());
}
void SleepActivity::renderTransparentCustomSleepScreen() const {
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_BMP)) return;
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_PNG)) return;
std::string selectedPath;
if (!selectRandomSleepFile(TRANSPARENT_SLEEP_DIR, SleepRecentKind::Overlay, selectedPath)) {
selectRandomSleepFile(TRANSPARENT_SLEEP_LEGACY_DIR, SleepRecentKind::Overlay, selectedPath);
}
if (!selectedPath.empty()) {
HalFile overlayFile;
if (Storage.openFileForRead("SLP", selectedPath, overlayFile) &&
renderSleepOverlayFile(overlayFile, selectedPath.c_str())) {
return;
}
}
if (!selectedPath.empty() && renderSleepOverlayPath(selectedPath)) return;
LOG_ERR("SLP", "No valid transparent sleep overlay found");
renderDefaultSleepScreen();
@@ -1,4 +1,6 @@
#pragma once
#include <string>
#include "activities/Activity.h"
class Bitmap;
@@ -16,6 +18,8 @@ class SleepActivity final : public Activity {
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap, bool preserveBackground = false) const;
bool renderSleepOverlayFile(HalFile& file, const char* pathForLog) const;
bool renderTransparentOverlayPng(const std::string& path) const;
bool renderSleepOverlayPath(const std::string& path) const;
void renderLastScreenSleepScreen() const;
void renderTransparentCustomSleepScreen() const;
void renderBlankSleepScreen() const;
+1 -1
View File
@@ -69,7 +69,7 @@ void FileBrowserActivity::loadFiles() {
}
} else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) ||
FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) ||
FsHelpers::hasBmpExtension(filename)) {
FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPngExtension(filename)) {
files.emplace_back(filename);
}
}
+34 -5
View File
@@ -789,6 +789,7 @@ void EpubReaderActivity::jumpToPercent(int percent) {
// Reset state so render() reloads and repositions on the target spine.
{
RenderLock lock(*this);
clearDeferredReposition();
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
pendingPercentJump = true;
@@ -814,6 +815,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
// possibly-different settings.
if (sync.hasVisibleTextOffset && sync.spineIndex >= 0 && sync.spineIndex < epub->getSpineItemsCount()) {
RenderLock lock(*this);
clearDeferredReposition();
if (section && currentSpineIndex == sync.spineIndex) {
// Already in this chapter and laid out: resolve straight away, no reload.
const auto page = section->getPageForVisibleTextOffset(sync.visibleTextOffset);
@@ -843,13 +845,16 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
targetPage = fallback.pageNumber;
}
// Any explicit selection supersedes the session-start resume/reflow anchor,
// including a selection that is already active.
RenderLock lock(*this);
clearDeferredReposition();
if (currentSpineIndex != targetSpineIndex) {
RenderLock lock(*this);
currentSpineIndex = targetSpineIndex;
nextPageNumber = targetPage;
section.reset();
} else if (section && section->currentPage != targetPage) {
RenderLock lock(*this);
const int clampedTargetPage = std::max(0, targetPage);
section->currentPage = clampedTargetPage;
} else if (!section) {
@@ -871,6 +876,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
const auto& chapterResult = std::get<ChapterResult>(result.data);
RenderLock lock(*this);
clearDeferredReposition();
currentSpineIndex = chapterResult.spineIndex;
// If anchor is not empty, it will be used later to calculate the page number.
@@ -1108,6 +1114,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
}
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
// A page turn is authoritative: do not let a resume/reflow position captured
// at session start snap the reader back after the incremental build completes.
{
RenderLock lock(*this);
clearDeferredReposition();
}
if (isForwardTurn) {
// Advance within the section while there are (or may still be) more pages: either a built
// page ahead, or the section is still building (windowed), in which case more pages exist
@@ -1252,8 +1264,9 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// Otherwise fall back to the settings-change reposition: read after the cache-hit reset above,
// a spec match means the saved page number still names the same content so there is nothing to
// reposition, while a page jump or fragment anchor is a deliberate navigation that outranks it.
const bool explicitOffsetJump = pendingOffsetJump.has_value();
const std::optional<uint32_t> offsetJump =
pendingOffsetJump.has_value() ? pendingOffsetJump
explicitOffsetJump ? pendingOffsetJump
: (pendingPageJump.has_value() || !pendingAnchor.empty() || currentSpineIndex != cachedSpineIndex)
? std::nullopt
: cachedVisibleTextOffset;
@@ -1411,8 +1424,18 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (offsetJump.has_value()) {
if (const auto offsetPage = section->getPageForVisibleTextOffset(*offsetJump)) {
section->currentPage = *offsetPage;
// This anchor has now established the landing page. It must not be
// applied again by applyDeferredReposition() after a background build
// finishes, or it would undo page turns made during that build.
clearDeferredReposition();
}
}
if (explicitOffsetJump) {
// An explicit bookmark/sync target supersedes any stale session-start
// resume anchor even when its offset cannot be resolved (for example an
// empty chapter).
clearDeferredReposition();
}
pendingOffsetJump.reset(); // one-shot explicit jump: consumed on this render
if (!pendingAnchor.empty()) {
@@ -1618,11 +1641,15 @@ bool EpubReaderActivity::applyDeferredReposition() {
changed = true;
}
}
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
cachedVisibleTextOffset.reset();
clearDeferredReposition();
return changed;
}
void EpubReaderActivity::clearDeferredReposition() {
cachedChapterTotalPageCount = 0;
cachedVisibleTextOffset.reset();
}
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
std::optional<uint32_t> offset;
if (section && spineIndex == currentSpineIndex && currentPage >= 0 && currentPage < section->pageCount) {
@@ -2002,6 +2029,7 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s
{
RenderLock lock(*this);
clearDeferredReposition();
pendingAnchor = std::move(anchor);
currentSpineIndex = targetSpineIndex;
nextPageNumber = 0;
@@ -2019,6 +2047,7 @@ void EpubReaderActivity::restoreSavedPosition() {
{
RenderLock lock(*this);
clearDeferredReposition();
currentSpineIndex = pos.spineIndex;
nextPageNumber = pos.pageNumber;
section.reset();
@@ -186,6 +186,10 @@ class EpubReaderActivity final : public Activity {
// settings change re-paginates a chapter). Returns true if currentPage moved.
// No-op while the section is still building or when the pagination is unchanged (plain resume).
bool applyDeferredReposition();
// The saved resume/reflow anchor is only valid until it has established the
// initial landing page. Later user navigation must never be overwritten when
// a background section build finishes.
void clearDeferredReposition();
void rememberCurrentContentOffset();
bool saveProgress(int spineIndex, int currentPage, int pageCount);
// Jump to a percentage of the book (0-100), mapping it to spine and page.
+4 -2
View File
@@ -26,7 +26,9 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader)
}
bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); }
bool ReaderActivity::isImageFile(const std::string& path) {
return FsHelpers::hasBmpExtension(path) || FsHelpers::hasPngExtension(path);
}
int ReaderActivity::initialRefreshCountdown() const {
if (!allowFastInitialRefresh) return 0;
@@ -152,7 +154,7 @@ void ReaderActivity::onEnter() {
sdFontSystem.ensureLoaded(renderer);
currentBookPath = initialBookPath;
if (isBmpFile(initialBookPath)) {
if (isImageFile(initialBookPath)) {
onGoToBmpViewer(initialBookPath);
} else if (isXtcFile(initialBookPath)) {
auto xtc = loadXtc(initialBookPath);
+1 -1
View File
@@ -18,7 +18,7 @@ class ReaderActivity final : public Activity {
static std::unique_ptr<Txt> loadTxt(const std::string& path);
static bool isXtcFile(const std::string& path);
static bool isTxtFile(const std::string& path);
static bool isBmpFile(const std::string& path);
static bool isImageFile(const std::string& path);
void goToLibrary(const std::string& fromBookPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub);
+53 -8
View File
@@ -1,6 +1,7 @@
#include "BmpViewerActivity.h"
#include <Bitmap.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -16,6 +17,7 @@
namespace {
constexpr char CUSTOM_SLEEP_ROOT_BMP[] = "/sleep.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_BMP[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_PNG[] = "/sleep-overlay.png";
constexpr size_t COPY_BUFFER_SIZE = 2048;
} // namespace
@@ -44,7 +46,7 @@ void BmpViewerActivity::loadSiblingImages() {
file.getName(name, sizeof(name));
if (name[0] != '.') {
std::string fname(name);
if (FsHelpers::hasBmpExtension(fname)) {
if (FsHelpers::hasBmpExtension(fname) || FsHelpers::hasPngExtension(fname)) {
siblingImages.push_back(fname);
}
}
@@ -63,6 +65,28 @@ void BmpViewerActivity::loadSiblingImages() {
}
}
bool BmpViewerActivity::canSetSleepCover() const {
return FsHelpers::hasBmpExtension(filePath) ||
(SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRANSPARENT_CUSTOM &&
FsHelpers::hasPngExtension(filePath));
}
bool BmpViewerActivity::renderPng() {
ImageDimensions dimensions;
if (!PngToFramebufferConverter::getDimensionsStatic(filePath, dimensions)) return false;
if (dimensions.width <= 0 || dimensions.height <= 0) return false;
const float scale = std::min(static_cast<float>(renderer.getScreenWidth()) / dimensions.width,
static_cast<float>(renderer.getScreenHeight()) / dimensions.height);
const int width = std::min(renderer.getScreenWidth(), static_cast<int>(dimensions.width * std::min(scale, 1.0f)));
const int height = std::min(renderer.getScreenHeight(), static_cast<int>(dimensions.height * std::min(scale, 1.0f)));
RenderConfig config{(renderer.getScreenWidth() - width) / 2, (renderer.getScreenHeight() - height) / 2, width,
height};
PngToFramebufferConverter converter;
return converter.decodeToFramebuffer(filePath, renderer, config);
}
void BmpViewerActivity::onEnter() {
Activity::onEnter();
@@ -70,13 +94,30 @@ void BmpViewerActivity::onEnter() {
loadSiblingImages();
}
HalFile file;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
GUI.fillPopupProgress(renderer, popupRect, 20); // Initial 20% progress
// 1. Open the file
if (FsHelpers::hasPngExtension(filePath)) {
renderer.clearScreen();
const bool hasPrevious = siblingImages.size() > 1 && currentImageIndex > 0;
const bool hasNext = siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1;
const auto labels = mappedInput.mapLabels(tr(STR_BACK), canSetSleepCover() ? tr(STR_SET_SLEEP_COVER) : "",
hasPrevious ? "<" : "", hasNext ? ">" : "");
if (renderPng()) {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_FILE_OPEN_FAILED));
GUI.drawButtonHints(renderer, labels.btn1, "", "", "");
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
return;
}
HalFile file;
// 1. Open the BMP file
if (Storage.openFileForRead("BMP", filePath, file)) {
Bitmap bitmap(file, true);
@@ -108,8 +149,8 @@ void BmpViewerActivity::onEnter() {
bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1);
const auto labels =
mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : ""));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), canSetSleepCover() ? tr(STR_SET_SLEEP_COVER) : "",
(hasPrevious ? "<" : ""), (hasNext ? ">" : ""));
GUI.fillPopupProgress(renderer, popupRect, 50);
@@ -154,7 +195,11 @@ void BmpViewerActivity::doSetSleepCover() {
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
const bool transparentMode = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRANSPARENT_CUSTOM;
const char* destination = transparentMode ? TRANSPARENT_SLEEP_ROOT_BMP : CUSTOM_SLEEP_ROOT_BMP;
if (!canSetSleepCover()) return;
const char* destination =
transparentMode ? (FsHelpers::hasPngExtension(filePath) ? TRANSPARENT_SLEEP_ROOT_PNG : TRANSPARENT_SLEEP_ROOT_BMP)
: CUSTOM_SLEEP_ROOT_BMP;
bool success = filePath == destination;
if (!success) {
@@ -226,7 +271,7 @@ void BmpViewerActivity::loop() {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
doSetSleepCover();
if (canSetSleepCover()) doSetSleepCover();
return;
}
+3 -1
View File
@@ -17,8 +17,10 @@ class BmpViewerActivity final : public Activity {
private:
void loadSiblingImages();
void doSetSleepCover();
bool canSetSleepCover() const;
bool renderPng();
std::string filePath;
std::vector<std::string> siblingImages;
int currentImageIndex = -1;
};
};
+1 -1
View File
@@ -139,7 +139,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) {
if (FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename)) {
return Text;
}
if (FsHelpers::hasBmpExtension(filename)) {
if (FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPngExtension(filename)) {
return Image;
}
return File;