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

@@ -11,6 +11,7 @@ bool uploadSuccess = false;
String uploadedFilename = "";
volatile int g_webDisplayRequest = -1;
volatile bool g_webDisplayDirty = false;
static String buildPage(const String& status, const String& file);
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -150,6 +151,31 @@ h1 {
align-items: center;
justify-content: center;
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 {
width: 100%;
@@ -199,7 +225,7 @@ h1 {
.upload-box {
border: 2px dashed rgba(255, 179, 0, 0.3);
border-radius: var(--radius);
padding: 32px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
margin-bottom: 16px;
@@ -295,13 +321,22 @@ input[type=range] { width: 100%; }
<!-- Step 2: Audio (Optional Choice) -->
<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="icon">🎙️</div>
<p>Tap to attach a voice note or song<br><strong>.mp3 or .wav file</strong></p>
<div class="icon">📁</div>
<p>Tap to upload an existing 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>
@@ -368,7 +403,7 @@ input[type=range] { width: 100%; }
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>
<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>
@@ -379,6 +414,16 @@ var memories = [];
var uploadedPhoto = '';
var uploadedAudio = '';
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 ──────────────────────────────────────────────────────────────
function goPage(name) {
@@ -402,9 +447,13 @@ function openAdd() {
// ── Gallery ─────────────────────────────────────────────────────────────────
function loadGallery() {
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){
memories = data;
renderGallery();
// Fetch settings to know the current favoriteId first, then load memories
fetch('/api/settings').then(function(r){return r.json()}).then(function(sData){
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(){});
}
@@ -423,8 +472,15 @@ function renderGallery() {
var imgHtml = imgSrc
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
: '<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">'
+ 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>'
+ '<div class="mem-info">'
+ '<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) {
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 = '<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) {
@@ -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}");
});