feat: Snapshots — 8 per preset save/recall

Adds snapshot system to Helix Stadium:

Backend:
- Snapshot data model (8 slots per preset, captures block bypass
  + params + master volume)
- Snapshot API endpoints: list, save, recall, rename, delete
- Integrated into preset serialization/deserialization
- Tracks current_snapshot in state / WebSocket broadcasts

Frontend:
- Camera icon in header bar with current snapshot number
- SnapshotPanel as slide-up overlay
- 8 snapshot slots in a horizontal row
- Tap to recall filled snapshot, tap empty slot to save
- Press-and-hold (600ms) to save current state
- Inline name editing on tap
- Discard Edits toggle
- Green flash indicator on save
- Delete button for active snapshot
This commit is contained in:
2026-06-12 19:20:56 -04:00
parent a1f35a56e3
commit b6dff3c0f3
7 changed files with 702 additions and 154 deletions
+215
View File
@@ -89,6 +89,13 @@ function usePedalState() {
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
apiGet('/api/state').then(s => setState(s));
}
if (msg.type === 'snapshot_recalled' && msg.state) {
setState(msg.state);
}
if (msg.type === 'snapshot_saved') {
// Refresh state to pick up current_snapshot
apiGet('/api/state').then(s => setState(s));
}
} catch (e) { /* ignore parse errors */ }
};
ws.onclose = () => {
@@ -263,6 +270,38 @@ const css = `
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; }
.slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border};
margin: 8px auto; flex-shrink: 0; }
/* Snapshot slots */
.snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
.snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px; border: 1.5px solid ${T.border};
background: ${T.surface}; cursor: pointer; text-align: center; padding: 8px 4px;
transition: all .12s; touch-action: manipulation; position: relative; }
.snap-slot:active { transform: scale(.95); }
.snap-slot.filled { border-color: ${T.amberDim}; }
.snap-slot.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
.snap-slot .snap-num { font-family: 'JetBrains Mono', monospace; font-size: 16px;
font-weight: 700; color: ${T.textDim}; }
.snap-slot.filled .snap-num { color: ${T.amber}; }
.snap-slot.active .snap-num { color: ${T.amber}; text-shadow: 0 0 8px ${T.amber}; }
.snap-slot .snap-name { font-size: 9px; color: ${T.textDim}; margin-top: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.snap-slot.filled .snap-name { color: ${T.textSec}; }
.snap-slot.active .snap-name { color: ${T.amber}; }
.snap-slot .snap-empty { font-size: 9px; color: ${T.textDim}; margin-top: 3px; font-style: italic; }
.snap-slot .snap-save-indicator { position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%; background: ${T.green};
box-shadow: 0 0 6px ${T.green}; animation: snap-pop .3s ease-out; }
@keyframes snap-pop { 0% { transform: scale(2); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.snap-name-input { background: ${T.surface}; border: 1px solid ${T.amber}; border-radius: 4px;
padding: 2px 6px; color: ${T.amber}; font-size: 10px; text-align: center; outline: none;
font-family: 'Inter', sans-serif; width: 100%; }
.snap-header-btn { cursor: pointer; border: none; background: none; color: ${T.textSec};
font-size: 16px; padding: 4px 6px; border-radius: 6px; transition: all .12s;
display: flex; align-items: center; gap: 4px; }
.snap-header-btn:hover { color: ${T.amber}; background: rgba(232,160,48,.1); }
.snap-header-btn.active { color: ${T.amber}; background: rgba(232,160,48,.15); }
.snap-header-num { font-family: 'JetBrains Mono', monospace; font-size: 11px;
font-weight: 700; color: ${T.amber}; min-width: 16px; text-align: center; }
`;
// ── Knob ──────────────────────────────────────────────────────
@@ -1640,6 +1679,167 @@ function RecordScreen({ state }) {
);
}
// ══════════════════════════════════════════════════════════════
// SNAPSHOT PANEL
// ══════════════════════════════════════════════════════════════
function SnapshotPanel({ onClose, state, connected }) {
const [snapshots, setSnapshots] = useState({});
const [loading, setLoading] = useState(true);
const [editingSlot, setEditingSlot] = useState(null);
const [editName, setEditName] = useState('');
const [saveFlash, setSaveFlash] = useState(null);
const [discardEdits, setDiscardEdits] = useState(false);
const holdTimer = useRef(null);
const currentSlot = state?.current_snapshot ?? 0;
const loadSnapshots = useCallback(async () => {
if (!connected) { setLoading(false); return; }
try {
const data = await apiGet('/api/snapshots');
setSnapshots(data.snapshots || {});
} catch (e) { /* ignore */ }
setLoading(false);
}, [connected]);
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
// Handle press & hold vs tap
const handlePointerDown = (slot) => {
holdTimer.current = setTimeout(() => {
// Long press → save
holdTimer.current = null;
handleSave(slot);
}, 600);
};
const handlePointerUp = (slot) => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
// Short tap → recall (or save if empty)
if (snapshots[String(slot)]) {
handleRecall(slot);
} else {
handleSave(slot);
}
}
};
const handlePointerLeave = () => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
};
const handleSave = async (slot) => {
try {
await apiPost(`/api/snapshots/${slot}/save`);
setSaveFlash(slot);
setTimeout(() => setSaveFlash(null), 800);
await loadSnapshots();
} catch (e) { /* ignore */ }
};
const handleRecall = async (slot) => {
try {
await apiPost(`/api/snapshots/${slot}/recall`);
await loadSnapshots();
} catch (e) { /* ignore */ }
};
const handleRename = async (slot) => {
if (!editName.trim()) return;
try {
await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() });
setEditingSlot(null);
await loadSnapshots();
} catch (e) { /* ignore */ }
};
const handleDelete = async (slot) => {
try {
await apiDelete(`/api/snapshots/${slot}`);
await loadSnapshots();
} catch (e) { /* ignore */ }
};
return (
<SlidePanel onClose={onClose}>
<div className="screen-header" style={{ justifyContent: "space-between" }}>
<div className="screen-title">Snapshots</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, color: T.textDim, cursor: "pointer" }}>
<div className={`switch ${discardEdits ? 'on' : ''}`} style={{ width: 28, height: 16 }}
onClick={() => setDiscardEdits(d => !d)}>
<div style={{ position: "absolute", top: 1, left: discardEdits ? 11 : 1, width: 13, height: 13,
borderRadius: "50%", background: "#fff", transition: "left .15s" }} />
</div>
Discard Edits
</label>
</div>
</div>
<div className="screen-body" style={{ gap: 8 }}>
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 4 }}>
Tap to recall &middot; Hold to save
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
</div>
<div className="snap-grid">
{[1,2,3,4,5,6,7,8].map(slot => {
const snap = snapshots[String(slot)];
const isActive = currentSlot === slot;
const isFilled = !!snap;
const isEditing = editingSlot === slot;
const isFlashing = saveFlash === slot;
return (
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
onMouseDown={() => handlePointerDown(slot)}
onTouchStart={() => handlePointerDown(slot)}
onMouseUp={() => handlePointerUp(slot)}
onTouchEnd={() => handlePointerUp(slot)}
onMouseLeave={handlePointerLeave}
onTouchCancel={handlePointerLeave}
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
<div className="snap-num">{slot}</div>
{isEditing ? (
<input className="snap-name-input" value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={() => handleRename(slot)}
onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
autoFocus onClick={e => e.stopPropagation()} />
) : snap ? (
<div className="snap-name"
onClick={e => {
e.stopPropagation();
setEditingSlot(slot);
setEditName(snap.name);
}}>{snap.name}</div>
) : (
<div className="snap-empty">Empty</div>
)}
{isFlashing && <div className="snap-save-indicator" />}
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
</div>
);
})}
</div>
{/* Delete button for active snapshot */}
{currentSlot > 0 && snapshots[String(currentSlot)] && (
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
onClick={() => handleDelete(currentSlot)}>
Delete Snapshot {currentSlot}
</button>
</div>
)}
</div>
</SlidePanel>
);
}
// ══════════════════════════════════════════════════════════════
// APP SHELL
// ══════════════════════════════════════════════════════════════
@@ -1656,6 +1856,7 @@ const TABS = [
export default function App() {
const [tab, setTab] = useState("rig");
const [showSnapshots, setShowSnapshots] = useState(false);
const { state, connected, refresh } = usePedalState();
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
@@ -1678,6 +1879,15 @@ export default function App() {
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* Snapshot camera icon */}
<button className={`snap-header-btn ${showSnapshots ? "active" : ""}`}
onClick={() => setShowSnapshots(true)}
title="Snapshots">
📷
{state?.current_snapshot > 0 && (
<span className="snap-header-num">{state.current_snapshot}</span>
)}
</button>
{state?.cpu_percent != null && (
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span>
)}
@@ -1710,6 +1920,11 @@ export default function App() {
</div>
))}
</div>
{/* Snapshot panel overlay */}
{showSnapshots && (
<SnapshotPanel onClose={() => setShowSnapshots(false)} state={state} connected={connected} />
)}
</div>
</>
);