diff --git a/Audiobook Player Guide.md b/Audiobook Player Guide.md index 50d39c0..7e930dd 100644 --- a/Audiobook Player Guide.md +++ b/Audiobook Player Guide.md @@ -1,24 +1,41 @@ ## Audiobook Player (Audio variant only) Press **P** from the home screen to open the audiobook player. -Place `.m4b`, `.m4a`, `.mp3`, or `.wav` files in `/audiobooks/` on the SD card. +Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card. +Files can be organised into subfolders (e.g. by author) — use **Enter** to +browse into folders and **.. (up)** to go back. | Key | Action | |-----|--------| | W / S | Scroll file list / Volume up-down | -| Enter | Select book / Play-Pause | +| Enter | Select book or folder / Play-Pause | | A | Seek back 30 seconds | | D | Seek forward 30 seconds | | [ | Previous chapter (M4B only) | | ] | Next chapter (M4B only) | | Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) | +### Recommended Format + +**MP3 is the recommended format.** M4B/M4A files are supported but currently +have playback issues with the ESP32-audioI2S library — some files may fail to +decode or produce silence. MP3 files play reliably and are the safest choice. + +MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates +(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S +hardware limitations. + **Bookmarks** are saved automatically every 30 seconds during playback and when you stop or exit. Reopening a book resumes from your last position. **Cover art** from M4B files is displayed as dithered monochrome on the e-ink screen, along with title, author, and chapter information. +**Metadata caching** — the first time you open the audiobook player, it reads +title and author tags from each file (which can take a few seconds with many +files). This metadata is cached to the SD card so subsequent visits load +near-instantly. If you add or remove files the cache updates automatically. + ### Background Playback Audio continues playing when you leave the audiobook player screen. Press **Q** @@ -37,8 +54,7 @@ T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm headphone jack. > **Note:** The audiobook player is not available on the 4G modem variant -> due to I2S pin conflicts. MP3 format is recommended over M4B for best -> compatibility with the ESP32-audioI2S library. +> due to I2S pin conflicts. ### SD Card Folder Structure @@ -48,8 +64,13 @@ SD Card │ ├── .bookmarks/ (auto-created, stores resume positions) │ │ ├── mybook.bmk │ │ └── another.bmk -│ ├── mybook.m4b -│ ├── another.m4b +│ ├── .metacache (auto-created, speeds up file list loading) +│ ├── Ann Leckie/ +│ │ ├── Ancillary Justice.mp3 +│ │ └── Ancillary Sword.mp3 +│ ├── Iain M. Banks/ +│ │ └── The Algebraist.mp3 +│ ├── mybook.mp3 │ └── podcast.mp3 ├── books/ (existing — text reader) │ └── ... diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8275a5c..1df419a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.8.7A" +#define FIRMWARE_VERSION "Meck v0.8.8A" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/ui-new/Audiobookplayerscreen.h b/examples/companion_radio/ui-new/Audiobookplayerscreen.h index 56f6f7b..4e83e09 100644 --- a/examples/companion_radio/ui-new/Audiobookplayerscreen.h +++ b/examples/companion_radio/ui-new/Audiobookplayerscreen.h @@ -55,6 +55,7 @@ class UITask; #define AB_DEFAULT_VOLUME 12 // 0-21 range for ESP32-audioI2S #define AB_SEEK_SECONDS 30 // Skip forward/back amount #define AB_POSITION_SAVE_INTERVAL 30000 // Auto-save bookmark every 30s +#define AB_METACACHE_FILE "/audiobooks/.metacache" // Supported file extensions static bool isAudiobookFile(const String& name) { @@ -120,11 +121,16 @@ static int coverDrawCallback(JPEGDRAW* pDraw) { } // ============================================================================ -// File entry with cached bookmark state (avoid SD.exists during render) +// File entry with cached metadata and bookmark state // ============================================================================ struct AudiobookFileEntry { - String name; + String name; // Original filename on SD (or directory name) + String displayTitle; // Extracted title (or cleaned filename / folder name) + String displayAuthor; // Extracted author (or empty) + String fileType; // "M4B" or "MP3" or "WAV" or "DIR" + uint32_t fileSize; // File size in bytes (for MP3 duration estimation) bool hasBookmark; + bool isDir; // true for subdirectory entries }; // ============================================================================ @@ -147,11 +153,13 @@ private: bool _sdReady; bool _i2sInitialized; // Track whether setPinout has been called bool _dacPowered; // Track GPIO 41 DAC power state + DisplayDriver* _displayRef; // Stored for splash screens during scan // File browser state std::vector _fileList; int _selectedFile; int _scrollOffset; + String _currentPath; // Current browsed directory (starts as AUDIOBOOKS_FOLDER) // Current book state String _currentFile; @@ -178,6 +186,9 @@ private: uint32_t _pendingSeekSec; // 0 = no pending seek bool _streamReady; // Set true once library reports duration + // File size for MP3 duration estimation (MP3 has no native duration header) + uint32_t _currentFileSize; + // M4B rename workaround — the audio library only recognises .m4a, // so we temporarily rename .m4b files on the SD card for playback. bool _m4bRenamed; // true if file was renamed for playback @@ -389,6 +400,110 @@ private: // ---- File Scanning ---- + // ---- Loading Splash ---- + + void drawLoadingSplash() { + if (!_displayRef) return; + _displayRef->startFrame(); + _displayRef->setTextSize(2); + _displayRef->setColor(DisplayDriver::GREEN); + _displayRef->setCursor(10, 15); + _displayRef->print("Loading"); + _displayRef->setCursor(10, 30); + _displayRef->print("Audiobooks"); + _displayRef->setTextSize(1); + _displayRef->setColor(DisplayDriver::LIGHT); + _displayRef->setCursor(10, 55); + _displayRef->print("Please wait..."); + _displayRef->endFrame(); + } + + // ---- Metadata Cache ---- + // Simple tab-separated cache file per directory: filename\tsize\ttitle\tauthor\ttype\n + // Avoids re-parsing every file's ID3/M4B tags on each screen entry. + + struct MetaCacheEntry { + String filename; + uint32_t fileSize; + String title; + String author; + String fileType; + }; + + String getMetaCachePath() { + return _currentPath + "/.metacache"; + } + + // Load metadata cache from SD. Returns entries in a vector. + std::vector loadMetaCache() { + std::vector cache; + String path = getMetaCachePath(); + File f = SD.open(path.c_str(), FILE_READ); + if (!f) return cache; + + char line[256]; + while (f.available()) { + int len = 0; + while (f.available() && len < (int)sizeof(line) - 1) { + char c = f.read(); + if (c == '\n') break; + if (c == '\r') continue; + line[len++] = c; + } + line[len] = '\0'; + if (len == 0) continue; + + // Parse: filename\tsize\ttitle\tauthor\ttype + MetaCacheEntry e; + char* tok = strtok(line, "\t"); + if (!tok) continue; + e.filename = String(tok); + + tok = strtok(nullptr, "\t"); + if (!tok) continue; + e.fileSize = (uint32_t)atol(tok); + + tok = strtok(nullptr, "\t"); + if (!tok) continue; + e.title = String(tok); + + tok = strtok(nullptr, "\t"); + if (!tok) continue; + e.author = String(tok); + + tok = strtok(nullptr, "\t"); + if (!tok) continue; + e.fileType = String(tok); + + cache.push_back(e); + } + f.close(); + digitalWrite(SDCARD_CS, HIGH); + return cache; + } + + // Save metadata cache to SD. + void saveMetaCache(const std::vector& entries) { + String path = getMetaCachePath(); + if (SD.exists(path.c_str())) SD.remove(path.c_str()); + File f = SD.open(path.c_str(), FILE_WRITE); + if (!f) return; + + for (const auto& e : entries) { + if (e.isDir) continue; // Don't cache directories + f.printf("%s\t%u\t%s\t%s\t%s\n", + e.name.c_str(), e.fileSize, + e.displayTitle.c_str(), + e.displayAuthor.length() > 0 ? e.displayAuthor.c_str() : "", + e.fileType.c_str()); + } + f.close(); + digitalWrite(SDCARD_CS, HIGH); + Serial.println("AB: Metadata cache saved"); + } + + // ---- File Scanning ---- + void scanFiles() { _fileList.clear(); if (!SD.exists(AUDIOBOOKS_FOLDER)) { @@ -396,30 +511,142 @@ private: Serial.printf("AB: Created %s\n", AUDIOBOOKS_FOLDER); } - File root = SD.open(AUDIOBOOKS_FOLDER); + File root = SD.open(_currentPath.c_str()); if (!root || !root.isDirectory()) return; + // Add ".." entry if not at the audiobooks root + if (_currentPath != String(AUDIOBOOKS_FOLDER)) { + AudiobookFileEntry upEntry; + upEntry.name = ".."; + upEntry.displayTitle = ".."; + upEntry.fileType = "DIR"; + upEntry.fileSize = 0; + upEntry.hasBookmark = false; + upEntry.isDir = true; + _fileList.push_back(upEntry); + } + + // Load metadata cache for this directory + std::vector metaCache = loadMetaCache(); + bool cacheDirty = false; + + // Collect directories and files separately, then combine (dirs first) + std::vector dirs; + std::vector files; + + // Reusable metadata parser (only used for cache misses) + M4BMetadata scanMeta; + File f = root.openNextFile(); - while (f && _fileList.size() < AB_MAX_FILES) { - if (!f.isDirectory()) { - String name = String(f.name()); - int slash = name.lastIndexOf('/'); - if (slash >= 0) name = name.substring(slash + 1); - if (isAudiobookFile(name) && !name.startsWith("._")) { - AudiobookFileEntry entry; - entry.name = name; - // Cache bookmark existence NOW — avoid SD.exists() during render - String bmkPath = getBookmarkPath(name); - entry.hasBookmark = SD.exists(bmkPath.c_str()); - _fileList.push_back(entry); + while (f && (dirs.size() + files.size()) < AB_MAX_FILES) { + String name = String(f.name()); + int slash = name.lastIndexOf('/'); + if (slash >= 0) name = name.substring(slash + 1); + + // Skip hidden files/dirs + if (name.startsWith(".") || name.startsWith("._")) { + f = root.openNextFile(); + continue; + } + + if (f.isDirectory()) { + AudiobookFileEntry entry; + entry.name = name; + entry.displayTitle = name; + entry.fileType = "DIR"; + entry.fileSize = 0; + entry.hasBookmark = false; + entry.isDir = true; + dirs.push_back(entry); + } else if (isAudiobookFile(name)) { + AudiobookFileEntry entry; + entry.name = name; + entry.fileSize = f.size(); + entry.isDir = false; + + // Determine file type + String nameLower = name; + nameLower.toLowerCase(); + if (nameLower.endsWith(".m4b") || nameLower.endsWith(".m4a")) { + entry.fileType = "M4B"; + } else if (nameLower.endsWith(".mp3")) { + entry.fileType = "MP3"; + } else { + entry.fileType = "WAV"; } + + // Check metadata cache first (match by filename + size) + bool cacheHit = false; + for (const auto& mc : metaCache) { + if (mc.filename == name && mc.fileSize == entry.fileSize) { + entry.displayTitle = mc.title; + entry.displayAuthor = mc.author; + cacheHit = true; + break; + } + } + + if (!cacheHit) { + // Cache miss — parse metadata from file (slow path) + String fullPath = _currentPath + "/" + name; + File metaFile = SD.open(fullPath.c_str(), FILE_READ); + if (metaFile) { + scanMeta.clear(); + if (entry.fileType == "M4B") { + if (scanMeta.parse(metaFile)) { + if (scanMeta.title[0]) entry.displayTitle = String(scanMeta.title); + if (scanMeta.author[0]) entry.displayAuthor = String(scanMeta.author); + } + } else if (entry.fileType == "MP3") { + if (scanMeta.parseID3v2(metaFile)) { + if (scanMeta.title[0]) entry.displayTitle = String(scanMeta.title); + if (scanMeta.author[0]) entry.displayAuthor = String(scanMeta.author); + } + } + metaFile.close(); + digitalWrite(SDCARD_CS, HIGH); + yield(); // Feed WDT between file parses + } + cacheDirty = true; + } + + // Fallback: clean up filename if no metadata title found + if (entry.displayTitle.length() == 0) { + String cleaned = name; + int dot = cleaned.lastIndexOf('.'); + if (dot > 0) cleaned = cleaned.substring(0, dot); + cleaned.replace("_", " "); + entry.displayTitle = cleaned; + } + + // Cache bookmark existence + String bmkPath = getBookmarkPath(name); + entry.hasBookmark = SD.exists(bmkPath.c_str()); + + files.push_back(entry); + Serial.printf("AB: [%s] %s - %s (%s)%s\n", + entry.fileType.c_str(), + entry.displayTitle.c_str(), + entry.displayAuthor.length() > 0 ? entry.displayAuthor.c_str() : "?", + entry.name.c_str(), + cacheHit ? " (cached)" : ""); } f = root.openNextFile(); } root.close(); digitalWrite(SDCARD_CS, HIGH); - Serial.printf("AB: Found %d audiobook files\n", (int)_fileList.size()); + // Append directories first, then files + for (auto& d : dirs) _fileList.push_back(d); + for (auto& fi : files) _fileList.push_back(fi); + + // Save metadata cache if any new entries were parsed + if (cacheDirty && files.size() > 0) { + saveMetaCache(files); + } + + Serial.printf("AB: %s — %d dirs, %d files\n", + _currentPath.c_str(), (int)dirs.size(), (int)files.size()); } // ---- Book Open / Close ---- @@ -457,10 +684,17 @@ private: _pendingSeekSec = 0; _streamReady = false; + // Cache file size for MP3 duration estimation + if (_selectedFile >= 0 && _selectedFile < (int)_fileList.size()) { + _currentFileSize = _fileList[_selectedFile].fileSize; + } else { + _currentFileSize = 0; + } + yield(); // Feed WDT between heavy operations // Parse metadata - String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + filename; + String fullPath = _currentPath + "/" + filename; File file = SD.open(fullPath.c_str(), FILE_READ); if (file) { String lower = filename; @@ -538,7 +772,7 @@ private: // Ensure I2S is configured (once only, before first connecttoFS) ensureI2SInit(); - String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + _currentFile; + String fullPath = _currentPath + "/" + _currentFile; // M4B workaround: the ESP32-audioI2S library only recognises .m4a // for MP4/AAC container parsing. M4B is identical but the extension @@ -550,7 +784,7 @@ private: if (lower.endsWith(".m4b")) { String m4aFile = _currentFile.substring(0, _currentFile.length() - 1) + "a"; _m4bOrigPath = fullPath; - _m4bTempPath = String(AUDIOBOOKS_FOLDER) + "/" + m4aFile; + _m4bTempPath = _currentPath + "/" + m4aFile; if (SD.rename(_m4bOrigPath.c_str(), _m4bTempPath.c_str())) { Serial.printf("AB: Renamed '%s' -> '%s' for playback\n", @@ -701,10 +935,13 @@ private: return; } - // Calculate visible items — reserve footerHeight=14 at bottom - int itemHeight = 10; + // Switch to tiny font for file list (6x8 built-in) + display.setTextSize(0); + + // Calculate visible items — tiny font uses ~8 virtual units per line + int itemHeight = 8; int listTop = 13; - int listBottom = display.height() - 14; + int listBottom = display.height() - 14; // Reserve footer space int visibleItems = (listBottom - listTop) / itemHeight; // Keep selection visible @@ -714,6 +951,9 @@ private: _scrollOffset = _selectedFile - visibleItems + 1; } + // Approx chars that fit in tiny font (~36 on 128 virtual width) + const int charsPerLine = 36; + // Draw file list for (int i = 0; i < visibleItems && (_scrollOffset + i) < (int)_fileList.size(); i++) { int fileIdx = _scrollOffset + i; @@ -721,31 +961,76 @@ private: if (fileIdx == _selectedFile) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), itemHeight - 1); + // setCursor adds +5 to y internally, but fillRect does not. + // Offset fillRect by +5 to align highlight bar with text. + display.fillRect(0, y + 5, display.width(), itemHeight - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - // Display filename without extension - String name = _fileList[fileIdx].name; - int dot = name.lastIndexOf('.'); - if (dot > 0) name = name.substring(0, dot); - if (name.length() > 20) { - name = name.substring(0, 17) + "..."; + // Build display string based on entry type + const AudiobookFileEntry& fe = _fileList[fileIdx]; + char fullLine[96]; + + if (fe.isDir) { + // Directory entry: show as "/ FolderName" or just ".." + if (fe.name == "..") { + snprintf(fullLine, sizeof(fullLine), ".. (up)"); + } else { + snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str()); + // Truncate if needed + if ((int)strlen(fullLine) > charsPerLine - 1) { + fullLine[charsPerLine - 4] = '.'; + fullLine[charsPerLine - 3] = '.'; + fullLine[charsPerLine - 2] = '.'; + fullLine[charsPerLine - 1] = '\0'; + } + } + } else { + // Audio file: "Title - Author [TYPE]" + char lineBuf[80]; + + // Reserve space for type tag and bookmark indicator + int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]" + int bmkLen = fe.hasBookmark ? 2 : 0; // " >" + int availChars = charsPerLine - suffixLen - bmkLen; + if (availChars < 10) availChars = 10; + + if (fe.displayAuthor.length() > 0) { + snprintf(lineBuf, sizeof(lineBuf), "%s - %s", + fe.displayTitle.c_str(), fe.displayAuthor.c_str()); + } else { + snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str()); + } + + // Truncate with ellipsis if needed + if ((int)strlen(lineBuf) > availChars) { + if (availChars > 3) { + lineBuf[availChars - 3] = '.'; + lineBuf[availChars - 2] = '.'; + lineBuf[availChars - 1] = '.'; + lineBuf[availChars] = '\0'; + } else { + lineBuf[availChars] = '\0'; + } + } + + // Append file type tag + snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str()); } display.setCursor(2, y); - display.print(name.c_str()); + display.print(fullLine); - // Bookmark indicator (cached from scanFiles — no SD access) - if (_fileList[fileIdx].hasBookmark) { + // Bookmark indicator (right-aligned, files only) + if (!fe.isDir && fe.hasBookmark) { display.setCursor(display.width() - 8, y); display.print(">"); } } - // Scrollbar + // Scrollbar (if needed) if ((int)_fileList.size() > visibleItems) { int barH = listBottom - listTop; int thumbH = max(4, barH * visibleItems / (int)_fileList.size()); @@ -756,9 +1041,22 @@ private: display.fillRect(display.width() - 1, thumbY, 1, thumbH); } - // Footer - char leftBuf[20]; - snprintf(leftBuf, sizeof(leftBuf), "%d files", (int)_fileList.size()); + // Footer (stays at size 1 for readability) + char leftBuf[32]; + if (_currentPath == String(AUDIOBOOKS_FOLDER)) { + snprintf(leftBuf, sizeof(leftBuf), "%d files", (int)_fileList.size()); + } else { + // Show current subfolder name + int lastSlash = _currentPath.lastIndexOf('/'); + String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath; + snprintf(leftBuf, sizeof(leftBuf), "/%s", folderName.c_str()); + if ((int)strlen(leftBuf) > 16) { + leftBuf[13] = '.'; + leftBuf[14] = '.'; + leftBuf[15] = '.'; + leftBuf[16] = '\0'; + } + } drawFooter(display, leftBuf, "W/S:Nav Enter:Open"); } @@ -883,13 +1181,16 @@ public: AudiobookPlayerScreen(UITask* task, Audio* audio) : _task(task), _audio(audio), _mode(FILE_LIST), _sdReady(false), _i2sInitialized(false), _dacPowered(false), + _displayRef(nullptr), _selectedFile(0), _scrollOffset(0), + _currentPath(AUDIOBOOKS_FOLDER), _bookOpen(false), _isPlaying(false), _isPaused(false), _volume(AB_DEFAULT_VOLUME), _coverBitmap(nullptr), _coverW(0), _coverH(0), _hasCover(false), _currentPosSec(0), _durationSec(0), _currentChapter(-1), _lastPositionSave(0), _lastPosUpdate(0), _pendingSeekSec(0), _streamReady(false), + _currentFileSize(0), _m4bRenamed(false), _transportSel(2), _showingInfo(false) {} @@ -916,7 +1217,19 @@ public: if (_durationSec == 0) { uint32_t dur = _audio->getAudioFileDuration(); - if (dur > 0) _durationSec = dur; + if (dur > 0) { + _durationSec = dur; + Serial.printf("AB: Duration from library: %us\n", dur); + } else { + // MP3 fallback: estimate from bitrate + file size + // getAudioFileDuration() returns 0 for MP3 (no native duration header) + uint32_t br = _audio->getBitRate(); + if (br > 0 && _currentFileSize > 0) { + _durationSec = (uint32_t)((uint64_t)_currentFileSize * 8 / br); + Serial.printf("AB: Duration estimated from bitrate: %us (br=%u, sz=%u)\n", + _durationSec, br, _currentFileSize); + } + } } // Apply deferred seek once stream is ready @@ -958,7 +1271,9 @@ public: } void enter(DisplayDriver& display) { + _displayRef = &display; if (!_bookOpen) { + drawLoadingSplash(); scanFiles(); _selectedFile = 0; _scrollOffset = 0; @@ -1036,11 +1351,34 @@ public: return false; } - // Enter - open selected audiobook + // Enter - open selected item (directory or audiobook) if (c == '\r' || c == 13) { if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) { - openBook(_fileList[_selectedFile].name, nullptr); - return true; + const AudiobookFileEntry& entry = _fileList[_selectedFile]; + + if (entry.isDir) { + if (entry.name == "..") { + // Navigate up to parent + int lastSlash = _currentPath.lastIndexOf('/'); + if (lastSlash > 0) { + _currentPath = _currentPath.substring(0, lastSlash); + } else { + _currentPath = AUDIOBOOKS_FOLDER; + } + } else { + // Navigate into subdirectory + _currentPath = _currentPath + "/" + entry.name; + } + // Rescan the new directory + scanFiles(); + _selectedFile = 0; + _scrollOffset = 0; + return true; + } else { + // Open audiobook file + openBook(entry.name, nullptr); + return true; + } } return false; } diff --git a/examples/companion_radio/ui-new/Textreaderscreen.h b/examples/companion_radio/ui-new/Textreaderscreen.h index 276183a..01f58c1 100644 --- a/examples/companion_radio/ui-new/Textreaderscreen.h +++ b/examples/companion_radio/ui-new/Textreaderscreen.h @@ -182,8 +182,10 @@ private: // File list state std::vector _fileList; + std::vector _dirList; // Subdirectories at current path std::vector _fileCache; int _selectedFile; + String _currentPath; // Current browsed directory // Reading state File _file; @@ -391,8 +393,8 @@ private: idxFile.read(&fullyFlag, 1); idxFile.read((uint8_t*)&lastRead, 4); - // Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache - String fullPath = String(BOOKS_FOLDER) + "/" + filename; + // Verify file hasn't changed - try current path first, then epub cache + String fullPath = _currentPath + "/" + filename; File txtFile = SD.open(fullPath.c_str(), FILE_READ); if (!txtFile) { // Fallback: check epub cache directory @@ -482,33 +484,94 @@ private: // ---- File Scanning ---- + // ---- Folder Navigation Helpers ---- + + bool isAtBooksRoot() const { + return _currentPath == String(BOOKS_FOLDER); + } + + // Number of non-file entries at the start of the visual list + int dirEntryCount() const { + int count = _dirList.size(); + if (!isAtBooksRoot()) count++; // ".." entry + return count; + } + + // Total items in the visual list (parent + dirs + files) + int totalListItems() const { + return dirEntryCount() + (int)_fileList.size(); + } + + // What type of entry is at visual list index idx? + // Returns: 0 = ".." parent, 1 = directory, 2 = file + int itemTypeAt(int idx) const { + bool hasParent = !isAtBooksRoot(); + if (hasParent && idx == 0) return 0; // ".." + int dirStart = hasParent ? 1 : 0; + if (idx < dirStart + (int)_dirList.size()) return 1; // directory + return 2; // file + } + + // Get directory name for visual index (only valid when itemTypeAt == 1) + const String& dirNameAt(int idx) const { + int dirStart = isAtBooksRoot() ? 0 : 1; + return _dirList[idx - dirStart]; + } + + // Get file list index for visual index (only valid when itemTypeAt == 2) + int fileIndexAt(int idx) const { + return idx - dirEntryCount(); + } + + void navigateToParent() { + int lastSlash = _currentPath.lastIndexOf('/'); + if (lastSlash > 0) { + _currentPath = _currentPath.substring(0, lastSlash); + } else { + _currentPath = BOOKS_FOLDER; + } + } + + void navigateToChild(const String& dirName) { + _currentPath = _currentPath + "/" + dirName; + } + + // ---- File Scanning ---- + void scanFiles() { _fileList.clear(); + _dirList.clear(); if (!SD.exists(BOOKS_FOLDER)) { SD.mkdir(BOOKS_FOLDER); Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER); } - File root = SD.open(BOOKS_FOLDER); + File root = SD.open(_currentPath.c_str()); if (!root || !root.isDirectory()) return; File f = root.openNextFile(); - while (f && _fileList.size() < READER_MAX_FILES) { - if (!f.isDirectory()) { - String name = String(f.name()); - int slash = name.lastIndexOf('/'); - if (slash >= 0) name = name.substring(slash + 1); + while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) { + String name = String(f.name()); + int slash = name.lastIndexOf('/'); + if (slash >= 0) name = name.substring(slash + 1); - if (!name.startsWith(".") && - (name.endsWith(".txt") || name.endsWith(".TXT") || - name.endsWith(".epub") || name.endsWith(".EPUB"))) { - _fileList.push_back(name); - } + // Skip hidden files/dirs + if (name.startsWith(".")) { + f = root.openNextFile(); + continue; + } + + if (f.isDirectory()) { + _dirList.push_back(name); + } else if (name.endsWith(".txt") || name.endsWith(".TXT") || + name.endsWith(".epub") || name.endsWith(".EPUB")) { + _fileList.push_back(name); } f = root.openNextFile(); } root.close(); - Serial.printf("TextReader: Found %d files\n", _fileList.size()); + Serial.printf("TextReader: %s — %d dirs, %d files\n", + _currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size()); } // ---- Book Open/Close ---- @@ -518,7 +581,7 @@ private: // ---- EPUB auto-conversion ---- String actualFilename = filename; - String actualFullPath = String(BOOKS_FOLDER) + "/" + filename; + String actualFullPath = _currentPath + "/" + filename; bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB"); if (isEpub) { @@ -755,15 +818,26 @@ private: display.setCursor(0, 0); display.setTextSize(1); display.setColor(DisplayDriver::GREEN); - display.print("Text Reader"); + if (isAtBooksRoot()) { + display.print("Text Reader"); + } else { + // Show current subfolder name + int lastSlash = _currentPath.lastIndexOf('/'); + String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath; + char hdrBuf[20]; + strncpy(hdrBuf, folderName.c_str(), 17); + hdrBuf[17] = '\0'; + display.print(hdrBuf); + } - sprintf(tmp, "[%d]", (int)_fileList.size()); + int totalItems = totalListItems(); + sprintf(tmp, "[%d]", totalItems); display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); display.print(tmp); display.drawRect(0, 11, display.width(), 1); - if (_fileList.size() == 0) { + if (totalItems == 0) { display.setCursor(0, 18); display.setColor(DisplayDriver::LIGHT); display.print("No files found"); @@ -780,8 +854,8 @@ private: if (maxVisible > 15) maxVisible = 15; int startIdx = max(0, min(_selectedFile - maxVisible / 2, - (int)_fileList.size() - maxVisible)); - int endIdx = min((int)_fileList.size(), startIdx + maxVisible); + totalItems - maxVisible)); + int endIdx = min(totalItems, startIdx + maxVisible); int y = startY; for (int i = startIdx; i < endIdx; i++) { @@ -800,27 +874,41 @@ private: // Set cursor AFTER fillRect so text draws on top of highlight display.setCursor(0, y); - // Build display string: "> filename.txt *" (asterisk if has bookmark) + int type = itemTypeAt(i); String line = selected ? "> " : " "; - String name = _fileList[i]; - // Check for resume indicator - String suffix = ""; - for (int j = 0; j < (int)_fileCache.size(); j++) { - if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) { - suffix = " *"; - break; + if (type == 0) { + // ".." parent directory + line += ".. (up)"; + } else if (type == 1) { + // Subdirectory + line += "/" + dirNameAt(i); + // Truncate if needed + if ((int)line.length() > _charsPerLine) { + line = line.substring(0, _charsPerLine - 3) + "..."; } + } else { + // File + int fi = fileIndexAt(i); + String name = _fileList[fi]; + + // Check for resume indicator + String suffix = ""; + if (fi < (int)_fileCache.size()) { + if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) { + suffix = " *"; + } + } + + // Truncate if needed + int maxLen = _charsPerLine - 4 - suffix.length(); + if ((int)name.length() > maxLen) { + name = name.substring(0, maxLen - 3) + "..."; + } + line += name + suffix; } - // Truncate if needed - int maxLen = _charsPerLine - 4 - suffix.length(); - if ((int)name.length() > maxLen) { - name = name.substring(0, maxLen - 3) + "..."; - } - line += name + suffix; display.print(line.c_str()); - y += listLineH; } display.setTextSize(1); // Restore @@ -928,7 +1016,8 @@ public: _bootIndexed(false), _display(nullptr), _charsPerLine(38), _linesPerPage(22), _lineHeight(5), _headerHeight(14), _footerHeight(14), - _selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0), + _selectedFile(0), _currentPath(BOOKS_FOLDER), + _fileOpen(false), _currentPage(0), _totalPages(0), _pageBufLen(0), _contentDirty(true) { } @@ -1068,8 +1157,8 @@ public: indexProgress++; drawBootSplash(indexProgress, needsIndexCount, _fileList[i]); - // Try BOOKS_FOLDER first, then epub cache fallback - String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i]; + // Try current path first, then epub cache fallback + String fullPath = _currentPath + "/" + _fileList[i]; File file = SD.open(fullPath.c_str(), FILE_READ); if (!file) { String cacheFallback = String("/books/.epub_cache/") + _fileList[i]; @@ -1166,6 +1255,8 @@ public: } bool handleFileListInput(char c) { + int total = totalListItems(); + // W - scroll up if (c == 'w' || c == 'W' || c == 0xF2) { if (_selectedFile > 0) { @@ -1177,18 +1268,36 @@ public: // S - scroll down if (c == 's' || c == 'S' || c == 0xF1) { - if (_selectedFile < (int)_fileList.size() - 1) { + if (_selectedFile < total - 1) { _selectedFile++; return true; } return false; } - // Enter - open selected file + // Enter - open selected item (directory or file) if (c == '\r' || c == 13) { - if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) { - openBook(_fileList[_selectedFile]); + if (total == 0 || _selectedFile >= total) return false; + + int type = itemTypeAt(_selectedFile); + + if (type == 0) { + // ".." — navigate to parent + navigateToParent(); + rescanAndIndex(); return true; + } else if (type == 1) { + // Subdirectory — navigate into it + navigateToChild(dirNameAt(_selectedFile)); + rescanAndIndex(); + return true; + } else { + // File — open it + int fi = fileIndexAt(_selectedFile); + if (fi >= 0 && fi < (int)_fileList.size()) { + openBook(_fileList[fi]); + return true; + } } return false; } @@ -1196,6 +1305,53 @@ public: return false; } + // Rescan current directory and re-index its files. + // Called when navigating into or out of a subfolder. + void rescanAndIndex() { + scanFiles(); + _selectedFile = 0; + + // Rebuild file cache for the new directory's files + _fileCache.clear(); + _fileCache.resize(_fileList.size()); + + for (int i = 0; i < (int)_fileList.size(); i++) { + if (!loadIndex(_fileList[i], _fileCache[i])) { + // Not cached — skip EPUB auto-indexing here (it happens on open) + // For .txt files, index now + if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) { + String fullPath = _currentPath + "/" + _fileList[i]; + File file = SD.open(fullPath.c_str(), FILE_READ); + if (!file) { + // Try epub cache fallback + String cacheFallback = String("/books/.epub_cache/") + _fileList[i]; + file = SD.open(cacheFallback.c_str(), FILE_READ); + } + if (file) { + FileCache& cache = _fileCache[i]; + cache.filename = _fileList[i]; + cache.fileSize = file.size(); + cache.fullyIndexed = false; + cache.lastReadPage = 0; + cache.pagePositions.clear(); + cache.pagePositions.push_back(0); + indexPagesWordWrap(file, 0, cache.pagePositions, + _linesPerPage, _charsPerLine, + PREINDEX_PAGES - 1); + cache.fullyIndexed = !file.available(); + file.close(); + saveIndex(cache.filename, cache.pagePositions, cache.fileSize, + cache.fullyIndexed, 0); + } + } else { + _fileCache[i].filename = ""; + } + } + yield(); // Feed WDT between files + } + digitalWrite(SDCARD_CS, HIGH); + } + bool handleReadingInput(char c) { // W/A - previous page if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) { diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index a3c759f..cf0172a 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -80,7 +80,7 @@ build_flags = -D PIN_DISPLAY_BL=45 -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 - -D FIRMWARE_VERSION='"Meck v0.8.7A"' + -D FIRMWARE_VERSION='"Meck v0.8.8A"' -D ARDUINO_LOOP_STACK_SIZE=32768 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro>