Redesign to keepsake Memory Frame: config file based named memories with beautiful mobile web dashboard, upload wizard, and delete flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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];
|
||||
|
||||
@@ -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());
|
||||
// ── 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("Image is too wide for line buffer!\r\n");
|
||||
} else {
|
||||
clearScreen(); // Clear leftovers from previous smaller image
|
||||
ret = png.decode(NULL, 0);
|
||||
printf("PNG: image too wide (%d px)\r\n", png.getWidth());
|
||||
png.close();
|
||||
return;
|
||||
}
|
||||
clearScreen();
|
||||
png.decode(NULL, 0);
|
||||
png.close();
|
||||
}
|
||||
} else {
|
||||
printf("Failed to open PNG image! Error code: %d\r\n", ret);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
|
||||
#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);
|
||||
|
||||
@@ -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)) {
|
||||
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);
|
||||
return false;
|
||||
void Flash_test() {
|
||||
uint32_t flashSize = ESP.getFlashChipSize();
|
||||
Flash_Size = flashSize / 1024 / 1024;
|
||||
printf("Flash: %d MB\r\n", Flash_Size);
|
||||
}
|
||||
File file = Path.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();
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
file = Path.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;
|
||||
}
|
||||
|
||||
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;
|
||||
// ── 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) { dir.close(); return true; }
|
||||
file = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
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());
|
||||
strncpy(File_Name[count], file.name(), 99);
|
||||
File_Name[count][99] = '\0';
|
||||
count++;
|
||||
}
|
||||
printf("File found: %s\r\n", filePath);
|
||||
fileCount++;
|
||||
}
|
||||
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");
|
||||
if (last_dot) *last_dot = '\0';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
uint16_t Folder_retrieval(const char* directory, const char* fileExtension,
|
||||
char File_Name[][100], uint16_t maxFiles);
|
||||
void remove_file_extension(char* file_name);
|
||||
|
||||
@@ -2,285 +2,626 @@
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#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(
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8">
|
||||
<title>Photo Frame Upload</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||
<title>Memory Frame</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.07);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 20px;
|
||||
padding: 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo { font-size: 32px; margin-bottom: 4px; text-align: center; }
|
||||
h1 { font-size: 22px; font-weight: 600; text-align: center; color: #e2e8f0; margin-bottom: 6px; }
|
||||
.subtitle { font-size: 13px; color: #94a3b8; text-align: center; margin-bottom: 28px; }
|
||||
.drop-zone {
|
||||
border: 2px dashed rgba(139,92,246,0.5);
|
||||
border-radius: 12px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(139,92,246,0.05);
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139,92,246,0.12);
|
||||
}
|
||||
.drop-icon { font-size: 36px; margin-bottom: 8px; }
|
||||
.drop-text { font-size: 14px; color: #94a3b8; }
|
||||
.drop-text span { color: #a78bfa; font-weight: 500; cursor: pointer; }
|
||||
input[type="file"] { display: none; }
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
color: #a78bfa;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
button:hover { opacity: 0.9; transform: translateY(-1px); }
|
||||
button:active { transform: translateY(0); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.hint { font-size: 11px; color: #64748b; text-align: center; margin-top: 14px; }
|
||||
.toast {
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast.success { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: #34d399; }
|
||||
.toast.error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #f87171; }
|
||||
.progress { display: none; margin-top: 14px; }
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #7c3aed, #a855f7);
|
||||
border-radius: 99px;
|
||||
width: 0%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.progress-text { font-size: 12px; color: #94a3b8; text-align: center; margin-top: 6px; }
|
||||
:root{--bg:#0d0d14;--card:#1a1a2e;--card2:#16213e;--accent:#e91e8c;--accent2:#9c27b0;--text:#f0f0f8;--muted:#7070a0;--radius:16px;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
||||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overscroll-behavior:none}
|
||||
/* Nav */
|
||||
nav{position:fixed;bottom:0;left:0;right:0;background:rgba(13,13,20,.95);backdrop-filter:blur(20px);display:flex;border-top:1px solid rgba(255,255,255,.08);z-index:100;padding-bottom:env(safe-area-inset-bottom)}
|
||||
nav button{flex:1;padding:14px 0 10px;background:none;border:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;gap:3px;transition:.2s;cursor:pointer}
|
||||
nav button.active{color:var(--accent)}
|
||||
nav button svg{width:22px;height:22px;fill:currentColor}
|
||||
/* Pages */
|
||||
.page{display:none;padding:16px 16px 90px;min-height:100vh}
|
||||
.page.active{display:block}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:4px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.sub{color:var(--muted);font-size:13px;margin-bottom:24px}
|
||||
/* Gallery */
|
||||
.gallery{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.mem-card{background:var(--card);border-radius:var(--radius);overflow:hidden;cursor:pointer;box-shadow:var(--shadow);transition:.2s;position:relative}
|
||||
.mem-card:active{transform:scale(.97)}
|
||||
.mem-thumb{width:100%;aspect-ratio:172/320;object-fit:cover;display:block;background:#222}
|
||||
.mem-thumb.no-img{display:flex;align-items:center;justify-content:center;font-size:40px}
|
||||
.mem-info{padding:10px 12px 12px}
|
||||
.mem-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mem-del{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.6);border:none;color:#fff;border-radius:50%;width:28px;height:28px;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
|
||||
.play-btn{width:100%;margin-top:8px;padding:8px;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;border-radius:8px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:.2s}
|
||||
.play-btn:active{opacity:.8}
|
||||
.empty{text-align:center;padding:60px 20px;color:var(--muted)}
|
||||
.empty .icon{font-size:56px;margin-bottom:16px}
|
||||
.fab{position:fixed;bottom:80px;right:20px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff;font-size:28px;cursor:pointer;box-shadow:0 4px 20px rgba(233,30,140,.4);display:flex;align-items:center;justify-content:center;z-index:90}
|
||||
/* Add Memory wizard */
|
||||
.wizard-step{display:none}.wizard-step.active{display:block}
|
||||
.step-label{font-size:12px;color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
||||
.upload-box{border:2px dashed rgba(233,30,140,.4);border-radius:var(--radius);padding:32px;text-align:center;cursor:pointer;margin-bottom:16px;transition:.2s;background:rgba(233,30,140,.04)}
|
||||
.upload-box:hover,.upload-box.hover{border-color:var(--accent);background:rgba(233,30,140,.08)}
|
||||
.upload-box .icon{font-size:40px;margin-bottom:8px}
|
||||
.upload-box p{color:var(--muted);font-size:13px}
|
||||
.upload-box strong{color:var(--accent)}
|
||||
.preview-img{width:100%;max-height:200px;object-fit:contain;border-radius:12px;margin-bottom:12px;display:none}
|
||||
.preview-audio{width:100%;margin-bottom:12px;display:none}
|
||||
input[type=file]{display:none}
|
||||
.text-input{width:100%;background:var(--card2);border:1.5px solid rgba(255,255,255,.1);border-radius:12px;padding:14px 16px;color:var(--text);font-size:16px;outline:none;transition:.2s;margin-bottom:16px}
|
||||
.text-input:focus{border-color:var(--accent)}
|
||||
.btn{width:100%;padding:16px;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;transition:.2s;margin-bottom:8px}
|
||||
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff}
|
||||
.btn-primary:active{opacity:.85}
|
||||
.btn-secondary{background:var(--card2);color:var(--text)}
|
||||
.progress-wrap{margin-bottom:16px;display:none}
|
||||
.progress-bar{height:4px;background:rgba(255,255,255,.1);border-radius:99px;overflow:hidden}
|
||||
.progress-fill{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .3s}
|
||||
.progress-txt{font-size:12px;color:var(--muted);text-align:center;margin-top:6px}
|
||||
.toast{border-radius:12px;padding:14px 16px;margin-bottom:16px;font-size:14px;font-weight:500;display:none}
|
||||
.toast.ok{background:rgba(16,185,129,.15);border:1px solid rgba(16,185,129,.3);color:#34d399}
|
||||
.toast.err{background:rgba(239,68,68,.15);border:1px solid rgba(239,68,68,.3);color:#f87171}
|
||||
/* Settings */
|
||||
.setting-row{background:var(--card);border-radius:var(--radius);padding:18px;margin-bottom:12px}
|
||||
.setting-label{font-size:13px;font-weight:600;margin-bottom:4px}
|
||||
.setting-sub{font-size:12px;color:var(--muted);margin-bottom:12px}
|
||||
input[type=range]{width:100%;accent-color:var(--accent)}
|
||||
.vol-display{text-align:right;font-size:13px;color:var(--accent);font-weight:700;margin-top:4px}
|
||||
.now-playing{background:linear-gradient(135deg,rgba(233,30,140,.15),rgba(156,39,176,.15));border:1px solid rgba(233,30,140,.3);border-radius:var(--radius);padding:18px;margin-bottom:12px}
|
||||
.np-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
|
||||
.np-name{font-size:18px;font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🖼️</div>
|
||||
<h1>Photo Frame</h1>
|
||||
<p class="subtitle">Upload media to the SD card wirelessly</p>
|
||||
|
||||
%TOAST%
|
||||
<!-- Gallery Page -->
|
||||
<div id="page-gallery" class="page active">
|
||||
<h1>Memory Frame</h1>
|
||||
<p class="sub" id="sub-count">Loading memories...</p>
|
||||
<div class="gallery" id="gallery"></div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/upload" enctype="multipart/form-data" id="uploadForm">
|
||||
<div class="drop-zone" id="dropZone" onclick="document.getElementById('file').click()">
|
||||
<div class="drop-icon">📁</div>
|
||||
<div class="drop-text">Drop a file here or <span>browse</span></div>
|
||||
<input type="file" id="file" name="file" accept=".png,.mp3,.wav" onchange="onFileSelected(this)">
|
||||
<!-- Add Memory Page -->
|
||||
<div id="page-add" class="page">
|
||||
<h1>Add Memory</h1>
|
||||
<p class="sub">Upload a photo and a soundbite together</p>
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Step 1: Photo -->
|
||||
<div id="step1" class="wizard-step active">
|
||||
<div class="step-label">Step 1 of 3 — Choose a Photo</div>
|
||||
<div class="upload-box" id="photoBox" onclick="document.getElementById('photoFile').click()">
|
||||
<div class="icon">📷</div>
|
||||
<p>Tap to choose a photo<br><strong>from your camera roll</strong></p>
|
||||
</div>
|
||||
<div class="file-name" id="fileName">No file selected</div>
|
||||
<button type="submit" id="uploadBtn" disabled>Upload to Frame</button>
|
||||
<div class="progress" id="progress">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="progress-text" id="progressText">Uploading...</div>
|
||||
<input type="file" id="photoFile" accept=".png,.jpg,.jpeg" onchange="onPhotoSelected(this)">
|
||||
<img id="previewImg" class="preview-img" alt="preview">
|
||||
<div id="progress1" class="progress-wrap">
|
||||
<div class="progress-bar"><div class="progress-fill" id="fill1"></div></div>
|
||||
<div class="progress-txt" id="txt1">Uploading photo...</div>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint">Accepted: .png (172x320) · .mp3 · .wav</p>
|
||||
<button class="btn btn-primary" id="nextStep1" onclick="uploadPhoto()" disabled>Next →</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Audio -->
|
||||
<div id="step2" class="wizard-step">
|
||||
<div class="step-label">Step 2 of 3 — Choose a Soundbite</div>
|
||||
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
|
||||
<div class="icon">🎤</div>
|
||||
<p>Tap to choose a sound clip<br><strong>.mp3 or .wav file</strong></p>
|
||||
</div>
|
||||
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
|
||||
<audio id="previewAudio" class="preview-audio" controls></audio>
|
||||
<div id="progress2" class="progress-wrap">
|
||||
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
|
||||
<div class="progress-txt" id="txt2">Uploading audio...</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="nextStep2" onclick="uploadAudio()" disabled>Next →</button>
|
||||
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Name -->
|
||||
<div id="step3" class="wizard-step">
|
||||
<div class="step-label">Step 3 of 3 — Name This Memory</div>
|
||||
<input class="text-input" id="memName" type="text" placeholder="e.g. Mom in the Garden" maxlength="60">
|
||||
<button class="btn btn-primary" onclick="saveMemory()">❤ Save Memory</button>
|
||||
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Settings Page -->
|
||||
<div id="page-settings" class="page">
|
||||
<h1>Settings</h1>
|
||||
<p class="sub">Configure your frame</p>
|
||||
|
||||
<div class="now-playing" id="nowPlaying">
|
||||
<div class="np-label">Now showing</div>
|
||||
<div class="np-name" id="npName">-</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Volume</div>
|
||||
<div class="setting-sub">Speaker volume for audio playback</div>
|
||||
<input type="range" id="volSlider" min="0" max="21" value="12" oninput="onVolChange(this.value)">
|
||||
<div class="vol-display" id="volDisplay">12 / 21</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary" onclick="saveSettings()" style="margin-top:8px">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<button class="fab" id="fab" onclick="openAdd()">+</button>
|
||||
|
||||
<!-- Bottom Nav -->
|
||||
<nav>
|
||||
<button id="nav-gallery" class="active" onclick="goPage('gallery')">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 6h16v12H4z" opacity=".3"/><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zm-8-7l-3 4h6z"/></svg>
|
||||
Gallery
|
||||
</button>
|
||||
<button id="nav-add" onclick="openAdd()">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Add
|
||||
</button>
|
||||
<button id="nav-settings" onclick="goPage('settings')">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const fileName = document.getElementById('fileName');
|
||||
const form = document.getElementById('uploadForm');
|
||||
const progress = document.getElementById('progress');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const progressText = document.getElementById('progressText');
|
||||
var API = '';
|
||||
var memories = [];
|
||||
var uploadedPhoto = '';
|
||||
var uploadedAudio = '';
|
||||
var currentStep = 1;
|
||||
|
||||
function onFileSelected(input) {
|
||||
if (input.files.length > 0) {
|
||||
fileName.textContent = input.files[0].name;
|
||||
uploadBtn.disabled = false;
|
||||
} else {
|
||||
fileName.textContent = 'No file selected';
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||
function goPage(name) {
|
||||
document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active')});
|
||||
document.querySelectorAll('nav button').forEach(function(b){b.classList.remove('active')});
|
||||
document.getElementById('page-'+name).classList.add('active');
|
||||
var nb = document.getElementById('nav-'+name);
|
||||
if(nb) nb.classList.add('active');
|
||||
document.getElementById('fab').style.display = name==='gallery' ? 'flex' : 'none';
|
||||
if(name==='gallery') loadGallery();
|
||||
if(name==='settings') loadSettings();
|
||||
}
|
||||
|
||||
dropZone.addEventListener('dragover', function(e) { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||
dropZone.addEventListener('dragleave', function() { dropZone.classList.remove('dragover'); });
|
||||
dropZone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
var dt = e.dataTransfer;
|
||||
if (dt.files.length > 0) {
|
||||
document.getElementById('file').files = dt.files;
|
||||
onFileSelected(document.getElementById('file'));
|
||||
function openAdd() {
|
||||
document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active')});
|
||||
document.querySelectorAll('nav button').forEach(function(b){b.classList.remove('active')});
|
||||
document.getElementById('page-add').classList.add('active');
|
||||
document.getElementById('fab').style.display = 'none';
|
||||
resetWizard();
|
||||
}
|
||||
|
||||
// ── Gallery ─────────────────────────────────────────────────────────────────
|
||||
function loadGallery() {
|
||||
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){
|
||||
memories = data;
|
||||
renderGallery();
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
function renderGallery() {
|
||||
var g = document.getElementById('gallery');
|
||||
var sub = document.getElementById('sub-count');
|
||||
sub.textContent = memories.length + ' memor' + (memories.length===1?'y':'ies') + ' saved';
|
||||
if(memories.length===0){
|
||||
g.innerHTML = '';
|
||||
g.insertAdjacentHTML('beforeend','<div class="empty" style="grid-column:1/-1"><div class="icon">💗</div><p>No memories yet.<br>Tap + to add the first one.</p></div>');
|
||||
return;
|
||||
}
|
||||
g.innerHTML = '';
|
||||
memories.forEach(function(m){
|
||||
var imgSrc = m.photo ? '/photo/'+encodeURIComponent(m.photo) : '';
|
||||
var imgHtml = imgSrc
|
||||
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
||||
: '<div class="mem-thumb no-img">🥻</div>';
|
||||
var html = '<div class="mem-card">'
|
||||
+ imgHtml
|
||||
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">✕</button>'
|
||||
+ '<div class="mem-info">'
|
||||
+ '<div class="mem-name">'+escHtml(m.name)+'</div>'
|
||||
+ '<button class="play-btn" onclick="playMemory('+m.id+')">▶ Play on Frame</button>'
|
||||
+ '</div></div>';
|
||||
g.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var file = document.getElementById('file').files[0];
|
||||
if (!file) return;
|
||||
var formData = new FormData(form);
|
||||
function playMemory(id) {
|
||||
fetch('/api/display?id='+id, {method:'POST'})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.ok) showToastGlobal('Showing on frame!', true); })
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function deleteMemory(id, e) {
|
||||
e.stopPropagation();
|
||||
if(!confirm('Delete this memory?')) return;
|
||||
fetch('/api/memory?id='+id, {method:'DELETE'})
|
||||
.then(function(){loadGallery()}).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Wizard ───────────────────────────────────────────────────────────────────
|
||||
function resetWizard() {
|
||||
uploadedPhoto=''; uploadedAudio='';
|
||||
document.getElementById('photoFile').value='';
|
||||
document.getElementById('audioFile').value='';
|
||||
document.getElementById('previewImg').style.display='none';
|
||||
document.getElementById('previewAudio').style.display='none';
|
||||
document.getElementById('memName').value='';
|
||||
document.getElementById('toast').style.display='none';
|
||||
document.getElementById('nextStep1').disabled=true;
|
||||
document.getElementById('nextStep2').disabled=true;
|
||||
goStep(1);
|
||||
}
|
||||
|
||||
function goStep(n) {
|
||||
document.querySelectorAll('.wizard-step').forEach(function(s){s.classList.remove('active')});
|
||||
document.getElementById('step'+n).classList.add('active');
|
||||
currentStep = n;
|
||||
}
|
||||
|
||||
function onPhotoSelected(input) {
|
||||
if(!input.files.length) return;
|
||||
var f = input.files[0];
|
||||
var url = URL.createObjectURL(f);
|
||||
var img = document.getElementById('previewImg');
|
||||
img.src = url; img.style.display='block';
|
||||
document.getElementById('nextStep1').disabled=false;
|
||||
}
|
||||
|
||||
function onAudioSelected(input) {
|
||||
if(!input.files.length) return;
|
||||
var f = input.files[0];
|
||||
var url = URL.createObjectURL(f);
|
||||
var aud = document.getElementById('previewAudio');
|
||||
aud.src = url; aud.style.display='block';
|
||||
document.getElementById('nextStep2').disabled=false;
|
||||
}
|
||||
|
||||
function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) {
|
||||
var f = fileInput.files[0];
|
||||
if(!f){ onDone(null); return; }
|
||||
var fd = new FormData(); fd.append('file', f);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.upload.addEventListener('progress', function(e) {
|
||||
xhr.open('POST', endpoint);
|
||||
progressWrap.style.display='block';
|
||||
xhr.upload.onprogress = function(e){
|
||||
if(e.lengthComputable){
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = pct + '%';
|
||||
progressText.textContent = 'Uploading... ' + pct + '%';
|
||||
var p = Math.round(e.loaded/e.total*100);
|
||||
fill.style.width=p+'%';
|
||||
txt.textContent='Uploading... '+p+'%';
|
||||
}
|
||||
});
|
||||
xhr.onload = function() {
|
||||
var ok = xhr.status === 200 || xhr.status === 303;
|
||||
window.location.href = '/?status=' + (ok ? 'ok&file=' + encodeURIComponent(file.name) : 'fail');
|
||||
};
|
||||
xhr.onerror = function() { window.location.href = '/?status=fail'; };
|
||||
progress.style.display = 'block';
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Uploading...';
|
||||
xhr.send(formData);
|
||||
});
|
||||
xhr.onload = function(){
|
||||
progressWrap.style.display='none';
|
||||
try{ var d = JSON.parse(xhr.responseText); onDone(d.filename); }
|
||||
catch(e){ onDone(null); }
|
||||
};
|
||||
xhr.onerror = function(){ progressWrap.style.display='none'; onDone(null); };
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
function uploadPhoto() {
|
||||
document.getElementById('nextStep1').disabled=true;
|
||||
uploadFile(
|
||||
document.getElementById('photoFile'),
|
||||
'/api/upload/photo',
|
||||
document.getElementById('progress1'),
|
||||
document.getElementById('fill1'),
|
||||
document.getElementById('txt1'),
|
||||
function(filename){
|
||||
if(filename){ uploadedPhoto=filename; goStep(2); }
|
||||
else showToast('Photo upload failed. Try again.', false);
|
||||
document.getElementById('nextStep1').disabled=false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
loadGallery();
|
||||
loadSettings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)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;
|
||||
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 (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;
|
||||
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 = "<div class=\"toast success\">✅ <span>\"" + safeName + "\" uploaded successfully!</span></div>";
|
||||
} else if (status == "fail") {
|
||||
toast = "<div class=\"toast error\">❌ <span>Upload failed. Only .png, .mp3 and .wav files are accepted.</span></div>";
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,240 +3,178 @@
|
||||
#include "Display_PNG.h"
|
||||
#include "Audio_Player.h"
|
||||
#include "Web_Server.h"
|
||||
#include <algorithm>
|
||||
|
||||
#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) {
|
||||
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 (hasError) return;
|
||||
if (currentState == STATE_AUTOPLAY) {
|
||||
currentState = STATE_IDLE;
|
||||
printf("Autoplay Disabled. Manual Mode active.\r\n");
|
||||
stopAudio();
|
||||
printf("Autoplay OFF\r\n");
|
||||
} else {
|
||||
currentState = STATE_AUTOPLAY;
|
||||
printf("Autoplay Enabled! Cycling slides every %d ms.\r\n", slideShowInterval);
|
||||
lastSlideTime = millis();
|
||||
playCurrentPair();
|
||||
}
|
||||
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
|
||||
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;
|
||||
lastButtonState = btn;
|
||||
|
||||
// Handle incoming web server clients
|
||||
// ── Web server ────────────────────────────────────────────────────────────
|
||||
handleWebServer();
|
||||
|
||||
// Update status RGB LED
|
||||
updateLEDStatus();
|
||||
|
||||
delay(10); // Yield to other RTOS tasks
|
||||
delay(10);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user