From 70e150e9c38ea97e9eab9c42dfdb6458d1bca1d4 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:24:19 +1000 Subject: [PATCH] What changed and what to expect: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix (six sites: 948, 978, 1519, 1651, 1719, 1906): the indexer now chooses pixel-vs-char wrapping from _display->getFontStyle(), the same basis the renderer (1200), the layout sizing (1336), and the cache key already use. With all four agreeing, the indexer's recorded page boundary lands on the exact byte where the renderer's height-based stop lands — no more swallowed run between pages. Version bump (INDEX_VERSION 13 → 14): your existing .idx caches were built by the old indexer and would otherwise be reloaded as-is (same font key), so the fix would appear to do nothing. Bumping the version makes the loader discard every v13 cache and rebuild it. All six edits are in and braces balance (267/267). Here's what changed in Audiobookplayerscreen.h: PNGdec include (47) and cover size 40→30 (67) — 30 virtual units ≈ 55px. PNG draw callback coverDrawCallbackPNG (147) — nearest-neighbour downscales the 256px source line-by-line and dithers to 1-bit using the same Bayer matrix as the JPEG path. The decoder is heap-allocated per-decode (new PNG()), mirroring how the JPEG path uses new JPEGDEC(), so there's no permanent BSS cost. decodeFolderCoverPNG() (419) + tryLoadFolderCover() (505) — reads cover.png from _currentPath into PSRAM and decodes it. Hooks in both openBook (1099) and advanceTrack (1198), so the cover loads on open and persists as tracks auto-advance within an album. It only loads when there's no embedded cover, so M4B/ID3 embedded art still wins; music (no embedded art) gets the folder PNG. Layout (1531, 1556) — cover now draws below the artist line, above the status/Paused-Vol line, exactly where you wanted it. --- .../ui-new/Audiobookplayerscreen.h | 173 ++++++++++++++++-- .../companion_radio/ui-new/Textreaderscreen.h | 14 +- variants/lilygo_tdeck_max/platformio.ini | 3 + 3 files changed, 167 insertions(+), 23 deletions(-) diff --git a/examples/companion_radio/ui-new/Audiobookplayerscreen.h b/examples/companion_radio/ui-new/Audiobookplayerscreen.h index dfbef1b7..b8440506 100644 --- a/examples/companion_radio/ui-new/Audiobookplayerscreen.h +++ b/examples/companion_radio/ui-new/Audiobookplayerscreen.h @@ -43,6 +43,9 @@ // JPEG decoder for cover art — JPEGDEC by bitbank2 #include +// PNG decoder for folder cover art (cover.png) — PNGdec by bitbank2 +#include + #include "../NodePrefs.h" // Forward declarations @@ -61,8 +64,8 @@ void meck_audio_codec_init(); #define AUDIOBOOKS_FOLDER "/audiobooks" #define AB_BOOKMARK_FOLDER "/audiobooks/.bookmarks" #define AB_MAX_FILES 50 -#define AB_COVER_W 40 // Virtual coords (reduced to fit layout) -#define AB_COVER_H 40 // Virtual coords +#define AB_COVER_W 30 // Virtual coords (128-unit canvas; ~55px on panel) +#define AB_COVER_H 30 // Virtual coords #define AB_COVER_BUF_SIZE ((AB_COVER_W + 7) / 8 * AB_COVER_H) #define AB_DEFAULT_VOLUME 12 // 0-21 range for ESP32-audioI2S #define AB_SEEK_SECONDS 30 // Skip forward/back amount @@ -132,6 +135,45 @@ static int coverDrawCallback(JPEGDRAW* pDraw) { return 1; } +// ============================================================================ +// PNG cover decode — for a standalone folder cover.png. PNGdec has no built-in +// scaling, so the callback nearest-neighbour downscales the source image to the +// 1-bit dithered target. g_coverPng / g_coverLineBuf are valid only during a +// decodeFolderCoverPNG() call (single decode at a time). +// ============================================================================ +static PNG* g_coverPng = nullptr; +static uint16_t* g_coverLineBuf = nullptr; // one decoded source row as RGB565 (PSRAM) + +static int coverDrawCallbackPNG(PNGDRAW* pDraw) { + CoverDecodeCtx* ctx = (CoverDecodeCtx*)pDraw->pUser; + if (!ctx || !ctx->bitmap || !g_coverPng || !g_coverLineBuf) return 0; + + // Map this source row to a target row; process only the representative source + // row for each target row (nearest-neighbour vertical downscale). + int ty = (int)((int64_t)pDraw->y * ctx->bitmapH / ctx->srcH); + if (ty < 0 || ty >= ctx->bitmapH) return 1; + int repSy = (int)((int64_t)ty * ctx->srcH / ctx->bitmapH); + if (pDraw->y != repSy) return 1; + + g_coverPng->getLineAsRGB565(pDraw, g_coverLineBuf, PNG_RGB565_LITTLE_ENDIAN, -1); + + int rowByteW = (ctx->bitmapW + 7) / 8; + for (int tx = 0; tx < ctx->bitmapW; tx++) { + int sx = (int)((int64_t)tx * ctx->srcW / ctx->bitmapW); + if (sx >= ctx->srcW) sx = ctx->srcW - 1; + uint16_t rgb565 = g_coverLineBuf[sx]; + uint8_t r = (rgb565 >> 11) << 3; + uint8_t g = ((rgb565 >> 5) & 0x3F) << 2; + uint8_t b = (rgb565 & 0x1F) << 3; + uint8_t gray = (uint8_t)(((uint16_t)r * 77 + (uint16_t)g * 150 + (uint16_t)b * 29) >> 8); + uint8_t threshold = BAYER4x4[ty & 3][tx & 3]; + if (gray < threshold) { + ctx->bitmap[ty * rowByteW + (tx / 8)] |= (0x80 >> (tx & 7)); + } + } + return 1; +} + // ============================================================================ // File entry with cached metadata and bookmark state // ============================================================================ @@ -372,6 +414,103 @@ private: return true; } + // Decode a standalone folder cover (cover.png) into the 1-bit dithered bitmap. + // Used when the track has no embedded cover (e.g. music files). + bool decodeFolderCoverPNG(const String& pngPath) { + freeCoverBitmap(); + + File f = SD.open(pngPath.c_str(), FILE_READ); + if (!f) return false; + size_t sz = f.size(); + uint8_t* pngBuf = (uint8_t*)ps_malloc(sz); + if (!pngBuf) { + Serial.println("AB: PNG cover PSRAM alloc failed"); + f.close(); + return false; + } + int bytesRead = f.read(pngBuf, sz); + f.close(); + digitalWrite(SDCARD_CS, HIGH); + if (bytesRead != (int)sz) { + free(pngBuf); + return false; + } + + g_coverPng = new PNG(); + if (!g_coverPng) { + free(pngBuf); + return false; + } + + if (g_coverPng->openRAM(pngBuf, sz, coverDrawCallbackPNG) != PNG_SUCCESS) { + Serial.println("AB: PNGdec failed to open cover.png"); + delete g_coverPng; g_coverPng = nullptr; + free(pngBuf); + return false; + } + + int srcW = g_coverPng->getWidth(); + int srcH = g_coverPng->getHeight(); + if (srcW <= 0 || srcH <= 0) { + g_coverPng->close(); + delete g_coverPng; g_coverPng = nullptr; + free(pngBuf); + return false; + } + + _coverW = AB_COVER_W; + _coverH = AB_COVER_H; + int bitmapBytes = ((_coverW + 7) / 8) * _coverH; + _coverBitmap = (uint8_t*)ps_calloc(1, bitmapBytes); + g_coverLineBuf = (uint16_t*)ps_malloc((size_t)srcW * sizeof(uint16_t)); + if (!_coverBitmap || !g_coverLineBuf) { + Serial.println("AB: PNG cover buffer alloc failed"); + if (g_coverLineBuf) { free(g_coverLineBuf); g_coverLineBuf = nullptr; } + freeCoverBitmap(); + g_coverPng->close(); + delete g_coverPng; g_coverPng = nullptr; + free(pngBuf); + return false; + } + + CoverDecodeCtx ctx; + ctx.bitmap = _coverBitmap; + ctx.bitmapW = _coverW; + ctx.bitmapH = _coverH; + ctx.srcW = srcW; + ctx.srcH = srcH; + ctx.offsetX = 0; + ctx.offsetY = 0; + + int rc = g_coverPng->decode(&ctx, 0); + + g_coverPng->close(); + delete g_coverPng; g_coverPng = nullptr; + free(g_coverLineBuf); g_coverLineBuf = nullptr; + free(pngBuf); + + if (rc != PNG_SUCCESS) { + Serial.printf("AB: PNGdec decode failed (%d)\n", rc); + freeCoverBitmap(); + return false; + } + + _hasCover = true; + Serial.printf("AB: Folder cover decoded %dx%d (source %dx%d)\n", + _coverW, _coverH, srcW, srcH); + return true; + } + + // If no embedded cover was decoded, fall back to a folder cover.png. + void tryLoadFolderCover() { + if (_hasCover) return; + String coverPath = _currentPath + "/cover.png"; + if (SD.exists(coverPath.c_str())) { + decodeFolderCoverPNG(coverPath); + digitalWrite(SDCARD_CS, HIGH); + } + } + void freeCoverBitmap() { if (_coverBitmap) { free(_coverBitmap); @@ -957,6 +1096,8 @@ private: } digitalWrite(SDCARD_CS, HIGH); + tryLoadFolderCover(); // Fall back to folder cover.png if no embedded cover + // Load bookmark for new track (may resume a previous position) loadBookmark(); @@ -1054,6 +1195,8 @@ private: } digitalWrite(SDCARD_CS, HIGH); + tryLoadFolderCover(); // Fall back to folder cover.png if no embedded cover + yield(); // Feed WDT before bookmark load // Load saved bookmark position @@ -1381,20 +1524,11 @@ private: // ---- Render: Player ---- void renderPlayer(DisplayDriver& display) { - // Layout budget: 128 total - 14 footer = 114 usable virtual units - // With cover: 1+40+1 = 42 for art, leaves 72 for text+controls - // Without cover: full 114 for text+controls - int y = 0; - - // ---- Cover Art (only for M4B with embedded art) ---- - if (_hasCover && _coverBitmap) { - int coverX = (display.width() - _coverW) / 2; - int coverY = y + 1; - display.drawXbm(coverX, coverY, _coverBitmap, _coverW, _coverH); - y = coverY + _coverH + 1; // y = 42 - } else { - y = 2; // No placeholder — start with title near top - } + // Layout budget: 128 total - 14 footer = 114 usable virtual units. + // Title + artist at top, then the cover (if any) below the artist, then + // status / progress / time / hints. A 30px cover keeps all of that above + // the footer. + int y = 2; // ---- Title ---- display.setTextSize(1); @@ -1419,6 +1553,13 @@ private: y += 10; } + // ---- Cover Art (below artist; M4B embedded JPEG or folder cover.png) ---- + if (_hasCover && _coverBitmap) { + int coverX = (display.width() - _coverW) / 2; + display.drawXbm(coverX, y + 1, _coverBitmap, _coverW, _coverH); + y += _coverH + 2; + } + // ---- Chapter Info ---- if (_metadata.chapterCount > 0 && _currentChapter >= 0) { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/Textreaderscreen.h b/examples/companion_radio/ui-new/Textreaderscreen.h index b7b5114d..a4612dbd 100644 --- a/examples/companion_radio/ui-new/Textreaderscreen.h +++ b/examples/companion_radio/ui-new/Textreaderscreen.h @@ -16,7 +16,7 @@ class UITask; // ============================================================================ #define BOOKS_FOLDER "/books" #define INDEX_FOLDER "/.indexes" -#define INDEX_VERSION 13 // v13: font key in header — auto-invalidate on font/style change +#define INDEX_VERSION 14 // v14: indexer wrap choice keyed to getFontStyle() (matches renderer); invalidates v13 caches #define PREINDEX_PAGES 100 #define READER_MAX_FILES 50 #define READER_BUF_SIZE 4096 @@ -945,7 +945,7 @@ private: } drawSplash("Indexing...", "Please wait", shortName); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); if (_pagePositions.empty()) { // Cache had no pages (e.g. dummy entry) — full index from scratch @@ -975,7 +975,7 @@ private: drawSplash("Indexing...", "Please wait", shortName); _pagePositions.push_back(0); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(_file, 0, _pagePositions, _linesPerPage, _charsPerLine, 0, @@ -1516,7 +1516,7 @@ public: cache.pagePositions.clear(); cache.pagePositions.push_back(0); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, @@ -1648,7 +1648,7 @@ public: cache.pagePositions.clear(); cache.pagePositions.push_back(0); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); int added = indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, @@ -1716,7 +1716,7 @@ public: // Layout was invalidated (orientation change) — reindex the open book Serial.println("TextReader: Reindexing after layout change"); _pagePositions.push_back(0); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(_file, 0, _pagePositions, _linesPerPage, _charsPerLine, 0, @@ -1903,7 +1903,7 @@ public: cache.lastReadPage = 0; cache.pagePositions.clear(); cache.pagePositions.push_back(0); - DisplayDriver* pxd = (_prefs->large_font || _prefs->ui_font_style > 0) ? _display : nullptr; + DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, diff --git a/variants/lilygo_tdeck_max/platformio.ini b/variants/lilygo_tdeck_max/platformio.ini index dd824229..247c50c1 100644 --- a/variants/lilygo_tdeck_max/platformio.ini +++ b/variants/lilygo_tdeck_max/platformio.ini @@ -184,6 +184,7 @@ lib_deps = ${LilyGo_TDeck_Pro_Max.lib_deps} densaugeo/base64 @ ~1.4.0 bitbank2/JPEGDEC + bitbank2/PNGdec ; MAX + WiFi companion (WiFi app bridging — no BLE, higher contact limit) ; WiFi credentials loaded from SD card (/web/wifi.cfg). @@ -212,6 +213,7 @@ lib_deps = ${LilyGo_TDeck_Pro_Max.lib_deps} densaugeo/base64 @ ~1.4.0 bitbank2/JPEGDEC + bitbank2/PNGdec ; MAX standalone (no BLE/WiFi — maximum battery life, LoRa mesh only) ; Contacts in PSRAM (1500 capacity). OTA enabled (WiFi AP on demand). @@ -233,4 +235,5 @@ build_src_filter = ${LilyGo_TDeck_Pro_Max.build_src_filter} + lib_deps = ${LilyGo_TDeck_Pro_Max.lib_deps} + bitbank2/PNGdec densaugeo/base64 @ ~1.4.0 \ No newline at end of file