improvemtns again, to the archetype builder, and playback
This commit is contained in:
@@ -28,6 +28,7 @@ class MelodyInfo(BaseModel):
|
||||
color: str = ""
|
||||
isTrueRing: bool = False
|
||||
previewURL: str = ""
|
||||
archetype_csv: Optional[str] = None
|
||||
|
||||
|
||||
class MelodyAttributes(BaseModel):
|
||||
|
||||
@@ -5,6 +5,25 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
||||
import PlaybackModal from "./PlaybackModal";
|
||||
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function copyText(text, onSuccess) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
|
||||
} else {
|
||||
fallbackCopy(text, onSuccess);
|
||||
}
|
||||
}
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getLanguageName,
|
||||
@@ -415,12 +434,7 @@ export default function MelodyDetail() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(builtMelody.progmem_code).then(() => {
|
||||
setCodeCopied(true);
|
||||
setTimeout(() => setCodeCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
onClick={() => copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000); })}
|
||||
className="px-3 py-1.5 text-xs rounded transition-colors"
|
||||
style={{
|
||||
backgroundColor: codeCopied ? "var(--success-bg)" : "var(--bg-card-hover)",
|
||||
@@ -453,6 +467,7 @@ export default function MelodyDetail() {
|
||||
melody={melody}
|
||||
builtMelody={builtMelody}
|
||||
files={files}
|
||||
archetypeCsv={melody?.information?.archetype_csv || null}
|
||||
onClose={() => setShowPlayback(false)}
|
||||
/>
|
||||
|
||||
@@ -460,6 +475,7 @@ export default function MelodyDetail() {
|
||||
open={showSpeedCalc}
|
||||
melody={melody}
|
||||
builtMelody={builtMelody}
|
||||
archetypeCsv={melody?.information?.archetype_csv || null}
|
||||
onClose={() => setShowSpeedCalc(false)}
|
||||
onSaved={() => {
|
||||
setShowSpeedCalc(false);
|
||||
|
||||
@@ -638,12 +638,14 @@ export default function MelodyForm() {
|
||||
open={showSpeedCalc}
|
||||
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
|
||||
builtMelody={builtMelody}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
onClose={() => setShowSpeedCalc(false)}
|
||||
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
|
||||
/>
|
||||
<SelectBuiltMelodyModal
|
||||
open={showSelectBuilt}
|
||||
melodyId={id}
|
||||
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
||||
onClose={() => setShowSelectBuilt(false)}
|
||||
onSuccess={(archetype) => {
|
||||
setShowSelectBuilt(false);
|
||||
@@ -655,6 +657,7 @@ export default function MelodyForm() {
|
||||
<BuildOnTheFlyModal
|
||||
open={showBuildOnTheFly}
|
||||
melodyId={id}
|
||||
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
||||
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
||||
defaultPid={pid}
|
||||
onClose={() => setShowBuildOnTheFly(false)}
|
||||
|
||||
@@ -42,6 +42,22 @@ function getActiveBells(stepValue) {
|
||||
return bells;
|
||||
}
|
||||
|
||||
function parseBellNotation(notation) {
|
||||
notation = notation.trim();
|
||||
if (notation === "0" || !notation) return 0;
|
||||
let value = 0;
|
||||
for (const part of notation.split("+")) {
|
||||
const n = parseInt(part.trim(), 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStepsString(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return [];
|
||||
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
|
||||
}
|
||||
|
||||
async function decodeBsmBinary(url) {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const res = await fetch(url, {
|
||||
@@ -81,7 +97,7 @@ const BEAT_DURATION_MS = 80; // fixed tone length for playback
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
const labelStyle = { color: "var(--text-secondary)" };
|
||||
|
||||
export default function PlaybackModal({ open, melody, builtMelody, files, onClose }) {
|
||||
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
||||
const info = melody?.information || {};
|
||||
const minSpeed = info.minSpeed || null;
|
||||
const maxSpeed = info.maxSpeed || null;
|
||||
@@ -115,7 +131,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, onClos
|
||||
setCurrentStep(-1);
|
||||
}, []);
|
||||
|
||||
// Load binary on open
|
||||
// Load steps on open — prefer archetype_csv, fall back to binary
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
stopPlayback();
|
||||
@@ -126,14 +142,23 @@ export default function PlaybackModal({ open, melody, builtMelody, files, onClos
|
||||
return;
|
||||
}
|
||||
|
||||
// builtMelody.binary_url is a relative path needing /api prefix;
|
||||
// files.binary_url from the /files endpoint is already a full URL path.
|
||||
// Prefer CSV from archetype (no network request needed)
|
||||
const csv = archetypeCsv || info.archetype_csv || null;
|
||||
if (csv) {
|
||||
const parsed = parseStepsString(csv);
|
||||
setSteps(parsed);
|
||||
stepsRef.current = parsed;
|
||||
setLoadError("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to binary fetch
|
||||
const binaryUrl = builtMelody?.binary_url
|
||||
? `/api${builtMelody.binary_url}`
|
||||
: files?.binary_url || null;
|
||||
|
||||
if (!binaryUrl) {
|
||||
setLoadError("No binary file available for this melody.");
|
||||
setLoadError("No binary or archetype data available for this melody.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const DELAY_MAX = 3000;
|
||||
function delayToSlider(ms) { return DELAY_MIN + DELAY_MAX - ms; }
|
||||
function sliderToDelay(val) { return DELAY_MIN + DELAY_MAX - val; }
|
||||
|
||||
export default function SpeedCalculatorModal({ open, melody, builtMelody, onClose, onSaved }) {
|
||||
export default function SpeedCalculatorModal({ open, melody, builtMelody, archetypeCsv, onClose, onSaved }) {
|
||||
const info = melody?.information || {};
|
||||
|
||||
// Raw steps input
|
||||
@@ -164,19 +164,28 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, onClos
|
||||
const maxWarning = capturedMax !== null && capturedMax < 100;
|
||||
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal < capturedMax;
|
||||
|
||||
// Reset on open
|
||||
// Reset on open — auto-load archetype CSV if available
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null);
|
||||
setCapturedNormal(null);
|
||||
setStepsInput("");
|
||||
setSteps([]);
|
||||
setBinaryLoadError("");
|
||||
setCurrentStep(-1);
|
||||
setPlaying(false);
|
||||
setPaused(false);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
|
||||
const csv = archetypeCsv || info.archetype_csv || null;
|
||||
if (csv) {
|
||||
const parsed = parseStepsString(csv);
|
||||
setStepsInput(csv);
|
||||
setSteps(parsed);
|
||||
stepsRef.current = parsed;
|
||||
} else {
|
||||
setStepsInput("");
|
||||
setSteps([]);
|
||||
}
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -10,7 +10,20 @@ function countSteps(stepsStr) {
|
||||
return stepsStr.trim().split(",").length;
|
||||
}
|
||||
|
||||
export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaultPid, onClose, onSuccess }) {
|
||||
function computeStepsAndNotes(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
|
||||
const tokens = stepsStr.trim().split(",");
|
||||
const bellSet = new Set();
|
||||
for (const token of tokens) {
|
||||
for (const part of token.split("+")) {
|
||||
const n = parseInt(part.trim(), 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
|
||||
}
|
||||
}
|
||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
||||
}
|
||||
|
||||
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
|
||||
const [name, setName] = useState(defaultName || "");
|
||||
const [pid, setPid] = useState(defaultPid || "");
|
||||
const [steps, setSteps] = useState("");
|
||||
@@ -57,8 +70,29 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul
|
||||
setStatusMsg("Linking to melody...");
|
||||
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
||||
|
||||
// Step 5: Update the melody's information with archetype_csv, steps, and totalNotes
|
||||
setStatusMsg("Saving archetype data...");
|
||||
const csv = steps.trim();
|
||||
const { steps: stepCount, totalNotes } = computeStepsAndNotes(csv);
|
||||
if (currentMelody && csv) {
|
||||
const existingInfo = currentMelody.information || {};
|
||||
await api.put(`/melodies/${melodyId}`, {
|
||||
information: {
|
||||
...existingInfo,
|
||||
archetype_csv: csv,
|
||||
steps: stepCount,
|
||||
totalNotes,
|
||||
},
|
||||
default_settings: currentMelody.default_settings,
|
||||
type: currentMelody.type,
|
||||
url: currentMelody.url,
|
||||
uid: currentMelody.uid,
|
||||
pid: currentMelody.pid,
|
||||
});
|
||||
}
|
||||
|
||||
setStatusMsg("Done!");
|
||||
onSuccess({ name: name.trim(), pid: pid.trim() });
|
||||
onSuccess({ name: name.trim(), pid: pid.trim(), steps: stepCount, totalNotes, archetype_csv: csv });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStatusMsg("");
|
||||
|
||||
@@ -7,6 +7,24 @@ const labelStyle = { color: "var(--text-secondary)" };
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||
|
||||
function copyText(text, onSuccess) {
|
||||
const fallback = () => {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
};
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
function countSteps(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return 0;
|
||||
return stepsStr.trim().split(",").length;
|
||||
@@ -128,10 +146,7 @@ export default function BuilderForm() {
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!progmemCode) return;
|
||||
navigator.clipboard.writeText(progmemCode).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -3,6 +3,25 @@ import { useNavigate } from "react-router-dom";
|
||||
import api from "../../api/client";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function copyText(text, onSuccess) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
|
||||
} else {
|
||||
fallbackCopy(text, onSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
function CodeSnippetModal({ melody, onClose }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
if (!melody) return null;
|
||||
@@ -23,12 +42,7 @@ function CodeSnippetModal({ melody, onClose }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(melody.progmem_code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
onClick={() => copyText(melody.progmem_code, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); })}
|
||||
className="px-3 py-1.5 text-xs rounded transition-colors"
|
||||
style={{
|
||||
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../../api/client";
|
||||
|
||||
export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSuccess }) {
|
||||
function computeStepsAndNotes(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
|
||||
const tokens = stepsStr.trim().split(",");
|
||||
const bellSet = new Set();
|
||||
for (const token of tokens) {
|
||||
for (const part of token.split("+")) {
|
||||
const n = parseInt(part.trim(), 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
|
||||
}
|
||||
}
|
||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
||||
}
|
||||
|
||||
export default function SelectBuiltMelodyModal({ open, melodyId, currentMelody, onClose, onSuccess }) {
|
||||
const [melodies, setMelodies] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assigning, setAssigning] = useState(null); // id of the one being assigned
|
||||
@@ -44,7 +57,27 @@ 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({ name: builtMelody.name, pid: builtMelody.pid });
|
||||
// 4. Update the melody's information with archetype_csv, steps, and totalNotes
|
||||
const csv = builtMelody.steps || "";
|
||||
const { steps, totalNotes } = computeStepsAndNotes(csv);
|
||||
if (currentMelody && csv) {
|
||||
const existingInfo = currentMelody.information || {};
|
||||
await api.put(`/melodies/${melodyId}`, {
|
||||
information: {
|
||||
...existingInfo,
|
||||
archetype_csv: csv,
|
||||
steps,
|
||||
totalNotes,
|
||||
},
|
||||
default_settings: currentMelody.default_settings,
|
||||
type: currentMelody.type,
|
||||
url: currentMelody.url,
|
||||
uid: currentMelody.uid,
|
||||
pid: currentMelody.pid,
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess({ name: builtMelody.name, pid: builtMelody.pid, steps, totalNotes, archetype_csv: csv });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user