From cca4aed3805aaf0a896555e453609a527ea7f820 Mon Sep 17 00:00:00 2001 From: Shawn Date: Sun, 14 Jun 2026 13:43:47 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20GlobalSettings=20=E2=80=94=20add=20Save/?= =?UTF-8?q?Discard=20button=20pattern=20with=20visible=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed auto-save-on-every-change (was silent, confusing) - Added local draft state: changes accumulate until user clicks Save - Added 'Unsaved' indicator when there are pending changes - Added 'Save' button (amber, disabled when no changes) - Added 'Discard' button to revert to last-saved state - Shows '✓ Saved' green feedback for 2s after success - Shows '✗ Save failed' on error - Bulk-saves all changed settings in one POST /api/settings call - Instantly routes master_volume/routing/audio_profile via dedicated endpoints --- src/GlobalSettings.jsx | 113 ++++++++++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 23 deletions(-) diff --git a/src/GlobalSettings.jsx b/src/GlobalSettings.jsx index 37b5f9d..4bd268e 100644 --- a/src/GlobalSettings.jsx +++ b/src/GlobalSettings.jsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; const T = { bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", @@ -292,7 +292,10 @@ function BpmWidget({ label, value, onChange }) { export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) { const [values, setValues] = useState(buildDefaults()); + const [savedValues, setSavedValues] = useState(buildDefaults()); const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); // "saved" | "error" | null useEffect(() => { let cancelled = false; @@ -316,41 +319,79 @@ export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile if (routing.routing_mode) overrides.routing_mode = routing.routing_mode; if (routing.routing_breakpoint != null) overrides.routing_breakpoint = String(routing.routing_breakpoint); } - setValues(prev => ({ ...prev, ...overrides })); + const merged = { ...buildDefaults(), ...overrides }; + setValues(merged); + setSavedValues(merged); setLoaded(true); }); return () => { cancelled = true; }; }, []); + const hasChanges = useMemo(() => { + return Object.keys(values).some(k => values[k] !== savedValues[k]); + }, [values, savedValues]); + const groups = Object.entries(SETTINGS); const update = useCallback((key, val) => { - setValues(prev => { - const next = { ...prev, [key]: val }; + setValues(prev => ({ ...prev, [key]: val })); + setSaveMsg(null); + }, []); - // Route via dedicated endpoints - if (key === "master_volume") onMasterVolume?.(val); - - else if (key === "sample_rate" || key === "buffer_size") { - const rate = key === "sample_rate" ? val : (next.sample_rate || "48000"); - const period = key === "buffer_size" ? val : (next.buffer_size || "512"); - onAudioProfile?.(parseInt(rate), parseInt(period)); + const handleSave = useCallback(async () => { + setSaving(true); + setSaveMsg(null); + const changed = {}; + for (const k of Object.keys(values)) { + if (values[k] !== savedValues[k]) { + changed[k] = values[k]; } + } + // Apply instant-effect settings via dedicated endpoints first + const promises = []; + if (changed.master_volume != null) { + onMasterVolume?.(changed.master_volume); + } + if (changed.sample_rate != null || changed.buffer_size != null) { + const rate = changed.sample_rate || values.sample_rate || "48000"; + const period = changed.buffer_size || values.buffer_size || "512"; + onAudioProfile?.(parseInt(rate), parseInt(period)); + } + if (changed.routing_mode != null || changed.routing_breakpoint != null) { + const mode = changed.routing_mode || values.routing_mode || "mono"; + const bp = changed.routing_breakpoint || values.routing_breakpoint || "7"; + onRoutingChange?.(mode, parseInt(bp)); + } - else if (key === "routing_mode" || key === "routing_breakpoint") { - const mode = key === "routing_mode" ? val : (next.routing_mode || "mono"); - const bp = key === "routing_breakpoint" ? val : (next.routing_breakpoint || "7"); - onRoutingChange?.(mode, parseInt(bp)); - } + // Everything else → bulk POST /api/settings + const settingsChanged = Object.fromEntries( + Object.entries(changed).filter(([k]) => + !["master_volume", "sample_rate", "buffer_size", "routing_mode", "routing_breakpoint"].includes(k) + ) + ); + if (Object.keys(settingsChanged).length > 0) { + promises.push( + api("POST", "/api/settings", settingsChanged) + .then(() => {}) + .catch(() => { throw new Error("Save failed"); }) + ); + } - else { - // All other settings → POST /api/settings (generic persistence) - onSaveSettings?.({ [key]: val }); - } + try { + await Promise.all(promises); + setSavedValues({ ...values }); + setSaveMsg("saved"); + setTimeout(() => { setSaveMsg(null); }, 2000); + } catch { + setSaveMsg("error"); + } + setSaving(false); + }, [values, savedValues, onMasterVolume, onAudioProfile, onRoutingChange]); - return next; - }); - }, [onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings]); + const handleDiscard = useCallback(() => { + setValues({ ...savedValues }); + setSaveMsg(null); + }, [savedValues]); return (
@@ -362,6 +403,32 @@ export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile {!loaded && Loading…}
+
+ {saveMsg === "saved" && ( + ✓ Saved + )} + {saveMsg === "error" && ( + ✗ Save failed + )} + {hasChanges && ( + Unsaved + )} + {hasChanges && ( + + )} + +