Added SpeedCalc and MelodyBuilder. Evaluation Pending
This commit is contained in:
562
frontend/src/melodies/SpeedCalculatorModal.jsx
Normal file
562
frontend/src/melodies/SpeedCalculatorModal.jsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import api from "../api/client";
|
||||
|
||||
// ============================================================================
|
||||
// Web Audio Engine
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bell frequencies: Bell 1 = highest (880 Hz A5), each subsequent bell 2 semitones lower.
|
||||
* freq = 880 * (2^(1/12))^(-2*(bell-1))
|
||||
*/
|
||||
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 bellNum = bit + 1;
|
||||
const freq = bellFrequency(bellNum);
|
||||
|
||||
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 + duration - fadeOut);
|
||||
gain.gain.linearRampToValueAtTime(0, now + duration);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parse raw steps string into list of uint16 values
|
||||
// ============================================================================
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function getActiveBells(stepValue) {
|
||||
const bells = [];
|
||||
for (let bit = 0; bit < 16; bit++) {
|
||||
if (stepValue & (1 << bit)) bells.push(bit + 1);
|
||||
}
|
||||
return bells;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Speed math
|
||||
// MIN is derived so that at 50%, speed = normal (geometric mean)
|
||||
// normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX
|
||||
// ============================================================================
|
||||
|
||||
function calcMin(normal, max) {
|
||||
if (!normal || !max || max <= 0) return null;
|
||||
return Math.round((normal * normal) / max);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
const labelStyle = { color: "var(--text-secondary)" };
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
|
||||
export default function SpeedCalculatorModal({ open, melody, onClose, onSaved }) {
|
||||
const info = melody?.information || {};
|
||||
|
||||
// Raw steps input (not stored in Firestore — only used locally for playback)
|
||||
const [stepsInput, setStepsInput] = useState("");
|
||||
const [steps, setSteps] = useState([]); // parsed uint16 values
|
||||
|
||||
// Playback
|
||||
const audioCtxRef = useRef(null);
|
||||
const playbackRef = useRef(null); // { timer, stepIndex }
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [loop, setLoop] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(-1);
|
||||
|
||||
// Sliders
|
||||
const [stepDelay, setStepDelay] = useState(500); // ms
|
||||
const [beatDuration, setBeatDuration] = useState(100); // ms
|
||||
|
||||
const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20));
|
||||
|
||||
// Speed capture
|
||||
const [capturedMax, setCapturedMax] = useState(null);
|
||||
const [capturedNormal, setCapturedNormal] = useState(null);
|
||||
const derivedMin = calcMin(capturedNormal, capturedMax);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Warnings
|
||||
const maxWarning = capturedMax !== null && capturedMax < 100;
|
||||
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal > capturedMax;
|
||||
|
||||
// Pre-fill existing speeds from melody
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null);
|
||||
setCapturedNormal(null);
|
||||
setStepsInput("");
|
||||
setSteps([]);
|
||||
setCurrentStep(-1);
|
||||
setPlaying(false);
|
||||
setPaused(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (playbackRef.current) {
|
||||
clearTimeout(playbackRef.current.timer);
|
||||
playbackRef.current = null;
|
||||
}
|
||||
setPlaying(false);
|
||||
setPaused(false);
|
||||
setCurrentStep(-1);
|
||||
}, []);
|
||||
|
||||
// Stop when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) stopPlayback();
|
||||
}, [open, stopPlayback]);
|
||||
|
||||
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, startFrom = 0) => {
|
||||
if (!steps.length) return;
|
||||
|
||||
const playFrom = stepIndex % steps.length;
|
||||
if (!loop && playFrom === 0 && stepIndex > 0) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = ensureAudioCtx();
|
||||
const stepValue = steps[playFrom];
|
||||
|
||||
setCurrentStep(playFrom);
|
||||
playStep(ctx, stepValue, effectiveBeat);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const next = playFrom + 1;
|
||||
if (next >= steps.length) {
|
||||
if (loop) {
|
||||
scheduleStep(0);
|
||||
} else {
|
||||
stopPlayback();
|
||||
}
|
||||
} else {
|
||||
scheduleStep(next);
|
||||
}
|
||||
}, stepDelay);
|
||||
|
||||
playbackRef.current = { timer, stepIndex: playFrom };
|
||||
},
|
||||
[steps, stepDelay, effectiveBeat, loop, stopPlayback]
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!steps.length) return;
|
||||
if (paused && playbackRef.current) {
|
||||
// Resume from current step
|
||||
setPaused(false);
|
||||
setPlaying(true);
|
||||
scheduleStep(playbackRef.current.stepIndex);
|
||||
return;
|
||||
}
|
||||
setPlaying(true);
|
||||
setPaused(false);
|
||||
scheduleStep(0);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (playbackRef.current) {
|
||||
clearTimeout(playbackRef.current.timer);
|
||||
}
|
||||
setPaused(true);
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
stopPlayback();
|
||||
};
|
||||
|
||||
const handleStepsParse = () => {
|
||||
const parsed = parseStepsString(stepsInput);
|
||||
setSteps(parsed);
|
||||
stopPlayback();
|
||||
};
|
||||
|
||||
const handleSetSliderToCapture = (value) => {
|
||||
if (value !== null) setStepDelay(value);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!capturedMax || !derivedMin) return;
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
await api.put(`/melodies/${melody.id}`, {
|
||||
information: {
|
||||
...info,
|
||||
minSpeed: derivedMin,
|
||||
maxSpeed: capturedMax,
|
||||
},
|
||||
default_settings: melody.default_settings,
|
||||
type: melody.type,
|
||||
url: melody.url,
|
||||
uid: melody.uid,
|
||||
pid: melody.pid,
|
||||
});
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => {
|
||||
onSaved();
|
||||
}, 800);
|
||||
} catch (err) {
|
||||
setSaveError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
|
||||
|
||||
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 max-w-2xl rounded-lg border shadow-xl"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "90vh", overflowY: "auto" }}
|
||||
>
|
||||
{/* 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)" }}>Speed Calculator</h2>
|
||||
<p className="text-xs mt-0.5" style={mutedStyle}>
|
||||
Play the melody at different speeds to find the right MIN / MAX.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Steps Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>
|
||||
Melody Steps
|
||||
<span className="font-normal ml-2" style={mutedStyle}>(paste your step notation)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={stepsInput}
|
||||
onChange={(e) => setStepsInput(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..."
|
||||
className="flex-1 px-3 py-2 rounded-md text-sm border"
|
||||
style={{ fontFamily: "monospace", resize: "none" }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleStepsParse}
|
||||
className="px-4 py-2 text-sm rounded-md self-start transition-colors"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
{totalSteps > 0 && (
|
||||
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
|
||||
{totalSteps} steps loaded · {allBellsUsed.size} unique bell{allBellsUsed.size !== 1 ? "s" : ""} used
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bell visualizer */}
|
||||
{totalSteps > 0 && (
|
||||
<div>
|
||||
<p className="text-xs mb-2" style={mutedStyle}>Bell indicator (current step)</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: Math.max(...Array.from(allBellsUsed), 1) }, (_, i) => i + 1).map((b) => {
|
||||
const isActive = currentBells.includes(b);
|
||||
const isUsed = allBellsUsed.has(b);
|
||||
return (
|
||||
<div
|
||||
key={b}
|
||||
className="w-8 h-8 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)" : isUsed ? "var(--border-primary)" : "var(--border-primary)"}`,
|
||||
transform: isActive ? "scale(1.2)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{currentStep >= 0 && (
|
||||
<p className="text-xs mt-1" style={mutedStyle}>
|
||||
Step {currentStep + 1} / {totalSteps}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!playing ? (
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
disabled={!totalSteps}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{paused ? "Resume" : "Play"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!playing && !paused}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<label className="flex items-center gap-2 ml-2 text-sm cursor-pointer" style={labelStyle}>
|
||||
<input type="checkbox" checked={loop} onChange={(e) => setLoop(e.target.checked)} className="h-4 w-4 rounded" />
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Step Delay Slider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
|
||||
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="3000"
|
||||
step="10"
|
||||
value={stepDelay}
|
||||
onChange={(e) => setStepDelay(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>50ms (fastest)</span>
|
||||
<span>3000ms (slowest)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beat Duration Slider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||
<span className="text-sm" style={mutedStyle}>
|
||||
{effectiveBeat} ms
|
||||
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="500"
|
||||
step="5"
|
||||
value={beatDuration}
|
||||
onChange={(e) => setBeatDuration(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>20ms (short)</span>
|
||||
<span>500ms (long)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speed Capture Panel */}
|
||||
<div className="rounded-lg border p-4 space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
|
||||
<p className="text-xs" style={mutedStyle}>
|
||||
Find the fastest speed the controller can handle (MAX), then find the speed that feels "normal" (Normal).
|
||||
MIN is auto-calculated so that 50% = Normal speed.
|
||||
</p>
|
||||
|
||||
{/* MAX */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>MAX (fastest safe)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold w-20" style={{ color: capturedMax ? "var(--accent)" : "var(--text-muted)" }}>
|
||||
{capturedMax !== null ? `${capturedMax} ms` : "—"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSetSliderToCapture(capturedMax)}
|
||||
disabled={capturedMax === null}
|
||||
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||
title="Load this value to slider"
|
||||
>
|
||||
Set ↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCapturedMax(stepDelay)}
|
||||
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Capture
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Normal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>Normal (50% speed feel)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold w-20" style={{ color: capturedNormal ? "var(--accent)" : "var(--text-muted)" }}>
|
||||
{capturedNormal !== null ? `${capturedNormal} ms` : "—"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSetSliderToCapture(capturedNormal)}
|
||||
disabled={capturedNormal === null}
|
||||
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||
title="Load this value to slider"
|
||||
>
|
||||
Set ↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCapturedNormal(stepDelay)}
|
||||
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Capture
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MIN (auto-calculated) */}
|
||||
<div className="pt-1 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<span className="text-xs font-medium block mb-0.5" style={mutedStyle}>MIN (auto-calculated)</span>
|
||||
<span className="text-lg font-bold" style={{ color: derivedMin ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||
{derivedMin !== null ? `${derivedMin} ms` : "—"}
|
||||
</span>
|
||||
{derivedMin !== null && (
|
||||
<span className="text-xs ml-2" style={mutedStyle}>(normal² / max)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{maxWarning && (
|
||||
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||
Warning: MAX speed of {capturedMax}ms is very fast (<100ms). This could damage the device.
|
||||
</div>
|
||||
)}
|
||||
{orderWarning && (
|
||||
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||
Warning: Normal speed ({capturedNormal}ms) is slower than MAX ({capturedMax}ms). Please check your values.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{saveError && (
|
||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
|
||||
Speeds saved to melody!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 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>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!capturedMax || !capturedNormal || !derivedMin || saving || orderWarning}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
||||
style={{ backgroundColor: "#16a34a", color: "#fff" }}
|
||||
title={!capturedMax || !capturedNormal ? "Capture MAX and Normal speeds first" : ""}
|
||||
>
|
||||
{saving ? "Saving..." : "Save to Melody"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user