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