Implement browser-based voice notes recording, standby flicking remembrance candle, and favorite memory pinning
This commit is contained in:
@@ -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 — Choose a Soundbite (Optional)</div>
|
||||
<div class="step-label">Step 2 of 3 — 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">✕</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}");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user