Various more changes to the Archetype Builder
This commit is contained in:
@@ -4,6 +4,7 @@ import api from "../api/client";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
||||||
|
import PlaybackModal from "./PlaybackModal";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -40,6 +41,7 @@ export default function MelodyDetail() {
|
|||||||
const [builtMelody, setBuiltMelody] = useState(null);
|
const [builtMelody, setBuiltMelody] = useState(null);
|
||||||
const [codeCopied, setCodeCopied] = useState(false);
|
const [codeCopied, setCodeCopied] = useState(false);
|
||||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
||||||
|
const [showPlayback, setShowPlayback] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
@@ -200,6 +202,13 @@ export default function MelodyDetail() {
|
|||||||
Unpublish
|
Unpublish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlayback(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Playback
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSpeedCalc(true)}
|
onClick={() => setShowSpeedCalc(true)}
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
@@ -303,7 +312,6 @@ export default function MelodyDetail() {
|
|||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Document ID">{melody.id}</Field>
|
<Field label="Document ID">{melody.id}</Field>
|
||||||
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
||||||
<Field label="UID">{melody.uid}</Field>
|
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<Field label="URL">{melody.url}</Field>
|
<Field label="URL">{melody.url}</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,6 +448,14 @@ export default function MelodyDetail() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PlaybackModal
|
||||||
|
open={showPlayback}
|
||||||
|
melody={melody}
|
||||||
|
builtMelody={builtMelody}
|
||||||
|
files={files}
|
||||||
|
onClose={() => setShowPlayback(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SpeedCalculatorModal
|
<SpeedCalculatorModal
|
||||||
open={showSpeedCalc}
|
open={showSpeedCalc}
|
||||||
melody={melody}
|
melody={melody}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function MelodyForm() {
|
|||||||
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
|
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
|
||||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
||||||
const [builtMelody, setBuiltMelody] = useState(null);
|
const [builtMelody, setBuiltMelody] = useState(null);
|
||||||
|
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
@@ -131,6 +132,7 @@ export default function MelodyForm() {
|
|||||||
try {
|
try {
|
||||||
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
|
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
|
||||||
setBuiltMelody(bm || null);
|
setBuiltMelody(bm || null);
|
||||||
|
setAssignedBinaryName(bm?.name || null);
|
||||||
} catch {
|
} catch {
|
||||||
setBuiltMelody(null);
|
setBuiltMelody(null);
|
||||||
}
|
}
|
||||||
@@ -498,10 +500,6 @@ export default function MelodyForm() {
|
|||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
||||||
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
|
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>UID (leave empty for now)</label>
|
|
||||||
<input type="text" value={uid} onChange={(e) => setUid(e.target.value)} className={inputClass} />
|
|
||||||
</div>
|
|
||||||
{isEdit && url && (
|
{isEdit && url && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
|
||||||
@@ -579,7 +577,13 @@ export default function MelodyForm() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
|
||||||
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
|
{existingFiles.binary_url && (
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>
|
||||||
|
{assignedBinaryName
|
||||||
|
? <><strong>{assignedBinaryName}.bsm</strong> is set. Selecting a new file will replace it.</>
|
||||||
|
: "Current file uploaded. Selecting a new file will replace it."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
@@ -641,8 +645,10 @@ export default function MelodyForm() {
|
|||||||
open={showSelectBuilt}
|
open={showSelectBuilt}
|
||||||
melodyId={id}
|
melodyId={id}
|
||||||
onClose={() => setShowSelectBuilt(false)}
|
onClose={() => setShowSelectBuilt(false)}
|
||||||
onSuccess={() => {
|
onSuccess={(archetype) => {
|
||||||
setShowSelectBuilt(false);
|
setShowSelectBuilt(false);
|
||||||
|
setAssignedBinaryName(archetype.name);
|
||||||
|
if (!pid.trim() && archetype.pid) setPid(archetype.pid);
|
||||||
loadMelody();
|
loadMelody();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -652,8 +658,10 @@ export default function MelodyForm() {
|
|||||||
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
||||||
defaultPid={pid}
|
defaultPid={pid}
|
||||||
onClose={() => setShowBuildOnTheFly(false)}
|
onClose={() => setShowBuildOnTheFly(false)}
|
||||||
onSuccess={() => {
|
onSuccess={(archetype) => {
|
||||||
setShowBuildOnTheFly(false);
|
setShowBuildOnTheFly(false);
|
||||||
|
setAssignedBinaryName(archetype.name);
|
||||||
|
if (!pid.trim() && archetype.pid) setPid(archetype.pid);
|
||||||
loadMelody();
|
loadMelody();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
375
frontend/src/melodies/PlaybackModal.jsx
Normal file
375
frontend/src/melodies/PlaybackModal.jsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Web Audio Engine (shared with SpeedCalculatorModal pattern)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function bellFrequency(bellNumber) {
|
||||||
|
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function playStep(audioCtx, stepValue, beatDurationMs) {
|
||||||
|
if (!stepValue || !audioCtx) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
const duration = beatDurationMs / 1000;
|
||||||
|
const fadeIn = 0.005;
|
||||||
|
const fadeOut = 0.03;
|
||||||
|
|
||||||
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
|
if (stepValue & (1 << bit)) {
|
||||||
|
const freq = bellFrequency(bit + 1);
|
||||||
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtx.destination);
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(freq, now);
|
||||||
|
gain.gain.setValueAtTime(0, now);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.3, now + fadeIn);
|
||||||
|
gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001));
|
||||||
|
gain.gain.linearRampToValueAtTime(0, now + duration);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBells(stepValue) {
|
||||||
|
const bells = [];
|
||||||
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
|
if (stepValue & (1 << bit)) bells.push(bit + 1);
|
||||||
|
}
|
||||||
|
return bells;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeBsmBinary(url) {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const view = new DataView(buf);
|
||||||
|
const steps = [];
|
||||||
|
for (let i = 0; i + 1 < buf.byteLength; i += 2) {
|
||||||
|
steps.push(view.getUint16(i, false));
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Speed math — exponential mapping matching the Flutter app:
|
||||||
|
// value = minSpeed * pow(maxSpeed / minSpeed, t) where t = percent / 100
|
||||||
|
// Note: in this system, MIN ms > MAX ms (MIN = slowest, MAX = fastest).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
||||||
|
if (minSpeed == null || maxSpeed == null) return null;
|
||||||
|
const t = Math.max(0, Math.min(100, percent)) / 100;
|
||||||
|
const a = minSpeed;
|
||||||
|
const b = maxSpeed;
|
||||||
|
if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t);
|
||||||
|
return Math.round(a * Math.pow(b / a, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
const info = melody?.information || {};
|
||||||
|
const minSpeed = info.minSpeed || null;
|
||||||
|
const maxSpeed = info.maxSpeed || null;
|
||||||
|
|
||||||
|
const [steps, setSteps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(-1);
|
||||||
|
const [speedPercent, setSpeedPercent] = useState(50);
|
||||||
|
|
||||||
|
const audioCtxRef = useRef(null);
|
||||||
|
const playbackRef = useRef(null);
|
||||||
|
const stepsRef = useRef([]);
|
||||||
|
const speedMsRef = useRef(500);
|
||||||
|
|
||||||
|
// Derived speed in ms from the current percent
|
||||||
|
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
|
||||||
|
|
||||||
|
// Keep refs in sync so the playback loop reads live values
|
||||||
|
useEffect(() => { stepsRef.current = steps; }, [steps]);
|
||||||
|
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(() => {
|
||||||
|
if (playbackRef.current) {
|
||||||
|
clearTimeout(playbackRef.current.timer);
|
||||||
|
playbackRef.current = null;
|
||||||
|
}
|
||||||
|
setPlaying(false);
|
||||||
|
setCurrentStep(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load binary on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
stopPlayback();
|
||||||
|
setSteps([]);
|
||||||
|
setCurrentStep(-1);
|
||||||
|
setLoadError("");
|
||||||
|
setSpeedPercent(50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtMelody.binary_url is a relative path needing /api prefix;
|
||||||
|
// files.binary_url from the /files endpoint is already a full URL path.
|
||||||
|
const binaryUrl = builtMelody?.binary_url
|
||||||
|
? `/api${builtMelody.binary_url}`
|
||||||
|
: files?.binary_url || null;
|
||||||
|
|
||||||
|
if (!binaryUrl) {
|
||||||
|
setLoadError("No binary file 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));
|
||||||
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const ensureAudioCtx = () => {
|
||||||
|
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
|
||||||
|
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
}
|
||||||
|
if (audioCtxRef.current.state === "suspended") {
|
||||||
|
audioCtxRef.current.resume();
|
||||||
|
}
|
||||||
|
return audioCtxRef.current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleStep = useCallback((stepIndex) => {
|
||||||
|
const currentSteps = stepsRef.current;
|
||||||
|
if (!currentSteps.length) return;
|
||||||
|
|
||||||
|
const playFrom = stepIndex % currentSteps.length;
|
||||||
|
|
||||||
|
// End of sequence — loop back
|
||||||
|
if (playFrom === 0 && stepIndex > 0) {
|
||||||
|
scheduleStep(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = ensureAudioCtx();
|
||||||
|
const stepValue = currentSteps[playFrom];
|
||||||
|
setCurrentStep(playFrom);
|
||||||
|
playStep(ctx, stepValue, BEAT_DURATION_MS);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const next = playFrom + 1;
|
||||||
|
if (next >= stepsRef.current.length) {
|
||||||
|
scheduleStep(0);
|
||||||
|
} else {
|
||||||
|
scheduleStep(next);
|
||||||
|
}
|
||||||
|
}, speedMsRef.current);
|
||||||
|
|
||||||
|
playbackRef.current = { timer, stepIndex: playFrom };
|
||||||
|
}, []); // no deps — reads everything from refs
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
if (!stepsRef.current.length) return;
|
||||||
|
setPlaying(true);
|
||||||
|
scheduleStep(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
stopPlayback();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const totalSteps = steps.length;
|
||||||
|
const allBellsUsed = steps.reduce((set, v) => {
|
||||||
|
getActiveBells(v).forEach((b) => set.add(b));
|
||||||
|
return set;
|
||||||
|
}, new Set());
|
||||||
|
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
|
||||||
|
const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
|
||||||
|
|
||||||
|
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !playing && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-lg border shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
maxWidth: "460px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-6 py-4 border-b"
|
||||||
|
style={{ borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Melody Playback
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={mutedStyle}>
|
||||||
|
{melody?.information?.name?.en || "Melody"} — looping
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPlayback(); onClose(); }}
|
||||||
|
className="text-xl leading-none"
|
||||||
|
style={mutedStyle}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
{/* Loading / error states */}
|
||||||
|
{loading && (
|
||||||
|
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
||||||
|
)}
|
||||||
|
{loadError && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !loadError && totalSteps > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Step info */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs" style={mutedStyle}>
|
||||||
|
{totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{currentStep >= 0 && (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--accent)" }}>
|
||||||
|
Step {currentStep + 1} / {totalSteps}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bell visualizer */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
||||||
|
const isActive = currentBells.includes(b);
|
||||||
|
const isUsed = allBellsUsed.has(b);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||||
|
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||||
|
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
|
||||||
|
transform: isActive ? "scale(1.2)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play / Stop */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!playing ? (
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
■ Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-xs" style={mutedStyle}>Loops continuously</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Speed</label>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
||||||
|
{speedPercent}%
|
||||||
|
</span>
|
||||||
|
{hasSpeedInfo && (
|
||||||
|
<span className="text-xs ml-2" style={mutedStyle}>
|
||||||
|
({speedMs} ms)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={speedPercent}
|
||||||
|
onChange={(e) => setSpeedPercent(Number(e.target.value))}
|
||||||
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
|
||||||
|
<span>1% (slowest)</span>
|
||||||
|
<span>100% (fastest)</span>
|
||||||
|
</div>
|
||||||
|
{!hasSpeedInfo && (
|
||||||
|
<p className="text-xs mt-1.5" style={{ color: "var(--warning, #f59e0b)" }}>
|
||||||
|
No MIN/MAX speed set for this melody — using linear fallback.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className="flex justify-end px-6 py-4 border-t"
|
||||||
|
style={{ borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPlayback(); onClose(); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul
|
|||||||
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
||||||
|
|
||||||
setStatusMsg("Done!");
|
setStatusMsg("Done!");
|
||||||
onSuccess();
|
onSuccess({ name: name.trim(), pid: pid.trim() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setStatusMsg("");
|
setStatusMsg("");
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ function countSteps(stepsStr) {
|
|||||||
return stepsStr.trim().split(",").length;
|
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() {
|
export default function BuilderForm() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
@@ -229,13 +244,14 @@ export default function BuilderForm() {
|
|||||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
||||||
</button>
|
</button>
|
||||||
{binaryUrl && (
|
{binaryUrl && (
|
||||||
<a
|
<button
|
||||||
href={`/api${binaryUrl}`}
|
type="button"
|
||||||
className="block text-center text-xs underline"
|
onClick={() => downloadBinary(binaryUrl, `${name}.bsm`).catch((e) => setError(e.message))}
|
||||||
style={{ color: "var(--accent)" }}
|
className="block w-full text-center text-xs underline cursor-pointer"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||||
>
|
>
|
||||||
Download {name}.bsm
|
Download {name}.bsm
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import api from "../../api/client";
|
import api from "../../api/client";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
|
|
||||||
|
function CodeSnippetModal({ melody, onClose }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
if (!melody) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-lg border shadow-xl"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "720px", maxHeight: "80vh", display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code Snippet</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{melody.name} · PID: <span className="font-mono">{melody.pid || "—"}</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(melody.progmem_code).then(() => {
|
||||||
|
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)",
|
||||||
|
color: copied ? "var(--success-text)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className="flex-1 overflow-auto p-5 text-xs"
|
||||||
|
style={{ fontFamily: "monospace", color: "var(--text-primary)", backgroundColor: "var(--bg-primary)", whiteSpace: "pre", margin: 0 }}
|
||||||
|
>
|
||||||
|
{melody.progmem_code}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
||||||
|
|
||||||
export default function BuilderList() {
|
export default function BuilderList() {
|
||||||
@@ -11,6 +60,8 @@ export default function BuilderList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
|
||||||
|
const [codeSnippetMelody, setCodeSnippetMelody] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMelodies();
|
loadMelodies();
|
||||||
@@ -46,6 +97,22 @@ export default function BuilderList() {
|
|||||||
return stepsStr.split(",").length;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -54,7 +121,7 @@ export default function BuilderList() {
|
|||||||
Archetype Builder
|
Archetype Builder
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Build binary (.bsm) files and firmware PROGMEM code from melody step notation.
|
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -121,7 +188,12 @@ export default function BuilderList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
{m.binary_path ? (
|
{m.binary_path ? (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
<span
|
||||||
|
title="Click to Download"
|
||||||
|
onClick={(e) => handleDownloadBinary(e, m)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
|
||||||
|
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
|
||||||
|
>
|
||||||
Built
|
Built
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -132,7 +204,12 @@ export default function BuilderList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
{m.progmem_code ? (
|
{m.progmem_code ? (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
<span
|
||||||
|
title="Click to View Code Snippet"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setCodeSnippetMelody(m); }}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
|
||||||
|
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
|
||||||
|
>
|
||||||
Generated
|
Generated
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -169,12 +246,48 @@ export default function BuilderList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Two-stage delete: warn if assigned, then confirm */}
|
||||||
|
{deleteTarget && !deleteWarningConfirmed && (deleteTarget.assigned_melody_ids?.length || 0) > 0 && (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
||||||
|
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
|
||||||
|
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
|
||||||
|
<strong>"{deleteTarget.name}"</strong> is currently assigned to{" "}
|
||||||
|
<strong>{deleteTarget.assigned_melody_ids.length} melody{deleteTarget.assigned_melody_ids.length !== 1 ? "ies" : "y"}</strong>.
|
||||||
|
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteWarningConfirmed(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
Yes, Delete Anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget) && (deleteWarningConfirmed || (deleteTarget?.assigned_melody_ids?.length || 0) === 0)}
|
||||||
title="Delete Built Melody"
|
title="Delete Archetype"
|
||||||
message={`Are you sure you want to delete "${deleteTarget?.name}"? This will also delete the .bsm binary file if it exists. This action cannot be undone.`}
|
message={`Are you sure you want to permanently delete "${deleteTarget?.name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeSnippetModal
|
||||||
|
melody={codeSnippetMelody}
|
||||||
|
onClose={() => setCodeSnippetMelody(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSucc
|
|||||||
// 3. Mark this built melody as assigned to this Firestore melody
|
// 3. Mark this built melody as assigned to this Firestore melody
|
||||||
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
|
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
|
||||||
|
|
||||||
onSuccess();
|
onSuccess({ name: builtMelody.name, pid: builtMelody.pid });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user