@@ -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 : 8 px ;
left : 8 px ;
background : rgba ( 0 , 0 , 0 , .6 ) ;
border : 1 px solid rgba ( 255 , 179 , 0 , 0.3 ) ;
color : var ( - - accent ) ;
border - radius : 50 % ;
width : 28 px ;
height : 28 px ;
font - size : 16 px ;
cursor : pointer ;
display : flex ;
align - items : center ;
justify - content : center ;
line - height : 1 ;
z - index : 10 ;
transition : .2 s ;
}
. 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 : 2 px dashed rgba ( 255 , 179 , 0 , 0.3 ) ;
border - radius : var ( - - radius ) ;
padding : 3 2px ;
padding : 24 px 16 px ;
text - align : center ;
cursor : pointer ;
margin - bottom : 16 px ;
@@ -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,10 +447,14 @@ function openAdd() {
// ── Gallery ─────────────────────────────────────────────────────────────────
function loadGallery ( ) {
// 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 ( ) { } ) ;
}
function renderGallery ( ) {
@@ -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 ;
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 ' ,
document . getElementById ( ' progress2 ' ) ,
document . getElementById ( ' fill2 ' ) ,
document . getElementById ( ' txt2 ' ) ,
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} " ) ;
} ) ;