'
+ '
'+escHtml(m.name)+'
'
@@ -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) {
fetch('/api/display?id='+id, {method:'POST'})
.then(function(r){return r.json()})
@@ -450,7 +524,7 @@ function deleteMemory(id, e) {
// ── Wizard ───────────────────────────────────────────────────────────────────
function resetWizard() {
- uploadedPhoto=''; uploadedAudio='';
+ uploadedPhoto=''; uploadedAudio=''; recordedBlob=null;
document.getElementById('photoFile').value='';
document.getElementById('audioFile').value='';
document.getElementById('previewImg').style.display='none';
@@ -459,6 +533,12 @@ function resetWizard() {
document.getElementById('toast').style.display='none';
document.getElementById('nextStep1').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);
}
@@ -479,11 +559,123 @@ function onPhotoSelected(input) {
function onAudioSelected(input) {
if(!input.files.length) return;
+ recordedBlob = null; // Clear mic recording if file is chosen
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;
+ 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 = '
Recording...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)
Tap to re-record';
+
+ 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) {
@@ -612,19 +804,62 @@ function uploadPhoto() {
}
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;
- }
- );
+ document.getElementById('nextStep2').disabled = true;
+ var progressWrap = document.getElementById('progress2');
+ var fill = document.getElementById('fill2');
+ var txt = document.getElementById('txt2');
+
+ // Check if we have a recorded blob
+ if (recordedBlob) {
+ var fd = new FormData();
+ // Unique timestamped voice note name
+ var vnName = 'voice_note_' + Math.floor(Date.now() / 1000) + '.wav';
+ 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() {
@@ -795,6 +1030,7 @@ void initWebServer() {
j += ",\"alarmHour\":"; j += g_tributeAlarmHour;
j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute;
j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false";
+ j += ",\"favoriteId\":"; j += g_favoriteMemoryId;
j += "}";
server.send(200, "application/json", j);
});
@@ -829,6 +1065,12 @@ void initWebServer() {
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();
server.send(200, "application/json", "{\"ok\":true}");
});
diff --git a/sound_picture_app/sound_picture_app.ino b/sound_picture_app/sound_picture_app.ino
index 396cfc4..2ea97b1 100644
--- a/sound_picture_app/sound_picture_app.ino
+++ b/sound_picture_app/sound_picture_app.ino
@@ -28,6 +28,11 @@ static bool g_tributeActive = false;
static int g_tributeNextIndex = 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 ──────────────────────────────────────────────
void showMemory(int index) {
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);
+ g_standbyActive = false;
+ lastInteractionTime = millis();
+
stopAudio();
Display_Memory_Photo(index);
playMemoryAudio(index);
@@ -110,12 +118,26 @@ void setup() {
initAudioPlayer();
setPlayerVolume(g_volume);
- // Show first memory (or error screen)
+ // Show favorite memory or first memory on startup
if (g_memoryCount == 0) {
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 {
- 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
@@ -160,6 +182,8 @@ void loop() {
// ── Button edge detection ─────────────────────────────────────────────────
if (btn == LOW && lastButtonState == HIGH) {
// Pressed down
+ lastInteractionTime = now;
+ g_standbyActive = false;
buttonPressStartTime = now;
isButtonPressed = true;
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;
// ── Web server ────────────────────────────────────────────────────────────