Files

1856 lines
59 KiB
C++

#pragma once
// =============================================================================
// AudiobookPlayerScreen.h - Audiobook player for LilyGo T-Deck Pro
//
// Features:
// - Browses /audiobooks/ on SD card for .m4b, .m4a, .mp3, .wav files
// - Parses M4B metadata (title, author, cover art, chapters)
// - Displays dithered cover art on e-ink (JPEG decode via JPEGDEC)
// - Audible-style player UI with transport controls
// - Bookmark persistence per file (auto-save/restore position)
// - Audio output via I2S to PCM5102A DAC (audio variant only)
// - Cooperative audio decode loop - yields to mesh stack
// - Graceful pause during LoRa TX (SPI bus contention)
//
// Keyboard controls:
// FILE_LIST mode: W/S = scroll, Enter = open, Q = exit
// PLAYER mode: Enter = play/pause, A = -30s, D = +30s,
// W = volume up, S = volume down,
// [ = prev chapter, ] = next chapter,
// N = next track in playlist,
// Z = toggle 45-min sleep timer,
// Q = leave (audio continues) / close book (if paused)
//
// Library dependencies (add to platformio.ini lib_deps):
// https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
// bitbank2/JPEGDEC
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include <algorithm>
#include "M4BMetadata.h"
// Audio library — ESP32-audioI2S by schreibfaul1
#include "Audio.h"
// Pin definitions for I2S DAC (from variant.h)
#include "variant.h"
// JPEG decoder for cover art — JPEGDEC by bitbank2
#include <JPEGDEC.h>
#include "../NodePrefs.h"
// Forward declarations
class UITask;
#ifdef HAS_ES8311_AUDIO
// Defined in target.cpp (where the board object + ES8311 driver are visible).
// Forward-declared here so this UI header doesn't need the heavy target.h.
void meck_audio_route_amp();
void meck_audio_codec_init();
#endif
// ============================================================================
// Configuration
// ============================================================================
#define AUDIOBOOKS_FOLDER "/audiobooks"
#define AB_BOOKMARK_FOLDER "/audiobooks/.bookmarks"
#define AB_MAX_FILES 50
#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
#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) {
String lower = name;
lower.toLowerCase();
// .m4b excluded: these can't currently be played, so they are hidden from
// the file explorer. (.m4a/.mp3/.wav remain listed.)
return lower.endsWith(".m4a") ||
lower.endsWith(".mp3") || lower.endsWith(".wav");
}
// ============================================================================
// 4x4 Bayer ordered dithering matrix (threshold values 0-255)
// ============================================================================
static const uint8_t BAYER4x4[4][4] = {
{ 15, 135, 45, 165 },
{ 195, 75, 225, 105 },
{ 60, 180, 30, 150 },
{ 240, 120, 210, 90 }
};
// ============================================================================
// JPEG decode callback context
// ============================================================================
struct CoverDecodeCtx {
uint8_t* bitmap;
int bitmapW;
int bitmapH;
int srcW;
int srcH;
int offsetX;
int offsetY;
};
// JPEGDEC draw callback — converts decoded pixels to 1-bit dithered
static int coverDrawCallback(JPEGDRAW* pDraw) {
CoverDecodeCtx* ctx = (CoverDecodeCtx*)pDraw->pUser;
if (!ctx || !ctx->bitmap) return 1;
for (int y = 0; y < pDraw->iHeight; y++) {
int destY = pDraw->y + y - ctx->offsetY;
if (destY < 0 || destY >= ctx->bitmapH) continue;
for (int x = 0; x < pDraw->iWidth; x++) {
int destX = pDraw->x + x - ctx->offsetX;
if (destX < 0 || destX >= ctx->bitmapW) continue;
uint16_t rgb565 = pDraw->pPixels[y * pDraw->iWidth + x];
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[destY & 3][destX & 3];
bool isBlack = (gray < threshold);
if (isBlack) {
int byteIdx = destY * ((ctx->bitmapW + 7) / 8) + (destX / 8);
uint8_t bitMask = 0x80 >> (destX & 7);
ctx->bitmap[byteIdx] |= bitMask;
}
}
}
return 1;
}
// ============================================================================
// File entry with cached metadata and bookmark state
// ============================================================================
struct AudiobookFileEntry {
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
};
// ============================================================================
// AudiobookPlayerScreen
// ============================================================================
class AudiobookPlayerScreen : public UIScreen {
public:
enum Mode { FILE_LIST, PLAYER };
struct Bookmark {
char filename[64];
uint32_t positionSec;
uint8_t volume;
};
private:
UITask* _task;
NodePrefs* _prefs;
Audio* _audio;
Mode _mode;
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<AudiobookFileEntry> _fileList;
int _selectedFile;
int _scrollOffset;
String _currentPath; // Current browsed directory (starts as AUDIOBOOKS_FOLDER)
String _lastScanPath; // Path of last completed scan (skip rescan if unchanged)
// Current book state
String _currentFile;
M4BMetadata _metadata;
bool _bookOpen;
bool _isPlaying;
bool _isPaused;
uint8_t _volume;
// Cover art bitmap
uint8_t* _coverBitmap;
int _coverW;
int _coverH;
bool _hasCover;
// Playback tracking
uint32_t _currentPosSec;
uint32_t _durationSec;
int _currentChapter;
unsigned long _lastPositionSave;
unsigned long _lastPosUpdate;
// Deferred seek — applied after audio library reports stream ready
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
String _m4bOrigPath; // original path (with .m4b extension)
String _m4bTempPath; // temporary path (with .m4a extension)
// UI state
int _transportSel;
bool _showingInfo;
// Sleep timer — press Z to start 45min countdown, Z again or pause to cancel
bool _sleepTimerActive;
unsigned long _sleepTimerEnd; // millis() when timer expires
// Playlist / track queue — all playable files in the current directory
std::vector<String> _playlist; // Sorted filenames in _currentPath
int _playlistIdx; // Current track index (-1 = no playlist)
volatile bool _eofFlag; // Set by audio_eof_mp3 callback
// Power on the PCM5102A DAC via GPIO 41 (BOARD_6609_EN).
// On the audio variant, this pin supplies power to the DAC circuit.
// TDeckBoard::begin() sets it LOW ("disable modem") which starves the DAC.
void enableDAC() {
#ifndef HAS_ES8311_AUDIO
pinMode(41, OUTPUT);
digitalWrite(41, HIGH);
if (!_dacPowered) {
delay(50);
}
_dacPowered = true;
#endif
}
void disableDAC() {
#ifndef HAS_ES8311_AUDIO
digitalWrite(41, LOW);
_dacPowered = false;
#endif
}
// Restore an M4B file that was temporarily renamed to .m4a for playback.
void restoreM4bRename() {
if (!_m4bRenamed) return;
if (SD.rename(_m4bTempPath.c_str(), _m4bOrigPath.c_str())) {
Serial.printf("AB: Restored '%s' -> '%s'\n",
_m4bTempPath.c_str(), _m4bOrigPath.c_str());
} else {
Serial.printf("AB: Warning - failed to restore '%s' to '%s'\n",
_m4bTempPath.c_str(), _m4bOrigPath.c_str());
}
_m4bRenamed = false;
_m4bOrigPath = "";
_m4bTempPath = "";
}
void ensureI2SInit() {
if (!_i2sInitialized && _audio) {
#ifdef HAS_ES8311_AUDIO
// MAX: route to the ES8311 + enable the speaker amp, then configure I2S
// WITH MCLK (the ES8311 is clock slave and needs MCLK on BOARD_I2S_MCLK).
// The codec registers are initialised after connecttoFS starts the clocks
// (see meck_audio_codec_init() in startPlayback).
meck_audio_route_amp();
bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT,
I2S_PIN_NO_CHANGE, BOARD_I2S_MCLK);
#else
bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0);
if (!ok) {
ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT);
}
#endif
if (!ok) Serial.println("AB: setPinout FAILED");
_i2sInitialized = true;
}
}
// ---- Cover Art Decoding ----
bool decodeCoverArt(File& file) {
freeCoverBitmap();
if (!_metadata.hasCoverArt || _metadata.coverSize == 0) return false;
if (_metadata.coverFormat != 13) {
Serial.printf("AB: Cover format %d not supported (JPEG only)\n", _metadata.coverFormat);
return false;
}
uint8_t* jpegBuf = (uint8_t*)ps_malloc(_metadata.coverSize);
if (!jpegBuf) {
Serial.println("AB: Failed to allocate JPEG buffer in PSRAM");
return false;
}
file.seek(_metadata.coverOffset);
int bytesRead = file.read(jpegBuf, _metadata.coverSize);
if (bytesRead != (int)_metadata.coverSize) {
Serial.printf("AB: Cover read failed (%d/%u bytes)\n", bytesRead, _metadata.coverSize);
free(jpegBuf);
return false;
}
digitalWrite(SDCARD_CS, HIGH);
_coverW = AB_COVER_W;
_coverH = AB_COVER_H;
int bitmapBytes = ((_coverW + 7) / 8) * _coverH;
_coverBitmap = (uint8_t*)ps_calloc(1, bitmapBytes);
if (!_coverBitmap) {
Serial.println("AB: Failed to allocate cover bitmap");
free(jpegBuf);
return false;
}
JPEGDEC* jpeg = new JPEGDEC();
if (!jpeg) {
Serial.println("AB: Failed to allocate JPEGDEC");
free(jpegBuf);
freeCoverBitmap();
return false;
}
CoverDecodeCtx ctx;
ctx.bitmap = _coverBitmap;
ctx.bitmapW = _coverW;
ctx.bitmapH = _coverH;
if (!jpeg->openRAM(jpegBuf, _metadata.coverSize, coverDrawCallback)) {
Serial.println("AB: JPEGDEC failed to open cover image");
delete jpeg;
free(jpegBuf);
freeCoverBitmap();
return false;
}
int srcW = jpeg->getWidth();
int srcH = jpeg->getHeight();
int scale = 0;
if (srcW > _coverW * 6 || srcH > _coverH * 6) scale = 3;
else if (srcW > _coverW * 3 || srcH > _coverH * 3) scale = 2;
else if (srcW > _coverW * 1.5 || srcH > _coverH * 1.5) scale = 1;
int divider = 1 << scale;
int scaledW = srcW / divider;
int scaledH = srcH / divider;
ctx.srcW = scaledW;
ctx.srcH = scaledH;
ctx.offsetX = (scaledW > _coverW) ? (scaledW - _coverW) / 2 : 0;
ctx.offsetY = (scaledH > _coverH) ? (scaledH - _coverH) / 2 : 0;
jpeg->setUserPointer(&ctx);
jpeg->setPixelType(RGB565_BIG_ENDIAN);
int scaleFlags[] = { JPEG_SCALE_HALF, JPEG_SCALE_QUARTER, JPEG_SCALE_EIGHTH };
if (scale > 0) {
jpeg->decode(0, 0, scaleFlags[scale - 1]);
} else {
jpeg->decode(0, 0, 0);
}
jpeg->close();
delete jpeg;
free(jpegBuf);
_hasCover = true;
Serial.printf("AB: Cover decoded %dx%d (source %dx%d, scale 1/%d)\n",
_coverW, _coverH, srcW, srcH, divider);
return true;
}
void freeCoverBitmap() {
if (_coverBitmap) {
free(_coverBitmap);
_coverBitmap = nullptr;
}
_hasCover = false;
_coverW = 0;
_coverH = 0;
}
// ---- Bookmark Persistence ----
String getBookmarkPath(const String& filename) {
String base = filename;
int slash = base.lastIndexOf('/');
if (slash >= 0) base = base.substring(slash + 1);
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
return String(AB_BOOKMARK_FOLDER) + "/" + base + ".bmk";
}
// True when the current folder is the music library (/audiobooks/music or any
// subfolder). Music files are played without bookmarks: no position is saved
// or restored, and any stale bookmark for the file is deleted when it opens.
bool isMusicPath() const {
String musicRoot = String(AUDIOBOOKS_FOLDER) + "/music";
return _currentPath == musicRoot || _currentPath.startsWith(musicRoot + "/");
}
// Compute WAV playback duration (seconds) from the file header by reading the
// fmt chunk's byte rate and the data chunk's size. Returns 0 if the header
// can't be parsed (caller falls back to the during-playback bitrate estimate).
uint32_t parseWavDurationSec(File& f) {
if (!f) return 0;
f.seek(0);
uint8_t hdr[12];
if (f.read(hdr, 12) != 12) return 0;
if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0) return 0;
uint32_t byteRate = 0;
uint32_t dataSize = 0;
while (f.available() >= 8) {
uint8_t ch[8];
if (f.read(ch, 8) != 8) break;
uint32_t sz = (uint32_t)ch[4] | ((uint32_t)ch[5] << 8) |
((uint32_t)ch[6] << 16) | ((uint32_t)ch[7] << 24);
if (memcmp(ch, "fmt ", 4) == 0) {
uint8_t fmt[16];
uint32_t toRead = sz < 16 ? sz : 16;
if ((uint32_t)f.read(fmt, toRead) != toRead) break;
// byteRate is bytes 8-11 of the fmt body (after format, channels, rate)
byteRate = (uint32_t)fmt[8] | ((uint32_t)fmt[9] << 8) |
((uint32_t)fmt[10] << 16) | ((uint32_t)fmt[11] << 24);
if (sz > toRead) f.seek(f.position() + (sz - toRead));
if (sz & 1) f.seek(f.position() + 1); // chunks are word-aligned
} else if (memcmp(ch, "data", 4) == 0) {
dataSize = sz;
break; // have what we need
} else {
f.seek(f.position() + sz + (sz & 1)); // skip other chunks (word-aligned)
}
}
if (byteRate == 0 || dataSize == 0) return 0;
return dataSize / byteRate;
}
void loadBookmark() {
String path = getBookmarkPath(_currentFile);
if (isMusicPath()) {
// Music files don't use bookmarks — delete any stale one and start at 0.
if (SD.exists(path.c_str())) {
SD.remove(path.c_str());
digitalWrite(SDCARD_CS, HIGH);
}
return;
}
File f = SD.open(path.c_str(), FILE_READ);
if (!f) return;
Bookmark bm;
if (f.read((uint8_t*)&bm, sizeof(bm)) == sizeof(bm)) {
_currentPosSec = bm.positionSec;
_volume = bm.volume;
if (_volume > 21) _volume = AB_DEFAULT_VOLUME;
Serial.printf("AB: Loaded bookmark - pos %us, vol %d\n", _currentPosSec, _volume);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
}
void saveBookmark() {
if (!_bookOpen || _currentFile.length() == 0) return;
if (isMusicPath()) return; // Music files don't use bookmarks
if (!SD.exists(AB_BOOKMARK_FOLDER)) {
SD.mkdir(AB_BOOKMARK_FOLDER);
}
String path = getBookmarkPath(_currentFile);
if (SD.exists(path.c_str())) SD.remove(path.c_str());
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return;
Bookmark bm;
memset(&bm, 0, sizeof(bm));
strncpy(bm.filename, _currentFile.c_str(), sizeof(bm.filename) - 1);
bm.positionSec = _currentPosSec;
bm.volume = _volume;
f.write((uint8_t*)&bm, sizeof(bm));
f.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("AB: Saved bookmark - pos %us\n", _currentPosSec);
}
// ---- 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<MetaCacheEntry> loadMetaCache() {
std::vector<MetaCacheEntry> 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<AudiobookFileEntry>& 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 ----
// Lightweight refresh: update only bookmark flags on existing file list.
// Used on re-entry when we skip the full rescan.
void refreshBookmarkFlags() {
std::vector<String> bookmarkNames;
File bmkDir = SD.open(AB_BOOKMARK_FOLDER);
if (bmkDir && bmkDir.isDirectory()) {
File bmkFile = bmkDir.openNextFile();
while (bmkFile) {
String bn = String(bmkFile.name());
int sl = bn.lastIndexOf('/');
if (sl >= 0) bn = bn.substring(sl + 1);
bookmarkNames.push_back(bn);
bmkFile = bmkDir.openNextFile();
}
bmkDir.close();
}
for (auto& fe : _fileList) {
if (fe.isDir) continue;
String base = fe.name;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
String bmkName = base + ".bmk";
fe.hasBookmark = false;
for (const auto& bn : bookmarkNames) {
if (bn == bmkName) { fe.hasBookmark = true; break; }
}
}
}
void scanFiles() {
_fileList.clear();
if (!SD.exists(AUDIOBOOKS_FOLDER)) {
SD.mkdir(AUDIOBOOKS_FOLDER);
Serial.printf("AB: Created %s\n", 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);
}
// ---- Batch bookmark check ----
// Scan .bookmarks/ directory once to build a set, instead of
// calling SD.exists() individually for each of the ~50 files.
std::vector<String> bookmarkNames;
File bmkDir = SD.open(AB_BOOKMARK_FOLDER);
if (bmkDir && bmkDir.isDirectory()) {
File bmkFile = bmkDir.openNextFile();
while (bmkFile) {
String bn = String(bmkFile.name());
int sl = bn.lastIndexOf('/');
if (sl >= 0) bn = bn.substring(sl + 1);
bookmarkNames.push_back(bn);
bmkFile = bmkDir.openNextFile();
}
bmkDir.close();
}
// Load metadata cache for this directory
std::vector<MetaCacheEntry> metaCache = loadMetaCache();
bool cacheDirty = false;
// Collect directories and files separately, then combine (dirs first)
std::vector<AudiobookFileEntry> dirs;
std::vector<AudiobookFileEntry> files;
// Reusable metadata parser (only used for cache misses)
M4BMetadata scanMeta;
int cacheHits = 0;
File f = root.openNextFile();
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;
cacheHits++;
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;
// Only log cache misses (the slow path)
Serial.printf("AB: [%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());
}
// 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("_", " ");
// In subdirectories, filenames often follow "Artist - Album - NN Track"
// pattern. The folder already provides context, so extract just the
// last segment after " - " to show the track-relevant part.
if (_currentPath != String(AUDIOBOOKS_FOLDER)) {
int lastSep = cleaned.lastIndexOf(" - ");
if (lastSep > 0 && lastSep < (int)cleaned.length() - 3) {
cleaned = cleaned.substring(lastSep + 3);
}
}
entry.displayTitle = cleaned;
}
// Bookmark check against pre-scanned set (no SD.exists per file)
String base = name;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
String bmkName = base + ".bmk";
entry.hasBookmark = false;
for (const auto& bn : bookmarkNames) {
if (bn == bmkName) {
entry.hasBookmark = true;
break;
}
}
files.push_back(entry);
}
f = root.openNextFile();
}
root.close();
digitalWrite(SDCARD_CS, HIGH);
// Sort directories and files alphabetically (case-insensitive)
std::sort(dirs.begin(), dirs.end(), [](const AudiobookFileEntry& a, const AudiobookFileEntry& b) {
String la = a.name; la.toLowerCase();
String lb = b.name; lb.toLowerCase();
return la < lb;
});
std::sort(files.begin(), files.end(), [](const AudiobookFileEntry& a, const AudiobookFileEntry& b) {
String la = a.name; la.toLowerCase();
String lb = b.name; lb.toLowerCase();
return la < lb;
});
// 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);
}
_lastScanPath = _currentPath;
Serial.printf("AB: %s — %d dirs, %d files (%d cached)\n",
_currentPath.c_str(), (int)dirs.size(), (int)files.size(), cacheHits);
}
// ---- Playlist / Track Queue ----
// Builds a sorted list of all playable files in the current directory.
// Called when opening a file — enables auto-advance and skip.
void buildPlaylist(const String& startingFile) {
_playlist.clear();
_playlistIdx = -1;
File dir = SD.open(_currentPath.c_str());
if (!dir || !dir.isDirectory()) return;
File f = dir.openNextFile();
while (f) {
if (!f.isDirectory()) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
if (!name.startsWith(".") && !name.startsWith("._") && isAudiobookFile(name)) {
_playlist.push_back(name);
}
}
f = dir.openNextFile();
}
dir.close();
digitalWrite(SDCARD_CS, HIGH);
// Sort alphabetically (case-insensitive)
std::sort(_playlist.begin(), _playlist.end(), [](const String& a, const String& b) {
String la = a; la.toLowerCase();
String lb = b; lb.toLowerCase();
return la < lb;
});
// Find the starting file's index
for (int i = 0; i < (int)_playlist.size(); i++) {
if (_playlist[i] == startingFile) {
_playlistIdx = i;
break;
}
}
Serial.printf("AB: Playlist built — %d tracks, current idx %d\n",
(int)_playlist.size(), _playlistIdx);
}
// Advance to next/previous track in playlist.
// direction: +1 = next, -1 = previous
// Returns true if a new track was started.
bool advanceTrack(int direction) {
if (_playlist.size() <= 1 || _playlistIdx < 0) return false;
int newIdx = _playlistIdx + direction;
if (newIdx < 0 || newIdx >= (int)_playlist.size()) {
Serial.println("AB: End of playlist reached");
// End of playlist — stop playback
stopPlayback();
return false;
}
// Stop current track cleanly
if (_audio) {
_audio->stopSong();
}
_isPlaying = false;
_isPaused = false;
_pendingSeekSec = 0;
_streamReady = false;
restoreM4bRename();
// Power down DAC briefly (startPlayback will re-enable it)
disableDAC();
_i2sInitialized = false;
// Save bookmark for current track before switching
saveBookmark();
// Free old cover art and metadata
freeCoverBitmap();
_metadata.clear();
// Switch to new track
_playlistIdx = newIdx;
String nextFile = _playlist[_playlistIdx];
Serial.printf("AB: Advancing to track %d/%d: %s\n",
_playlistIdx + 1, (int)_playlist.size(), nextFile.c_str());
// Reset state for new track
_currentFile = nextFile;
_currentPosSec = 0;
_durationSec = 0;
_currentChapter = -1;
_lastPosUpdate = 0;
_currentFileSize = 0;
_eofFlag = false;
// Find file size from the file list (if available)
for (const auto& fe : _fileList) {
if (fe.name == nextFile) {
_currentFileSize = fe.fileSize;
break;
}
}
// Parse metadata for new track
String fullPath = _currentPath + "/" + nextFile;
File file = SD.open(fullPath.c_str(), FILE_READ);
if (file) {
if (_currentFileSize == 0) _currentFileSize = file.size();
String lower = nextFile;
lower.toLowerCase();
if (lower.endsWith(".m4b") || lower.endsWith(".m4a")) {
_metadata.parse(file);
yield();
decodeCoverArt(file);
yield();
} else if (lower.endsWith(".mp3")) {
_metadata.parseID3v2(file);
yield();
decodeCoverArt(file);
yield();
if (_metadata.title[0] == '\0') {
String base = nextFile;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
}
} else {
String base = nextFile;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
}
file.close();
}
digitalWrite(SDCARD_CS, HIGH);
// Load bookmark for new track (may resume a previous position)
loadBookmark();
if (_audio) _audio->setVolume(_volume);
if (_metadata.durationMs > 0) _durationSec = _metadata.durationMs / 1000;
// Start playing the new track
_lastPositionSave = millis();
startPlayback();
return true;
}
// ---- Book Open / Close ----
void openBook(const String& filename, DisplayDriver* display) {
// Show loading splash
if (display) {
display->startFrame();
display->setTextSize(1);
display->setColor(DisplayDriver::GREEN);
display->setCursor(10, 11);
display->print("Loading...");
display->setTextSize(1);
display->setColor(DisplayDriver::LIGHT);
display->setCursor(10, 30);
String dispName = filename;
int dot = dispName.lastIndexOf('.');
if (dot > 0) dispName = dispName.substring(0, dot);
if (dispName.length() > 22) dispName = dispName.substring(0, 19) + "...";
display->print(dispName.c_str());
display->endFrame();
}
_currentFile = filename;
_bookOpen = true;
_isPlaying = false;
_isPaused = false;
_currentPosSec = 0;
_durationSec = 0;
_currentChapter = -1;
_lastPositionSave = millis();
_lastPosUpdate = 0;
_transportSel = 2;
_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 = _currentPath + "/" + filename;
File file = SD.open(fullPath.c_str(), FILE_READ);
if (file) {
String lower = filename;
lower.toLowerCase();
if (lower.endsWith(".m4b") || lower.endsWith(".m4a")) {
_metadata.parse(file);
yield(); // Feed WDT after metadata parse
decodeCoverArt(file);
yield(); // Feed WDT after cover decode
} else if (lower.endsWith(".mp3")) {
_metadata.parseID3v2(file);
yield(); // Feed WDT after metadata parse
decodeCoverArt(file);
yield(); // Feed WDT after cover decode
// Fall back to filename for title if ID3 had none
if (_metadata.title[0] == '\0') {
String base = filename;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
}
} else {
// Other audio formats — use filename as title
_metadata.clear();
String base = filename;
int dot = base.lastIndexOf('.');
if (dot > 0) base = base.substring(0, dot);
strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1);
// WAV: exact duration is in the header, so show it at open.
if (lower.endsWith(".wav")) {
uint32_t wavSec = parseWavDurationSec(file);
if (wavSec > 0) _durationSec = wavSec;
}
}
file.close();
}
digitalWrite(SDCARD_CS, HIGH);
yield(); // Feed WDT before bookmark load
// Load saved bookmark position
loadBookmark();
// Set volume
if (_audio) {
_audio->setVolume(_volume);
}
if (_metadata.durationMs > 0) {
_durationSec = _metadata.durationMs / 1000;
}
_mode = PLAYER;
_eofFlag = false;
// Build playlist from current directory for track queuing
buildPlaylist(filename);
Serial.printf("AB: Opened '%s' -- %s by %s, %us, %d chapters\n",
filename.c_str(), _metadata.title, _metadata.author,
_durationSec, _metadata.chapterCount);
}
void closeBook() {
if (_isPlaying || _isPaused) {
stopPlayback();
}
restoreM4bRename(); // Safety: ensure rename is restored even if state was odd
saveBookmark();
freeCoverBitmap();
_metadata.clear();
_bookOpen = false;
_currentFile = "";
_sleepTimerActive = false;
_playlist.clear();
_playlistIdx = -1;
_eofFlag = false;
_mode = FILE_LIST;
}
// ---- Playback Control ----
void startPlayback() {
if (!_audio || _currentFile.length() == 0) return;
_eofFlag = false; // Clear any stale EOF from previous track
// Ensure DAC has power (must be re-enabled after each stop)
enableDAC();
// Ensure I2S is configured (once only, before first connecttoFS)
ensureI2SInit();
String fullPath = _currentPath + "/" + _currentFile;
// M4B workaround: the ESP32-audioI2S library only recognises .m4a
// for MP4/AAC container parsing. M4B is identical but the extension
// isn't checked, so the library treats it as raw AAC and fails.
// Temporarily rename the file on the SD card (FAT32 rename is instant).
_m4bRenamed = false;
String lower = _currentFile;
lower.toLowerCase();
if (lower.endsWith(".m4b")) {
String m4aFile = _currentFile.substring(0, _currentFile.length() - 1) + "a";
_m4bOrigPath = fullPath;
_m4bTempPath = _currentPath + "/" + m4aFile;
if (SD.rename(_m4bOrigPath.c_str(), _m4bTempPath.c_str())) {
Serial.printf("AB: Renamed '%s' -> '%s' for playback\n",
_m4bOrigPath.c_str(), _m4bTempPath.c_str());
fullPath = _m4bTempPath;
_m4bRenamed = true;
} else {
Serial.println("AB: Warning - failed to rename .m4b to .m4a");
}
}
// Connect to file — library parses headers asynchronously via loop()
_audio->connecttoFS(SD, fullPath.c_str());
#ifdef HAS_ES8311_AUDIO
// MAX: I2S clocks are now running, so initialise the ES8311 codec (once;
// idempotent). Without this, audiobook/music output is silent until a
// notification tone happens to bring the codec up.
meck_audio_codec_init();
#endif
_audio->setVolume(_volume);
// DON'T seek immediately — the library hasn't parsed headers yet.
// Store pending seek; apply once stream reports ready (has duration).
_streamReady = false;
if (_currentPosSec > 5) {
_pendingSeekSec = (_currentPosSec > 3) ? _currentPosSec - 3 : 0;
} else {
_pendingSeekSec = 0;
}
_isPlaying = true;
_isPaused = false;
_lastPositionSave = millis();
Serial.printf("AB: Playing '%s'\n", _currentFile.c_str());
}
void stopPlayback() {
if (_audio) {
uint32_t pos = _audio->getAudioCurrentTime();
if (pos > 0) _currentPosSec = pos;
_audio->stopSong();
}
_isPlaying = false;
_isPaused = false;
_pendingSeekSec = 0;
_streamReady = false;
_sleepTimerActive = false; // Cancel sleep timer on stop
_eofFlag = false;
saveBookmark();
// Restore .m4b filename if we renamed it for playback
restoreM4bRename();
// Power down the PCM5102A DAC to save battery
disableDAC();
// Force I2S re-init for next file (sample rate may differ)
_i2sInitialized = false;
Serial.println("AB: Stopped");
}
void togglePause() {
if (!_audio) return;
if (_isPlaying && !_isPaused) {
_audio->pauseResume();
_isPaused = true;
_sleepTimerActive = false; // Cancel sleep timer on pause
saveBookmark();
} else if (_isPaused) {
_audio->pauseResume();
_isPaused = false;
} else {
// Not playing yet — start from bookmark
startPlayback();
}
}
void seekRelative(int seconds) {
if (!_audio || !_isPlaying) return;
uint32_t current = _audio->getAudioCurrentTime();
int32_t target = (int32_t)current + seconds;
if (target < 0) target = 0;
if (_durationSec > 0 && (uint32_t)target > _durationSec) {
target = _durationSec;
}
_audio->setTimeOffset((uint32_t)target);
_currentPosSec = (uint32_t)target;
}
void seekToChapter(int chapterIdx) {
if (chapterIdx < 0 || chapterIdx >= _metadata.chapterCount) return;
uint32_t targetMs = _metadata.chapters[chapterIdx].startMs;
uint32_t targetSec = targetMs / 1000;
if (_audio && _isPlaying) {
_audio->setTimeOffset(targetSec);
}
_currentPosSec = targetSec;
_currentChapter = chapterIdx;
}
// ---- Rendering Helpers ----
static void formatTime(uint32_t totalSec, char* buf, int bufLen) {
uint32_t h = totalSec / 3600;
uint32_t m = (totalSec % 3600) / 60;
uint32_t s = totalSec % 60;
if (h > 0) {
snprintf(buf, bufLen, "%u:%02u:%02u", h, m, s);
} else {
snprintf(buf, bufLen, "%u:%02u", m, s);
}
}
// ---- Standard footer (matching ChannelScreen / TextReaderScreen) ----
// All screens use: setTextSize(1), footerY = height-12, separator at footerY-2
void drawFooter(DisplayDriver& display, const char* left, const char* right) {
display.setTextSize(1);
int footerY = display.height() - 12;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, footerY - 2, display.width(), 1); // Separator line
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print(left);
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
// ---- Render: File List ----
void renderFileList(DisplayDriver& display) {
// Header
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("Audiobooks");
display.setColor(DisplayDriver::LIGHT);
if (_fileList.size() == 0) {
display.setCursor(0, 20);
display.print("No audiobooks found.");
display.setCursor(0, 30);
display.print("Place .m4b/.mp3 in");
display.setCursor(0, 38);
display.print("/audiobooks/ on SD");
drawFooter(display, "0 files", "Sh+Del:Back");
return;
}
// Switch to tiny font for file list (6x8 built-in)
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
// Calculate visible items
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
int listTop = 13;
int listBottom = display.height() - 14; // Reserve footer space
int visibleItems = (listBottom - listTop) / itemHeight;
// Keep selection visible
if (_selectedFile < _scrollOffset) {
_scrollOffset = _selectedFile;
} else if (_selectedFile >= _scrollOffset + visibleItems) {
_scrollOffset = _selectedFile - visibleItems + 1;
}
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
const int charsPerLine = 36;
// Draw file list
for (int i = 0; i < visibleItems && (_scrollOffset + i) < (int)_fileList.size(); i++) {
int fileIdx = _scrollOffset + i;
int y = listTop + i * itemHeight;
if (fileIdx == _selectedFile) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
// Build display string based on entry type
const AudiobookFileEntry& fe = _fileList[fileIdx];
char fullLine[96];
if (fe.isDir) {
if (fe.name == "..") {
snprintf(fullLine, sizeof(fullLine), ".. (up)");
} else {
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
}
} else {
// Audio file: "Title - Author [TYPE]"
char lineBuf[80];
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());
}
// Append file type tag
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
}
// Pixel-aware ellipsis — reserve space for bookmark indicator
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
// Bookmark indicator (right-aligned, files only)
if (!fe.isDir && fe.hasBookmark) {
display.setCursor(display.width() - 8, y);
display.print(">");
}
}
// Scrollbar (if needed)
if ((int)_fileList.size() > visibleItems) {
int barH = listBottom - listTop;
int thumbH = max(4, barH * visibleItems / (int)_fileList.size());
int thumbY = listTop + (barH - thumbH) * _scrollOffset /
max(1, (int)_fileList.size() - visibleItems);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(display.width() - 1, listTop, 1, barH);
display.fillRect(display.width() - 1, thumbY, 1, thumbH);
}
// Footer (stays at size 1 for readability)
// Left label is always the file count — the folder path was overflowing and
// colliding with the right-hand nav text, so it is no longer shown here.
char leftBuf[32];
snprintf(leftBuf, sizeof(leftBuf), "%d files", (int)_fileList.size());
drawFooter(display, leftBuf, "W/S:Nav Enter:Open");
}
// ---- Render: Player ----
void renderPlayer(DisplayDriver& display) {
// 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);
display.setColor(DisplayDriver::GREEN);
{
char titleBuf[24];
const char* src = _metadata.title[0] ? _metadata.title : _currentFile.c_str();
strncpy(titleBuf, src, sizeof(titleBuf) - 1);
titleBuf[sizeof(titleBuf) - 1] = '\0';
display.drawTextCentered(display.width() / 2, y, titleBuf);
}
y += 10;
// ---- Author ----
if (_metadata.author[0]) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char authBuf[24];
strncpy(authBuf, _metadata.author, sizeof(authBuf) - 1);
authBuf[sizeof(authBuf) - 1] = '\0';
display.drawTextCentered(display.width() / 2, y, authBuf);
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);
display.setColor(DisplayDriver::LIGHT);
char chBuf[24];
snprintf(chBuf, sizeof(chBuf), "Ch %d/%d",
_currentChapter + 1, _metadata.chapterCount);
display.drawTextCentered(display.width() / 2, y, chBuf);
y += 10;
}
// ---- Playback State + Volume ----
{
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
const char* stateStr = _isPlaying ? (_isPaused ? "Paused" : "Playing") : "Stopped";
char stateBuf[24];
snprintf(stateBuf, sizeof(stateBuf), "%s Vol:%d", stateStr, _volume);
display.drawTextCentered(display.width() / 2, y, stateBuf);
}
y += 10;
// ---- Progress Bar ----
int barX = 6;
int barW = display.width() - 12;
int barH = 4;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(barX, y, barW, barH);
if (_durationSec > 0 && _currentPosSec > 0) {
int fillW = (int)((uint64_t)_currentPosSec * (barW - 2) / _durationSec);
if (fillW > barW - 2) fillW = barW - 2;
if (fillW > 0) {
display.fillRect(barX + 1, y + 1, fillW, barH - 2);
}
}
y += barH + 2;
// ---- Time Display ----
{
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char timeBuf[32];
char curStr[12], totStr[12];
formatTime(_currentPosSec, curStr, sizeof(curStr));
formatTime(_durationSec, totStr, sizeof(totStr));
snprintf(timeBuf, sizeof(timeBuf), "%s / %s", curStr, totStr);
display.drawTextCentered(display.width() / 2, y, timeBuf);
}
y += 10;
// ---- Hint Text (replaces visual transport controls) ----
{
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
// Show track position in playlist (if multiple tracks)
if (_playlist.size() > 1 && _playlistIdx >= 0) {
char trackBuf[24];
snprintf(trackBuf, sizeof(trackBuf), "Track %d/%d",
_playlistIdx + 1, (int)_playlist.size());
display.drawTextCentered(display.width() / 2, y, trackBuf);
y += 10;
}
display.drawTextCentered(display.width() / 2, y, "Enter: Play/Pause");
y += 10;
// Sleep timer or additional hints
if (y < display.height() - 26) {
display.setColor(DisplayDriver::YELLOW);
if (_sleepTimerActive) {
// Show countdown
unsigned long remaining = 0;
if (millis() < _sleepTimerEnd) remaining = (_sleepTimerEnd - millis()) / 1000;
int mins = remaining / 60;
int secs = remaining % 60;
char sleepBuf[32];
snprintf(sleepBuf, sizeof(sleepBuf), "Sleep: %d:%02d (Z:Off)", mins, secs);
display.drawTextCentered(display.width() / 2, y, sleepBuf);
} else if (_isPlaying && !_isPaused) {
display.drawTextCentered(display.width() / 2, y,
"Z: Start 45m sleep timer");
} else if (_metadata.chapterCount > 0) {
display.drawTextCentered(display.width() / 2, y,
"[/]: Prev/Next Chapter");
} else if (_playlist.size() > 1) {
display.drawTextCentered(display.width() / 2, y,
"N: Next Track");
}
}
}
// Transport controls drawn — footer is at fixed position below
// ---- Footer Nav Bar ----
{
const char* rightText = (_isPlaying && !_isPaused) ? "Sh+Del:Leave" : "Sh+Del:Close";
if (_playlist.size() > 1) {
drawFooter(display, "A/D:Seek N:Next", rightText);
} else {
drawFooter(display, "A/D:Seek W/S:Vol", rightText);
}
}
}
public:
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
_displayRef(nullptr),
_selectedFile(0), _scrollOffset(0),
_currentPath(AUDIOBOOKS_FOLDER),
_lastScanPath(""),
_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),
_sleepTimerActive(false), _sleepTimerEnd(0),
_playlistIdx(-1), _eofFlag(false) {}
~AudiobookPlayerScreen() {
freeCoverBitmap();
}
void setSDReady(bool ready) { _sdReady = ready; }
// ---- Audio Tick ----
// Called from main loop() every iteration for uninterrupted playback.
void audioTick() {
if (!_audio || !_isPlaying) return;
// Feed the audio decode pipeline (skip when paused)
if (!_isPaused) {
_audio->loop();
}
// Throttle position/duration reads to every 500ms
if (millis() - _lastPosUpdate > 500) {
uint32_t pos = _audio->getAudioCurrentTime();
if (pos > 0) _currentPosSec = pos;
if (_durationSec == 0) {
uint32_t dur = _audio->getAudioFileDuration();
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
if (!_streamReady && _durationSec > 0) {
_streamReady = true;
if (_pendingSeekSec > 0) {
_audio->setTimeOffset(_pendingSeekSec);
_currentPosSec = _pendingSeekSec;
_pendingSeekSec = 0;
}
}
// Update chapter tracking
if (_metadata.chapterCount > 0) {
uint32_t posMs = _currentPosSec * 1000;
_currentChapter = _metadata.getChapterForPosition(posMs);
}
_lastPosUpdate = millis();
}
// Auto-save bookmark periodically
if (millis() - _lastPositionSave > AB_POSITION_SAVE_INTERVAL) {
saveBookmark();
_lastPositionSave = millis();
}
// Sleep timer — auto-pause when countdown expires
if (_sleepTimerActive && millis() >= _sleepTimerEnd) {
_sleepTimerActive = false;
Serial.println("AB: Sleep timer expired — pausing playback");
togglePause();
return; // Don't process further this tick
}
// EOF auto-advance — when a track finishes, play the next one
if (_eofFlag) {
_eofFlag = false;
Serial.println("AB: EOF detected");
if (_playlist.size() > 1 && _playlistIdx >= 0 &&
_playlistIdx < (int)_playlist.size() - 1) {
// More tracks in playlist — advance
advanceTrack(1);
} else {
// Last track or no playlist — just stop
stopPlayback();
}
}
}
bool isAudioActive() const { return _isPlaying && !_isPaused; }
bool isPaused() const { return _isPaused; }
bool isBookOpenAndPaused() const { return _bookOpen && (_isPaused || !_isPlaying); }
// Called from audio_eof_mp3 callback in main.cpp to signal end of file
void onEOF() { _eofFlag = true; }
// Playlist info for external queries
int getPlaylistSize() const { return (int)_playlist.size(); }
int getPlaylistIndex() const { return _playlistIdx; }
// Public method to close the current book (stops playback, saves bookmark,
// returns to file list). Used by main.cpp when user presses Q while paused.
void closeCurrentBook() {
if (_bookOpen) {
closeBook();
}
}
void enter(DisplayDriver& display) {
_displayRef = &display;
if (!_bookOpen) {
// Skip full rescan if we already have files for this path
if (_fileList.size() > 0 && _lastScanPath == _currentPath) {
// Fast path: just refresh bookmark flags (user may have created new ones)
refreshBookmarkFlags();
Serial.printf("AB: Re-entered file list (skipped rescan, %d files)\n", (int)_fileList.size());
_mode = FILE_LIST;
} else {
// First load or path changed: full scan needed
drawLoadingSplash();
scanFiles();
_selectedFile = 0;
_scrollOffset = 0;
_mode = FILE_LIST;
}
} else {
_mode = PLAYER;
}
}
void exitPlayer() {
if (_bookOpen) closeBook();
_mode = FILE_LIST;
}
bool isInFileList() const { return _mode == FILE_LIST; }
bool isBookOpen() const { return _bookOpen; }
bool isPlaying() const { return _isPlaying; }
// ---- UIScreen Interface ----
int render(DisplayDriver& display) override {
if (!_sdReady) {
display.setCursor(0, 20);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.print("SD card not found");
display.setCursor(0, 35);
display.print("Insert SD with");
display.setCursor(0, 43);
display.print("/audiobooks/ folder");
return 5000;
}
if (_mode == FILE_LIST) {
renderFileList(display);
} else if (_mode == PLAYER) {
renderPlayer(display);
}
// E-ink refresh takes ~648ms during which audio.loop() can't run
// (SPI bus shared between display and SD card). This causes audible
// glitches during playback. Solution: during active playback, only
// auto-refresh once per minute (for time/progress updates). Key presses
// always trigger immediate refresh regardless of this interval.
// When paused or stopped, refresh every 5s as normal.
if (_isPlaying && !_isPaused) {
if (_durationSec == 0) return 1000; // refresh quickly until duration resolves
return 60000; // then back to once-a-minute (avoids stutter)
}
return 5000;
}
bool handleInput(char c) override {
if (_mode == FILE_LIST) {
return handleFileListInput(c);
} else if (_mode == PLAYER) {
return handlePlayerInput(c);
}
return false;
}
bool handleFileListInput(char c) {
// W - scroll up
if (c == 'w' || c == 0xF2) {
if (_selectedFile > 0) {
_selectedFile--;
return true;
}
return false;
}
// S - scroll down
if (c == 's' || c == 0xF1) {
if (_selectedFile < (int)_fileList.size() - 1) {
_selectedFile++;
return true;
}
return false;
}
// Enter - open selected item (directory or audiobook)
if (c == '\r' || c == 13) {
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
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;
}
return false;
}
bool handlePlayerInput(char c) {
// Enter - play/pause
if (c == '\r' || c == 13) {
togglePause();
return true;
}
// A - seek backward
if (c == 'a') {
seekRelative(-AB_SEEK_SECONDS);
return true;
}
// D - seek forward
if (c == 'd') {
seekRelative(AB_SEEK_SECONDS);
return true;
}
// W - volume up
if (c == 'w' || c == 0xF2) {
if (_volume < 21) {
_volume++;
if (_audio) _audio->setVolume(_volume);
}
return true; // Always consume & refresh (show current volume)
}
// S - volume down
if (c == 's' || c == 0xF1) {
if (_volume > 0) {
_volume--;
if (_audio) _audio->setVolume(_volume);
}
return true; // Always consume & refresh
}
// [ - previous chapter (shift key required on T-Deck Pro)
if (c == '[') {
if (_metadata.chapterCount > 0 && _currentChapter > 0) {
seekToChapter(_currentChapter - 1);
return true;
}
return false;
}
// ] - next chapter (shift key required on T-Deck Pro)
if (c == ']') {
if (_metadata.chapterCount > 0 && _currentChapter < _metadata.chapterCount - 1) {
seekToChapter(_currentChapter + 1);
return true;
}
return false;
}
// N - next track in playlist (always, regardless of chapters)
if (c == 'n') {
if (_playlist.size() > 1 && _playlistIdx < (int)_playlist.size() - 1) {
advanceTrack(1);
return true;
}
return false;
}
// Z - toggle 45-minute sleep timer
if (c == 'z') {
if (_sleepTimerActive) {
_sleepTimerActive = false;
Serial.println("AB: Sleep timer cancelled");
} else {
_sleepTimerActive = true;
_sleepTimerEnd = millis() + (45UL * 60UL * 1000UL);
Serial.println("AB: Sleep timer set for 45 minutes");
}
return true;
}
// Q - handled by main.cpp (leave screen or close book depending on play state)
// Not handled here - main.cpp intercepts Q before it reaches the player
return false;
}
void poll() override {
audioTick();
}
};