Files
sunflower-picture-app/sound_picture_app/Web_Server.cpp

716 lines
31 KiB
C++

#include "Web_Server.h"
#include <WiFi.h>
#include <WebServer.h>
#include "SD_Card.h"
#include "Audio_Player.h"
// ── Inter-task flags (set by web handler, read by main loop) ──────────────────
volatile int g_webDisplayRequest = -1;
volatile bool g_webDisplayDirty = false;
static WebServer server(80);
static File uploadFile;
static bool uploadOK = false;
static String uploadSavedAs = "";
// ── Helpers ───────────────────────────────────────────────────────────────────
static bool allowedPhoto(const String& name) {
String l = name; l.toLowerCase();
return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg");
}
static bool allowedAudio(const String& name) {
String l = name; l.toLowerCase();
return l.endsWith(".mp3") || l.endsWith(".wav");
}
// Build a compact JSON representation of all memories
static String memoriesJson() {
String j = "[";
for (int i = 0; i < g_memoryCount; i++) {
if (i > 0) j += ",";
j += "{\"id\":"; j += g_memories[i].id;
j += ",\"name\":\""; j += g_memories[i].name;
j += "\",\"photo\":\""; j += g_memories[i].photo;
j += "\",\"audio\":\""; j += g_memories[i].audio;
j += "\"}";
}
j += "]";
return j;
}
// ── Single-page app HTML ──────────────────────────────────────────────────────
// Served from PROGMEM to save RAM
static const char APP_HTML[] PROGMEM = R"HTML(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Memory Frame</title>
<style>
:root{--bg:#0d0d14;--card:#1a1a2e;--card2:#16213e;--accent:#e91e8c;--accent2:#9c27b0;--text:#f0f0f8;--muted:#7070a0;--radius:16px;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overscroll-behavior:none}
/* Nav */
nav{position:fixed;bottom:0;left:0;right:0;background:rgba(13,13,20,.95);backdrop-filter:blur(20px);display:flex;border-top:1px solid rgba(255,255,255,.08);z-index:100;padding-bottom:env(safe-area-inset-bottom)}
nav button{flex:1;padding:14px 0 10px;background:none;border:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;gap:3px;transition:.2s;cursor:pointer}
nav button.active{color:var(--accent)}
nav button svg{width:22px;height:22px;fill:currentColor}
/* Pages */
.page{display:none;padding:16px 16px 90px;min-height:100vh}
.page.active{display:block}
h1{font-size:24px;font-weight:700;margin-bottom:4px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.sub{color:var(--muted);font-size:13px;margin-bottom:24px}
/* Gallery */
.gallery{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.mem-card{background:var(--card);border-radius:var(--radius);overflow:hidden;cursor:pointer;box-shadow:var(--shadow);transition:.2s;position:relative}
.mem-card:active{transform:scale(.97)}
.mem-thumb{width:100%;aspect-ratio:172/320;object-fit:cover;display:block;background:#222}
.mem-thumb.no-img{display:flex;align-items:center;justify-content:center;font-size:40px}
.mem-info{padding:10px 12px 12px}
.mem-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mem-del{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.6);border:none;color:#fff;border-radius:50%;width:28px;height:28px;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.play-btn{width:100%;margin-top:8px;padding:8px;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;border-radius:8px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:.2s}
.play-btn:active{opacity:.8}
.empty{text-align:center;padding:60px 20px;color:var(--muted)}
.empty .icon{font-size:56px;margin-bottom:16px}
.fab{position:fixed;bottom:80px;right:20px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff;font-size:28px;cursor:pointer;box-shadow:0 4px 20px rgba(233,30,140,.4);display:flex;align-items:center;justify-content:center;z-index:90}
/* Add Memory wizard */
.wizard-step{display:none}.wizard-step.active{display:block}
.step-label{font-size:12px;color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.upload-box{border:2px dashed rgba(233,30,140,.4);border-radius:var(--radius);padding:32px;text-align:center;cursor:pointer;margin-bottom:16px;transition:.2s;background:rgba(233,30,140,.04)}
.upload-box:hover,.upload-box.hover{border-color:var(--accent);background:rgba(233,30,140,.08)}
.upload-box .icon{font-size:40px;margin-bottom:8px}
.upload-box p{color:var(--muted);font-size:13px}
.upload-box strong{color:var(--accent)}
.preview-img{width:100%;max-height:200px;object-fit:contain;border-radius:12px;margin-bottom:12px;display:none}
.preview-audio{width:100%;margin-bottom:12px;display:none}
input[type=file]{display:none}
.text-input{width:100%;background:var(--card2);border:1.5px solid rgba(255,255,255,.1);border-radius:12px;padding:14px 16px;color:var(--text);font-size:16px;outline:none;transition:.2s;margin-bottom:16px}
.text-input:focus{border-color:var(--accent)}
.btn{width:100%;padding:16px;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;transition:.2s;margin-bottom:8px}
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff}
.btn-primary:active{opacity:.85}
.btn-secondary{background:var(--card2);color:var(--text)}
.progress-wrap{margin-bottom:16px;display:none}
.progress-bar{height:4px;background:rgba(255,255,255,.1);border-radius:99px;overflow:hidden}
.progress-fill{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .3s}
.progress-txt{font-size:12px;color:var(--muted);text-align:center;margin-top:6px}
.toast{border-radius:12px;padding:14px 16px;margin-bottom:16px;font-size:14px;font-weight:500;display:none}
.toast.ok{background:rgba(16,185,129,.15);border:1px solid rgba(16,185,129,.3);color:#34d399}
.toast.err{background:rgba(239,68,68,.15);border:1px solid rgba(239,68,68,.3);color:#f87171}
/* Settings */
.setting-row{background:var(--card);border-radius:var(--radius);padding:18px;margin-bottom:12px}
.setting-label{font-size:13px;font-weight:600;margin-bottom:4px}
.setting-sub{font-size:12px;color:var(--muted);margin-bottom:12px}
input[type=range]{width:100%;accent-color:var(--accent)}
.vol-display{text-align:right;font-size:13px;color:var(--accent);font-weight:700;margin-top:4px}
.now-playing{background:linear-gradient(135deg,rgba(233,30,140,.15),rgba(156,39,176,.15));border:1px solid rgba(233,30,140,.3);border-radius:var(--radius);padding:18px;margin-bottom:12px}
.np-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
.np-name{font-size:18px;font-weight:700}
</style>
</head>
<body>
<!-- Gallery Page -->
<div id="page-gallery" class="page active">
<h1>Memory Frame</h1>
<p class="sub" id="sub-count">Loading memories...</p>
<div class="gallery" id="gallery"></div>
</div>
<!-- Add Memory Page -->
<div id="page-add" class="page">
<h1>Add Memory</h1>
<p class="sub">Upload a photo and a soundbite together</p>
<div id="toast" class="toast"></div>
<!-- Step 1: Photo -->
<div id="step1" class="wizard-step active">
<div class="step-label">Step 1 of 3 &mdash; Choose a Photo</div>
<div class="upload-box" id="photoBox" onclick="document.getElementById('photoFile').click()">
<div class="icon">&#x1F4F7;</div>
<p>Tap to choose a photo<br><strong>from your camera roll</strong></p>
</div>
<input type="file" id="photoFile" accept="image/*" onchange="onPhotoSelected(this)">
<img id="previewImg" class="preview-img" alt="preview">
<div id="progress1" class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="fill1"></div></div>
<div class="progress-txt" id="txt1">Uploading photo...</div>
</div>
<button class="btn btn-primary" id="nextStep1" onclick="uploadPhoto()" disabled>Next &rarr;</button>
</div>
<!-- Step 2: Audio -->
<div id="step2" class="wizard-step">
<div class="step-label">Step 2 of 3 &mdash; Choose a Soundbite</div>
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
<div class="icon">&#x1F3A4;</div>
<p>Tap to choose a 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>
</div>
<button class="btn btn-primary" id="nextStep2" onclick="uploadAudio()" disabled>Next &rarr;</button>
<button class="btn btn-secondary" onclick="goStep(1)">&#x2190; Back</button>
</div>
<!-- Step 3: Name -->
<div id="step3" class="wizard-step">
<div class="step-label">Step 3 of 3 &mdash; Name This Memory</div>
<input class="text-input" id="memName" type="text" placeholder="e.g. Mom in the Garden" maxlength="60">
<button class="btn btn-primary" onclick="saveMemory()">&#x2764; Save Memory</button>
<button class="btn btn-secondary" onclick="goStep(2)">&#x2190; Back</button>
</div>
</div>
<!-- Settings Page -->
<div id="page-settings" class="page">
<h1>Settings</h1>
<p class="sub">Configure your frame</p>
<div class="now-playing" id="nowPlaying">
<div class="np-label">Now showing</div>
<div class="np-name" id="npName">-</div>
</div>
<div class="setting-row">
<div class="setting-label">Volume</div>
<div class="setting-sub">Speaker volume for audio playback</div>
<input type="range" id="volSlider" min="0" max="21" value="12" oninput="onVolChange(this.value)">
<div class="vol-display" id="volDisplay">12 / 21</div>
</div>
<button class="btn btn-secondary" onclick="saveSettings()" style="margin-top:8px">Save Settings</button>
</div>
<!-- FAB -->
<button class="fab" id="fab" onclick="openAdd()">+</button>
<!-- Bottom Nav -->
<nav>
<button id="nav-gallery" class="active" onclick="goPage('gallery')">
<svg viewBox="0 0 24 24"><path d="M4 6h16v12H4z" opacity=".3"/><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zm-8-7l-3 4h6z"/></svg>
Gallery
</button>
<button id="nav-add" onclick="openAdd()">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
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>
Settings
</button>
</nav>
<script>
var API = '';
var memories = [];
var uploadedPhoto = '';
var uploadedAudio = '';
var currentStep = 1;
// ── Navigation ──────────────────────────────────────────────────────────────
function goPage(name) {
document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active')});
document.querySelectorAll('nav button').forEach(function(b){b.classList.remove('active')});
document.getElementById('page-'+name).classList.add('active');
var nb = document.getElementById('nav-'+name);
if(nb) nb.classList.add('active');
document.getElementById('fab').style.display = name==='gallery' ? 'flex' : 'none';
if(name==='gallery') loadGallery();
if(name==='settings') loadSettings();
}
function openAdd() {
document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active')});
document.querySelectorAll('nav button').forEach(function(b){b.classList.remove('active')});
document.getElementById('page-add').classList.add('active');
document.getElementById('fab').style.display = 'none';
resetWizard();
}
// ── Gallery ─────────────────────────────────────────────────────────────────
function loadGallery() {
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){
memories = data;
renderGallery();
}).catch(function(){});
}
function renderGallery() {
var g = document.getElementById('gallery');
var sub = document.getElementById('sub-count');
sub.textContent = memories.length + ' memor' + (memories.length===1?'y':'ies') + ' saved';
if(memories.length===0){
g.innerHTML = '';
g.insertAdjacentHTML('beforeend','<div class="empty" style="grid-column:1/-1"><div class="icon">&#x1F497;</div><p>No memories yet.<br>Tap + to add the first one.</p></div>');
return;
}
g.innerHTML = '';
memories.forEach(function(m){
var imgSrc = m.photo ? '/photo/'+encodeURIComponent(m.photo) : '';
var imgHtml = imgSrc
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
: '<div class="mem-thumb no-img">&#x1F97B;</div>';
var html = '<div class="mem-card">'
+ imgHtml
+ '<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>'
+ '<button class="play-btn" onclick="playMemory('+m.id+')">&#x25B6; Play on Frame</button>'
+ '</div></div>';
g.insertAdjacentHTML('beforeend', html);
});
}
function playMemory(id) {
fetch('/api/display?id='+id, {method:'POST'})
.then(function(r){return r.json()})
.then(function(d){ if(d.ok) showToastGlobal('Showing on frame!', true); })
.catch(function(){});
}
function deleteMemory(id, e) {
e.stopPropagation();
if(!confirm('Delete this memory?')) return;
fetch('/api/memory?id='+id, {method:'DELETE'})
.then(function(){loadGallery()}).catch(function(){});
}
// ── Wizard ───────────────────────────────────────────────────────────────────
function resetWizard() {
uploadedPhoto=''; uploadedAudio='';
document.getElementById('photoFile').value='';
document.getElementById('audioFile').value='';
document.getElementById('previewImg').style.display='none';
document.getElementById('previewAudio').style.display='none';
document.getElementById('memName').value='';
document.getElementById('toast').style.display='none';
document.getElementById('nextStep1').disabled=true;
document.getElementById('nextStep2').disabled=true;
goStep(1);
}
function goStep(n) {
document.querySelectorAll('.wizard-step').forEach(function(s){s.classList.remove('active')});
document.getElementById('step'+n).classList.add('active');
currentStep = n;
}
function onPhotoSelected(input) {
if(!input.files.length) return;
var f = input.files[0];
var url = URL.createObjectURL(f);
var img = document.getElementById('previewImg');
img.src = url; img.style.display='block';
document.getElementById('nextStep1').disabled=false;
}
function onAudioSelected(input) {
if(!input.files.length) return;
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;
}
function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) {
var f = fileInput.files[0];
if(!f){ onDone(null); return; }
var fd = new FormData(); fd.append('file', f);
var xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
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... '+p+'%';
}
};
xhr.onload = function(){
progressWrap.style.display='none';
try{ var d = JSON.parse(xhr.responseText); onDone(d.filename); }
catch(e){ onDone(null); }
};
xhr.onerror = function(){ progressWrap.style.display='none'; onDone(null); };
xhr.send(fd);
}
function uploadPhoto() {
var fileInput = document.getElementById('photoFile');
var file = fileInput.files[0];
if (!file) return;
document.getElementById('nextStep1').disabled = true;
var progressWrap = document.getElementById('progress1');
var fill = document.getElementById('fill1');
var txt = document.getElementById('txt1');
progressWrap.style.display = 'block';
txt.textContent = 'Preparing photo...';
fill.style.width = '10%';
var reader = new FileReader();
reader.onload = function(e) {
var img = new Image();
img.onload = function() {
var canvas = document.createElement('canvas');
canvas.width = 172;
canvas.height = 320;
var ctx = canvas.getContext('2d');
// Center crop cover math
var srcWidth = img.width;
var srcHeight = img.height;
var ratio = Math.max(172 / srcWidth, 320 / srcHeight);
var newWidth = srcWidth * ratio;
var newHeight = srcHeight * ratio;
var x = (172 - newWidth) / 2;
var y = (320 - newHeight) / 2;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, 172, 320);
ctx.drawImage(img, x, y, newWidth, newHeight);
canvas.toBlob(function(blob) {
if (!blob) {
showToast('Photo preparation failed.', false);
document.getElementById('nextStep1').disabled = false;
progressWrap.style.display = 'none';
return;
}
var origName = file.name;
var lastDot = origName.lastIndexOf('.');
var baseName = lastDot >= 0 ? origName.substring(0, lastDot) : 'photo';
var newFilename = baseName + '.png';
var fd = new FormData();
fd.append('file', blob, newFilename);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload/photo');
xhr.upload.onprogress = function(progressEvt) {
if (progressEvt.lengthComputable) {
var pct = Math.round(progressEvt.loaded / progressEvt.total * 100);
fill.style.width = pct + '%';
txt.textContent = 'Uploading photo... ' + pct + '%';
}
};
xhr.onload = function() {
progressWrap.style.display = 'none';
try {
var res = JSON.parse(xhr.responseText);
if (res.ok && res.filename) {
uploadedPhoto = res.filename;
goStep(2);
} else {
showToast('Photo upload failed: ' + (res.error || 'unknown error'), false);
}
} catch(err) {
showToast('Photo upload failed.', false);
}
document.getElementById('nextStep1').disabled = false;
};
xhr.onerror = function() {
progressWrap.style.display = 'none';
showToast('Photo upload failed.', false);
document.getElementById('nextStep1').disabled = false;
};
xhr.send(fd);
}, 'image/png');
};
img.onerror = function() {
showToast('Invalid image file.', false);
document.getElementById('nextStep1').disabled = false;
progressWrap.style.display = 'none';
};
img.src = e.target.result;
};
reader.onerror = function() {
showToast('Failed to read file.', false);
document.getElementById('nextStep1').disabled = false;
progressWrap.style.display = 'none';
};
reader.readAsDataURL(file);
}
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;
}
);
}
function saveMemory() {
var name = document.getElementById('memName').value.trim();
if(!name){ showToast('Please give this memory a name.', false); return; }
if(!uploadedPhoto){ showToast('Photo is missing.', false); return; }
var body = JSON.stringify({name:name, photo:uploadedPhoto, audio:uploadedAudio||''});
fetch('/api/memory', {method:'POST', headers:{'Content-Type':'application/json'}, body:body})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
showToast('Memory saved! \u2764\uFE0F', true);
setTimeout(function(){ goPage('gallery'); }, 1500);
} else showToast('Save failed: '+d.error, false);
}).catch(function(){ showToast('Save failed.', false); });
}
// ── Settings ─────────────────────────────────────────────────────────────────
function loadSettings() {
fetch('/api/settings').then(function(r){return r.json()}).then(function(d){
document.getElementById('volSlider').value = d.volume||12;
document.getElementById('volDisplay').textContent = (d.volume||12)+' / 21';
var nm = document.getElementById('npName');
if(d.currentName) nm.textContent = d.currentName;
else nm.textContent = memories.length ? memories[d.currentIndex||0].name||'-' : '-';
}).catch(function(){});
}
function onVolChange(v) {
document.getElementById('volDisplay').textContent = v+' / 21';
}
function saveSettings() {
var v = parseInt(document.getElementById('volSlider').value)||12;
fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({volume:v})})
.then(function(r){return r.json()})
.then(function(d){ if(d.ok) showToastGlobal('Settings saved!', true); })
.catch(function(){});
}
// ── Toast helpers ─────────────────────────────────────────────────────────────
function showToast(msg, ok) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast '+(ok?'ok':'err');
t.style.display='block';
setTimeout(function(){ t.style.display='none'; }, 3000);
}
var globalToastTimer;
function showToastGlobal(msg, ok) {
var old = document.getElementById('gtToast');
if(old) old.remove();
var t = document.createElement('div');
t.id='gtToast';
t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:999;padding:12px 20px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.4)';
t.style.background = ok ? 'rgba(16,185,129,.9)' : 'rgba(239,68,68,.9)';
t.style.color = '#fff';
t.textContent = msg;
document.body.appendChild(t);
clearTimeout(globalToastTimer);
globalToastTimer = setTimeout(function(){ t.remove(); }, 2500);
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadGallery();
loadSettings();
</script>
</body>
</html>
)HTML";
// ── Upload handlers ───────────────────────────────────────────────────────────
static String pendingUploadPath = "";
static void handleUploadFile(const String& subdir, bool (*validator)(const String&)) {
HTTPUpload& up = server.upload();
if (up.status == UPLOAD_FILE_START) {
String fname = up.filename;
uploadOK = false;
uploadSavedAs = "";
if (!validator(fname)) {
printf("Upload: rejected %s\r\n", fname.c_str());
return;
}
pendingUploadPath = "/" + subdir + "/" + fname;
uploadFile = SD_MMC.open(pendingUploadPath, "w");
uploadOK = (bool)uploadFile;
if (uploadOK) uploadSavedAs = fname;
printf("Upload: writing to %s\r\n", pendingUploadPath.c_str());
}
else if (up.status == UPLOAD_FILE_WRITE) {
if (uploadFile) uploadFile.write(up.buf, up.currentSize);
}
else if (up.status == UPLOAD_FILE_END) {
if (uploadFile) { uploadFile.close(); printf("Upload: done (%u bytes)\r\n", up.totalSize); }
else { uploadOK = false; }
}
}
// ── Route setup ───────────────────────────────────────────────────────────────
void initWebServer() {
WiFi.softAP("love", "love1234");
printf("WiFi AP: SSID='love' PW='love1234' IP=%s\r\n", WiFi.softAPIP().toString().c_str());
// Main SPA
server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", APP_HTML);
});
// GET /api/memories → JSON array
server.on("/api/memories", HTTP_GET, []() {
server.send(200, "application/json", memoriesJson());
});
// GET /api/settings → {volume, currentIndex, currentName}
server.on("/api/settings", HTTP_GET, []() {
String name = "";
if (g_currentIndex >= 0 && g_currentIndex < g_memoryCount)
name = String(g_memories[g_currentIndex].name);
String j = "{\"volume\":"; j += g_volume;
j += ",\"currentIndex\":"; j += g_currentIndex;
j += ",\"currentName\":\""; j += name; j += "\"}";
server.send(200, "application/json", j);
});
// POST /api/settings body: {volume:N}
server.on("/api/settings", HTTP_POST, []() {
String body = server.arg("plain");
// parse volume from simple JSON: {"volume":12}
int vi = body.indexOf("\"volume\":");
if (vi >= 0) {
int v = body.substring(vi + 9).toInt();
if (v < 0) v = 0; if (v > 21) v = 21;
setPlayerVolume((uint8_t)v);
Config_Save();
}
server.send(200, "application/json", "{\"ok\":true}");
});
// POST /api/upload/photo — multipart upload
server.on("/api/upload/photo", HTTP_POST,
[]() {
if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}");
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
},
[]() { handleUploadFile("photos", allowedPhoto); }
);
// POST /api/upload/audio — multipart upload
server.on("/api/upload/audio", HTTP_POST,
[]() {
if (uploadOK) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadSavedAs + "\"}");
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
},
[]() { handleUploadFile("audio", allowedAudio); }
);
// POST /api/memory body: {name, photo, audio}
server.on("/api/memory", HTTP_POST, []() {
String body = server.arg("plain");
// Minimal parser — extract name, photo, audio
auto extract = [&](const char* key) -> String {
String k = String("\"") + key + "\":\"";
int p = body.indexOf(k);
if (p < 0) return "";
int s = p + k.length();
int e = body.indexOf('"', s);
if (e < 0) return "";
return body.substring(s, e);
};
String name = extract("name");
String photo = extract("photo");
String audio = extract("audio");
if (name.isEmpty() || photo.isEmpty()) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing name or photo\"}");
return;
}
int id = Config_Add_Memory(name.c_str(), photo.c_str(), audio.c_str());
if (id > 0) server.send(200, "application/json", "{\"ok\":true,\"id\":" + String(id) + "}");
else server.send(500, "application/json", "{\"ok\":false,\"error\":\"too many memories\"}");
});
// DELETE /api/memory?id=N
server.on("/api/memory", HTTP_DELETE, []() {
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
int id = server.arg("id").toInt();
bool ok = Config_Delete_Memory(id);
server.send(200, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"not found\"}");
});
// POST /api/display?id=N — show a memory on the frame NOW
server.on("/api/display", HTTP_POST, []() {
if (!server.hasArg("id")) { server.send(400, "application/json", "{\"ok\":false}"); return; }
int id = server.arg("id").toInt();
for (int i = 0; i < g_memoryCount; i++) {
if (g_memories[i].id == id) {
g_currentIndex = i;
g_webDisplayRequest = i;
g_webDisplayDirty = true;
Config_Save();
server.send(200, "application/json", "{\"ok\":true}");
return;
}
}
server.send(404, "application/json", "{\"ok\":false,\"error\":\"memory not found\"}");
});
// GET /photo/{filename} — serve a photo thumbnail from SD
server.on("/photo", HTTP_GET, []() {
String name = server.pathArg(0);
if (name.isEmpty() && server.hasArg("f")) name = server.arg("f");
// Fallback: extract from URL
String uri = server.uri();
if (name.isEmpty()) {
int slash = uri.lastIndexOf('/');
if (slash >= 0) name = uri.substring(slash + 1);
}
if (name.isEmpty()) { server.send(404, "text/plain", "Not found"); return; }
String path = "/photos/" + name;
File f = SD_MMC.open(path, "r");
if (!f) { server.send(404, "text/plain", "Not found"); return; }
server.streamFile(f, "image/png");
f.close();
});
server.onNotFound([]() {
// Also handle /photo/xxx.png via URI parsing
String uri = server.uri();
if (uri.startsWith("/photo/")) {
String name = uri.substring(7);
String path = "/photos/" + name;
File f = SD_MMC.open(path, "r");
if (f) {
String ct = name.endsWith(".png") ? "image/png" : "image/jpeg";
server.streamFile(f, ct);
f.close();
return;
}
}
server.send(404, "text/plain", "Not found");
});
server.begin();
printf("Web server started\r\n");
}
void handleWebServer() {
server.handleClient();
}