From 12e793aa7e965f4b07bf2f84a6b76a8609d1be89 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 23 Feb 2026 09:57:12 +0200 Subject: [PATCH] CODEX - Added Modal Playback for Archetypes --- frontend/src/melodies/MelodyDetail.jsx | 75 +++++- frontend/src/melodies/MelodyForm.jsx | 67 ++++-- frontend/src/melodies/MelodyList.jsx | 293 +++++++++++++++++++++++- frontend/src/melodies/PlaybackModal.jsx | 176 ++++++++++++-- 4 files changed, 554 insertions(+), 57 deletions(-) diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 475d895..409beb1 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -47,6 +47,13 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) { return Math.round(a * Math.pow(b / a, t)); } +function normalizeFileUrl(url) { + if (!url || typeof url !== "string") return null; + if (url.startsWith("http") || url.startsWith("/api")) return url; + if (url.startsWith("/")) return `/api${url}`; + return `/api/${url}`; +} + function hueForDepth(index, count) { const safeCount = Math.max(1, count); const t = Math.max(0, Math.min(1, index / safeCount)); @@ -121,6 +128,7 @@ export default function MelodyDetail() { const [codeCopied, setCodeCopied] = useState(false); const [showSpeedCalc, setShowSpeedCalc] = useState(false); const [showPlayback, setShowPlayback] = useState(false); + const [offlineSaving, setOfflineSaving] = useState(false); useEffect(() => { api.get("/settings/melody").then((ms) => { @@ -192,6 +200,32 @@ export default function MelodyDetail() { } }; + const handleToggleAvailableOffline = async (nextValue) => { + if (!canEdit || !melody) return; + setOfflineSaving(true); + setError(""); + try { + const body = { + information: { ...(melody.information || {}), available_offline: nextValue }, + default_settings: melody.default_settings || {}, + type: melody.type || "all", + uid: melody.uid || "", + pid: melody.pid || "", + metadata: melody.metadata || {}, + }; + if (melody.url) body.url = melody.url; + await api.put(`/melodies/${id}`, body); + setMelody((prev) => ({ + ...prev, + information: { ...(prev?.information || {}), available_offline: nextValue }, + })); + } catch (err) { + setError(err.message); + } finally { + setOfflineSaving(false); + } + }; + if (loading) { return
Loading...
; } @@ -490,10 +524,24 @@ export default function MelodyDetail() { >

Files

+ + + {(() => { // Prefer the uploaded file URL, fall back to melody.url (legacy/firebase storage URL) - const binaryUrl = files.binary_url || melody.url || null; + const binaryUrl = normalizeFileUrl(files.binary_url || melody.url || null); if (!binaryUrl) return Not uploaded; const binaryPid = builtMelody?.pid || melody.pid || "binary"; @@ -539,15 +587,26 @@ export default function MelodyDetail() { {builtMelody?.name ? ( {builtMelody.name} ) : ( - + {downloadName} - + )} + + {!files.binary_url && melody.url && ( via URL diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx index f21e5b4..9715200 100644 --- a/frontend/src/melodies/MelodyForm.jsx +++ b/frontend/src/melodies/MelodyForm.jsx @@ -31,6 +31,7 @@ const defaultInfo = { steps: 0, color: "", isTrueRing: false, + available_offline: false, previewURL: "", }; @@ -69,6 +70,13 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) { return Math.round(a * Math.pow(b / a, t)); } +function normalizeFileUrl(url) { + if (!url || typeof url !== "string") return null; + if (url.startsWith("http") || url.startsWith("/api")) return url; + if (url.startsWith("/")) return `/api${url}`; + return `/api/${url}`; +} + export default function MelodyForm() { const { id } = useParams(); const isEdit = Boolean(id); @@ -717,26 +725,53 @@ export default function MelodyForm() {

Files

+
+ updateInfo("available_offline", e.target.checked)} + className="h-4 w-4 rounded" + /> + +
{(() => { - const binaryUrl = existingFiles.binary_url || url || null; + const binaryUrl = normalizeFileUrl(existingFiles.binary_url || url || null); const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download"); const binaryName = resolveFilename(binaryUrl, fallback); return (
{binaryUrl ? ( - downloadExistingFile(binaryUrl, binaryName, e)} - className="underline" - style={{ color: "var(--accent)" }} - > + {binaryName} - + ) : ( No binary uploaded )} + {binaryUrl && ( +
+ + +
+ )}
{information.totalNotes ?? 0} active notes
); @@ -908,15 +943,17 @@ export default function MelodyForm() { multiline={translationModal.multiline} /> + setShowPlayback(false)} + /> + {isEdit && ( <> - setShowPlayback(false)} - /> Number.parseInt(part.trim(), 10)) + .filter((n) => Number.isInteger(n) && n > 0) + .reduce((mask, bell) => { + if (bell > 16) return mask; + return mask | (1 << (bell - 1)); + }, 0) & 0xffff; + } + const n = Number.parseInt(raw, 10); + if (!Number.isInteger(n) || n < 0) return 0; + if (n === 0) return 0; + if (n <= 16) return (1 << (n - 1)) & 0xffff; + return n & 0xffff; +} + +function parseArchetypeCsv(archetypeCsv) { + if (!archetypeCsv || typeof archetypeCsv !== "string") return []; + return archetypeCsv + .split(",") + .map((step) => step.trim()) + .filter(Boolean) + .map(parseStepTokenToMask); +} + +function formatHex16(value) { + return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`; +} + +function buildOfflineCppCode(rows) { + const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline)); + const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19); + + if (selected.length === 0) { + return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`; + } + + const arrays = []; + const libraryEntries = []; + + for (const row of selected) { + const info = row?.information || {}; + const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody")); + const uid = row?.uid || ""; + const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`; + const steps = parseArchetypeCsv(info.archetype_csv); + const stepCount = Number(info.steps || 0); + + arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`); + arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`); + if (steps.length === 0) { + arrays.push(" // No archetype_csv step data found"); + } else { + for (let i = 0; i < steps.length; i += 8) { + const chunk = steps.slice(i, i + 8).map(formatHex16).join(", "); + arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`); + } + } + arrays.push("};"); + arrays.push(""); + + libraryEntries.push(" {"); + libraryEntries.push(` "${escapeCppString(displayName)}",`); + libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`); + libraryEntries.push(` ${symbol},`); + libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`); + libraryEntries.push(" }"); + } + + return [ + `// Generated: ${generatedAt}`, + "", + ...arrays, + "// --- Add or replace your MELODY_LIBRARY[] with this: ---", + "const MelodyInfo MELODY_LIBRARY[] = {", + libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"), + "};", + "", + ].join("\n"); +} + export default function MelodyList() { const [melodies, setMelodies] = useState([]); const [total, setTotal] = useState(0); @@ -189,6 +291,9 @@ export default function MelodyList() { const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); const [showColumnPicker, setShowColumnPicker] = useState(false); const [showCreatorPicker, setShowCreatorPicker] = useState(false); + const [showOfflineModal, setShowOfflineModal] = useState(false); + const [builtInSavingId, setBuiltInSavingId] = useState(null); + const [viewRow, setViewRow] = useState(null); const columnPickerRef = useRef(null); const creatorPickerRef = useRef(null); const navigate = useNavigate(); @@ -323,6 +428,41 @@ export default function MelodyList() { } }; + const openBinaryView = (e, row) => { + e.stopPropagation(); + setViewRow(row); + }; + + const updateBuiltInState = async (e, row, nextValue) => { + e.stopPropagation(); + if (!canEdit) return; + setBuiltInSavingId(row.id); + setError(""); + try { + const body = { + information: { ...(row.information || {}), available_offline: nextValue }, + default_settings: row.default_settings || {}, + type: row.type || "all", + uid: row.uid || "", + pid: row.pid || "", + metadata: row.metadata || {}, + }; + if (row.url) body.url = row.url; + await api.put(`/melodies/${row.id}`, body); + setMelodies((prev) => + prev.map((m) => + m.id === row.id + ? { ...m, information: { ...(m.information || {}), available_offline: nextValue } } + : m + ) + ); + } catch (err) { + setError(err.message); + } finally { + setBuiltInSavingId(null); + } + }; + const toggleColumn = (key) => { const col = ALL_COLUMNS.find((c) => c.key === key); if (col?.alwaysOn) return; @@ -403,6 +543,8 @@ export default function MelodyList() { }); }, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps + const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]); + const handleSortClick = (columnKey) => { const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey]; if (!nextSortKey) return; @@ -634,6 +776,36 @@ export default function MelodyList() { ) : ( "-" ); + case "builtIn": { + const enabled = Boolean(info.available_offline); + const saving = builtInSavingId === row.id; + return ( + + ); + } case "binaryFile": { const binaryUrl = getBinaryUrl(row); const filename = getBinaryFilename(row); @@ -648,15 +820,26 @@ export default function MelodyList() { } return ( - + {filename || "binary.bsm"} + + + + {totalNotes} active notes ); @@ -902,9 +1085,25 @@ export default function MelodyList() { )}
- - {displayRows.length} / {total} {total === 1 ? "melody" : "melodies"} - +
+ {canEdit && ( + + )} + + {displayRows.length} / {total} {total === 1 ? "melody" : "melodies"} + +
@@ -1066,6 +1265,76 @@ export default function MelodyList() { onConfirm={handleUnpublish} onCancel={() => setUnpublishTarget(null)} /> + + {showOfflineModal && ( +
setShowOfflineModal(false)} + > +
e.stopPropagation()} + > +
+
+

Offline Built-In Code

+

+ Includes melodies where Built-in = Yes +

+
+
+ + +
+
+
+
+                {offlineCode}
+              
+
+
+
+ )} + + setViewRow(null)} + /> ); } diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx index f506d9c..84506d4 100644 --- a/frontend/src/melodies/PlaybackModal.jsx +++ b/frontend/src/melodies/PlaybackModal.jsx @@ -50,6 +50,13 @@ function parseStepsString(stepsStr) { return stepsStr.trim().split(",").map((s) => parseBellNotation(s)); } +function normalizePlaybackUrl(url) { + if (!url || typeof url !== "string") return null; + if (url.startsWith("http") || url.startsWith("/api")) return url; + if (url.startsWith("/")) return `/api${url}`; + return `/api/${url}`; +} + async function decodeBsmBinary(url) { // Try with auth token first (for our API endpoints), then without (for Firebase URLs) const token = localStorage.getItem("access_token"); @@ -143,6 +150,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const [currentStep, setCurrentStep] = useState(-1); const [speedPercent, setSpeedPercent] = useState(50); const [toneLengthMs, setToneLengthMs] = useState(80); + const [loopEnabled, setLoopEnabled] = useState(true); // activeBells: Set of bell numbers currently lit (for flash effect) const [activeBells, setActiveBells] = useState(new Set()); @@ -153,6 +161,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const speedMsRef = useRef(500); const toneLengthRef = useRef(80); const noteAssignmentsRef = useRef(noteAssignments); + const loopEnabledRef = useRef(true); const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500; @@ -160,6 +169,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]); useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]); useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]); const stopPlayback = useCallback(() => { if (playbackRef.current) { @@ -180,11 +190,39 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet setCurrentStep(-1); setLoadError(""); setSpeedPercent(50); + setLoopEnabled(true); setActiveBells(new Set()); return; } + const binaryUrlCandidate = builtMelody?.binary_url + ? `/api${builtMelody.binary_url}` + : files?.binary_url || melody?.url || null; + const binaryUrl = normalizePlaybackUrl(binaryUrlCandidate); const csv = archetypeCsv || info.archetype_csv || null; + + if (binaryUrl) { + setLoading(true); + setLoadError(""); + decodeBsmBinary(binaryUrl) + .then((decoded) => { + setSteps(decoded); + stepsRef.current = decoded; + }) + .catch((err) => { + if (csv) { + const parsed = parseStepsString(csv); + setSteps(parsed); + stepsRef.current = parsed; + setLoadError(""); + return; + } + setLoadError(err.message); + }) + .finally(() => setLoading(false)); + return; + } + if (csv) { const parsed = parseStepsString(csv); setSteps(parsed); @@ -193,25 +231,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet return; } - // Fall back to binary - const binaryUrl = builtMelody?.binary_url - ? `/api${builtMelody.binary_url}` - : files?.binary_url || melody?.url || null; - - if (!binaryUrl) { - setLoadError("No binary or archetype data available for this melody."); - return; - } - - setLoading(true); - setLoadError(""); - decodeBsmBinary(binaryUrl) - .then((decoded) => { - setSteps(decoded); - stepsRef.current = decoded; - }) - .catch((err) => setLoadError(err.message)) - .finally(() => setLoading(false)); + setLoadError("No binary or archetype data available for this melody."); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps const ensureAudioCtx = () => { @@ -255,11 +275,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet // Schedule next step after step interval const timer = setTimeout(() => { const next = playFrom + 1; - scheduleStep(next >= stepsRef.current.length ? 0 : next); + if (next >= stepsRef.current.length) { + if (loopEnabledRef.current) { + scheduleStep(0); + } else { + stopPlayback(); + } + return; + } + scheduleStep(next); }, speedMsRef.current); playbackRef.current = { timer, flashTimer, stepIndex: playFrom }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps const handlePlay = () => { if (!stepsRef.current.length) return; @@ -286,6 +314,18 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0; const hasSpeedInfo = minSpeed != null && maxSpeed != null; + const detectedNoteCount = steps.reduce((max, stepValue) => { + let highest = 0; + for (let bit = 15; bit >= 0; bit--) { + if (stepValue & (1 << bit)) { + highest = bit + 1; + break; + } + } + return Math.max(max, highest); + }, 0); + const configuredNoteCount = Number(info.totalNotes || noteAssignments.length || 0); + const gridNoteCount = Math.max(1, Math.min(16, configuredNoteCount || detectedNoteCount || 1)); return (
)} - Loops continuously + +
+ + {/* Steps matrix */} +
+

Note/Step Matrix

+
+ + + + + {steps.map((_, stepIdx) => ( + + ))} + + + + {Array.from({ length: gridNoteCount }, (_, noteIdx) => ( + + + {steps.map((stepValue, stepIdx) => { + const enabled = Boolean(stepValue & (1 << noteIdx)); + const isCurrent = currentStep === stepIdx; + return ( + + ); + })} + + ))} + +
+ Note \ Step + + {stepIdx + 1} +
+ {NOTE_LABELS[noteIdx]} + +
+
{/* Speed Slider */}