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