1216 lines
43 KiB
C++
1216 lines
43 KiB
C++
#include "Web_Server.h"
|
||
#include <WiFi.h>
|
||
#include <WebServer.h>
|
||
#include "SD_Card.h"
|
||
#include "Display_PNG.h"
|
||
#include "Audio_Player.h"
|
||
|
||
WebServer server(80);
|
||
File uploadFile;
|
||
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 ───────────────────────────────────────────────────────────────────
|
||
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");
|
||
}
|
||
|
||
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 ──────────────────────────────────────────────────────
|
||
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>Sunflower Keepsake Frame</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0d0a05;
|
||
--card: #1f160b;
|
||
--card2: #2b1f11;
|
||
--accent: #ffb300; /* Sunflower Gold */
|
||
--accent-orange: #f57c00; /* Warm Amber */
|
||
--accent-light: #ffe082; /* Soft Petal Yellow */
|
||
--text: #fcfaf2;
|
||
--muted: #a3907c;
|
||
--radius: 16px;
|
||
--shadow: 0 8px 32px rgba(0,0,0,.7);
|
||
--border: 1.5px solid rgba(255, 179, 0, 0.15);
|
||
--border-hover: 1.5px solid rgba(255, 179, 0, 0.45);
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
background-image: radial-gradient(circle at 50% 30%, #1e1307 0%, var(--bg) 70%);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
overscroll-behavior: none;
|
||
}
|
||
/* Nav */
|
||
nav {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(13,10,5,.95);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
display: flex;
|
||
border-top: 1px solid rgba(255, 179, 0, 0.12);
|
||
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(--accent-orange));
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.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: var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow);
|
||
transition: .2s;
|
||
position: relative;
|
||
}
|
||
.mem-card:active { transform: scale(.97); }
|
||
.mem-card:hover { border-color: rgba(255, 179, 0, 0.35); }
|
||
.mem-thumb { width: 100%; aspect-ratio: 172/320; object-fit: cover; display: block; background: #1a150e; }
|
||
.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; color: #fff; }
|
||
.mem-del {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background: rgba(0,0,0,.6);
|
||
border: 1px solid rgba(255,179,0,0.3);
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
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%;
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-orange));
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: #000;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
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; animation: pulse 3s infinite ease-in-out; }
|
||
.fab {
|
||
position: fixed;
|
||
bottom: 80px;
|
||
right: 20px;
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-orange));
|
||
border: none;
|
||
color: #000;
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 20px rgba(255,179,0,.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 90;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { transform: scale(1); }
|
||
50% { transform: scale(1.08); }
|
||
}
|
||
|
||
/* Wizard */
|
||
.wizard-step { display: none; }
|
||
.wizard-step.active { display: block; }
|
||
.step-label { font-size: 12px; color: var(--accent); margin-bottom: 6px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
|
||
.upload-box {
|
||
border: 2px dashed rgba(255, 179, 0, 0.3);
|
||
border-radius: var(--radius);
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
margin-bottom: 16px;
|
||
transition: .2s;
|
||
background: rgba(255, 179, 0, 0.03);
|
||
}
|
||
.upload-box:hover, .upload-box.hover {
|
||
border-color: var(--accent);
|
||
background: rgba(255, 179, 0, 0.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; border: var(--border); margin-bottom: 12px; display: none; }
|
||
.preview-audio { width: 100%; margin-bottom: 12px; display: none; accent-color: var(--accent); }
|
||
input[type=file] { display: none; }
|
||
.text-input {
|
||
width: 100%;
|
||
background: var(--card2);
|
||
border: var(--border);
|
||
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); box-shadow: 0 0 10px rgba(255,179,0,0.2); }
|
||
|
||
.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(--accent-orange)); color: #000; }
|
||
.btn-primary:active { opacity: .85; }
|
||
.btn-secondary { background: var(--card2); border: var(--border); color: var(--text); }
|
||
.btn-secondary:hover { border-color: var(--accent); }
|
||
|
||
.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(--accent-orange)); 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: var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 12px; }
|
||
.setting-label { font-size: 13px; font-weight: 600; margin-bottom: 4px; color: var(--accent); }
|
||
.setting-sub { font-size: 12px; color: var(--muted); margin-bottom: 12px; }
|
||
input[type=range] { width: 100%; }
|
||
.vol-display { text-align: right; font-size: 13px; color: var(--accent); font-weight: 700; margin-top: 4px; }
|
||
.now-playing {
|
||
background: linear-gradient(135deg, rgba(255,179,0,.15), rgba(245,124,0,.15));
|
||
border: 1px solid rgba(255,179,0,.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; color: #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Gallery Page -->
|
||
<div id="page-gallery" class="page active">
|
||
<h1>🌻 Keepsake 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 Keepsake</h1>
|
||
<p class="sub">Create a beautiful photo-sound memorial</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 — Choose a Photo</div>
|
||
<div class="upload-box" id="photoBox" onclick="document.getElementById('photoFile').click()">
|
||
<div class="icon">📸</div>
|
||
<p>Tap to select any photo<br><strong>from your library or camera</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">Preparing photo...</div>
|
||
</div>
|
||
<button class="btn btn-primary" id="nextStep1" onclick="uploadPhoto()" disabled>Next Step →</button>
|
||
</div>
|
||
|
||
<!-- Step 2: Audio (Optional Choice) -->
|
||
<div id="step2" class="wizard-step">
|
||
<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 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>
|
||
</div>
|
||
<button class="btn btn-primary" id="nextStep2" onclick="uploadAudio()" disabled>Upload Soundbite →</button>
|
||
<button class="btn btn-secondary" onclick="skipAudio()" style="color:var(--accent);">No Soundbite (Skip) →</button>
|
||
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
|
||
</div>
|
||
|
||
<!-- Step 3: Name -->
|
||
<div id="step3" class="wizard-step">
|
||
<div class="step-label">Step 3 of 3 — Name This Memory</div>
|
||
<input class="text-input" id="memName" type="text" placeholder="e.g. Mom Laughing in the Garden" maxlength="60">
|
||
<button class="btn btn-primary" onclick="saveMemory()">🌻 Save Memory Keepsake</button>
|
||
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Settings Page -->
|
||
<div id="page-settings" class="page">
|
||
<h1>🌻 Settings</h1>
|
||
<p class="sub">Configure frame preferences</p>
|
||
|
||
<div class="now-playing" id="nowPlaying">
|
||
<div class="np-label">Active Memory</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>
|
||
|
||
<div class="setting-row">
|
||
<div class="setting-label">🌻 Tribute Time Alarm</div>
|
||
<div class="setting-sub">Select a daily time to play through all memories automatically</div>
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
|
||
<span style="font-size:14px; color:var(--muted);">Enable Daily Tribute</span>
|
||
<input type="checkbox" id="alarmEnabled" style="width:20px; height:20px; accent-color:var(--accent);">
|
||
</div>
|
||
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||
<span style="font-size:14px; color:var(--muted);">Scheduled Time</span>
|
||
<input type="time" id="alarmTime" style="background:var(--card2); border:var(--border); color:#fff; border-radius:8px; padding:6px 12px; font-family:inherit; outline:none;">
|
||
</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;
|
||
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) {
|
||
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 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() {
|
||
var g = document.getElementById('gallery');
|
||
var sub = document.getElementById('sub-count');
|
||
sub.textContent = memories.length + ' keepsake' + (memories.length===1?'':'s') + ' saved';
|
||
if(memories.length===0){
|
||
g.innerHTML = '';
|
||
g.insertAdjacentHTML('beforeend','<div class="empty" style="grid-column:1/-1"><div class="icon">🌻</div><p>No memories yet.<br>Tap + to add a sunflower keepsake.</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">🌻</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>'
|
||
+ '<button class="play-btn" onclick="playMemory('+m.id+')">▶ Show Keepsake</button>'
|
||
+ '</div></div>';
|
||
g.insertAdjacentHTML('beforeend', html);
|
||
});
|
||
}
|
||
|
||
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()})
|
||
.then(function(d){ if(d.ok) showToastGlobal('Showing keepsake!', true); })
|
||
.catch(function(){});
|
||
}
|
||
|
||
function deleteMemory(id, e) {
|
||
e.stopPropagation();
|
||
if(!confirm('Delete this keepsake?')) return;
|
||
fetch('/api/memory?id='+id, {method:'DELETE'})
|
||
.then(function(){loadGallery()}).catch(function(){});
|
||
}
|
||
|
||
// ── Wizard ───────────────────────────────────────────────────────────────────
|
||
function resetWizard() {
|
||
uploadedPhoto=''; uploadedAudio=''; recordedBlob=null;
|
||
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;
|
||
|
||
// Reset Record UI
|
||
isRecording = false;
|
||
document.getElementById('recordIcon').textContent = '🎙️';
|
||
document.getElementById('recordTxt').innerHTML = 'Tap to **Record Voice Note** directly from mic';
|
||
|
||
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;
|
||
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) {
|
||
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;
|
||
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() {
|
||
uploadedAudio = '';
|
||
goStep(3);
|
||
}
|
||
|
||
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('Keepsake saved! 🌻💛', 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';
|
||
|
||
document.getElementById('alarmEnabled').checked = d.alarmEnabled||false;
|
||
if (d.alarmHour !== undefined && d.alarmMinute !== undefined) {
|
||
var h = String(d.alarmHour).padStart(2, '0');
|
||
var m = String(d.alarmMinute).padStart(2, '0');
|
||
document.getElementById('alarmTime').value = h + ':' + m;
|
||
}
|
||
|
||
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;
|
||
var ae = document.getElementById('alarmEnabled').checked;
|
||
var at = document.getElementById('alarmTime').value || "19:00";
|
||
var parts = at.split(':');
|
||
var ah = parseInt(parts[0])||19;
|
||
var am = parseInt(parts[1])||0;
|
||
|
||
var payload = {
|
||
volume: v,
|
||
alarmEnabled: ae,
|
||
alarmHour: ah,
|
||
alarmMinute: am
|
||
};
|
||
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.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(255,179,0,.9)' : 'rgba(239,68,68,.9)';
|
||
t.style.color = ok ? '#000' : '#fff';
|
||
t.textContent = msg;
|
||
document.body.appendChild(t);
|
||
clearTimeout(globalToastTimer);
|
||
globalToastTimer = setTimeout(function(){ t.remove(); }, 2500);
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function syncTime() {
|
||
var epoch = Math.floor(Date.now() / 1000);
|
||
var offset = new Date().getTimezoneOffset() * 60; // offset in seconds
|
||
fetch('/api/time', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ epoch: epoch, offset: offset })
|
||
}).catch(function(){});
|
||
}
|
||
|
||
syncTime();
|
||
loadGallery();
|
||
loadSettings();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)HTML";
|
||
|
||
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;
|
||
uploadSuccess = false;
|
||
uploadedFilename = "";
|
||
if (!validator(fname)) {
|
||
printf("Upload: rejected %s\r\n", fname.c_str());
|
||
return;
|
||
}
|
||
pendingUploadPath = "/" + subdir + "/" + fname;
|
||
uploadFile = FFat.open(pendingUploadPath, "w");
|
||
uploadSuccess = (bool)uploadFile;
|
||
if (uploadSuccess) uploadedFilename = 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 { uploadSuccess = false; }
|
||
}
|
||
}
|
||
|
||
void initWebServer() {
|
||
WiFi.softAP("love", "love1234");
|
||
IPAddress IP = WiFi.softAPIP();
|
||
printf("WiFi AP Started. SSID: 'love' Password: 'love1234'\r\n");
|
||
printf("Web Server IP: %s\r\n", IP.toString().c_str());
|
||
|
||
server.on("/", HTTP_GET, []() {
|
||
String status = server.hasArg("status") ? server.arg("status") : "";
|
||
String file = server.hasArg("file") ? server.arg("file") : "";
|
||
server.send(200, "text/html", buildPage(status, file));
|
||
});
|
||
|
||
server.on("/api/memories", HTTP_GET, []() {
|
||
server.send(200, "application/json", memoriesJson());
|
||
});
|
||
|
||
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 += "\",\"alarmEnabled\":"; j += g_tributeAlarmEnabled ? "true" : "false";
|
||
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);
|
||
});
|
||
|
||
server.on("/api/settings", HTTP_POST, []() {
|
||
String body = server.arg("plain");
|
||
|
||
// Volume
|
||
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);
|
||
}
|
||
|
||
// Alarm Enabled
|
||
int ae = body.indexOf("\"alarmEnabled\":");
|
||
if (ae >= 0) {
|
||
String sub = body.substring(ae + 15);
|
||
g_tributeAlarmEnabled = sub.startsWith("true");
|
||
}
|
||
|
||
// Alarm Hour
|
||
int ah = body.indexOf("\"alarmHour\":");
|
||
if (ah >= 0) {
|
||
g_tributeAlarmHour = body.substring(ah + 12).toInt();
|
||
}
|
||
|
||
// Alarm Minute
|
||
int am = body.indexOf("\"alarmMinute\":");
|
||
if (am >= 0) {
|
||
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}");
|
||
});
|
||
|
||
server.on("/api/time", HTTP_POST, []() {
|
||
String body = server.arg("plain");
|
||
int ep = body.indexOf("\"epoch\":");
|
||
int os = body.indexOf("\"offset\":");
|
||
if (ep >= 0 && os >= 0) {
|
||
long long epoch = strtoll(body.substring(ep + 8).c_str(), NULL, 10);
|
||
int offset = body.substring(os + 9).toInt();
|
||
|
||
#include <sys/time.h>
|
||
struct timeval tv;
|
||
tv.tv_sec = epoch;
|
||
tv.tv_usec = 0;
|
||
settimeofday(&tv, NULL);
|
||
|
||
g_timezoneOffset = offset;
|
||
g_timeSynchronized = true;
|
||
|
||
printf("Time Synced: UTC Epoch %lld, Local offset %d sec\r\n", epoch, offset);
|
||
Config_Save();
|
||
}
|
||
server.send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/api/upload/photo", HTTP_POST,
|
||
[]() {
|
||
if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}");
|
||
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
||
},
|
||
[]() { handleUploadFile("photos", allowedPhoto); }
|
||
);
|
||
|
||
server.on("/api/upload/audio", HTTP_POST,
|
||
[]() {
|
||
if (uploadSuccess) server.send(200, "application/json", "{\"ok\":true,\"filename\":\"" + uploadedFilename + "\"}");
|
||
else server.send(400, "application/json", "{\"ok\":false,\"error\":\"upload failed\"}");
|
||
},
|
||
[]() { handleUploadFile("audio", allowedAudio); }
|
||
);
|
||
|
||
server.on("/api/memory", HTTP_POST, []() {
|
||
String body = server.arg("plain");
|
||
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\"}");
|
||
});
|
||
|
||
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\"}");
|
||
});
|
||
|
||
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\"}");
|
||
});
|
||
|
||
server.on("/photo", HTTP_GET, []() {
|
||
String name = server.pathArg(0);
|
||
if (name.isEmpty() && server.hasArg("f")) name = server.arg("f");
|
||
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 = FFat.open(path, "r");
|
||
if (!f) { server.send(404, "text/plain", "Not found"); return; }
|
||
server.streamFile(f, "image/png");
|
||
f.close();
|
||
});
|
||
|
||
server.onNotFound([]() {
|
||
String uri = server.uri();
|
||
if (uri.startsWith("/photo/")) {
|
||
String name = uri.substring(7);
|
||
String path = "/photos/" + name;
|
||
File f = FFat.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();
|
||
}
|
||
|
||
static String buildPage(const String& status, const String& file) {
|
||
String toast = "";
|
||
if (status == "ok") {
|
||
String safeName = file;
|
||
safeName.replace("<", "<");
|
||
safeName.replace(">", ">");
|
||
toast = "<div class=\"toast success\">✅ <span>\"" + safeName + "\" uploaded successfully!</span></div>";
|
||
} else if (status == "fail") {
|
||
toast = "<div class=\"toast error\">❌ <span>Upload failed. Only .png, .mp3 and .wav files are accepted.</span></div>";
|
||
}
|
||
String page = String(APP_HTML);
|
||
page.replace("%TOAST%", toast);
|
||
return page;
|
||
}
|
||
|
||
void handleWebServer() {
|
||
server.handleClient();
|
||
}
|