Implement browser-based voice notes recording, standby flicking remembrance candle, and favorite memory pinning

This commit is contained in:
drjones
2026-07-04 02:38:18 -07:00
parent 2c19aeb6a9
commit 9186bccd80
6 changed files with 371 additions and 26 deletions

View File

@@ -71,3 +71,54 @@ void Display_Memory_Photo(int memoryIndex) {
snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo); snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo);
Display_Image_From_SD(path); Display_Image_From_SD(path);
} }
void Draw_Candle_Body() {
clearScreen();
// Draw candle body (ivory column): x from 76 to 96 (width 21), y from 175 to 250 (height 75)
uint16_t body[21 * 75];
for (int i = 0; i < 21 * 75; i++) body[i] = 0xF7BE; // Warm Ivory / light cream
LCD_addWindow(76, 175, 96, 249, body);
// Draw candle wick: x = 86, y from 168 to 175
uint16_t wick[1 * 8];
for (int i = 0; i < 8; i++) wick[i] = 0x2104; // Dark gray
LCD_addWindow(86, 168, 86, 175, wick);
}
void Draw_Candle_Flicker() {
static uint32_t lastFlickerTime = 0;
static uint8_t flickerState = 0;
uint32_t now = millis();
if (now - lastFlickerTime < 180) return; // flicker every 180ms
lastFlickerTime = now;
flickerState = (flickerState + 1) % 4;
// Bounding box: width 31, height 43
static uint16_t canvas[31 * 43];
memset(canvas, 0, sizeof(canvas)); // fill black
int hOffset = (flickerState % 2 == 0) ? 0 : ((flickerState == 1) ? 1 : -1);
int vHeight = 22 + (flickerState % 3); // flame height varies
for (int y = 0; y < 43; y++) {
for (int x = 0; x < 31; x++) {
int dx = abs(x - 15 - hOffset);
int dy = 35 - y;
if (dy >= 0 && dy < vHeight) {
int maxWidth = (dy < 8) ? (dy * 1.2) : (vHeight - dy) * 0.4;
if (maxWidth < 1) maxWidth = 1;
if (dx <= maxWidth) {
if (dx <= maxWidth / 2 && dy < vHeight - 4) {
canvas[y * 31 + x] = 0xFFE0; // Yellow
} else {
canvas[y * 31 + x] = 0xFD20; // Orange
}
}
}
}
}
LCD_addWindow(71, 125, 101, 167, canvas);
}

View File

@@ -8,5 +8,8 @@
void Display_Image_From_SD(const char* fullPath); void Display_Image_From_SD(const char* fullPath);
// Convenience: display the photo for a given memory index
void Display_Memory_Photo(int memoryIndex); void Display_Memory_Photo(int memoryIndex);
// Standby Remembrance Candle Flicker animation
void Draw_Candle_Body();
void Draw_Candle_Flicker();

View File

@@ -14,6 +14,7 @@ int g_tributeAlarmHour = 19;
int g_tributeAlarmMinute = 0; int g_tributeAlarmMinute = 0;
int g_timezoneOffset = 0; int g_timezoneOffset = 0;
bool g_timeSynchronized = false; bool g_timeSynchronized = false;
int g_favoriteMemoryId = -1;
// ── SD Init ─────────────────────────────────────────────────────────────────── // ── SD Init ───────────────────────────────────────────────────────────────────
void SD_Init() { void SD_Init() {
@@ -87,6 +88,10 @@ bool Config_Load() {
g_currentIndex = atoi(line + 6); g_currentIndex = atoi(line + 6);
continue; continue;
} }
if (strncmp(line, "FAVORITE|", 9) == 0) {
g_favoriteMemoryId = atoi(line + 9);
continue;
}
if (strncmp(line, "ALARM|", 6) == 0) { if (strncmp(line, "ALARM|", 6) == 0) {
// Parse: ALARM|enabled|hour|minute|timezone_offset // Parse: ALARM|enabled|hour|minute|timezone_offset
char* tok = strtok(line + 6, "|"); char* tok = strtok(line + 6, "|");
@@ -139,6 +144,7 @@ bool Config_Save() {
} }
f.printf("VOLUME|%d\n", g_volume); f.printf("VOLUME|%d\n", g_volume);
f.printf("INDEX|%d\n", g_currentIndex); f.printf("INDEX|%d\n", g_currentIndex);
f.printf("FAVORITE|%d\n", g_favoriteMemoryId);
f.printf("ALARM|%d|%d|%d|%d\n", g_tributeAlarmEnabled ? 1 : 0, g_tributeAlarmHour, g_tributeAlarmMinute, g_timezoneOffset); 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++) { for (int i = 0; i < g_memoryCount; i++) {
Memory& m = g_memories[i]; Memory& m = g_memories[i];

View File

@@ -35,6 +35,7 @@ extern int g_tributeAlarmHour;
extern int g_tributeAlarmMinute; extern int g_tributeAlarmMinute;
extern int g_timezoneOffset; extern int g_timezoneOffset;
extern bool g_timeSynchronized; extern bool g_timeSynchronized;
extern int g_favoriteMemoryId;
// SD init // SD init
void SD_Init(); void SD_Init();

View File

@@ -11,6 +11,7 @@ bool uploadSuccess = false;
String uploadedFilename = ""; String uploadedFilename = "";
volatile int g_webDisplayRequest = -1; volatile int g_webDisplayRequest = -1;
volatile bool g_webDisplayDirty = false; volatile bool g_webDisplayDirty = false;
static String buildPage(const String& status, const String& file); static String buildPage(const String& status, const String& file);
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -150,6 +151,31 @@ h1 {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
line-height: 1; line-height: 1;
z-index: 10;
}
.mem-fav {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,.6);
border: 1px solid rgba(255,179,0,0.3);
color: var(--accent);
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
z-index: 10;
transition: .2s;
}
.mem-fav.active {
background: rgba(255, 179, 0, 0.25);
border-color: var(--accent);
color: #fff;
} }
.play-btn { .play-btn {
width: 100%; width: 100%;
@@ -199,7 +225,7 @@ h1 {
.upload-box { .upload-box {
border: 2px dashed rgba(255, 179, 0, 0.3); border: 2px dashed rgba(255, 179, 0, 0.3);
border-radius: var(--radius); border-radius: var(--radius);
padding: 32px; padding: 24px 16px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
margin-bottom: 16px; margin-bottom: 16px;
@@ -295,13 +321,22 @@ input[type=range] { width: 100%; }
<!-- Step 2: Audio (Optional Choice) --> <!-- Step 2: Audio (Optional Choice) -->
<div id="step2" class="wizard-step"> <div id="step2" class="wizard-step">
<div class="step-label">Step 2 of 3 &mdash; Choose a Soundbite (Optional)</div> <div class="step-label">Step 2 of 3 &mdash; Choose or Record a Soundbite (Optional)</div>
<!-- Record Mic Box -->
<div class="upload-box" id="recordBox" onclick="toggleRecord()" style="border-color:var(--accent-orange); background:rgba(245,124,0,0.03);">
<div class="icon" id="recordIcon">🎙️</div>
<p id="recordTxt">Tap to **Record Voice Note** directly from mic</p>
</div>
<!-- File Select Box -->
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()"> <div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
<div class="icon">🎙️</div> <div class="icon">📁</div>
<p>Tap to attach a voice note or song<br><strong>.mp3 or .wav file</strong></p> <p>Tap to upload an existing sound clip<br><strong>.mp3 or .wav file</strong></p>
</div> </div>
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)"> <input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
<audio id="previewAudio" class="preview-audio" controls></audio> <audio id="previewAudio" class="preview-audio" controls></audio>
<div id="progress2" class="progress-wrap"> <div id="progress2" class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div> <div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
<div class="progress-txt" id="txt2">Uploading audio...</div> <div class="progress-txt" id="txt2">Uploading audio...</div>
@@ -368,7 +403,7 @@ input[type=range] { width: 100%; }
Add Add
</button> </button>
<button id="nav-settings" onclick="goPage('settings')"> <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> <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 Settings
</button> </button>
</nav> </nav>
@@ -379,6 +414,16 @@ var memories = [];
var uploadedPhoto = ''; var uploadedPhoto = '';
var uploadedAudio = ''; var uploadedAudio = '';
var currentStep = 1; var currentStep = 1;
var favoriteId = -1;
// Audio Recorder variables
var mediaStream = null;
var audioCtx = null;
var scriptProcessor = null;
var inputNode = null;
var audioBuffers = [];
var isRecording = false;
var recordedBlob = null;
// ── Navigation ────────────────────────────────────────────────────────────── // ── Navigation ──────────────────────────────────────────────────────────────
function goPage(name) { function goPage(name) {
@@ -402,9 +447,13 @@ function openAdd() {
// ── Gallery ───────────────────────────────────────────────────────────────── // ── Gallery ─────────────────────────────────────────────────────────────────
function loadGallery() { function loadGallery() {
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){ // Fetch settings to know the current favoriteId first, then load memories
memories = data; fetch('/api/settings').then(function(r){return r.json()}).then(function(sData){
renderGallery(); favoriteId = sData.favoriteId !== undefined ? sData.favoriteId : -1;
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){
memories = data;
renderGallery();
}).catch(function(){});
}).catch(function(){}); }).catch(function(){});
} }
@@ -423,8 +472,15 @@ function renderGallery() {
var imgHtml = imgSrc var imgHtml = imgSrc
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">' ? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
: '<div class="mem-thumb no-img">🌻</div>'; : '<div class="mem-thumb no-img">🌻</div>';
// Check if favorite
var isFav = (m.id === favoriteId);
var favClass = isFav ? 'mem-fav active' : 'mem-fav';
var favStar = isFav ? '★' : '☆';
var html = '<div class="mem-card">' var html = '<div class="mem-card">'
+ imgHtml + imgHtml
+ '<button class="'+favClass+'" onclick="toggleFavorite('+m.id+',event)" title="Favorite">'+favStar+'</button>'
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">&#x2715;</button>' + '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">&#x2715;</button>'
+ '<div class="mem-info">' + '<div class="mem-info">'
+ '<div class="mem-name">'+escHtml(m.name)+'</div>' + '<div class="mem-name">'+escHtml(m.name)+'</div>'
@@ -434,6 +490,24 @@ function renderGallery() {
}); });
} }
function toggleFavorite(id, e) {
e.stopPropagation();
var nextFav = (id === favoriteId) ? -1 : id;
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favoriteId: nextFav })
})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok) {
favoriteId = nextFav;
loadGallery();
showToastGlobal(nextFav === -1 ? 'Favorite removed.' : 'Favorite keepsake pinned! ⭐', true);
}
}).catch(function(){});
}
function playMemory(id) { function playMemory(id) {
fetch('/api/display?id='+id, {method:'POST'}) fetch('/api/display?id='+id, {method:'POST'})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
@@ -450,7 +524,7 @@ function deleteMemory(id, e) {
// ── Wizard ─────────────────────────────────────────────────────────────────── // ── Wizard ───────────────────────────────────────────────────────────────────
function resetWizard() { function resetWizard() {
uploadedPhoto=''; uploadedAudio=''; uploadedPhoto=''; uploadedAudio=''; recordedBlob=null;
document.getElementById('photoFile').value=''; document.getElementById('photoFile').value='';
document.getElementById('audioFile').value=''; document.getElementById('audioFile').value='';
document.getElementById('previewImg').style.display='none'; document.getElementById('previewImg').style.display='none';
@@ -459,6 +533,12 @@ function resetWizard() {
document.getElementById('toast').style.display='none'; document.getElementById('toast').style.display='none';
document.getElementById('nextStep1').disabled=true; document.getElementById('nextStep1').disabled=true;
document.getElementById('nextStep2').disabled=true; document.getElementById('nextStep2').disabled=true;
// Reset Record UI
isRecording = false;
document.getElementById('recordIcon').textContent = '🎙️';
document.getElementById('recordTxt').innerHTML = 'Tap to **Record Voice Note** directly from mic';
goStep(1); goStep(1);
} }
@@ -479,11 +559,123 @@ function onPhotoSelected(input) {
function onAudioSelected(input) { function onAudioSelected(input) {
if(!input.files.length) return; if(!input.files.length) return;
recordedBlob = null; // Clear mic recording if file is chosen
var f = input.files[0]; var f = input.files[0];
var url = URL.createObjectURL(f); var url = URL.createObjectURL(f);
var aud = document.getElementById('previewAudio'); var aud = document.getElementById('previewAudio');
aud.src = url; aud.style.display='block'; aud.src = url; aud.style.display='block';
document.getElementById('nextStep2').disabled=false; document.getElementById('nextStep2').disabled=false;
document.getElementById('nextStep2').textContent = 'Upload Soundbite ➔';
}
// ── Audio Recording Wizardry (Record mono WAV on the fly) ───────────────────
function toggleRecord() {
if (!isRecording) {
startRecording();
} else {
stopRecording();
}
}
function startRecording() {
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(s) {
mediaStream = s;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
inputNode = audioCtx.createMediaStreamSource(mediaStream);
scriptProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
audioBuffers = [];
scriptProcessor.onaudioprocess = function(e) {
if (!isRecording) return;
var chunk = new Float32Array(e.inputBuffer.getChannelData(0));
audioBuffers.push(chunk);
};
inputNode.connect(scriptProcessor);
scriptProcessor.connect(audioCtx.destination);
isRecording = true;
recordedBlob = null;
// Update UI
document.getElementById('recordIcon').textContent = '🔴';
document.getElementById('recordTxt').innerHTML = '<span style="color:#ef4444; font-weight:bold; animation:pulse 1.5s infinite">Recording...</span><br>Tap box again to STOP recording';
}).catch(function(err) {
showToast('Microphone access denied or unsupported.', false);
});
}
function stopRecording() {
if (!isRecording) return;
isRecording = false;
// Stop media tracks
if (mediaStream) {
mediaStream.getTracks().forEach(function(t){ t.stop(); });
}
// Disconnect web audio nodes
if (scriptProcessor) scriptProcessor.disconnect();
if (inputNode) inputNode.disconnect();
var sampleRate = audioCtx ? audioCtx.sampleRate : 44100;
if (audioCtx) audioCtx.close();
// Flatten audio buffers
var totalLength = audioBuffers.reduce(function(acc, b){ return acc + b.length; }, 0);
var result = new Float32Array(totalLength);
var offset = 0;
for (var i = 0; i < audioBuffers.length; i++) {
result.set(audioBuffers[i], offset);
offset += audioBuffers[i].length;
}
// Encode to standard 16-bit PCM WAV
recordedBlob = encodeWAV(result, sampleRate);
// Preview
var url = URL.createObjectURL(recordedBlob);
var aud = document.getElementById('previewAudio');
aud.src = url; aud.style.display = 'block';
// Update UI
document.getElementById('recordIcon').textContent = '✅';
document.getElementById('recordTxt').innerHTML = 'Voice Note recorded! (' + Math.round(totalLength/sampleRate) + ' sec)<br><strong>Tap to re-record</strong>';
document.getElementById('nextStep2').disabled = false;
document.getElementById('nextStep2').textContent = 'Upload Recorded Voice Note ➔';
}
function encodeWAV(samples, sampleRate) {
var buffer = new ArrayBuffer(44 + samples.length * 2);
var view = new DataView(buffer);
var writeString = function(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM format
view.setUint16(22, 1, true); // Mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); // Byte rate
view.setUint16(32, 2, true); // Block align
view.setUint16(34, 16, true); // 16-bit
writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
// Convert floats to 16-bit PCM signed integers
var offset = 44;
for (var i = 0; i < samples.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([view], { type: 'audio/wav' });
} }
function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) { function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) {
@@ -612,19 +804,62 @@ function uploadPhoto() {
} }
function uploadAudio() { function uploadAudio() {
document.getElementById('nextStep2').disabled=true; document.getElementById('nextStep2').disabled = true;
uploadFile( var progressWrap = document.getElementById('progress2');
document.getElementById('audioFile'), var fill = document.getElementById('fill2');
'/api/upload/audio', var txt = document.getElementById('txt2');
document.getElementById('progress2'),
document.getElementById('fill2'), // Check if we have a recorded blob
document.getElementById('txt2'), if (recordedBlob) {
function(filename){ var fd = new FormData();
if(filename){ uploadedAudio=filename; goStep(3); } // Unique timestamped voice note name
else showToast('Audio upload failed. Try again.', false); var vnName = 'voice_note_' + Math.floor(Date.now() / 1000) + '.wav';
document.getElementById('nextStep2').disabled=false; fd.append('file', recordedBlob, vnName);
}
); var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload/audio');
progressWrap.style.display = 'block';
xhr.upload.onprogress = function(e){
if(e.lengthComputable){
var p = Math.round(e.loaded/e.total*100);
fill.style.width=p+'%';
txt.textContent='Uploading Voice note... '+p+'%';
}
};
xhr.onload = function(){
progressWrap.style.display = 'none';
try {
var d = JSON.parse(xhr.responseText);
if (d.ok && d.filename) {
uploadedAudio = d.filename;
goStep(3);
} else {
showToast('Voice note upload failed.', false);
}
} catch(e) { showToast('Voice note upload failed.', false); }
document.getElementById('nextStep2').disabled = false;
};
xhr.onerror = function(){
progressWrap.style.display = 'none';
showToast('Voice note upload failed.', false);
document.getElementById('nextStep2').disabled = false;
};
xhr.send(fd);
} else {
// Standard file upload
uploadFile(
document.getElementById('audioFile'),
'/api/upload/audio',
progressWrap,
fill,
txt,
function(filename){
if(filename){ uploadedAudio=filename; goStep(3); }
else showToast('Audio upload failed. Try again.', false);
document.getElementById('nextStep2').disabled=false;
}
);
}
} }
function skipAudio() { function skipAudio() {
@@ -795,6 +1030,7 @@ void initWebServer() {
j += ",\"alarmHour\":"; j += g_tributeAlarmHour; j += ",\"alarmHour\":"; j += g_tributeAlarmHour;
j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute; j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute;
j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false"; j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false";
j += ",\"favoriteId\":"; j += g_favoriteMemoryId;
j += "}"; j += "}";
server.send(200, "application/json", j); server.send(200, "application/json", j);
}); });
@@ -829,6 +1065,12 @@ void initWebServer() {
g_tributeAlarmMinute = body.substring(am + 14).toInt(); g_tributeAlarmMinute = body.substring(am + 14).toInt();
} }
// Favorite Memory ID
int fav = body.indexOf("\"favoriteId\":");
if (fav >= 0) {
g_favoriteMemoryId = body.substring(fav + 13).toInt();
}
Config_Save(); Config_Save();
server.send(200, "application/json", "{\"ok\":true}"); server.send(200, "application/json", "{\"ok\":true}");
}); });

View File

@@ -28,6 +28,11 @@ static bool g_tributeActive = false;
static int g_tributeNextIndex = 0; static int g_tributeNextIndex = 0;
static uint32_t lastTributeCheck = 0; static uint32_t lastTributeCheck = 0;
// Standby Remembrance Candle State
static bool g_standbyActive = false;
static uint32_t lastInteractionTime = 0;
static const uint32_t STANDBY_TIMEOUT = 120000; // 2 minutes standby timeout
// ── Core function: show a memory ────────────────────────────────────────────── // ── Core function: show a memory ──────────────────────────────────────────────
void showMemory(int index) { void showMemory(int index) {
if (g_memoryCount == 0) return; if (g_memoryCount == 0) return;
@@ -36,6 +41,9 @@ void showMemory(int index) {
printf("Showing memory %d: '%s'\r\n", index, g_memories[index].name); printf("Showing memory %d: '%s'\r\n", index, g_memories[index].name);
g_standbyActive = false;
lastInteractionTime = millis();
stopAudio(); stopAudio();
Display_Memory_Photo(index); Display_Memory_Photo(index);
playMemoryAudio(index); playMemoryAudio(index);
@@ -110,12 +118,26 @@ void setup() {
initAudioPlayer(); initAudioPlayer();
setPlayerVolume(g_volume); setPlayerVolume(g_volume);
// Show first memory (or error screen) // Show favorite memory or first memory on startup
if (g_memoryCount == 0) { if (g_memoryCount == 0) {
printf("No memories found. Add some via the web app.\r\n"); printf("No memories found. Add some via the web app.\r\n");
// Screen stays dark — user will add via web g_standbyActive = true;
Draw_Candle_Body();
} else { } else {
showMemory(g_currentIndex); int favoriteIndex = -1;
if (g_favoriteMemoryId >= 0) {
for (int i = 0; i < g_memoryCount; i++) {
if (g_memories[i].id == g_favoriteMemoryId) {
favoriteIndex = i;
break;
}
}
}
if (favoriteIndex >= 0) {
showMemory(favoriteIndex);
} else {
showMemory(g_currentIndex);
}
} }
// Start WiFi + web server // Start WiFi + web server
@@ -160,6 +182,8 @@ void loop() {
// ── Button edge detection ───────────────────────────────────────────────── // ── Button edge detection ─────────────────────────────────────────────────
if (btn == LOW && lastButtonState == HIGH) { if (btn == LOW && lastButtonState == HIGH) {
// Pressed down // Pressed down
lastInteractionTime = now;
g_standbyActive = false;
buttonPressStartTime = now; buttonPressStartTime = now;
isButtonPressed = true; isButtonPressed = true;
longPressTriggered = false; longPressTriggered = false;
@@ -210,6 +234,24 @@ void loop() {
} }
} }
// ── Standby Candle Controller ─────────────────────────────────────────────
if (g_memoryCount == 0) {
if (!g_standbyActive) {
g_standbyActive = true;
Draw_Candle_Body();
}
} else if (!g_standbyActive && !isAudioPlaying() && currentState == STATE_IDLE && !g_tributeActive) {
if (now - lastInteractionTime > STANDBY_TIMEOUT) {
g_standbyActive = true;
Draw_Candle_Body();
printf("Standby: entering candle standby mode\r\n");
}
}
if (g_standbyActive) {
Draw_Candle_Flicker();
}
lastButtonState = btn; lastButtonState = btn;
// ── Web server ──────────────────────────────────────────────────────────── // ── Web server ────────────────────────────────────────────────────────────