From db8a73004ed7fbea25d292a9db82568d35b86b98 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:38:37 +1100 Subject: [PATCH] audiobook function redo - initial success - attempt 1 --- Readme audiobook.md | 46 + examples/companion_radio/main.cpp | 74 +- .../ui-new/Audiobookplayerscreen.h | 1072 +++++++++++++++++ examples/companion_radio/ui-new/M4BMetadata.h | 421 +++++++ examples/companion_radio/ui-new/UITask.cpp | 16 + examples/companion_radio/ui-new/UITask.h | 5 + variants/lilygo_tdeck_pro/platformio.ini | 3 + 7 files changed, 1636 insertions(+), 1 deletion(-) create mode 100644 Readme audiobook.md create mode 100644 examples/companion_radio/ui-new/Audiobookplayerscreen.h create mode 100644 examples/companion_radio/ui-new/M4BMetadata.h diff --git a/Readme audiobook.md b/Readme audiobook.md new file mode 100644 index 00000000..f0b531fb --- /dev/null +++ b/Readme audiobook.md @@ -0,0 +1,46 @@ +## 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. + +| Key | Action | +|-----|--------| +| W / S | Scroll file list / Volume up-down | +| Enter | Select book / Play-Pause | +| A | Seek back 30 seconds | +| D | Seek forward 30 seconds | +| [ | Previous chapter (M4B only) | +| ] | Next chapter (M4B only) | +| Q | Stop & back to file list / Exit player | + +**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. + +### Audio Hardware + +The audiobook player uses the PCM5102A I2S DAC on the audio variant of the +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. + +### SD Card Folder Structure + +``` +SD Card +├── audiobooks/ +│ ├── .bookmarks/ (auto-created, stores resume positions) +│ │ ├── mybook.bmk +│ │ └── another.bmk +│ ├── mybook.m4b +│ ├── another.m4b +│ └── podcast.mp3 +├── books/ (existing — text reader) +│ └── ... +└── ... +``` \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 86692f0f..a4c5d94e 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -47,6 +47,12 @@ // Notes mode state static bool notesMode = false; + // Audiobook player + #include "AudiobookPlayerScreen.h" + #include "Audio.h" + Audio audio; + static bool audiobookMode = false; + // Power management #if HAS_GPS GPSDutyCycle gpsDuty; @@ -521,6 +527,14 @@ void setup() { notesScr->setSDReady(true); } + // Create audiobook player screen and register with UITask + { + AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, &audio); + abScreen->setSDReady(true); + ui_task.setAudiobookScreen(abScreen); + MESH_DEBUG_PRINTLN("setup() - Audiobook player screen created"); + } + // Do an initial settings backup to SD (captures any first-boot defaults) backupSettingsToSD(); } @@ -588,6 +602,19 @@ void loop() { // CPU frequency auto-timeout back to idle cpuPower.loop(); + + // Audiobook: service audio decode regardless of which screen is active + { + AudiobookPlayerScreen* abPlayer = + (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); + if (abPlayer) { + abPlayer->audioTick(); + // Keep CPU at high freq during active audio decode + if (abPlayer->isAudioActive()) { + cpuPower.setBoost(); + } + } + } #ifdef DISPLAY_CLASS // Skip UITask rendering when in compose mode to prevent flickering #if defined(LilyGo_TDeck_Pro) @@ -615,9 +642,10 @@ void loop() { composeNeedsRefresh = false; } } - // Track reader/notes mode state for key routing + // Track reader/notes/audiobook mode state for key routing readerMode = ui_task.isOnTextReader(); notesMode = ui_task.isOnNotesScreen(); + audiobookMode = ui_task.isOnAudiobookPlayer(); #else ui_task.loop(); #endif @@ -807,6 +835,29 @@ void handleKeyboardInput() { return; } + // *** AUDIOBOOK MODE *** + if (audiobookMode) { + AudiobookPlayerScreen* abPlayer = + (AudiobookPlayerScreen*)ui_task.getAudiobookScreen(); + + // Q key: if book is open, player handles it (stop & go to file list) + // if on file list, exit player entirely + if (key == 'q') { + if (abPlayer->isBookOpen()) { + ui_task.injectKey('q'); + } else { + abPlayer->exitPlayer(); + Serial.println("Exiting audiobook player"); + ui_task.gotoHomeScreen(); + } + return; + } + + // All other keys pass through to the player screen + ui_task.injectKey(key); + return; + } + // *** TEXT READER MODE *** if (readerMode) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); @@ -1007,6 +1058,12 @@ void handleKeyboardInput() { ui_task.gotoTextReader(); break; + case 'p': + // Open audiobook player + Serial.println("Opening audiobook player"); + ui_task.gotoAudiobookPlayer(); + break; + case 'n': // Open notes Serial.println("Opening notes"); @@ -1308,4 +1365,19 @@ void sendComposedMessage() { } } +// ============================================================================ +// ESP32-audioI2S CALLBACKS +// ============================================================================ +// The audio library calls these global functions — must be defined at file scope. + +void audio_info(const char *info) { + Serial.printf("Audio: %s\n", info); +} + +void audio_eof_mp3(const char *info) { + Serial.printf("Audio: End of file - %s\n", info); + // Playback finished — the player screen will detect this + // via audio.isRunning() returning false +} + #endif // LilyGo_TDeck_Pro \ No newline at end of file diff --git a/examples/companion_radio/ui-new/Audiobookplayerscreen.h b/examples/companion_radio/ui-new/Audiobookplayerscreen.h new file mode 100644 index 00000000..c62f636f --- /dev/null +++ b/examples/companion_radio/ui-new/Audiobookplayerscreen.h @@ -0,0 +1,1072 @@ +#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, Q = stop & exit +// +// Library dependencies (add to platformio.ini lib_deps): +// https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6 +// bitbank2/JPEGDEC +// ============================================================================= + +#include +#include +#include +#include +#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 + +// Forward declarations +class UITask; + +// ============================================================================ +// Configuration +// ============================================================================ +#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_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 + +// Supported file extensions +static bool isAudiobookFile(const String& name) { + String lower = name; + lower.toLowerCase(); + return lower.endsWith(".m4b") || 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 bookmark state (avoid SD.exists during render) +// ============================================================================ +struct AudiobookFileEntry { + String name; + bool hasBookmark; +}; + +// ============================================================================ +// 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; + Audio* _audio; + Mode _mode; + bool _sdReady; + bool _i2sInitialized; // Track whether setPinout has been called + bool _dacPowered; // Track GPIO 41 DAC power state + + // File browser state + std::vector _fileList; + int _selectedFile; + int _scrollOffset; + + // 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 + + // UI state + int _transportSel; + bool _showingInfo; + + // 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() { + pinMode(41, OUTPUT); + digitalWrite(41, HIGH); + if (!_dacPowered) { + delay(50); // Let DAC power rail stabilise on first enable + Serial.println("AB: GPIO 41 (DAC power) -> HIGH"); + } + _dacPowered = true; + } + + void disableDAC() { + digitalWrite(41, LOW); + _dacPowered = false; + Serial.println("AB: GPIO 41 (DAC power) -> LOW"); + } + + void ensureI2SInit() { + if (!_i2sInitialized && _audio) { + // Configure I2S output pins + // Try with MCLK=0 (ESP32-S3 may need explicit MCLK even if PCM5102A + // uses internal PLL — setting 0 lets the driver auto-assign or skip) + bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0); + Serial.printf("AB: setPinout(BCLK=%d, LRC=%d, DOUT=%d, MCLK=0) -> %s\n", + BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, + ok ? "OK" : "FAILED"); + if (!ok) { + // Retry without MCLK (original behavior) + ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT); + Serial.printf("AB: setPinout retry without MCLK -> %s\n", ok ? "OK" : "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; + 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"); + 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(); + 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"; + } + + void loadBookmark() { + String path = getBookmarkPath(_currentFile); + 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 (!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 ---- + + 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(AUDIOBOOKS_FOLDER); + if (!root || !root.isDirectory()) return; + + 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); + } + } + f = root.openNextFile(); + } + root.close(); + digitalWrite(SDCARD_CS, HIGH); + + Serial.printf("AB: Found %d audiobook files\n", (int)_fileList.size()); + } + + // ---- 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; + + yield(); // Feed WDT between heavy operations + + // Parse metadata + String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + 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 { + _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); + } + 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; + 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(); + } + saveBookmark(); + freeCoverBitmap(); + _metadata.clear(); + _bookOpen = false; + _currentFile = ""; + _mode = FILE_LIST; + } + + // ---- Playback Control ---- + + void startPlayback() { + if (!_audio || _currentFile.length() == 0) return; + + // Ensure DAC has power (must be re-enabled after each stop) + enableDAC(); + + // Ensure I2S is configured (once only, before first connecttoFS) + ensureI2SInit(); + + String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + _currentFile; + + // Connect to file — library parses headers asynchronously via loop() + _audio->connecttoFS(SD, fullPath.c_str()); + _audio->setVolume(_volume); + + Serial.printf("AB: Volume=%d, isRunning=%d, getVolume=%d\n", + _volume, _audio->isRunning(), _audio->getVolume()); + + // 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; + Serial.printf("AB: Deferred seek to %us (bookmark was %us)\n", + _pendingSeekSec, _currentPosSec); + } else { + _pendingSeekSec = 0; + } + + _isPlaying = true; + _isPaused = false; + _lastPositionSave = millis(); + + Serial.println("AB: Playback started"); + } + + void stopPlayback() { + if (_audio) { + uint32_t pos = _audio->getAudioCurrentTime(); + if (pos > 0) _currentPosSec = pos; + _audio->stopSong(); + } + _isPlaying = false; + _isPaused = false; + _pendingSeekSec = 0; + _streamReady = false; + saveBookmark(); + + // Power down the PCM5102A DAC to save battery + disableDAC(); + + Serial.println("AB: Playback stopped"); + } + + void togglePause() { + if (!_audio) return; + + if (_isPlaying && !_isPaused) { + _audio->pauseResume(); + _isPaused = true; + saveBookmark(); + Serial.println("AB: Paused"); + } else if (_isPaused) { + _audio->pauseResume(); + _isPaused = false; + Serial.println("AB: Resumed"); + } 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; + Serial.printf("AB: Seek %+ds -> %us\n", seconds, (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; + Serial.printf("AB: Jump to chapter %d '%s' at %us\n", + chapterIdx, _metadata.chapters[chapterIdx].name, targetSec); + } + + // ---- 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", "Q:Back"); + return; + } + + // Calculate visible items — reserve footerHeight=14 at bottom + int itemHeight = 10; + int listTop = 13; + int listBottom = display.height() - 14; + int visibleItems = (listBottom - listTop) / itemHeight; + + // Keep selection visible + if (_selectedFile < _scrollOffset) { + _scrollOffset = _selectedFile; + } else if (_selectedFile >= _scrollOffset + visibleItems) { + _scrollOffset = _selectedFile - visibleItems + 1; + } + + // 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 - 1, 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) + "..."; + } + + display.setCursor(2, y); + display.print(name.c_str()); + + // Bookmark indicator (cached from scanFiles — no SD access) + if (_fileList[fileIdx].hasBookmark) { + display.setCursor(display.width() - 8, y); + display.print(">"); + } + } + + // Scrollbar + 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 + char leftBuf[20]; + 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 + // 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 + } + + // ---- 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; + } + + // ---- 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; + + // ---- Transport Controls (text labels) ---- + { + display.setTextSize(1); + const char* labels[] = { "|<<", "-30", nullptr, "+30", ">>|" }; + const char* playLabel = (_isPlaying && !_isPaused) ? "||" : ">"; + + int spacing = 2; + int totalW = 0; + for (int i = 0; i < 5; i++) { + const char* lbl = (i == 2) ? playLabel : labels[i]; + totalW += display.getTextWidth(lbl); + if (i < 4) totalW += spacing; + } + + int x = (display.width() - totalW) / 2; + for (int i = 0; i < 5; i++) { + const char* lbl = (i == 2) ? playLabel : labels[i]; + uint16_t lblW = display.getTextWidth(lbl); + + if (i == _transportSel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(x - 1, y - 1, lblW + 2, 9); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(x, y); + display.print(lbl); + x += lblW + spacing; + } + } + // Transport controls drawn — footer is at fixed position below + + // ---- Footer Nav Bar ---- + drawFooter(display, "A/D:Seek W/S:Vol", "Q:Back"); + } + +public: + AudiobookPlayerScreen(UITask* task, Audio* audio) + : _task(task), _audio(audio), _mode(FILE_LIST), + _sdReady(false), _i2sInitialized(false), _dacPowered(false), + _selectedFile(0), _scrollOffset(0), + _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), + _transportSel(2), _showingInfo(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; + + // Get duration from library once available + if (_durationSec == 0) { + uint32_t dur = _audio->getAudioFileDuration(); + if (dur > 0) { + _durationSec = dur; + Serial.printf("AB: Duration from library: %us\n", dur); + } + } + + // Apply deferred seek once stream is ready + // Stream is ready when the library reports a valid duration + if (!_streamReady && _durationSec > 0) { + _streamReady = true; + Serial.printf("AB: Stream ready! isRunning=%d, duration=%us\n", + _audio->isRunning(), _durationSec); + if (_pendingSeekSec > 0) { + Serial.printf("AB: Applying deferred seek to %us\n", _pendingSeekSec); + _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(); + } + } + + bool isAudioActive() const { return _isPlaying && !_isPaused; } + + void enter(DisplayDriver& display) { + if (!_bookOpen) { + 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); + } + + // 3s refresh during playback for time/progress updates; + // key events trigger immediate refresh via handleInput returning true + return _isPlaying ? 3000 : 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 audiobook + if (c == '\r' || c == 13) { + if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) { + openBook(_fileList[_selectedFile].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); + Serial.printf("AB: Volume -> %d\n", _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); + Serial.printf("AB: Volume -> %d\n", _volume); + } + return true; // Always consume & refresh + } + + // [ - previous chapter + if (c == '[') { + if (_metadata.chapterCount > 0 && _currentChapter > 0) { + seekToChapter(_currentChapter - 1); + return true; + } + return false; + } + + // ] - next chapter + if (c == ']') { + if (_metadata.chapterCount > 0 && _currentChapter < _metadata.chapterCount - 1) { + seekToChapter(_currentChapter + 1); + return true; + } + return false; + } + + // Q - stop and exit to file list + if (c == 'q') { + closeBook(); + return true; + } + + return false; + } + + void poll() override { + audioTick(); + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/M4BMetadata.h b/examples/companion_radio/ui-new/M4BMetadata.h new file mode 100644 index 00000000..f3a80a81 --- /dev/null +++ b/examples/companion_radio/ui-new/M4BMetadata.h @@ -0,0 +1,421 @@ +#pragma once + +// ============================================================================= +// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction +// +// Walks the MP4 atom (box) tree to extract: +// - Title (moov/udta/meta/ilst/©nam) +// - Author (moov/udta/meta/ilst/©ART) +// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file +// - Duration (moov/mvhd timescale + duration) +// - Chapter markers (moov/udta/chpl) - Nero-style chapter list +// +// Designed for embedded use: no dynamic allocation, reads directly from SD +// via Arduino File API, uses a small stack buffer for atom headers. +// +// Usage: +// M4BMetadata meta; +// File f = SD.open("/audiobooks/mybook.m4b"); +// if (meta.parse(f)) { +// Serial.printf("Title: %s\n", meta.title); +// Serial.printf("Author: %s\n", meta.author); +// if (meta.hasCoverArt) { +// // JPEG data is at meta.coverOffset, meta.coverSize bytes +// } +// } +// f.close(); +// ============================================================================= + +#include + +// Maximum metadata string lengths (including null terminator) +#define M4B_MAX_TITLE 128 +#define M4B_MAX_AUTHOR 64 +#define M4B_MAX_CHAPTERS 100 + +struct M4BChapter { + uint32_t startMs; // Chapter start time in milliseconds + char name[48]; // Chapter title (truncated to fit) +}; + +class M4BMetadata { +public: + // Extracted metadata + char title[M4B_MAX_TITLE]; + char author[M4B_MAX_AUTHOR]; + bool hasCoverArt; + uint32_t coverOffset; // Byte offset of JPEG/PNG data within file + uint32_t coverSize; // Size of cover image data in bytes + uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type) + uint32_t durationMs; // Total duration in milliseconds + uint32_t sampleRate; // Audio sample rate (from audio stsd) + uint32_t bitrate; // Approximate bitrate in bps + + // Chapter data + M4BChapter chapters[M4B_MAX_CHAPTERS]; + int chapterCount; + + M4BMetadata() { clear(); } + + void clear() { + title[0] = '\0'; + author[0] = '\0'; + hasCoverArt = false; + coverOffset = 0; + coverSize = 0; + coverFormat = 0; + durationMs = 0; + sampleRate = 44100; + bitrate = 0; + chapterCount = 0; + } + + // Parse an open file. Returns true if at least title or duration was found. + // File position is NOT preserved — caller should seek as needed afterward. + bool parse(File& file) { + clear(); + if (!file || file.size() < 8) return false; + + _fileSize = file.size(); + + // Walk top-level atoms looking for 'moov' + uint32_t pos = 0; + while (pos < _fileSize) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + if (hdr.type == ATOM_MOOV) { + parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize); + break; // moov found and parsed, we're done + } + + // Skip to next top-level atom + pos += hdr.size; + if (hdr.size == 0) break; // size=0 means "extends to EOF" + } + + return (title[0] != '\0' || durationMs > 0); + } + + // Get chapter index for a given playback position (milliseconds). + // Returns -1 if no chapters or position is before first chapter. + int getChapterForPosition(uint32_t positionMs) const { + if (chapterCount == 0) return -1; + int ch = 0; + for (int i = 1; i < chapterCount; i++) { + if (chapters[i].startMs > positionMs) break; + ch = i; + } + return ch; + } + + // Get the start position of the next chapter after the given position. + // Returns 0 if no next chapter. + uint32_t getNextChapterMs(uint32_t positionMs) const { + for (int i = 0; i < chapterCount; i++) { + if (chapters[i].startMs > positionMs) return chapters[i].startMs; + } + return 0; + } + + // Get the start position of the current or previous chapter. + uint32_t getPrevChapterMs(uint32_t positionMs) const { + uint32_t prev = 0; + for (int i = 0; i < chapterCount; i++) { + if (chapters[i].startMs >= positionMs) break; + prev = chapters[i].startMs; + } + return prev; + } + +private: + uint32_t _fileSize; + + // MP4 atom type codes (big-endian FourCC) + static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov' + static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd' + static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta' + static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta' + static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst' + static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam' + static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART' + static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr' + static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data' + static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters) + static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak' + static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia' + static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd' + static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr' + + struct AtomHeader { + uint32_t type; + uint64_t size; // Total atom size including header + uint32_t dataOffset; // File offset where data begins (after header) + uint64_t dataSize; // size - header_length + }; + + // Read a 32-bit big-endian value from file at current position + static uint32_t readU32BE(File& file) { + uint8_t buf[4]; + file.read(buf, 4); + return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | + ((uint32_t)buf[2] << 8) | buf[3]; + } + + // Read a 64-bit big-endian value + static uint64_t readU64BE(File& file) { + uint32_t hi = readU32BE(file); + uint32_t lo = readU32BE(file); + return ((uint64_t)hi << 32) | lo; + } + + // Read a 16-bit big-endian value + static uint16_t readU16BE(File& file) { + uint8_t buf[2]; + file.read(buf, 2); + return ((uint16_t)buf[0] << 8) | buf[1]; + } + + // Read atom header at given file offset + bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) { + if (offset + 8 > _fileSize) return false; + + file.seek(offset); + uint32_t size32 = readU32BE(file); + hdr.type = readU32BE(file); + + if (size32 == 1) { + // 64-bit extended size + if (offset + 16 > _fileSize) return false; + hdr.size = readU64BE(file); + hdr.dataOffset = offset + 16; + hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0; + } else if (size32 == 0) { + // Atom extends to end of file + hdr.size = _fileSize - offset; + hdr.dataOffset = offset + 8; + hdr.dataSize = hdr.size - 8; + } else { + hdr.size = size32; + hdr.dataOffset = offset + 8; + hdr.dataSize = (size32 > 8) ? size32 - 8 : 0; + } + + return true; + } + + // Parse the moov container atom + void parseMoov(File& file, uint32_t start, uint32_t end) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + switch (hdr.type) { + case ATOM_MVHD: + parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize); + break; + case ATOM_UDTA: + parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize); + break; + case ATOM_TRAK: + break; + } + + pos += (uint32_t)hdr.size; + } + } + + // Parse mvhd (movie header) for duration + void parseMvhd(File& file, uint32_t offset, uint32_t size) { + file.seek(offset); + uint8_t version = file.read(); + + if (version == 0) { + file.seek(offset + 4); // skip version(1) + flags(3) + /* create_time */ readU32BE(file); + /* modify_time */ readU32BE(file); + uint32_t timescale = readU32BE(file); + uint32_t duration = readU32BE(file); + if (timescale > 0) { + durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale); + } + } else if (version == 1) { + file.seek(offset + 4); + /* create_time */ readU64BE(file); + /* modify_time */ readU64BE(file); + uint32_t timescale = readU32BE(file); + uint64_t duration = readU64BE(file); + if (timescale > 0) { + durationMs = (uint32_t)(duration * 1000 / timescale); + } + } + } + + // Parse udta container — contains meta and/or chpl + void parseUdta(File& file, uint32_t start, uint32_t end) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + if (hdr.type == ATOM_META) { + parseMeta(file, hdr.dataOffset + 4, + hdr.dataOffset + (uint32_t)hdr.dataSize); + } else if (hdr.type == ATOM_CHPL) { + parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize); + } + + pos += (uint32_t)hdr.size; + } + } + + // Parse meta container — contains hdlr + ilst + void parseMeta(File& file, uint32_t start, uint32_t end) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + if (hdr.type == ATOM_ILST) { + parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize); + } + + pos += (uint32_t)hdr.size; + } + } + + // Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc. + void parseIlst(File& file, uint32_t start, uint32_t end) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + switch (hdr.type) { + case ATOM_NAM: + extractTextData(file, hdr.dataOffset, + hdr.dataOffset + (uint32_t)hdr.dataSize, + title, M4B_MAX_TITLE); + break; + case ATOM_ART: + extractTextData(file, hdr.dataOffset, + hdr.dataOffset + (uint32_t)hdr.dataSize, + author, M4B_MAX_AUTHOR); + break; + case ATOM_COVR: + extractCoverData(file, hdr.dataOffset, + hdr.dataOffset + (uint32_t)hdr.dataSize); + break; + } + + pos += (uint32_t)hdr.size; + } + } + + // Extract text from a 'data' sub-atom within an ilst entry. + void extractTextData(File& file, uint32_t start, uint32_t end, + char* dest, int maxLen) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + if (hdr.type == ATOM_DATA && hdr.dataSize > 8) { + uint32_t textOffset = hdr.dataOffset + 8; + uint32_t textLen = (uint32_t)hdr.dataSize - 8; + if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1; + + file.seek(textOffset); + file.read((uint8_t*)dest, textLen); + dest[textLen] = '\0'; + return; + } + + pos += (uint32_t)hdr.size; + } + } + + // Extract cover art location from 'data' sub-atom within covr. + void extractCoverData(File& file, uint32_t start, uint32_t end) { + uint32_t pos = start; + while (pos < end) { + AtomHeader hdr; + if (!readAtomHeader(file, pos, hdr)) break; + if (hdr.size < 8) break; + + if (hdr.type == ATOM_DATA && hdr.dataSize > 8) { + file.seek(hdr.dataOffset); + uint32_t typeIndicator = readU32BE(file); + uint8_t wellKnownType = typeIndicator & 0xFF; + + coverOffset = hdr.dataOffset + 8; + coverSize = (uint32_t)hdr.dataSize - 8; + coverFormat = wellKnownType; // 13=JPEG, 14=PNG + hasCoverArt = (coverSize > 0); + + Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n", + wellKnownType == 13 ? "JPEG" : + wellKnownType == 14 ? "PNG" : "unknown", + coverSize, coverOffset); + return; + } + + pos += (uint32_t)hdr.size; + } + } + + // Parse Nero-style chapter list (chpl atom). + void parseChpl(File& file, uint32_t offset, uint32_t size) { + if (size < 9) return; + + file.seek(offset); + uint8_t version = file.read(); + file.read(); // flags byte 1 + file.read(); // flags byte 2 + file.read(); // flags byte 3 + + file.read(); // reserved + + uint32_t count; + if (version == 1) { + count = readU32BE(file); + } else { + count = file.read(); + } + + if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS; + + chapterCount = 0; + for (uint32_t i = 0; i < count; i++) { + if (!file.available()) break; + + uint64_t timestamp = readU64BE(file); + uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms + + uint8_t nameLen = file.read(); + if (nameLen == 0 || !file.available()) break; + + M4BChapter& ch = chapters[chapterCount]; + ch.startMs = startMs; + + uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1; + file.read((uint8_t*)ch.name, readLen); + ch.name[readLen] = '\0'; + + if (nameLen > readLen) { + file.seek(file.position() + (nameLen - readLen)); + } + + chapterCount++; + } + + Serial.printf("M4B: Found %d chapters\n", chapterCount); + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8d28c8a0..42c55496 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -36,6 +36,7 @@ #include "ContactsScreen.h" #include "TextReaderScreen.h" #include "SettingsScreen.h" +#include "AudiobookPlayerScreen.h" class SplashScreen : public UIScreen { UITask* _task; @@ -751,6 +752,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no text_reader = new TextReaderScreen(this); notes_screen = new NotesScreen(this); settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); + audiobook_screen = nullptr; // Created from main.cpp with Audio object setCurrScreen(splash); } @@ -1220,6 +1222,20 @@ void UITask::gotoOnboarding() { _next_refresh = 100; } +void UITask::gotoAudiobookPlayer() { + if (audiobook_screen == nullptr) return; + AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen; + if (_display != NULL) { + player->enter(*_display); + } + setCurrScreen(audiobook_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + uint8_t UITask::getChannelScreenViewIdx() const { return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 1b50f051..d2da8c49 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -56,6 +56,7 @@ class UITask : public AbstractUITask { UIScreen* text_reader; // *** NEW: Text reader screen *** UIScreen* notes_screen; // Notes editor screen UIScreen* settings_screen; // Settings/onboarding screen + UIScreen* audiobook_screen; // Audiobook player screen UIScreen* curr; void userLedHandler(); @@ -84,6 +85,7 @@ public: void gotoNotesScreen(); // Navigate to notes editor void gotoSettingsScreen(); // Navigate to settings void gotoOnboarding(); // Navigate to settings in onboarding mode + void gotoAudiobookPlayer(); // Navigate to audiobook player void showAlert(const char* text, int duration_millis) override; void forceRefresh() override { _next_refresh = 100; } int getMsgCount() const { return _msgcount; } @@ -94,6 +96,7 @@ public: bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** bool isOnNotesScreen() const { return curr == notes_screen; } bool isOnSettingsScreen() const { return curr == settings_screen; } + bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); @@ -117,6 +120,8 @@ public: UIScreen* getContactsScreen() const { return contacts_screen; } UIScreen* getChannelScreen() const { return channel_screen; } UIScreen* getSettingsScreen() const { return settings_screen; } + UIScreen* getAudiobookScreen() const { return audiobook_screen; } + void setAudiobookScreen(UIScreen* screen) { audiobook_screen = screen; } // from AbstractUITask void msgRead(int msgcount) override; diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 0b8e1afc..600eb881 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -81,6 +81,7 @@ build_flags = -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 -D FIRMWARE_VERSION='"Meck v0.9.0"' + -D ARDUINO_LOOP_STACK_SIZE=16384 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro> + @@ -127,6 +128,8 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} lib_deps = ${LilyGo_TDeck_Pro.lib_deps} densaugeo/base64 @ ~1.4.0 + https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6 + bitbank2/JPEGDEC [env:LilyGo_TDeck_Pro_repeater] extends = LilyGo_TDeck_Pro