diff --git a/sound_picture_app/SD_Card.cpp b/sound_picture_app/SD_Card.cpp index d38eb84..f8e3db7 100644 --- a/sound_picture_app/SD_Card.cpp +++ b/sound_picture_app/SD_Card.cpp @@ -8,6 +8,13 @@ uint8_t g_volume = 12; uint16_t SDCard_Size = 0; uint16_t Flash_Size = 0; +// Tribute Alarm Settings Globals +bool g_tributeAlarmEnabled = false; +int g_tributeAlarmHour = 19; +int g_tributeAlarmMinute = 0; +int g_timezoneOffset = 0; +bool g_timeSynchronized = false; + // ── SD Init ─────────────────────────────────────────────────────────────────── void SD_Init() { if (!SD_MMC.setPins(SD_CLK_PIN, SD_CMD_PIN, SD_D0_PIN)) { @@ -80,6 +87,18 @@ bool Config_Load() { g_currentIndex = atoi(line + 6); continue; } + if (strncmp(line, "ALARM|", 6) == 0) { + // Parse: ALARM|enabled|hour|minute|timezone_offset + char* tok = strtok(line + 6, "|"); + if (tok) g_tributeAlarmEnabled = (atoi(tok) != 0); + tok = strtok(NULL, "|"); + if (tok) g_tributeAlarmHour = atoi(tok); + tok = strtok(NULL, "|"); + if (tok) g_tributeAlarmMinute = atoi(tok); + tok = strtok(NULL, "|"); + if (tok) g_timezoneOffset = atoi(tok); + continue; + } // Parse memory line: id|name|photo|audio if (g_memoryCount >= MAX_MEMORIES) continue; @@ -120,6 +139,7 @@ bool Config_Save() { } f.printf("VOLUME|%d\n", g_volume); f.printf("INDEX|%d\n", g_currentIndex); + f.printf("ALARM|%d|%d|%d|%d\n", g_tributeAlarmEnabled ? 1 : 0, g_tributeAlarmHour, g_tributeAlarmMinute, g_timezoneOffset); 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); diff --git a/sound_picture_app/SD_Card.h b/sound_picture_app/SD_Card.h index 97fc739..9619f19 100644 --- a/sound_picture_app/SD_Card.h +++ b/sound_picture_app/SD_Card.h @@ -29,6 +29,13 @@ extern uint8_t g_volume; extern uint16_t SDCard_Size; extern uint16_t Flash_Size; +// Tribute Alarm Settings +extern bool g_tributeAlarmEnabled; +extern int g_tributeAlarmHour; +extern int g_tributeAlarmMinute; +extern int g_timezoneOffset; +extern bool g_timeSynchronized; + // SD init void SD_Init(); void Flash_test(); diff --git a/sound_picture_app/Web_Server.cpp b/sound_picture_app/Web_Server.cpp index b7f9bf2..c729c2e 100644 --- a/sound_picture_app/Web_Server.cpp +++ b/sound_picture_app/Web_Server.cpp @@ -2,16 +2,16 @@ #include #include #include "SD_Card.h" +#include "Display_PNG.h" #include "Audio_Player.h" -// ── Inter-task flags (set by web handler, read by main loop) ────────────────── +WebServer server(80); +File uploadFile; +bool uploadSuccess = false; +String uploadedFilename = ""; volatile int g_webDisplayRequest = -1; volatile bool g_webDisplayDirty = false; - -static WebServer server(80); -static File uploadFile; -static bool uploadOK = false; -static String uploadSavedAs = ""; +static String buildPage(const String& status, const String& file); // ── Helpers ─────────────────────────────────────────────────────────────────── static bool allowedPhoto(const String& name) { @@ -23,7 +23,6 @@ static bool allowedAudio(const String& name) { 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++) { @@ -39,114 +38,267 @@ static String memoriesJson() { } // ── Single-page app HTML ────────────────────────────────────────────────────── -// Served from PROGMEM to save RAM static const char APP_HTML[] PROGMEM = R"HTML( -Memory Frame +Sunflower Keepsake Frame
-

Add Memory

-

Upload a photo and a soundbite together

+

🌻 Add Keepsake

+

Create a beautiful photo-sound memorial

Step 1 of 3 — Choose a Photo
-
📷
-

Tap to choose a photo
from your camera roll

+
📸
+

Tap to select any photo
from your library or camera

preview
-
Uploading photo...
+
Preparing photo...
- +
- +
-
Step 2 of 3 — Choose a Soundbite
+
Step 2 of 3 — Choose a Soundbite (Optional)
-
🎤
-

Tap to choose a sound clip
.mp3 or .wav file

+
🎙️
+

Tap to attach a voice note or song
.mp3 or .wav file

@@ -154,15 +306,16 @@ input[type=range]{width:100%;accent-color:var(--accent)}
Uploading audio...
- + +
Step 3 of 3 — Name This Memory
- - + +
@@ -170,11 +323,11 @@ input[type=range]{width:100%;accent-color:var(--accent)}
-

Settings

-

Configure your frame

+

🌻 Settings

+

Configure frame preferences

-
Now showing
+
Active Memory
-
@@ -185,6 +338,19 @@ input[type=range]{width:100%;accent-color:var(--accent)}
12 / 21
+
+
🌻 Tribute Time Alarm
+
Select a daily time to play through all memories automatically
+
+ Enable Daily Tribute + +
+
+ Scheduled Time + +
+
+ @@ -245,10 +411,10 @@ function loadGallery() { function renderGallery() { var g = document.getElementById('gallery'); var sub = document.getElementById('sub-count'); - sub.textContent = memories.length + ' memor' + (memories.length===1?'y':'ies') + ' saved'; + sub.textContent = memories.length + ' keepsake' + (memories.length===1?'':'s') + ' saved'; if(memories.length===0){ g.innerHTML = ''; - g.insertAdjacentHTML('beforeend','
💗

No memories yet.
Tap + to add the first one.

'); + g.insertAdjacentHTML('beforeend','
🌻

No memories yet.
Tap + to add a sunflower keepsake.

'); return; } g.innerHTML = ''; @@ -256,13 +422,13 @@ function renderGallery() { var imgSrc = m.photo ? '/photo/'+encodeURIComponent(m.photo) : ''; var imgHtml = imgSrc ? '' - : '
🥻
'; + : '
🌻
'; var html = '
' + imgHtml + '' + '
' + '
'+escHtml(m.name)+'
' - + '' + + '' + '
'; g.insertAdjacentHTML('beforeend', html); }); @@ -271,13 +437,13 @@ function renderGallery() { 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); }) + .then(function(d){ if(d.ok) showToastGlobal('Showing keepsake!', true); }) .catch(function(){}); } function deleteMemory(id, e) { e.stopPropagation(); - if(!confirm('Delete this memory?')) return; + if(!confirm('Delete this keepsake?')) return; fetch('/api/memory?id='+id, {method:'DELETE'}) .then(function(){loadGallery()}).catch(function(){}); } @@ -461,6 +627,11 @@ function uploadAudio() { ); } +function skipAudio() { + uploadedAudio = ''; + goStep(3); +} + function saveMemory() { var name = document.getElementById('memName').value.trim(); if(!name){ showToast('Please give this memory a name.', false); return; } @@ -470,7 +641,7 @@ function saveMemory() { .then(function(r){return r.json()}) .then(function(d){ if(d.ok){ - showToast('Memory saved! \u2764\uFE0F', true); + showToast('Keepsake saved! 🌻💛', true); setTimeout(function(){ goPage('gallery'); }, 1500); } else showToast('Save failed: '+d.error, false); }).catch(function(){ showToast('Save failed.', false); }); @@ -481,6 +652,14 @@ 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'; + + document.getElementById('alarmEnabled').checked = d.alarmEnabled||false; + if (d.alarmHour !== undefined && d.alarmMinute !== undefined) { + var h = String(d.alarmHour).padStart(2, '0'); + var m = String(d.alarmMinute).padStart(2, '0'); + document.getElementById('alarmTime').value = h + ':' + m; + } + var nm = document.getElementById('npName'); if(d.currentName) nm.textContent = d.currentName; else nm.textContent = memories.length ? memories[d.currentIndex||0].name||'-' : '-'; @@ -493,7 +672,24 @@ function onVolChange(v) { 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})}) + var ae = document.getElementById('alarmEnabled').checked; + var at = document.getElementById('alarmTime').value || "19:00"; + var parts = at.split(':'); + var ah = parseInt(parts[0])||19; + var am = parseInt(parts[1])||0; + + var payload = { + volume: v, + alarmEnabled: ae, + alarmHour: ah, + alarmMinute: am + }; + + fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) .then(function(r){return r.json()}) .then(function(d){ if(d.ok) showToastGlobal('Settings saved!', true); }) .catch(function(){}); @@ -515,8 +711,8 @@ function showToastGlobal(msg, ok) { 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.style.background = ok ? 'rgba(255,179,0,.9)' : 'rgba(239,68,68,.9)'; + t.style.color = ok ? '#000' : '#fff'; t.textContent = msg; document.body.appendChild(t); clearTimeout(globalToastTimer); @@ -527,7 +723,17 @@ function escHtml(s) { return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } -// ── Init ────────────────────────────────────────────────────────────────────── +function syncTime() { + var epoch = Math.floor(Date.now() / 1000); + var offset = new Date().getTimezoneOffset() * 60; // offset in seconds + fetch('/api/time', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ epoch: epoch, offset: offset }) + }).catch(function(){}); +} + +syncTime(); loadGallery(); loadSettings(); @@ -535,23 +741,22 @@ loadSettings(); )HTML"; -// ── Upload handlers ─────────────────────────────────────────────────────────── static String pendingUploadPath = ""; 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 = ""; + uploadSuccess = false; + uploadedFilename = ""; if (!validator(fname)) { printf("Upload: rejected %s\r\n", fname.c_str()); return; } pendingUploadPath = "/" + subdir + "/" + fname; uploadFile = SD_MMC.open(pendingUploadPath, "w"); - uploadOK = (bool)uploadFile; - if (uploadOK) uploadSavedAs = fname; + uploadSuccess = (bool)uploadFile; + if (uploadSuccess) uploadedFilename = fname; printf("Upload: writing to %s\r\n", pendingUploadPath.c_str()); } else if (up.status == UPLOAD_FILE_WRITE) { @@ -559,72 +764,116 @@ static void handleUploadFile(const String& subdir, bool (*validator)(const Strin } else if (up.status == UPLOAD_FILE_END) { if (uploadFile) { uploadFile.close(); printf("Upload: done (%u bytes)\r\n", up.totalSize); } - else { uploadOK = false; } + else { uploadSuccess = false; } } } -// ── Route setup ─────────────────────────────────────────────────────────────── void initWebServer() { WiFi.softAP("love", "love1234"); - printf("WiFi AP: SSID='love' PW='love1234' IP=%s\r\n", WiFi.softAPIP().toString().c_str()); - - // Main SPA + 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()); + server.on("/", HTTP_GET, []() { - server.send_P(200, "text/html", APP_HTML); + String status = server.hasArg("status") ? server.arg("status") : ""; + String file = server.hasArg("file") ? server.arg("file") : ""; + server.send(200, "text/html", buildPage(status, file)); }); - - // 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 += "\"}"; + j += ",\"currentName\":\""; j += name; + j += "\",\"alarmEnabled\":"; j += g_tributeAlarmEnabled ? "true" : "false"; + j += ",\"alarmHour\":"; j += g_tributeAlarmHour; + j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute; + j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false"; + 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} + + // Volume 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); + } + + // Alarm Enabled + int ae = body.indexOf("\"alarmEnabled\":"); + if (ae >= 0) { + String sub = body.substring(ae + 15); + g_tributeAlarmEnabled = sub.startsWith("true"); + } + + // Alarm Hour + int ah = body.indexOf("\"alarmHour\":"); + if (ah >= 0) { + g_tributeAlarmHour = body.substring(ah + 12).toInt(); + } + + // Alarm Minute + int am = body.indexOf("\"alarmMinute\":"); + if (am >= 0) { + g_tributeAlarmMinute = body.substring(am + 14).toInt(); + } + + Config_Save(); + server.send(200, "application/json", "{\"ok\":true}"); + }); + + server.on("/api/time", HTTP_POST, []() { + String body = server.arg("plain"); + int ep = body.indexOf("\"epoch\":"); + int os = body.indexOf("\"offset\":"); + if (ep >= 0 && os >= 0) { + long long epoch = strtoll(body.substring(ep + 8).c_str(), NULL, 10); + int offset = body.substring(os + 9).toInt(); + + #include + struct timeval tv; + tv.tv_sec = epoch; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + + g_timezoneOffset = offset; + g_timeSynchronized = true; + + printf("Time Synced: UTC Epoch %lld, Local offset %d sec\r\n", epoch, offset); 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\"}"); + if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}"); + 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\"}"); + if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}"); + 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); @@ -646,7 +895,6 @@ void initWebServer() { 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(); @@ -654,7 +902,6 @@ void initWebServer() { 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(); @@ -671,11 +918,9 @@ void initWebServer() { 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('/'); @@ -690,7 +935,6 @@ void initWebServer() { }); server.onNotFound([]() { - // Also handle /photo/xxx.png via URI parsing String uri = server.uri(); if (uri.startsWith("/photo/")) { String name = uri.substring(7); @@ -707,7 +951,21 @@ void initWebServer() { }); server.begin(); - printf("Web server started\r\n"); +} + +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(APP_HTML); + page.replace("%TOAST%", toast); + return page; } void handleWebServer() { diff --git a/sound_picture_app/sound_picture_app.ino b/sound_picture_app/sound_picture_app.ino index 80f0667..396cfc4 100644 --- a/sound_picture_app/sound_picture_app.ino +++ b/sound_picture_app/sound_picture_app.ino @@ -23,6 +23,11 @@ static bool longPressTriggered = false; static uint32_t lastSlideTime = 0; static const uint32_t SLIDE_INTERVAL = 8000; // 8 s between slides in autoplay +// Tribute Daily Alarm Run-Through State +static bool g_tributeActive = false; +static int g_tributeNextIndex = 0; +static uint32_t lastTributeCheck = 0; + // ── Core function: show a memory ────────────────────────────────────────────── void showMemory(int index) { if (g_memoryCount == 0) return; @@ -40,6 +45,7 @@ void showMemory(int index) { // ── Button handlers ─────────────────────────────────────────────────────────── void handleSinglePress() { if (hasError || g_memoryCount == 0) return; + g_tributeActive = false; // Cancel alarm playback if active if (currentState == STATE_AUTOPLAY) currentState = STATE_IDLE; showMemory((g_currentIndex + 1) % g_memoryCount); } @@ -51,6 +57,7 @@ void handleDoublePress() { void handleTriplePress() { if (hasError || g_memoryCount == 0) return; + g_tributeActive = false; // Cancel alarm playback if active if (currentState == STATE_AUTOPLAY) currentState = STATE_IDLE; showMemory((g_currentIndex + g_memoryCount - 1) % g_memoryCount); } @@ -64,6 +71,7 @@ void handleQuadruplePress() { void handleLongPress() { if (hasError) return; + g_tributeActive = false; // Cancel alarm playback if active if (currentState == STATE_AUTOPLAY) { currentState = STATE_IDLE; stopAudio(); @@ -124,6 +132,7 @@ void loop() { // ── Web display request (triggered by phone) ────────────────────────────── if (g_webDisplayDirty) { g_webDisplayDirty = false; + g_tributeActive = false; // Cancel alarm playback if user interacts int req = g_webDisplayRequest; g_webDisplayRequest = -1; if (req >= 0 && req < g_memoryCount) { @@ -131,6 +140,23 @@ void loop() { } } + // ── Tribute Daily Time Alarm Trigger ────────────────────────────────────── + if (g_timeSynchronized && g_tributeAlarmEnabled && g_memoryCount > 0 && !g_tributeActive) { + if (now - lastTributeCheck >= 1000) { + lastTributeCheck = now; + time_t locTime = time(NULL) - g_timezoneOffset; + struct tm* lt = gmtime(&locTime); + if (lt && lt->tm_hour == g_tributeAlarmHour && lt->tm_min == g_tributeAlarmMinute && lt->tm_sec == 0) { + printf("Tribute Alarm triggered! Starting keepsake memories playback.\r\n"); + g_tributeActive = true; + g_tributeNextIndex = 0; + currentState = STATE_IDLE; + lastSlideTime = now; + showMemory(0); + } + } + } + // ── Button edge detection ───────────────────────────────────────────────── if (btn == LOW && lastButtonState == HIGH) { // Pressed down @@ -163,8 +189,21 @@ void loop() { clickCount = 0; } - // ── Autoplay advance ────────────────────────────────────────────────────── - if (currentState == STATE_AUTOPLAY && g_memoryCount > 0) { + // ── Autoplay and Tribute advance ────────────────────────────────────────── + if (g_tributeActive && g_memoryCount > 0) { + if (!isAudioPlaying() && (now - lastSlideTime > SLIDE_INTERVAL)) { + g_tributeNextIndex++; + if (g_tributeNextIndex < g_memoryCount) { + lastSlideTime = now; + showMemory(g_tributeNextIndex); + } else { + g_tributeActive = false; + stopAudio(); + printf("Tribute daily playback loop complete. Returning to Idle.\r\n"); + } + } + } + else if (currentState == STATE_AUTOPLAY && g_memoryCount > 0) { if (!isAudioPlaying() && (now - lastSlideTime > SLIDE_INTERVAL)) { lastSlideTime = now; showMemory((g_currentIndex + 1) % g_memoryCount);