diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index a101d37..7211742 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -4,6 +4,7 @@ import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; import SpeedCalculatorModal from "./SpeedCalculatorModal"; +import PlaybackModal from "./PlaybackModal"; import { getLocalizedValue, getLanguageName, @@ -40,6 +41,7 @@ export default function MelodyDetail() { const [builtMelody, setBuiltMelody] = useState(null); const [codeCopied, setCodeCopied] = useState(false); const [showSpeedCalc, setShowSpeedCalc] = useState(false); + const [showPlayback, setShowPlayback] = useState(false); useEffect(() => { api.get("/settings/melody").then((ms) => { @@ -200,6 +202,13 @@ export default function MelodyDetail() { Unpublish )} + + + + {/* Body */} +
+ {/* Loading / error states */} + {loading && ( +

Loading binary...

+ )} + {loadError && ( +
+ {loadError} +
+ )} + + {!loading && !loadError && totalSteps > 0 && ( + <> + {/* Step info */} +
+ + {totalSteps} steps  ·  {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""} + + {currentStep >= 0 && ( + + Step {currentStep + 1} / {totalSteps} + + )} +
+ + {/* Bell visualizer */} +
+ {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { + const isActive = currentBells.includes(b); + const isUsed = allBellsUsed.has(b); + return ( +
+ {b} +
+ ); + })} +
+ + {/* Play / Stop */} +
+ {!playing ? ( + + ) : ( + + )} + Loops continuously +
+ + {/* Speed Slider */} +
+
+ +
+ + {speedPercent}% + + {hasSpeedInfo && ( + + ({speedMs} ms) + + )} +
+
+ setSpeedPercent(Number(e.target.value))} + className="w-full h-2 rounded-lg appearance-none cursor-pointer" + /> +
+ 1% (slowest) + 100% (fastest) +
+ {!hasSpeedInfo && ( +

+ No MIN/MAX speed set for this melody — using linear fallback. +

+ )} +
+ + )} +
+ + {/* Footer */} +
+ +
+ + + ); +} diff --git a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx index 740a1f7..2a95071 100644 --- a/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx +++ b/frontend/src/melodies/builder/BuildOnTheFlyModal.jsx @@ -58,7 +58,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`); setStatusMsg("Done!"); - onSuccess(); + onSuccess({ name: name.trim(), pid: pid.trim() }); } catch (err) { setError(err.message); setStatusMsg(""); diff --git a/frontend/src/melodies/builder/BuilderForm.jsx b/frontend/src/melodies/builder/BuilderForm.jsx index 15c36c2..8714c9d 100644 --- a/frontend/src/melodies/builder/BuilderForm.jsx +++ b/frontend/src/melodies/builder/BuilderForm.jsx @@ -12,6 +12,21 @@ function countSteps(stepsStr) { return stepsStr.trim().split(",").length; } +async function downloadBinary(binaryUrl, filename) { + const token = localStorage.getItem("access_token"); + const res = await fetch(`/api${binaryUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + export default function BuilderForm() { const { id } = useParams(); const isEdit = Boolean(id); @@ -229,13 +244,14 @@ export default function BuilderForm() { {buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"} {binaryUrl && ( - downloadBinary(binaryUrl, `${name}.bsm`).catch((e) => setError(e.message))} + className="block w-full text-center text-xs underline cursor-pointer" + style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }} > Download {name}.bsm - + )} diff --git a/frontend/src/melodies/builder/BuilderList.jsx b/frontend/src/melodies/builder/BuilderList.jsx index 8d02b24..ac8dbcc 100644 --- a/frontend/src/melodies/builder/BuilderList.jsx +++ b/frontend/src/melodies/builder/BuilderList.jsx @@ -3,6 +3,55 @@ import { useNavigate } from "react-router-dom"; import api from "../../api/client"; import ConfirmDialog from "../../components/ConfirmDialog"; +function CodeSnippetModal({ melody, onClose }) { + const [copied, setCopied] = useState(false); + if (!melody) return null; + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+
+

Firmware Code Snippet

+

{melody.name} · PID: {melody.pid || "—"}

+
+
+ + +
+
+
+          {melody.progmem_code}
+        
+
+
+ ); +} + const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }; export default function BuilderList() { @@ -11,6 +60,8 @@ export default function BuilderList() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false); + const [codeSnippetMelody, setCodeSnippetMelody] = useState(null); useEffect(() => { loadMelodies(); @@ -46,6 +97,22 @@ export default function BuilderList() { return stepsStr.split(",").length; }; + const handleDownloadBinary = async (e, m) => { + e.stopPropagation(); + const token = localStorage.getItem("access_token"); + const res = await fetch(`/api/builder/melodies/${m.id}/download`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${m.name || m.id}.bsm`; + a.click(); + URL.revokeObjectURL(url); + }; + return (
@@ -54,7 +121,7 @@ export default function BuilderList() { Archetype Builder

- Build binary (.bsm) files and firmware PROGMEM code from melody step notation. + Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.

)} + {/* Two-stage delete: warn if assigned, then confirm */} + {deleteTarget && !deleteWarningConfirmed && (deleteTarget.assigned_melody_ids?.length || 0) > 0 && ( +
+
+

Archetype In Use

+
+ "{deleteTarget.name}" is currently assigned to{" "} + {deleteTarget.assigned_melody_ids.length} melody{deleteTarget.assigned_melody_ids.length !== 1 ? "ies" : "y"}. + Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary. +
+

Do you still want to delete this archetype?

+
+ + +
+
+
+ )} + setDeleteTarget(null)} + onCancel={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }} + /> + + setCodeSnippetMelody(null)} /> ); diff --git a/frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx b/frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx index 3d1896b..11f6225 100644 --- a/frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx +++ b/frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx @@ -44,7 +44,7 @@ export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSucc // 3. Mark this built melody as assigned to this Firestore melody await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`); - onSuccess(); + onSuccess({ name: builtMelody.name, pid: builtMelody.pid }); } catch (err) { setError(err.message); } finally {