From 1c5be95d4cf06d7d02be10993021e5e90e59c518 Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 4 Jul 2026 02:18:39 -0700 Subject: [PATCH] Redesign to keepsake Memory Frame: config file based named memories with beautiful mobile web dashboard, upload wizard, and delete flow --- sound_picture_app/Audio_Player.cpp | 95 +-- sound_picture_app/Audio_Player.h | 18 +- sound_picture_app/Display_PNG.cpp | 91 ++- sound_picture_app/Display_PNG.h | 10 +- sound_picture_app/SD_Card.cpp | 283 +++++--- sound_picture_app/SD_Card.h | 36 +- sound_picture_app/Web_Server.cpp | 839 +++++++++++++++++------- sound_picture_app/Web_Server.h | 4 + sound_picture_app/sound_picture_app.ino | 318 ++++----- 9 files changed, 1019 insertions(+), 675 deletions(-) diff --git a/sound_picture_app/Audio_Player.cpp b/sound_picture_app/Audio_Player.cpp index 87cfda5..60cc7e1 100644 --- a/sound_picture_app/Audio_Player.cpp +++ b/sound_picture_app/Audio_Player.cpp @@ -1,20 +1,16 @@ #include "Audio_Player.h" #include "Audio.h" -Audio audio; -TaskHandle_t audioTaskHandle = NULL; -volatile bool isAudioTaskRunning = false; -uint8_t currentVolume = 12; // 0 to 21 range +static Audio audio; +static TaskHandle_t audioTaskHandle = NULL; +static volatile bool audioTaskRunning = false; -uint16_t Audio_Count = 0; -char Audio_Names[100][100]; - -// FreeRTOS background task on Core 0 to run ESP32-audioI2S decode loop -void audioLoopTask(void* parameter) { - isAudioTaskRunning = true; - while (isAudioTaskRunning) { +// ── Background decode task (Core 0) ────────────────────────────────────────── +static void audioLoopTask(void* param) { + audioTaskRunning = true; + while (audioTaskRunning) { audio.loop(); - vTaskDelay(pdMS_TO_TICKS(1)); // Yield to let other RTOS tasks run + vTaskDelay(pdMS_TO_TICKS(1)); } audioTaskHandle = NULL; vTaskDelete(NULL); @@ -22,25 +18,25 @@ void audioLoopTask(void* parameter) { void initAudioPlayer() { audio.setPinout(I2S_BCLK, I2S_LRCK, I2S_DIN); - audio.setVolume(currentVolume); - - // Start the background loop task on Core 0 - if (audioTaskHandle == NULL) { - xTaskCreatePinnedToCore( - audioLoopTask, - "audioLoopTask", - 16384, // Increased from 8192 — ESP32-audioI2S MP3 decode needs ~12-16KB stack - NULL, - 5, // Priority - &audioTaskHandle, - 0 // Core 0 - ); + audio.setVolume(g_volume); + if (!audioTaskHandle) { + xTaskCreatePinnedToCore(audioLoopTask, "audioLoop", 16384, NULL, 5, &audioTaskHandle, 0); } } -void playAudio(const char* filePath) { - printf("AudioPlayer: Connecting to FS for file: %s\r\n", filePath); - audio.connecttoFS(SD_MMC, filePath); +// ── Public controls ─────────────────────────────────────────────────────────── +void playAudio(const char* fullPath) { + printf("Audio: playing %s\r\n", fullPath); + audio.connecttoFS(SD_MMC, fullPath); +} + +void playMemoryAudio(int memoryIndex) { + if (memoryIndex < 0 || memoryIndex >= g_memoryCount) return; + const char* audioFile = g_memories[memoryIndex].audio; + if (audioFile[0] == '\0') return; // no audio for this memory + char path[120]; + snprintf(path, sizeof(path), "/audio/%s", audioFile); + playAudio(path); } void stopAudio() { @@ -57,46 +53,11 @@ bool isAudioPlaying() { void setPlayerVolume(uint8_t volume) { if (volume > 21) volume = 21; - currentVolume = volume; - audio.setVolume(currentVolume); - printf("AudioPlayer: Volume set to %d / 21\r\n", currentVolume); + g_volume = volume; + audio.setVolume(g_volume); + printf("Audio: volume -> %d/21\r\n", g_volume); } uint8_t getPlayerVolume() { - return currentVolume; -} - -// Case-insensitive check if filename ends with a given extension -static bool hasExtension(const char* name, const char* ext) { - size_t nLen = strlen(name); - size_t eLen = strlen(ext); - if (nLen < eLen) return false; - return strcasecmp(name + nLen - eLen, ext) == 0; -} - -void Search_Audio(const char* directory) { - File Path = SD_MMC.open(directory); - if (!Path) { - printf("Path: <%s> does not exist\r\n", directory); - Audio_Count = 0; - return; - } - - uint16_t fileCount = 0; - File file = Path.openNextFile(); - while (file && fileCount < 100) { - if (!file.isDirectory()) { - const char* name = file.name(); - // Case-insensitive extension match to handle FAT32 uppercase names - if (hasExtension(name, ".wav") || hasExtension(name, ".mp3")) { - strncpy(Audio_Names[fileCount], name, sizeof(Audio_Names[fileCount]) - 1); - Audio_Names[fileCount][sizeof(Audio_Names[fileCount]) - 1] = '\0'; - fileCount++; - } - } - file = Path.openNextFile(); - } - Path.close(); - Audio_Count = fileCount; - printf("Found %d audio files (.mp3/.wav)\r\n", Audio_Count); + return g_volume; } diff --git a/sound_picture_app/Audio_Player.h b/sound_picture_app/Audio_Player.h index 7fbc168..bdec0b5 100644 --- a/sound_picture_app/Audio_Player.h +++ b/sound_picture_app/Audio_Player.h @@ -3,19 +3,19 @@ #include "FS.h" #include "SD_Card.h" -// Define I2S Pins for external DAC (MAX98357A, etc.) -#define I2S_BCLK 1 -#define I2S_LRCK 2 -#define I2S_DIN 4 +#define I2S_BCLK 1 +#define I2S_LRCK 2 +#define I2S_DIN 4 void initAudioPlayer(); -void playAudio(const char* filePath); + +// Play a memory's audio clip +void playMemoryAudio(int memoryIndex); + +// Low-level controls +void playAudio(const char* fullPath); void stopAudio(); void pauseResumeAudio(); bool isAudioPlaying(); void setPlayerVolume(uint8_t volume); uint8_t getPlayerVolume(); - -void Search_Audio(const char* directory); -extern uint16_t Audio_Count; -extern char Audio_Names[100][100]; diff --git a/sound_picture_app/Display_PNG.cpp b/sound_picture_app/Display_PNG.cpp index a1e42ca..b09249a 100644 --- a/sound_picture_app/Display_PNG.cpp +++ b/sound_picture_app/Display_PNG.cpp @@ -1,88 +1,73 @@ #include "Display_PNG.h" -PNG png; -File Image_file; +static PNG png; +static File Image_file; +static int16_t xpos = 0; +static int16_t ypos = 0; -uint16_t Image_Count = 0; -char Image_Names[100][100]; - -int16_t xpos = 0; -int16_t ypos = 0; - -void * pngOpen(const char *filePath, int32_t *size) { +// ── PNG callbacks ───────────────────────────────────────────────────────────── +static void* pngOpen(const char* filePath, int32_t* size) { Image_file = SD_MMC.open(filePath); + if (!Image_file) { printf("PNG: failed to open %s\r\n", filePath); return nullptr; } *size = Image_file.size(); return &Image_file; } -void pngClose(void *handle) { +static void pngClose(void* handle) { File* fp = (File*)handle; if (fp && *fp) fp->close(); } -int32_t pngRead(PNGFILE *page, uint8_t *buffer, int32_t length) { +static int32_t pngRead(PNGFILE* page, uint8_t* buffer, int32_t length) { if (!Image_file) return 0; return Image_file.read(buffer, length); } -int32_t pngSeek(PNGFILE *page, int32_t position) { +static int32_t pngSeek(PNGFILE* page, int32_t position) { if (!Image_file) return 0; return Image_file.seek(position); } static uint16_t lineBuffer[MAX_IMAGE_WIDTH]; -void pngDraw(PNGDRAW *pDraw) { +static void pngDraw(PNGDRAW* pDraw) { png.getLineAsRGB565(pDraw, lineBuffer, PNG_RGB565_BIG_ENDIAN, 0xffffffff); - uint32_t size = pDraw->iWidth; - for (size_t i = 0; i < size; i++) { - lineBuffer[i] = __builtin_bswap16(lineBuffer[i]); - } - LCD_addWindow(xpos, pDraw->y, xpos + pDraw->iWidth - 1, ypos + pDraw->y, lineBuffer); + uint32_t w = pDraw->iWidth; + for (size_t i = 0; i < w; i++) lineBuffer[i] = __builtin_bswap16(lineBuffer[i]); + LCD_addWindow(xpos, pDraw->y, xpos + w - 1, ypos + pDraw->y, lineBuffer); } -void Search_Images(const char* directory) { - Image_Count = Folder_retrieval(directory, ".png", Image_Names, 100); -} - -// Fill entire screen with black before drawing a new image +// ── Screen fill ─────────────────────────────────────────────────────────────── static void clearScreen() { static uint16_t blackLine[MAX_IMAGE_WIDTH]; memset(blackLine, 0, sizeof(blackLine)); - for (int y = 0; y < LCD_HEIGHT; y++) { + for (int y = 0; y < LCD_HEIGHT; y++) LCD_addWindow(0, y, LCD_WIDTH - 1, y, blackLine); - } } -void Display_Image_By_Path(const char* filePath) { - printf("Displaying image: %s\r\n", filePath); - int16_t ret = png.open(filePath, pngOpen, pngClose, pngRead, pngSeek, pngDraw); - if (ret == PNG_SUCCESS) { - printf("Image dimensions: %d x %d, %d bpp\r\n", png.getWidth(), png.getHeight(), png.getBpp()); - if (png.getWidth() > MAX_IMAGE_WIDTH) { - printf("Image is too wide for line buffer!\r\n"); - } else { - clearScreen(); // Clear leftovers from previous smaller image - ret = png.decode(NULL, 0); - png.close(); - } - } else { - printf("Failed to open PNG image! Error code: %d\r\n", ret); +// ── Public API ──────────────────────────────────────────────────────────────── +void Display_Image_From_SD(const char* fullPath) { + printf("PNG: loading %s\r\n", fullPath); + int16_t rc = png.open(fullPath, pngOpen, pngClose, pngRead, pngSeek, pngDraw); + if (rc != PNG_SUCCESS) { + printf("PNG: open failed, rc=%d\r\n", rc); + return; } + if (png.getWidth() > MAX_IMAGE_WIDTH) { + printf("PNG: image too wide (%d px)\r\n", png.getWidth()); + png.close(); + return; + } + clearScreen(); + png.decode(NULL, 0); + png.close(); } -void Display_Image_By_Index(const char* directory, uint16_t index) { - if (Image_Count == 0) { - Search_Images(directory); - } - if (Image_Count > 0 && index < Image_Count) { - String filePath; - if (String(directory) == "/") { - filePath = String(directory) + Image_Names[index]; - } else { - filePath = String(directory) + "/" + Image_Names[index]; - } - Display_Image_By_Path(filePath.c_str()); - } else { - printf("Invalid image index: %d (Total images: %d)\r\n", index, Image_Count); +void Display_Memory_Photo(int memoryIndex) { + if (memoryIndex < 0 || memoryIndex >= g_memoryCount) { + printf("PNG: invalid memory index %d\r\n", memoryIndex); + return; } + char path[120]; + snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo); + Display_Image_From_SD(path); } diff --git a/sound_picture_app/Display_PNG.h b/sound_picture_app/Display_PNG.h index be9dd69..d73034b 100644 --- a/sound_picture_app/Display_PNG.h +++ b/sound_picture_app/Display_PNG.h @@ -4,11 +4,9 @@ #include "SD_Card.h" #include "Display_ST7789.h" -#define MAX_IMAGE_WIDTH 172 +#define MAX_IMAGE_WIDTH 172 -void Search_Images(const char* directory); -void Display_Image_By_Index(const char* directory, uint16_t index); -void Display_Image_By_Path(const char* filePath); +void Display_Image_From_SD(const char* fullPath); -extern uint16_t Image_Count; -extern char Image_Names[100][100]; +// Convenience: display the photo for a given memory index +void Display_Memory_Photo(int memoryIndex); diff --git a/sound_picture_app/SD_Card.cpp b/sound_picture_app/SD_Card.cpp index 038a72f..d38eb84 100644 --- a/sound_picture_app/SD_Card.cpp +++ b/sound_picture_app/SD_Card.cpp @@ -1,120 +1,211 @@ #include "SD_Card.h" -bool SDCard_Flag; -bool SDCard_Finish; - -uint16_t SDCard_Size; -uint16_t Flash_Size; +// ── Globals ────────────────────────────────────────────────────────────────── +Memory g_memories[MAX_MEMORIES]; +int g_memoryCount = 0; +int g_currentIndex = 0; +uint8_t g_volume = 12; +uint16_t SDCard_Size = 0; +uint16_t Flash_Size = 0; +// ── SD Init ─────────────────────────────────────────────────────────────────── void SD_Init() { - if(!SD_MMC.setPins(SD_CLK_PIN, SD_CMD_PIN, SD_D0_PIN)){ + if (!SD_MMC.setPins(SD_CLK_PIN, SD_CMD_PIN, SD_D0_PIN)) { printf("SD MMC: Pin change failed!\r\n"); return; } - if (SD_MMC.begin("/sdcard", true, true)) { // true = 1-bit mode (avoids GPIO conflict on D3/CS) - printf("SD card initialization successful (4-bit mode)!\r\n"); - } else { + if (!SD_MMC.begin("/sdcard", true, true)) { printf("SD card initialization failed!\r\n"); - } - uint8_t cardType = SD_MMC.cardType(); - if(cardType == CARD_NONE){ - printf("No SD card attached\r\n"); return; } - else{ - printf("SD Card Type: "); - if(cardType == CARD_MMC){ - printf("MMC\r\n"); - } else if(cardType == CARD_SD){ - printf("SDSC\r\n"); - } else if(cardType == CARD_SDHC){ - printf("SDHC\r\n"); - } else { - printf("UNKNOWN\r\n"); - } - uint64_t totalBytes = SD_MMC.totalBytes(); - uint64_t usedBytes = SD_MMC.usedBytes(); - SDCard_Size = totalBytes/(1024*1024); - printf("Total space: %llu\n", totalBytes); - printf("Used space: %llu\n", usedBytes); - printf("Free space: %llu\n", totalBytes - usedBytes); - } + printf("SD card OK (1-bit mode)\r\n"); + uint8_t cardType = SD_MMC.cardType(); + if (cardType == CARD_NONE) { printf("No SD card attached\r\n"); return; } + + uint64_t total = SD_MMC.totalBytes(); + uint64_t used = SD_MMC.usedBytes(); + SDCard_Size = total / (1024 * 1024); + printf("SD: %llu MB total, %llu MB used\r\n", total/(1024*1024), used/(1024*1024)); + + // Ensure required directories exist + if (!SD_MMC.exists("/photos")) SD_MMC.mkdir("/photos"); + if (!SD_MMC.exists("/audio")) SD_MMC.mkdir("/audio"); } -bool File_Search(const char* directory, const char* fileName) -{ - File Path = SD_MMC.open(directory); - if (!Path) { - printf("Path: <%s> does not exist\r\n",directory); +void Flash_test() { + uint32_t flashSize = ESP.getFlashChipSize(); + Flash_Size = flashSize / 1024 / 1024; + printf("Flash: %d MB\r\n", Flash_Size); +} + +// ── Config helpers ──────────────────────────────────────────────────────────── +// We use a dead-simple line-based text format so we don't need ArduinoJson: +// ID|name|photo_filename|audio_filename +// Volume is stored on its own line: VOLUME|12 +// This is robust against partial reads and avoids heap fragmentation. + +static const char* CONFIG_PATH = "/config.txt"; + +bool Config_Load() { + g_memoryCount = 0; + g_currentIndex = 0; + g_volume = 12; + + File f = SD_MMC.open(CONFIG_PATH, "r"); + if (!f) { + printf("Config: no config file found, starting fresh\r\n"); + return true; // not an error — first boot + } + + char line[200]; + while (f.available()) { + // Read a line + int len = 0; + while (f.available() && len < (int)sizeof(line) - 1) { + char c = f.read(); + if (c == '\n') break; + line[len++] = c; + } + line[len] = '\0'; + // Trim trailing \r + if (len > 0 && line[len-1] == '\r') { line[len-1] = '\0'; len--; } + if (len == 0) continue; + + if (strncmp(line, "VOLUME|", 7) == 0) { + g_volume = (uint8_t)atoi(line + 7); + if (g_volume > 21) g_volume = 12; + continue; + } + if (strncmp(line, "INDEX|", 6) == 0) { + g_currentIndex = atoi(line + 6); + continue; + } + + // Parse memory line: id|name|photo|audio + if (g_memoryCount >= MAX_MEMORIES) continue; + char* tok = strtok(line, "|"); + if (!tok) continue; + int id = atoi(tok); + + tok = strtok(NULL, "|"); if (!tok) continue; + char name[MAX_NAME_LEN]; strncpy(name, tok, MAX_NAME_LEN-1); name[MAX_NAME_LEN-1]='\0'; + + tok = strtok(NULL, "|"); if (!tok) continue; + char photo[MAX_FILE_LEN]; strncpy(photo, tok, MAX_FILE_LEN-1); photo[MAX_FILE_LEN-1]='\0'; + + tok = strtok(NULL, "|"); if (!tok) continue; + char audio[MAX_FILE_LEN]; strncpy(audio, tok, MAX_FILE_LEN-1); audio[MAX_FILE_LEN-1]='\0'; + + Memory& m = g_memories[g_memoryCount++]; + m.id = id; + strncpy(m.name, name, MAX_NAME_LEN-1); m.name[MAX_NAME_LEN-1] = '\0'; + strncpy(m.photo, photo, MAX_FILE_LEN-1); m.photo[MAX_FILE_LEN-1] = '\0'; + strncpy(m.audio, audio, MAX_FILE_LEN-1); m.audio[MAX_FILE_LEN-1] = '\0'; + } + f.close(); + + // Clamp currentIndex + if (g_memoryCount > 0 && g_currentIndex >= g_memoryCount) + g_currentIndex = 0; + + printf("Config: loaded %d memories, volume=%d\r\n", g_memoryCount, g_volume); + return true; +} + +bool Config_Save() { + File f = SD_MMC.open(CONFIG_PATH, "w"); + if (!f) { + printf("Config: failed to open for writing\r\n"); return false; } - File file = Path.openNextFile(); + f.printf("VOLUME|%d\n", g_volume); + f.printf("INDEX|%d\n", g_currentIndex); + for (int i = 0; i < g_memoryCount; i++) { + Memory& m = g_memories[i]; + f.printf("%d|%s|%s|%s\n", m.id, m.name, m.photo, m.audio); + } + f.close(); + printf("Config: saved %d memories\r\n", g_memoryCount); + return true; +} + +int Config_Add_Memory(const char* name, const char* photo, const char* audio) { + if (g_memoryCount >= MAX_MEMORIES) return -1; + + // Generate a new unique id (max existing + 1) + int newId = 1; + for (int i = 0; i < g_memoryCount; i++) + if (g_memories[i].id >= newId) newId = g_memories[i].id + 1; + + Memory& m = g_memories[g_memoryCount]; + m.id = newId; + strncpy(m.name, name, MAX_NAME_LEN-1); m.name[MAX_NAME_LEN-1] = '\0'; + strncpy(m.photo, photo, MAX_FILE_LEN-1); m.photo[MAX_FILE_LEN-1] = '\0'; + strncpy(m.audio, audio, MAX_FILE_LEN-1); m.audio[MAX_FILE_LEN-1] = '\0'; + g_memoryCount++; + + Config_Save(); + printf("Config: added memory id=%d name='%s'\r\n", newId, name); + return newId; +} + +bool Config_Delete_Memory(int id) { + for (int i = 0; i < g_memoryCount; i++) { + if (g_memories[i].id == id) { + // Optionally delete the files from SD + char path[80]; + snprintf(path, sizeof(path), "/photos/%s", g_memories[i].photo); + SD_MMC.remove(path); + snprintf(path, sizeof(path), "/audio/%s", g_memories[i].audio); + SD_MMC.remove(path); + + // Shift array down + for (int j = i; j < g_memoryCount - 1; j++) + g_memories[j] = g_memories[j+1]; + g_memoryCount--; + if (g_currentIndex >= g_memoryCount && g_memoryCount > 0) + g_currentIndex = g_memoryCount - 1; + Config_Save(); + printf("Config: deleted memory id=%d\r\n", id); + return true; + } + } + return false; +} + +// ── Legacy helpers ───────────────────────────────────────────────────────────── +bool File_Search(const char* directory, const char* fileName) { + File dir = SD_MMC.open(directory); + if (!dir) return false; + File file = dir.openNextFile(); while (file) { - if (strcmp(file.name(), fileName) == 0) { - if (strcmp(directory, "/") == 0) - printf("File '%s%s' found in root directory.\r\n",directory,fileName); - else - printf("File '%s/%s' found in root directory.\r\n",directory,fileName); - Path.close(); - return true; - } - file = Path.openNextFile(); + if (strcmp(file.name(), fileName) == 0) { dir.close(); return true; } + file = dir.openNextFile(); } - if (strcmp(directory, "/") == 0) - printf("File '%s%s' not found in root directory.\r\n",directory,fileName); - else - printf("File '%s/%s' not found in root directory.\r\n",directory,fileName); - Path.close(); - return false; + dir.close(); + return false; } -uint16_t Folder_retrieval(const char* directory, const char* fileExtension, char File_Name[][100],uint16_t maxFiles) -{ - File Path = SD_MMC.open(directory); - if (!Path) { - printf("Path: <%s> does not exist\r\n",directory); - return 0; - } - - uint16_t fileCount = 0; - char filePath[100]; - File file = Path.openNextFile(); - while (file && fileCount < maxFiles) { +uint16_t Folder_retrieval(const char* directory, const char* fileExtension, + char File_Name[][100], uint16_t maxFiles) { + File dir = SD_MMC.open(directory); + if (!dir) { printf("Folder not found: %s\r\n", directory); return 0; } + + uint16_t count = 0; + File file = dir.openNextFile(); + while (file && count < maxFiles) { if (!file.isDirectory() && strstr(file.name(), fileExtension)) { - strncpy(File_Name[fileCount], file.name(), sizeof(File_Name[fileCount]) - 1); - File_Name[fileCount][sizeof(File_Name[fileCount]) - 1] = '\0'; // Ensure null-termination - if (strcmp(directory, "/") == 0) { - snprintf(filePath, 100, "%s%s", directory, file.name()); - } else { - snprintf(filePath, 100, "%s/%s", directory, file.name()); - } - printf("File found: %s\r\n", filePath); - fileCount++; + strncpy(File_Name[count], file.name(), 99); + File_Name[count][99] = '\0'; + count++; } - file = Path.openNextFile(); - } - Path.close(); - if (fileCount > 0) { - printf("%d <%s> files were retrieved\r\n",fileCount,fileExtension); - return fileCount; - } else { - printf("No files with extension '%s' found in directory: %s\r\n", fileExtension, directory); - return 0; + file = dir.openNextFile(); } + dir.close(); + return count; } -void remove_file_extension(char *file_name) { - char *last_dot = strrchr(file_name, '.'); - if (last_dot != NULL) { - *last_dot = '\0'; - } -} - -void Flash_test() -{ - printf("/********** RAM Test**********/\r\n"); - uint32_t flashSize = ESP.getFlashChipSize(); - Flash_Size = flashSize/1024/1024; - printf("Flash size: %d MB \r\n", flashSize/1024/1024); - printf("/******* RAM Test Over********/\r\n\r\n"); +void remove_file_extension(char* file_name) { + char* last_dot = strrchr(file_name, '.'); + if (last_dot) *last_dot = '\0'; } diff --git a/sound_picture_app/SD_Card.h b/sound_picture_app/SD_Card.h index f4f22a9..97fc739 100644 --- a/sound_picture_app/SD_Card.h +++ b/sound_picture_app/SD_Card.h @@ -4,19 +4,45 @@ #include "FS.h" #include "SD_MMC.h" +// SD card MMC pins (1-bit mode to avoid GPIO 21 conflict with LCD_CS) #define SD_CLK_PIN 14 #define SD_CMD_PIN 15 #define SD_D0_PIN 16 -#define SD_D1_PIN 18 -#define SD_D2_PIN 17 -#define SD_D3_PIN 21 +#define MAX_MEMORIES 50 +#define MAX_NAME_LEN 64 +#define MAX_FILE_LEN 64 + +// A single named memory: photo + audio pair +struct Memory { + int id; + char name[MAX_NAME_LEN]; + char photo[MAX_FILE_LEN]; // filename only, lives in /photos/ + char audio[MAX_FILE_LEN]; // filename only, lives in /audio/ +}; + +// Global memory list & settings +extern Memory g_memories[MAX_MEMORIES]; +extern int g_memoryCount; +extern int g_currentIndex; +extern uint8_t g_volume; extern uint16_t SDCard_Size; extern uint16_t Flash_Size; +// SD init void SD_Init(); void Flash_test(); +// Config load / save +bool Config_Load(); +bool Config_Save(); + +// Memory CRUD +int Config_Add_Memory(const char* name, const char* photo, const char* audio); +bool Config_Delete_Memory(int id); + +// Low-level helpers (kept for legacy use) bool File_Search(const char* directory, const char* fileName); -uint16_t Folder_retrieval(const char* directory, const char* fileExtension, char File_Name[][100],uint16_t maxFiles); -void remove_file_extension(char *file_name); +uint16_t Folder_retrieval(const char* directory, const char* fileExtension, + char File_Name[][100], uint16_t maxFiles); +void remove_file_extension(char* file_name); diff --git a/sound_picture_app/Web_Server.cpp b/sound_picture_app/Web_Server.cpp index 0403493..2cdb8b0 100644 --- a/sound_picture_app/Web_Server.cpp +++ b/sound_picture_app/Web_Server.cpp @@ -2,285 +2,626 @@ #include #include #include "SD_Card.h" -#include "Display_PNG.h" #include "Audio_Player.h" -WebServer server(80); -File uploadFile; -bool uploadSuccess = false; -String uploadedFilename = ""; +// ── Inter-task flags (set by web handler, read by main loop) ────────────────── +volatile int g_webDisplayRequest = -1; +volatile bool g_webDisplayDirty = false; -const char* uploadPage = R"rawliteral( +static WebServer server(80); +static File uploadFile; +static bool uploadOK = false; +static String uploadSavedAs = ""; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +static bool allowedPhoto(const String& name) { + String l = name; l.toLowerCase(); + return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg"); +} +static bool allowedAudio(const String& name) { + String l = name; l.toLowerCase(); + return l.endsWith(".mp3") || l.endsWith(".wav"); +} + +// Build a compact JSON representation of all memories +static String memoriesJson() { + String j = "["; + for (int i = 0; i < g_memoryCount; i++) { + if (i > 0) j += ","; + j += "{\"id\":"; j += g_memories[i].id; + j += ",\"name\":\""; j += g_memories[i].name; + j += "\",\"photo\":\""; j += g_memories[i].photo; + j += "\",\"audio\":\""; j += g_memories[i].audio; + j += "\"}"; + } + j += "]"; + return j; +} + +// ── Single-page app HTML ────────────────────────────────────────────────────── +// Served from PROGMEM to save RAM +static const char APP_HTML[] PROGMEM = R"HTML( - - - Photo Frame Upload - + + +Memory Frame + -
- -

Photo Frame

-

Upload media to the SD card wirelessly

- %TOAST% + + -
-
-
📁
-
Drop a file here or browse
- -
-
No file selected
- -
-
-
Uploading...
-
-
-

Accepted: .png (172x320)  ·  .mp3  ·  .wav

+ +
+

Add Memory

+

Upload a photo and a soundbite together

+
+ + +
+
Step 1 of 3 — Choose a Photo
+
+
📷
+

Tap to choose a photo
from your camera roll

+
+ + preview +
+
+
Uploading photo...
+
+
- +function uploadAudio() { + document.getElementById('nextStep2').disabled=true; + uploadFile( + document.getElementById('audioFile'), + '/api/upload/audio', + document.getElementById('progress2'), + document.getElementById('fill2'), + document.getElementById('txt2'), + function(filename){ + if(filename){ uploadedAudio=filename; goStep(3); } + else showToast('Audio upload failed. Try again.', false); + document.getElementById('nextStep2').disabled=false; + } + ); +} + +function saveMemory() { + var name = document.getElementById('memName').value.trim(); + if(!name){ showToast('Please give this memory a name.', false); return; } + if(!uploadedPhoto){ showToast('Photo is missing.', false); return; } + var body = JSON.stringify({name:name, photo:uploadedPhoto, audio:uploadedAudio||''}); + fetch('/api/memory', {method:'POST', headers:{'Content-Type':'application/json'}, body:body}) + .then(function(r){return r.json()}) + .then(function(d){ + if(d.ok){ + showToast('Memory saved! \u2764\uFE0F', true); + setTimeout(function(){ goPage('gallery'); }, 1500); + } else showToast('Save failed: '+d.error, false); + }).catch(function(){ showToast('Save failed.', false); }); +} + +// ── Settings ───────────────────────────────────────────────────────────────── +function loadSettings() { + fetch('/api/settings').then(function(r){return r.json()}).then(function(d){ + document.getElementById('volSlider').value = d.volume||12; + document.getElementById('volDisplay').textContent = (d.volume||12)+' / 21'; + var nm = document.getElementById('npName'); + if(d.currentName) nm.textContent = d.currentName; + else nm.textContent = memories.length ? memories[d.currentIndex||0].name||'-' : '-'; + }).catch(function(){}); +} + +function onVolChange(v) { + document.getElementById('volDisplay').textContent = v+' / 21'; +} + +function saveSettings() { + var v = parseInt(document.getElementById('volSlider').value)||12; + fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({volume:v})}) + .then(function(r){return r.json()}) + .then(function(d){ if(d.ok) showToastGlobal('Settings saved!', true); }) + .catch(function(){}); +} + +// ── Toast helpers ───────────────────────────────────────────────────────────── +function showToast(msg, ok) { + var t = document.getElementById('toast'); + t.textContent = msg; + t.className = 'toast '+(ok?'ok':'err'); + t.style.display='block'; + setTimeout(function(){ t.style.display='none'; }, 3000); +} + +var globalToastTimer; +function showToastGlobal(msg, ok) { + var old = document.getElementById('gtToast'); + if(old) old.remove(); + var t = document.createElement('div'); + t.id='gtToast'; + t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:999;padding:12px 20px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.4)'; + t.style.background = ok ? 'rgba(16,185,129,.9)' : 'rgba(239,68,68,.9)'; + t.style.color = '#fff'; + t.textContent = msg; + document.body.appendChild(t); + clearTimeout(globalToastTimer); + globalToastTimer = setTimeout(function(){ t.remove(); }, 2500); +} + +function escHtml(s) { + return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// ── Init ────────────────────────────────────────────────────────────────────── +loadGallery(); +loadSettings(); + -)rawliteral"; +)HTML"; -static bool isAllowedExtension(const String& filename) { - String lower = filename; - lower.toLowerCase(); - return lower.endsWith(".png") || lower.endsWith(".mp3") || lower.endsWith(".wav"); -} +// ── Upload handlers ─────────────────────────────────────────────────────────── +static String pendingUploadPath = ""; -void handleUpload() { - HTTPUpload& upload = server.upload(); - if (upload.status == UPLOAD_FILE_START) { - String filename = upload.filename; - if (!filename.startsWith("/")) filename = "/" + filename; - - // Reject files that are not PNG, MP3, or WAV - if (!isAllowedExtension(filename)) { - printf("WebUpload: Rejected non-media file: %s\r\n", filename.c_str()); - uploadSuccess = false; +static void handleUploadFile(const String& subdir, bool (*validator)(const String&)) { + HTTPUpload& up = server.upload(); + if (up.status == UPLOAD_FILE_START) { + String fname = up.filename; + uploadOK = false; + uploadSavedAs = ""; + if (!validator(fname)) { + printf("Upload: rejected %s\r\n", fname.c_str()); return; } - - printf("WebUpload: Starting upload of %s\r\n", filename.c_str()); - uploadedFilename = filename; - uploadFile = SD_MMC.open(filename, "w"); - uploadSuccess = (bool)uploadFile; - } - else if (upload.status == UPLOAD_FILE_WRITE) { - if (uploadFile) { - uploadFile.write(upload.buf, upload.currentSize); - } - } - else if (upload.status == UPLOAD_FILE_END) { - if (uploadFile) { - uploadFile.close(); - printf("WebUpload: Successfully uploaded %s (%d bytes)\r\n", upload.filename.c_str(), upload.totalSize); - uploadSuccess = true; - // Re-scan SD card to index the new file - Search_Images("/"); - Search_Audio("/"); - } else { - uploadSuccess = false; - } + pendingUploadPath = "/" + subdir + "/" + fname; + uploadFile = SD_MMC.open(pendingUploadPath, "w"); + uploadOK = (bool)uploadFile; + if (uploadOK) uploadSavedAs = fname; + printf("Upload: writing to %s\r\n", pendingUploadPath.c_str()); + } + else if (up.status == UPLOAD_FILE_WRITE) { + if (uploadFile) uploadFile.write(up.buf, up.currentSize); + } + else if (up.status == UPLOAD_FILE_END) { + if (uploadFile) { uploadFile.close(); printf("Upload: done (%u bytes)\r\n", up.totalSize); } + else { uploadOK = false; } } } -static String buildPage(const String& status, const String& file) { - String toast = ""; - if (status == "ok") { - String safeName = file; - safeName.replace("<", "<"); - safeName.replace(">", ">"); - toast = "
✅ \"" + safeName + "\" uploaded successfully!
"; - } else if (status == "fail") { - toast = "
❌ Upload failed. Only .png, .mp3 and .wav files are accepted.
"; - } - String page = String(uploadPage); - page.replace("%TOAST%", toast); - return page; -} - +// ── Route setup ─────────────────────────────────────────────────────────────── void initWebServer() { WiFi.softAP("love", "love1234"); - IPAddress IP = WiFi.softAPIP(); - printf("WiFi AP Started. SSID: 'love' Password: 'love1234'\r\n"); - printf("Web Server IP: %s\r\n", IP.toString().c_str()); - + printf("WiFi AP: SSID='love' PW='love1234' IP=%s\r\n", WiFi.softAPIP().toString().c_str()); + + // Main SPA server.on("/", HTTP_GET, []() { - String status = server.hasArg("status") ? server.arg("status") : ""; - String file = server.hasArg("file") ? server.arg("file") : ""; - server.send(200, "text/html", buildPage(status, file)); + server.send_P(200, "text/html", APP_HTML); }); - - server.on("/upload", HTTP_POST, []() { - String loc = uploadSuccess - ? "/?status=ok&file=" + uploadedFilename - : "/?status=fail"; - server.sendHeader("Location", loc); - server.send(303); - }, handleUpload); - + + // GET /api/memories → JSON array + server.on("/api/memories", HTTP_GET, []() { + server.send(200, "application/json", memoriesJson()); + }); + + // GET /api/settings → {volume, currentIndex, currentName} + server.on("/api/settings", HTTP_GET, []() { + String name = ""; + if (g_currentIndex >= 0 && g_currentIndex < g_memoryCount) + name = String(g_memories[g_currentIndex].name); + String j = "{\"volume\":"; j += g_volume; + j += ",\"currentIndex\":"; j += g_currentIndex; + j += ",\"currentName\":\""; j += name; j += "\"}"; + server.send(200, "application/json", j); + }); + + // POST /api/settings body: {volume:N} + server.on("/api/settings", HTTP_POST, []() { + String body = server.arg("plain"); + // parse volume from simple JSON: {"volume":12} + int vi = body.indexOf("\"volume\":"); + if (vi >= 0) { + int v = body.substring(vi + 9).toInt(); + if (v < 0) v = 0; if (v > 21) v = 21; + setPlayerVolume((uint8_t)v); + Config_Save(); + } + server.send(200, "application/json", "{\"ok\":true}"); + }); + + // POST /api/upload/photo — multipart upload + server.on("/api/upload/photo", HTTP_POST, + []() { + if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}"); + else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}"); + }, + []() { handleUploadFile("photos", allowedPhoto); } + ); + + // POST /api/upload/audio — multipart upload + server.on("/api/upload/audio", HTTP_POST, + []() { + if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}"); + else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}"); + }, + []() { handleUploadFile("audio", allowedAudio); } + ); + + // POST /api/memory body: {name, photo, audio} + server.on("/api/memory", HTTP_POST, []() { + String body = server.arg("plain"); + // Minimal parser — extract name, photo, audio + auto extract = [&](const char* key) -> String { + String k = String("\"") + key + "\":\""; + int p = body.indexOf(k); + if (p < 0) return ""; + int s = p + k.length(); + int e = body.indexOf('"', s); + if (e < 0) return ""; + return body.substring(s, e); + }; + String name = extract("name"); + String photo = extract("photo"); + String audio = extract("audio"); + if (name.isEmpty() || photo.isEmpty()) { + server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing name or photo\"}"); + return; + } + int id = Config_Add_Memory(name.c_str(), photo.c_str(), audio.c_str()); + if (id > 0) server.send(200, "application/json", "{\"ok\":true,\"id\":" + String(id) + "}"); + else server.send(500, "application/json", "{\"ok\":false,\"error\":\"too many memories\"}"); + }); + + // DELETE /api/memory?id=N + server.on("/api/memory", HTTP_DELETE, []() { + if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; } + int id = server.arg("id").toInt(); + bool ok = Config_Delete_Memory(id); + server.send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"not found\"}"); + }); + + // POST /api/display?id=N — show a memory on the frame NOW + server.on("/api/display", HTTP_POST, []() { + if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; } + int id = server.arg("id").toInt(); + for (int i = 0; i < g_memoryCount; i++) { + if (g_memories[i].id == id) { + g_currentIndex = i; + g_webDisplayRequest = i; + g_webDisplayDirty = true; + Config_Save(); + server.send(200, "application/json", "{\"ok\":true}"); + return; + } + } + server.send(404, "application/json", "{\"ok\":false,\"error\":\"memory not found\"}"); + }); + + // GET /photo/{filename} — serve a photo thumbnail from SD + server.on("/photo", HTTP_GET, []() { + String name = server.pathArg(0); + if (name.isEmpty() && server.hasArg("f")) name = server.arg("f"); + // Fallback: extract from URL + String uri = server.uri(); + if (name.isEmpty()) { + int slash = uri.lastIndexOf('/'); + if (slash >= 0) name = uri.substring(slash + 1); + } + if (name.isEmpty()) { server.send(404, "text/plain", "Not found"); return; } + String path = "/photos/" + name; + File f = SD_MMC.open(path, "r"); + if (!f) { server.send(404, "text/plain", "Not found"); return; } + server.streamFile(f, "image/png"); + f.close(); + }); + + server.onNotFound([]() { + // Also handle /photo/xxx.png via URI parsing + String uri = server.uri(); + if (uri.startsWith("/photo/")) { + String name = uri.substring(7); + String path = "/photos/" + name; + File f = SD_MMC.open(path, "r"); + if (f) { + String ct = name.endsWith(".png") ? "image/png" : "image/jpeg"; + server.streamFile(f, ct); + f.close(); + return; + } + } + server.send(404, "text/plain", "Not found"); + }); + server.begin(); + printf("Web server started\r\n"); } void handleWebServer() { diff --git a/sound_picture_app/Web_Server.h b/sound_picture_app/Web_Server.h index bf4ebbd..0a60848 100644 --- a/sound_picture_app/Web_Server.h +++ b/sound_picture_app/Web_Server.h @@ -3,3 +3,7 @@ void initWebServer(); void handleWebServer(); + +// Called by web server to trigger display on the ESP32 screen +extern volatile int g_webDisplayRequest; // -1 = none, >=0 = memory index to show +extern volatile bool g_webDisplayDirty; diff --git a/sound_picture_app/sound_picture_app.ino b/sound_picture_app/sound_picture_app.ino index 96dca17..80f0667 100644 --- a/sound_picture_app/sound_picture_app.ino +++ b/sound_picture_app/sound_picture_app.ino @@ -3,240 +3,178 @@ #include "Display_PNG.h" #include "Audio_Player.h" #include "Web_Server.h" -#include #define BOOT_KEY_PIN 0 -// Playback states -enum AppState { - STATE_IDLE, - STATE_PLAYING, - STATE_AUTOPLAY, - STATE_ERROR -}; +// ── App state ───────────────────────────────────────────────────────────────── +enum AppState { STATE_IDLE, STATE_AUTOPLAY, STATE_ERROR }; +static AppState currentState = STATE_IDLE; +static bool hasError = false; -AppState currentState = STATE_IDLE; -bool hasError = false; +// ── Button gesture state ────────────────────────────────────────────────────── +static bool lastButtonState = HIGH; +static uint32_t buttonPressStartTime = 0; +static uint32_t lastButtonRelease = 0; +static uint8_t clickCount = 0; +static bool isButtonPressed = false; +static bool longPressTriggered = false; -uint16_t currentIndex = 0; -bool lastButtonState = HIGH; -uint32_t lastDebounceTime = 0; -const uint32_t debounceDelay = 50; +// Autoplay +static uint32_t lastSlideTime = 0; +static const uint32_t SLIDE_INTERVAL = 8000; // 8 s between slides in autoplay -// Slide Show timing -uint32_t lastSlideTime = 0; -const uint32_t slideShowInterval = 5000; // 5 seconds +// ── Core function: show a memory ────────────────────────────────────────────── +void showMemory(int index) { + if (g_memoryCount == 0) return; + if (index < 0 || index >= g_memoryCount) index = 0; + g_currentIndex = index; -// Pulse control for RGB LED -uint32_t lastLedPulseTime = 0; -uint8_t pulseBrightness = 0; -bool pulseDirection = true; + printf("Showing memory %d: '%s'\r\n", index, g_memories[index].name); -// Click gesture timers -uint32_t lastButtonReleaseTime = 0; -uint32_t buttonPressStartTime = 0; -uint8_t clickCount = 0; -bool isButtonPressed = false; -bool longPressTriggered = false; - -void setup() { - Serial.begin(115200); - delay(1000); - - printf("\r\n==========================================\r\n"); - printf("Starting Sound and Picture Player...\r\n"); - printf("==========================================\r\n"); - - // Initialize Display and Backlight first so the screen lights up immediately - LCD_Init(); - Set_Backlight(90); - - // Initialize other peripherals - Flash_test(); - SD_Init(); - - // Setup button - pinMode(BOOT_KEY_PIN, INPUT_PULLUP); - - // Retrieve media files from SD Card - Search_Images("/"); - Search_Audio("/"); - - // Initialize ESP32-audioI2S player - initAudioPlayer(); - - printf("Initialization complete!\r\n"); - printf("Found %d PNG images and %d Audio files (.mp3/.wav).\r\n", Image_Count, Audio_Count); - - if (Image_Count == 0 && Audio_Count == 0) { - hasError = true; - currentState = STATE_ERROR; - printf("ERROR: No media found on microSD card!\r\n"); - } else { - if (Image_Count == 0) printf("WARNING: No PNG images found — audio-only mode.\r\n"); - if (Audio_Count == 0) printf("WARNING: No audio files found — silent slideshow mode.\r\n"); - if (Image_Count > 0) { - printf("Displaying initial image (index 0): %s\r\n", Image_Names[0]); - Display_Image_By_Index("/", 0); - } - currentState = STATE_IDLE; - } - - // Start the SoftAP and local Web Server - initWebServer(); -} - -void playCurrentPair() { - if (Image_Count == 0 && Audio_Count == 0) { - printf("Cannot play: missing PNG images and Audio files!\r\n"); - return; - } - - // Stop current playing audio if any stopAudio(); - - printf("Playing item index %d:\r\n", currentIndex); - - // Display the image - if (Image_Count > 0) { - uint16_t pngIndex = currentIndex % Image_Count; - Display_Image_By_Index("/", pngIndex); - } - - // Play the audio bite - if (Audio_Count > 0) { - uint16_t audioIndex = currentIndex % Audio_Count; - String soundPath = String("/") + Audio_Names[audioIndex]; - printf("Playing audio: %s\r\n", soundPath.c_str()); - playAudio(soundPath.c_str()); - } + Display_Memory_Photo(index); + playMemoryAudio(index); + Config_Save(); // persist currentIndex } +// ── Button handlers ─────────────────────────────────────────────────────────── void handleSinglePress() { - printf("Gesture: Single Click - Next Slide\r\n"); - if (!hasError) { - uint16_t total = std::max(Image_Count, Audio_Count); - if (total == 0) return; - if (currentState == STATE_AUTOPLAY) { - currentState = STATE_IDLE; - printf("Autoplay Disabled.\r\n"); - } - currentIndex = (currentIndex + 1) % total; - playCurrentPair(); - } + if (hasError || g_memoryCount == 0) return; + if (currentState == STATE_AUTOPLAY) currentState = STATE_IDLE; + showMemory((g_currentIndex + 1) % g_memoryCount); } void handleDoublePress() { - printf("Gesture: Double Click - Pause / Resume Audio\r\n"); - if (!hasError) { - pauseResumeAudio(); - } + if (hasError) return; + pauseResumeAudio(); } void handleTriplePress() { - printf("Gesture: Triple Click - Previous Slide\r\n"); - if (!hasError) { - uint16_t total = std::max(Image_Count, Audio_Count); - if (total == 0) return; - if (currentState == STATE_AUTOPLAY) { - currentState = STATE_IDLE; - printf("Autoplay Disabled.\r\n"); - } - currentIndex = (currentIndex + total - 1) % total; - playCurrentPair(); - } + if (hasError || g_memoryCount == 0) return; + if (currentState == STATE_AUTOPLAY) currentState = STATE_IDLE; + showMemory((g_currentIndex + g_memoryCount - 1) % g_memoryCount); } void handleQuadruplePress() { - if (!hasError) { - uint8_t nextVol = getPlayerVolume() + 4; - if (nextVol > 20) nextVol = 4; // Loop back - setPlayerVolume(nextVol); - } + uint8_t v = getPlayerVolume() + 4; + if (v > 21) v = 4; + setPlayerVolume(v); + printf("Volume: %d/21\r\n", v); } void handleLongPress() { - printf("Gesture: Long Press - Toggle Autoplay Slideshow\r\n"); - if (!hasError) { - if (currentState == STATE_AUTOPLAY) { - currentState = STATE_IDLE; - printf("Autoplay Disabled. Manual Mode active.\r\n"); - stopAudio(); - } else { - currentState = STATE_AUTOPLAY; - printf("Autoplay Enabled! Cycling slides every %d ms.\r\n", slideShowInterval); - lastSlideTime = millis(); - playCurrentPair(); - } + if (hasError) return; + if (currentState == STATE_AUTOPLAY) { + currentState = STATE_IDLE; + stopAudio(); + printf("Autoplay OFF\r\n"); + } else { + currentState = STATE_AUTOPLAY; + lastSlideTime = millis(); + showMemory(g_currentIndex); + printf("Autoplay ON\r\n"); } } -void updateLEDStatus() { - // Disabled NeoPixel code to resolve hardware conflict with SPI SCLK (GPIO 38) +// ── Setup ───────────────────────────────────────────────────────────────────── +void setup() { + Serial.begin(115200); + delay(1000); + printf("\r\n============================\r\n"); + printf(" Memory Frame v2.0\r\n"); + printf("============================\r\n"); + + // Display on first + LCD_Init(); + Set_Backlight(90); + + // SD card + Flash_test(); + SD_Init(); + + // Load memories from config + Config_Load(); + + // Button + pinMode(BOOT_KEY_PIN, INPUT_PULLUP); + + // Audio player + initAudioPlayer(); + setPlayerVolume(g_volume); + + // Show first memory (or error screen) + if (g_memoryCount == 0) { + printf("No memories found. Add some via the web app.\r\n"); + // Screen stays dark — user will add via web + } else { + showMemory(g_currentIndex); + } + + // Start WiFi + web server + initWebServer(); + + printf("Ready! %d memor%s loaded.\r\n", g_memoryCount, g_memoryCount==1?"y":"ies"); } +// ── Loop ────────────────────────────────────────────────────────────────────── void loop() { - bool currentButtonReading = digitalRead(BOOT_KEY_PIN); + bool btn = digitalRead(BOOT_KEY_PIN); uint32_t now = millis(); - - // Detect state transitions for debouncing and gesture capturing - if (currentButtonReading == LOW && lastButtonState == HIGH) { - // Button Pressed down - lastDebounceTime = now; + + // ── Web display request (triggered by phone) ────────────────────────────── + if (g_webDisplayDirty) { + g_webDisplayDirty = false; + int req = g_webDisplayRequest; + g_webDisplayRequest = -1; + if (req >= 0 && req < g_memoryCount) { + showMemory(req); + } + } + + // ── Button edge detection ───────────────────────────────────────────────── + if (btn == LOW && lastButtonState == HIGH) { + // Pressed down buttonPressStartTime = now; - isButtonPressed = true; - longPressTriggered = false; - } - else if (currentButtonReading == LOW && lastButtonState == LOW && isButtonPressed) { - // Button is held down + isButtonPressed = true; + longPressTriggered = false; + } + else if (btn == LOW && lastButtonState == LOW && isButtonPressed) { + // Held down if (!longPressTriggered && (now - buttonPressStartTime > 1500)) { longPressTriggered = true; handleLongPress(); } } - else if (currentButtonReading == HIGH && lastButtonState == LOW) { - // Button Released + else if (btn == HIGH && lastButtonState == LOW) { + // Released isButtonPressed = false; - uint32_t pressDuration = now - buttonPressStartTime; - - if (!longPressTriggered && pressDuration > 50) { // Debounced release + if (!longPressTriggered && (now - buttonPressStartTime > 50)) { clickCount++; - lastButtonReleaseTime = now; + lastButtonRelease = now; } } - - // Process click gestures after 300ms timeout has elapsed since last release - if (clickCount > 0 && !isButtonPressed && (now - lastButtonReleaseTime > 300)) { - if (clickCount == 1) { - handleSinglePress(); - } else if (clickCount == 2) { - handleDoublePress(); - } else if (clickCount == 3) { - handleTriplePress(); - } else if (clickCount >= 4) { - handleQuadruplePress(); - } - clickCount = 0; // Reset counter + + // ── Multi-click gesture resolution (300ms window) ───────────────────────── + if (clickCount > 0 && !isButtonPressed && (now - lastButtonRelease > 300)) { + if (clickCount == 1) handleSinglePress(); + else if (clickCount == 2) handleDoublePress(); + else if (clickCount == 3) handleTriplePress(); + else handleQuadruplePress(); + clickCount = 0; } - - // Handle slideshow auto-advance in Autoplay mode - if (currentState == STATE_AUTOPLAY && !hasError) { - uint16_t total = std::max(Image_Count, Audio_Count); - // Only advance when the audio stops playing and slideShowInterval (5s) has passed - if (total > 0 && !isAudioPlaying() && (now - lastSlideTime > slideShowInterval)) { + + // ── Autoplay advance ────────────────────────────────────────────────────── + if (currentState == STATE_AUTOPLAY && g_memoryCount > 0) { + if (!isAudioPlaying() && (now - lastSlideTime > SLIDE_INTERVAL)) { lastSlideTime = now; - currentIndex = (currentIndex + 1) % total; - playCurrentPair(); + showMemory((g_currentIndex + 1) % g_memoryCount); } } - - lastButtonState = currentButtonReading; - - // Handle incoming web server clients + + lastButtonState = btn; + + // ── Web server ──────────────────────────────────────────────────────────── handleWebServer(); - - // Update status RGB LED - updateLEDStatus(); - - delay(10); // Yield to other RTOS tasks + + delay(10); }