CODEX - Varioues Fixes, and Replaced the Playback Modal with VIEW modal
This commit is contained in:
228
frontend/src/melodies/BinaryTableModal.jsx
Normal file
228
frontend/src/melodies/BinaryTableModal.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||||
|
|
||||||
|
function parseBellNotation(notation) {
|
||||||
|
const raw = String(notation || "").trim();
|
||||||
|
if (raw === "0" || !raw) return 0;
|
||||||
|
let value = 0;
|
||||||
|
for (const part of raw.split("+")) {
|
||||||
|
const n = Number.parseInt(part.trim(), 10);
|
||||||
|
if (Number.isInteger(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStepsString(stepsStr) {
|
||||||
|
if (!stepsStr || !String(stepsStr).trim()) return [];
|
||||||
|
return String(stepsStr).trim().split(",").map((s) => parseBellNotation(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBinaryResponse(url) {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.ok) return res;
|
||||||
|
if (url.startsWith("http")) {
|
||||||
|
const retry = await fetch(url);
|
||||||
|
if (retry.ok) return retry;
|
||||||
|
throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch binary: ${res.statusText || res.status}`);
|
||||||
|
} catch {
|
||||||
|
if (url.startsWith("http")) {
|
||||||
|
const retry = await fetch(url);
|
||||||
|
if (retry.ok) return retry;
|
||||||
|
throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch binary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeBsmBinary(url) {
|
||||||
|
const res = await fetchBinaryResponse(url);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BinaryTableModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
||||||
|
const info = melody?.information || {};
|
||||||
|
const [steps, setSteps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSteps([]);
|
||||||
|
setLoading(false);
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryUrlCandidate = builtMelody?.binary_url
|
||||||
|
? `/api${builtMelody.binary_url}`
|
||||||
|
: files?.binary_url || melody?.url || null;
|
||||||
|
const binaryUrl = normalizeFileUrl(binaryUrlCandidate);
|
||||||
|
const csv = archetypeCsv || info.archetype_csv || null;
|
||||||
|
|
||||||
|
if (binaryUrl) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
decodeBsmBinary(binaryUrl)
|
||||||
|
.then((decoded) => setSteps(decoded))
|
||||||
|
.catch((err) => {
|
||||||
|
if (csv) {
|
||||||
|
setSteps(parseStepsString(csv));
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err.message || "Failed to load melody data.");
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csv) {
|
||||||
|
setSteps(parseStepsString(csv));
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("No binary or archetype data available for this melody.");
|
||||||
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (!open) return 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 gridNoteCount = Math.max(1, Math.min(16, Number(info.totalNotes || 0) || detectedNoteCount || 1));
|
||||||
|
|
||||||
|
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: "1100px" }}
|
||||||
|
>
|
||||||
|
<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)" }}>Archetype View</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{steps.length} steps</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
{loading && <p className="text-sm text-center py-4" style={{ color: "var(--text-muted)" }}>Loading binary...</p>}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 border"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && steps.length > 0 && (
|
||||||
|
<div className="rounded-md border overflow-auto" style={{ borderColor: "var(--border-primary)", maxHeight: "520px" }}>
|
||||||
|
<table className="min-w-max border-separate border-spacing-0 text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="sticky top-0 left-0 z-20 px-2 py-1.5 text-left border-b border-r"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
Note \ Step
|
||||||
|
</th>
|
||||||
|
{steps.map((_, stepIdx) => (
|
||||||
|
<th
|
||||||
|
key={stepIdx}
|
||||||
|
className="sticky top-0 z-10 px-2 py-1.5 text-center border-b border-r"
|
||||||
|
style={{ minWidth: "40px", backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{stepIdx + 1}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: gridNoteCount }, (_, noteIdx) => (
|
||||||
|
<tr key={noteIdx}>
|
||||||
|
<th
|
||||||
|
className="sticky left-0 z-[1] px-2 py-1.5 text-left border-b border-r"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{NOTE_LABELS[noteIdx]}
|
||||||
|
</th>
|
||||||
|
{steps.map((stepValue, stepIdx) => {
|
||||||
|
const enabled = Boolean(stepValue & (1 << noteIdx));
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${noteIdx}-${stepIdx}`}
|
||||||
|
className="border-b border-r"
|
||||||
|
style={{ width: "40px", height: "40px", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<span className="w-full h-full flex items-center justify-center" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "60%",
|
||||||
|
height: "60%",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: "var(--btn-primary)",
|
||||||
|
opacity: enabled ? 1 : 0,
|
||||||
|
transform: enabled ? "scale(1)" : "scale(0.4)",
|
||||||
|
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
|
||||||
|
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<button
|
||||||
|
onClick={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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ 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 PlaybackModal from "./PlaybackModal";
|
||||||
|
import BinaryTableModal from "./BinaryTableModal";
|
||||||
|
|
||||||
function fallbackCopy(text, onSuccess) {
|
function fallbackCopy(text, onSuccess) {
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
@@ -54,12 +55,6 @@ function normalizeFileUrl(url) {
|
|||||||
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));
|
|
||||||
return 190 + (15 - 190) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, children }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -128,6 +123,7 @@ export default function MelodyDetail() {
|
|||||||
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);
|
const [showPlayback, setShowPlayback] = useState(false);
|
||||||
|
const [showBinaryView, setShowBinaryView] = useState(false);
|
||||||
const [offlineSaving, setOfflineSaving] = useState(false);
|
const [offlineSaving, setOfflineSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -482,9 +478,6 @@ export default function MelodyDetail() {
|
|||||||
{settings.noteAssignments?.length > 0 ? (
|
{settings.noteAssignments?.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{settings.noteAssignments.map((assignedBell, noteIdx) => {
|
{settings.noteAssignments.map((assignedBell, noteIdx) => {
|
||||||
const noteHue = hueForDepth(noteIdx, settings.noteAssignments.length - 1);
|
|
||||||
const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1));
|
|
||||||
const bellHue = hueForDepth(bellDepthIdx, 15);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={noteIdx}
|
key={noteIdx}
|
||||||
@@ -493,16 +486,15 @@ export default function MelodyDetail() {
|
|||||||
minWidth: "36px",
|
minWidth: "36px",
|
||||||
padding: "4px 6px",
|
padding: "4px 6px",
|
||||||
backgroundColor: "var(--bg-card-hover)",
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
|
borderColor: "var(--border-primary)",
|
||||||
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
||||||
{String.fromCharCode(65 + noteIdx)}
|
{String.fromCharCode(65 + noteIdx)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
{assignedBell > 0 ? assignedBell : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -561,11 +553,19 @@ export default function MelodyDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
let res = await fetch(binaryUrl, {
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = await fetch(binaryUrl, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
// For external URLs (e.g. Firebase Storage), retry without auth header
|
} catch {
|
||||||
if (!res.ok && binaryUrl.startsWith("http")) {
|
if (binaryUrl.startsWith("http")) {
|
||||||
|
res = await fetch(binaryUrl);
|
||||||
|
} else {
|
||||||
|
throw new Error("Download failed: network error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!res || !res.ok) && binaryUrl.startsWith("http")) {
|
||||||
res = await fetch(binaryUrl);
|
res = await fetch(binaryUrl);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
||||||
@@ -577,7 +577,7 @@ export default function MelodyDetail() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ export default function MelodyDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPlayback(true)}
|
onClick={() => setShowBinaryView(true)}
|
||||||
className="px-2 py-0.5 text-xs rounded-full"
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
>
|
>
|
||||||
@@ -634,11 +634,11 @@ export default function MelodyDetail() {
|
|||||||
})()}
|
})()}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Audio Preview">
|
<Field label="Audio Preview">
|
||||||
{files.preview_url ? (
|
{normalizeFileUrl(files.preview_url) ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<audio controls src={files.preview_url} className="h-8" />
|
<audio controls src={normalizeFileUrl(files.preview_url)} className="h-8" />
|
||||||
<a
|
<a
|
||||||
href={files.preview_url}
|
href={normalizeFileUrl(files.preview_url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline text-xs"
|
className="underline text-xs"
|
||||||
@@ -757,6 +757,14 @@ export default function MelodyDetail() {
|
|||||||
archetypeCsv={melody?.information?.archetype_csv || null}
|
archetypeCsv={melody?.information?.archetype_csv || null}
|
||||||
onClose={() => setShowPlayback(false)}
|
onClose={() => setShowPlayback(false)}
|
||||||
/>
|
/>
|
||||||
|
<BinaryTableModal
|
||||||
|
open={showBinaryView}
|
||||||
|
melody={melody}
|
||||||
|
builtMelody={builtMelody}
|
||||||
|
files={files}
|
||||||
|
archetypeCsv={melody?.information?.archetype_csv || null}
|
||||||
|
onClose={() => setShowBinaryView(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SpeedCalculatorModal
|
<SpeedCalculatorModal
|
||||||
open={showSpeedCalc}
|
open={showSpeedCalc}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SelectArchetypeModal from "./archetypes/SelectArchetypeModal";
|
|||||||
import BuildOnTheFlyModal from "./archetypes/BuildOnTheFlyModal";
|
import BuildOnTheFlyModal from "./archetypes/BuildOnTheFlyModal";
|
||||||
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
||||||
import PlaybackModal from "./PlaybackModal";
|
import PlaybackModal from "./PlaybackModal";
|
||||||
|
import BinaryTableModal from "./BinaryTableModal";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -115,6 +116,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 [showPlayback, setShowPlayback] = useState(false);
|
const [showPlayback, setShowPlayback] = useState(false);
|
||||||
|
const [showBinaryView, setShowBinaryView] = useState(false);
|
||||||
const [builtMelody, setBuiltMelody] = useState(null);
|
const [builtMelody, setBuiltMelody] = useState(null);
|
||||||
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
|
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
|
||||||
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null);
|
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null);
|
||||||
@@ -218,10 +220,19 @@ export default function MelodyForm() {
|
|||||||
if (!fileUrl) return;
|
if (!fileUrl) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
let res = await fetch(fileUrl, {
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = await fetch(fileUrl, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok && fileUrl.startsWith("http")) {
|
} catch {
|
||||||
|
if (fileUrl.startsWith("http")) {
|
||||||
|
res = await fetch(fileUrl);
|
||||||
|
} else {
|
||||||
|
throw new Error("Download failed: network error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!res || !res.ok) && fileUrl.startsWith("http")) {
|
||||||
res = await fetch(fileUrl);
|
res = await fetch(fileUrl);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
||||||
@@ -764,7 +775,7 @@ export default function MelodyForm() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPlayback(true)}
|
onClick={() => setShowBinaryView(true)}
|
||||||
className="px-2 py-0.5 text-xs rounded-full"
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
>
|
>
|
||||||
@@ -786,7 +797,7 @@ export default function MelodyForm() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => binaryInputRef.current?.click()}
|
onClick={() => binaryInputRef.current?.click()}
|
||||||
className="px-3 py-1.5 text-xs rounded-full transition-colors"
|
className="px-3 py-1.5 text-xs rounded-md transition-colors"
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
Choose Binary File
|
Choose Binary File
|
||||||
@@ -844,17 +855,23 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
|
||||||
{existingFiles.preview_url ? (
|
{normalizeFileUrl(existingFiles.preview_url) ? (
|
||||||
<div className="mb-2 space-y-1">
|
<div className="mb-2 space-y-1">
|
||||||
|
{(() => {
|
||||||
|
const previewUrl = normalizeFileUrl(existingFiles.preview_url);
|
||||||
|
const previewName = resolveFilename(previewUrl, "preview.mp3");
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
href={existingFiles.preview_url}
|
href={previewUrl}
|
||||||
onClick={(e) => downloadExistingFile(existingFiles.preview_url, resolveFilename(existingFiles.preview_url, "preview.mp3"), e)}
|
onClick={(e) => downloadExistingFile(previewUrl, previewName, e)}
|
||||||
className="underline text-xs"
|
className="underline text-xs"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
{resolveFilename(existingFiles.preview_url, "Click to Download")}
|
{resolveFilename(previewUrl, "Click to Download")}
|
||||||
</a>
|
</a>
|
||||||
<audio controls src={existingFiles.preview_url} className="h-8" />
|
);
|
||||||
|
})()}
|
||||||
|
<audio controls src={normalizeFileUrl(existingFiles.preview_url)} className="h-8" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>No preview uploaded</p>
|
<p className="text-xs mb-2" style={mutedStyle}>No preview uploaded</p>
|
||||||
@@ -869,7 +886,7 @@ export default function MelodyForm() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => previewInputRef.current?.click()}
|
onClick={() => previewInputRef.current?.click()}
|
||||||
className="px-3 py-1.5 text-xs rounded-full transition-colors"
|
className="px-3 py-1.5 text-xs rounded-md transition-colors"
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
Choose Preview File
|
Choose Preview File
|
||||||
@@ -951,6 +968,14 @@ export default function MelodyForm() {
|
|||||||
archetypeCsv={information.archetype_csv || null}
|
archetypeCsv={information.archetype_csv || null}
|
||||||
onClose={() => setShowPlayback(false)}
|
onClose={() => setShowPlayback(false)}
|
||||||
/>
|
/>
|
||||||
|
<BinaryTableModal
|
||||||
|
open={showBinaryView}
|
||||||
|
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
|
||||||
|
builtMelody={builtMelody}
|
||||||
|
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }}
|
||||||
|
archetypeCsv={information.archetype_csv || null}
|
||||||
|
onClose={() => setShowBinaryView(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import api from "../api/client";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import PlaybackModal from "./PlaybackModal";
|
import BinaryTableModal from "./BinaryTableModal";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -140,12 +140,6 @@ function formatRelativeTime(isoValue) {
|
|||||||
return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;
|
return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hueForDepth(index, count) {
|
|
||||||
const safeCount = Math.max(1, count);
|
|
||||||
const t = Math.max(0, Math.min(1, index / safeCount));
|
|
||||||
return 190 + (15 - 190) * t; // high notes blue-ish -> deep notes warm red
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBinaryUrl(row) {
|
function getBinaryUrl(row) {
|
||||||
const candidate = row?.url;
|
const candidate = row?.url;
|
||||||
if (!candidate || typeof candidate !== "string") return null;
|
if (!candidate || typeof candidate !== "string") return null;
|
||||||
@@ -407,11 +401,19 @@ export default function MelodyList() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
let res = await fetch(binaryUrl, {
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = await fetch(binaryUrl, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
if (!res.ok && binaryUrl.startsWith("http")) {
|
if (binaryUrl.startsWith("http")) {
|
||||||
|
res = await fetch(binaryUrl);
|
||||||
|
} else {
|
||||||
|
throw new Error("Download failed: network error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!res || !res.ok) && binaryUrl.startsWith("http")) {
|
||||||
res = await fetch(binaryUrl);
|
res = await fetch(binaryUrl);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
||||||
@@ -495,6 +497,12 @@ export default function MelodyList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isVisible = (key) => visibleColumns.includes(key);
|
const isVisible = (key) => visibleColumns.includes(key);
|
||||||
|
const orderedColumnPickerColumns = useMemo(() => {
|
||||||
|
const byKey = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
|
||||||
|
const visibleOrdered = visibleColumns.map((k) => byKey.get(k)).filter(Boolean);
|
||||||
|
const hidden = ALL_COLUMNS.filter((c) => !visibleColumns.includes(c.key));
|
||||||
|
return [...visibleOrdered, ...hidden];
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
||||||
|
|
||||||
@@ -745,11 +753,6 @@ export default function MelodyList() {
|
|||||||
return ds.noteAssignments?.length > 0 ? (
|
return ds.noteAssignments?.length > 0 ? (
|
||||||
<div className="flex flex-nowrap gap-1 whitespace-nowrap">
|
<div className="flex flex-nowrap gap-1 whitespace-nowrap">
|
||||||
{ds.noteAssignments.map((assignedBell, noteIdx) => (
|
{ds.noteAssignments.map((assignedBell, noteIdx) => (
|
||||||
(() => {
|
|
||||||
const noteHue = hueForDepth(noteIdx, ds.noteAssignments.length - 1);
|
|
||||||
const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1));
|
|
||||||
const bellHue = hueForDepth(bellDepthIdx, 15);
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={noteIdx}
|
key={noteIdx}
|
||||||
className="flex flex-col items-center rounded-md border"
|
className="flex flex-col items-center rounded-md border"
|
||||||
@@ -757,20 +760,17 @@ export default function MelodyList() {
|
|||||||
minWidth: "26px",
|
minWidth: "26px",
|
||||||
padding: "3px 3px",
|
padding: "3px 3px",
|
||||||
backgroundColor: "var(--bg-card-hover)",
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
|
borderColor: "var(--border-primary)",
|
||||||
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
||||||
{NOTE_LABELS[noteIdx]}
|
{NOTE_LABELS[noteIdx]}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
{assignedBell > 0 ? assignedBell : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1039,7 +1039,7 @@ export default function MelodyList() {
|
|||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ALL_COLUMNS.map((col) => {
|
{orderedColumnPickerColumns.map((col) => {
|
||||||
const orderIdx = visibleColumns.indexOf(col.key);
|
const orderIdx = visibleColumns.indexOf(col.key);
|
||||||
const canMove = orderIdx >= 0;
|
const canMove = orderIdx >= 0;
|
||||||
return (
|
return (
|
||||||
@@ -1063,18 +1063,18 @@ export default function MelodyList() {
|
|||||||
onClick={() => moveColumn(col.key, "up")}
|
onClick={() => moveColumn(col.key, "up")}
|
||||||
className="text-[10px] px-1 rounded border"
|
className="text-[10px] px-1 rounded border"
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
||||||
title="Move left"
|
title="Move up"
|
||||||
>
|
>
|
||||||
←
|
↑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => moveColumn(col.key, "down")}
|
onClick={() => moveColumn(col.key, "down")}
|
||||||
className="text-[10px] px-1 rounded border"
|
className="text-[10px] px-1 rounded border"
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
||||||
title="Move right"
|
title="Move down"
|
||||||
>
|
>
|
||||||
→
|
↓
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1327,7 +1327,7 @@ export default function MelodyList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlaybackModal
|
<BinaryTableModal
|
||||||
open={!!viewRow}
|
open={!!viewRow}
|
||||||
melody={viewRow || null}
|
melody={viewRow || null}
|
||||||
builtMelody={null}
|
builtMelody={null}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Web Audio Engine
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function bellFrequency(bellNumber) {
|
function bellFrequency(bellNumber) {
|
||||||
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
|
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
|
||||||
@@ -35,19 +31,19 @@ function playStep(audioCtx, stepValue, beatDurationMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseBellNotation(notation) {
|
function parseBellNotation(notation) {
|
||||||
notation = notation.trim();
|
const raw = String(notation || "").trim();
|
||||||
if (notation === "0" || !notation) return 0;
|
if (raw === "0" || !raw) return 0;
|
||||||
let value = 0;
|
let value = 0;
|
||||||
for (const part of notation.split("+")) {
|
for (const part of raw.split("+")) {
|
||||||
const n = parseInt(part.trim(), 10);
|
const n = Number.parseInt(part.trim(), 10);
|
||||||
if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
|
if (Number.isInteger(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStepsString(stepsStr) {
|
function parseStepsString(stepsStr) {
|
||||||
if (!stepsStr || !stepsStr.trim()) return [];
|
if (!stepsStr || !String(stepsStr).trim()) return [];
|
||||||
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
|
return String(stepsStr).trim().split(",").map((s) => parseBellNotation(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlaybackUrl(url) {
|
function normalizePlaybackUrl(url) {
|
||||||
@@ -57,29 +53,31 @@ function normalizePlaybackUrl(url) {
|
|||||||
return `/api/${url}`;
|
return `/api/${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decodeBsmBinary(url) {
|
async function fetchBinaryResponse(url) {
|
||||||
// Try with auth token first (for our API endpoints), then without (for Firebase URLs)
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
let res = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
} catch {
|
if (res.ok) return res;
|
||||||
throw new Error("Failed to fetch binary: network error");
|
if (url.startsWith("http")) {
|
||||||
|
const retry = await fetch(url);
|
||||||
|
if (retry.ok) return retry;
|
||||||
|
throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`);
|
||||||
}
|
}
|
||||||
|
throw new Error(`Failed to fetch binary: ${res.statusText || res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (url.startsWith("http")) {
|
||||||
|
const retry = await fetch(url);
|
||||||
|
if (retry.ok) return retry;
|
||||||
|
throw new Error(`Failed to fetch binary: ${retry.statusText || retry.status}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If unauthorized and it looks like a Firebase URL, try without auth header
|
async function decodeBsmBinary(url) {
|
||||||
if (!res.ok && res.status === 401 && url.startsWith("http")) {
|
const res = await fetchBinaryResponse(url);
|
||||||
try {
|
|
||||||
res = await fetch(url);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to fetch binary: network error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
|
||||||
const buf = await res.arrayBuffer();
|
const buf = await res.arrayBuffer();
|
||||||
const view = new DataView(buf);
|
const view = new DataView(buf);
|
||||||
const steps = [];
|
const steps = [];
|
||||||
@@ -89,10 +87,6 @@ async function decodeBsmBinary(url) {
|
|||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Speed math — exponential mapping
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
||||||
if (minSpeed == null || maxSpeed == null) return null;
|
if (minSpeed == null || maxSpeed == null) return null;
|
||||||
const t = Math.max(0, Math.min(100, percent)) / 100;
|
const t = Math.max(0, Math.min(100, percent)) / 100;
|
||||||
@@ -102,38 +96,22 @@ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
|||||||
return Math.round(a * Math.pow(b / a, t));
|
return Math.round(a * Math.pow(b / a, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Apply note assignments: map archetype note bits → assigned bell bits
|
|
||||||
//
|
|
||||||
// The archetype steps encode which NOTES fire using bit flags (note 1 = bit 0,
|
|
||||||
// note 2 = bit 1, etc). noteAssignments[noteIdx] gives the bell number to fire
|
|
||||||
// for that note (0 = silence / no bell). We rebuild the step value using the
|
|
||||||
// assigned bells instead of the raw note numbers.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function applyNoteAssignments(rawStepValue, noteAssignments) {
|
function applyNoteAssignments(rawStepValue, noteAssignments) {
|
||||||
if (!noteAssignments || noteAssignments.length === 0) return rawStepValue;
|
if (!noteAssignments || noteAssignments.length === 0) return rawStepValue;
|
||||||
let result = 0;
|
let result = 0;
|
||||||
for (let bit = 0; bit < 16; bit++) {
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
if (rawStepValue & (1 << bit)) {
|
if (rawStepValue & (1 << bit)) {
|
||||||
const noteIdx = bit; // bit 0 = note 1, bit 1 = note 2, ...
|
const assignedBell = noteAssignments[bit];
|
||||||
const assignedBell = noteAssignments[noteIdx];
|
|
||||||
if (assignedBell && assignedBell > 0) {
|
if (assignedBell && assignedBell > 0) {
|
||||||
result |= 1 << (assignedBell - 1);
|
result |= 1 << (assignedBell - 1);
|
||||||
}
|
}
|
||||||
// assignedBell === 0 means silence — do not set any bell bit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Component
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const mutedStyle = { color: "var(--text-muted)" };
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
const labelStyle = { color: "var(--text-secondary)" };
|
const labelStyle = { color: "var(--text-secondary)" };
|
||||||
|
|
||||||
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||||
|
|
||||||
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
||||||
@@ -151,8 +129,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
const [speedPercent, setSpeedPercent] = useState(50);
|
const [speedPercent, setSpeedPercent] = useState(50);
|
||||||
const [toneLengthMs, setToneLengthMs] = useState(80);
|
const [toneLengthMs, setToneLengthMs] = useState(80);
|
||||||
const [loopEnabled, setLoopEnabled] = useState(true);
|
const [loopEnabled, setLoopEnabled] = useState(true);
|
||||||
|
|
||||||
// activeBells: Set of bell numbers currently lit (for flash effect)
|
|
||||||
const [activeBells, setActiveBells] = useState(new Set());
|
const [activeBells, setActiveBells] = useState(new Set());
|
||||||
|
|
||||||
const audioCtxRef = useRef(null);
|
const audioCtxRef = useRef(null);
|
||||||
@@ -168,7 +144,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
useEffect(() => { stepsRef.current = steps; }, [steps]);
|
useEffect(() => { stepsRef.current = steps; }, [steps]);
|
||||||
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
||||||
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
|
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
|
||||||
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]);
|
||||||
useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]);
|
useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]);
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
@@ -182,7 +158,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
setActiveBells(new Set());
|
setActiveBells(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load steps on open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
@@ -217,7 +192,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
setLoadError("");
|
setLoadError("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadError(err.message);
|
setLoadError(err.message || "Failed to load melody data.");
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
return;
|
return;
|
||||||
@@ -249,16 +224,12 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
if (!currentSteps.length) return;
|
if (!currentSteps.length) return;
|
||||||
|
|
||||||
const playFrom = stepIndex % currentSteps.length;
|
const playFrom = stepIndex % currentSteps.length;
|
||||||
|
|
||||||
const ctx = ensureAudioCtx();
|
const ctx = ensureAudioCtx();
|
||||||
const rawStepValue = currentSteps[playFrom];
|
const rawStepValue = currentSteps[playFrom];
|
||||||
|
|
||||||
// Map archetype notes → assigned bells
|
|
||||||
const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current);
|
const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current);
|
||||||
|
|
||||||
setCurrentStep(playFrom);
|
setCurrentStep(playFrom);
|
||||||
|
|
||||||
// Flash active bells for tone length, then clear
|
|
||||||
const bellsNow = new Set();
|
const bellsNow = new Set();
|
||||||
for (let bit = 0; bit < 16; bit++) {
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
if (stepValue & (1 << bit)) bellsNow.add(bit + 1);
|
if (stepValue & (1 << bit)) bellsNow.add(bit + 1);
|
||||||
@@ -267,27 +238,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
|
|
||||||
playStep(ctx, stepValue, toneLengthRef.current);
|
playStep(ctx, stepValue, toneLengthRef.current);
|
||||||
|
|
||||||
// Clear bell highlight after tone length
|
const flashTimer = setTimeout(() => setActiveBells(new Set()), toneLengthRef.current);
|
||||||
const flashTimer = setTimeout(() => {
|
|
||||||
setActiveBells(new Set());
|
|
||||||
}, toneLengthRef.current);
|
|
||||||
|
|
||||||
// Schedule next step after step interval
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const next = playFrom + 1;
|
const next = playFrom + 1;
|
||||||
if (next >= stepsRef.current.length) {
|
if (next >= stepsRef.current.length) {
|
||||||
if (loopEnabledRef.current) {
|
if (loopEnabledRef.current) scheduleStep(0);
|
||||||
scheduleStep(0);
|
else stopPlayback();
|
||||||
} else {
|
|
||||||
stopPlayback();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleStep(next);
|
scheduleStep(next);
|
||||||
}, speedMsRef.current);
|
}, speedMsRef.current);
|
||||||
|
|
||||||
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
|
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
|
||||||
}, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [stopPlayback]);
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (!stepsRef.current.length) return;
|
if (!stepsRef.current.length) return;
|
||||||
@@ -295,15 +258,9 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
scheduleStep(0);
|
scheduleStep(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
stopPlayback();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const totalSteps = steps.length;
|
const totalSteps = steps.length;
|
||||||
|
|
||||||
// Compute which bells are actually used (after assignment mapping)
|
|
||||||
const allBellsUsed = steps.reduce((set, v) => {
|
const allBellsUsed = steps.reduce((set, v) => {
|
||||||
const mapped = applyNoteAssignments(v, noteAssignments);
|
const mapped = applyNoteAssignments(v, noteAssignments);
|
||||||
for (let bit = 0; bit < 16; bit++) {
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
@@ -338,66 +295,39 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
maxWidth: "480px",
|
maxWidth: "1100px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-6 py-4 border-b"
|
|
||||||
style={{ borderColor: "var(--border-primary)" }}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Melody Playback</h2>
|
||||||
Melody Playback
|
<p className="text-xs mt-0.5" style={mutedStyle}>{melody?.information?.name?.en || "Melody"} - looping</p>
|
||||||
</h2>
|
|
||||||
<p className="text-xs mt-0.5" style={mutedStyle}>
|
|
||||||
{melody?.information?.name?.en || "Melody"} — looping
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||||
onClick={() => { stopPlayback(); onClose(); }}
|
|
||||||
className="text-xl leading-none"
|
|
||||||
style={mutedStyle}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-6 py-5 space-y-5">
|
<div className="px-6 py-5 space-y-5">
|
||||||
{loading && (
|
{loading && <p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>}
|
||||||
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
|
||||||
)}
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<div
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
className="text-sm rounded-md p-3 border"
|
|
||||||
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
|
||||||
>
|
|
||||||
{loadError}
|
{loadError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !loadError && totalSteps > 0 && (
|
{!loading && !loadError && totalSteps > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Step info */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs" style={mutedStyle}>
|
<span className="text-xs" style={mutedStyle}>{totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}</span>
|
||||||
{totalSteps} steps · {allBellsUsed.size} bell{allBellsUsed.size !== 1 ? "s" : ""}
|
{currentStep >= 0 && <span className="text-xs font-mono" style={{ color: "var(--accent)" }}>Step {currentStep + 1} / {totalSteps}</span>}
|
||||||
</span>
|
|
||||||
{currentStep >= 0 && (
|
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--accent)" }}>
|
|
||||||
Step {currentStep + 1} / {totalSteps}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note → Bell assignment visualizer (shows when assignments exist) */}
|
{noteAssignments.length > 0 && (
|
||||||
{noteAssignments.length > 0 ? (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>Note → Assigned Bell</p>
|
<p className="text-xs mb-2" style={mutedStyle}>Note to Assigned Bell</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{noteAssignments.map((assignedBell, noteIdx) => {
|
{noteAssignments.map((assignedBell, noteIdx) => {
|
||||||
// A note is active (flashing) if its assigned bell is currently lit in activeBells
|
|
||||||
const firesABell = assignedBell && assignedBell > 0;
|
const firesABell = assignedBell && assignedBell > 0;
|
||||||
const isActive = firesABell && activeBells.has(assignedBell);
|
const isActive = firesABell && activeBells.has(assignedBell);
|
||||||
return (
|
return (
|
||||||
@@ -407,32 +337,20 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
style={{
|
style={{
|
||||||
minWidth: "36px",
|
minWidth: "36px",
|
||||||
padding: "4px 6px",
|
padding: "4px 6px",
|
||||||
backgroundColor: isActive && firesABell
|
backgroundColor: isActive && firesABell ? "var(--accent)" : "var(--bg-card-hover)",
|
||||||
? "var(--accent)"
|
|
||||||
: isActive && !firesABell
|
|
||||||
? "rgba(156,163,175,0.15)"
|
|
||||||
: "var(--bg-card-hover)",
|
|
||||||
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
||||||
transform: isActive && firesABell ? "scale(1.1)" : "scale(1)",
|
|
||||||
opacity: isActive && !firesABell ? 0.5 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>{NOTE_LABELS[noteIdx]}</span>
|
||||||
{NOTE_LABELS[noteIdx]}
|
|
||||||
</span>
|
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>{assignedBell > 0 ? assignedBell : "-"}</span>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>Top = Note, Bottom = Bell assigned</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{/* Active Bell circles (always shown) */}
|
|
||||||
{maxBell > 0 && (
|
{maxBell > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
|
<p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
|
||||||
@@ -448,8 +366,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||||
color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||||
border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
|
border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
|
||||||
transition: "background-color 0.05s, border-color 0.05s",
|
|
||||||
transform: isActive ? "scale(1.15)" : "scale(1)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{b}
|
{b}
|
||||||
@@ -459,59 +375,59 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Play / Stop */}
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{!playing ? (
|
{!playing ? (
|
||||||
<button
|
<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)" }}>
|
||||||
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
|
Play
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button onClick={stopPlayback} className="px-5 py-2 text-sm rounded-md font-medium transition-colors" style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}>
|
||||||
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
|
Stop
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<label className="inline-flex items-center gap-2 text-xs" style={mutedStyle}>
|
<label className="inline-flex items-center gap-2 text-xs" style={mutedStyle}>
|
||||||
<input
|
<input type="checkbox" checked={loopEnabled} onChange={(e) => setLoopEnabled(e.target.checked)} className="h-3.5 w-3.5 rounded" />
|
||||||
type="checkbox"
|
|
||||||
checked={loopEnabled}
|
|
||||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
|
||||||
className="h-3.5 w-3.5 rounded"
|
|
||||||
/>
|
|
||||||
Loop
|
Loop
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps matrix */}
|
<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/step)</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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{toneLengthMs} ms</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="20" max="400" step="10" value={toneLengthMs} onChange={(e) => setToneLengthMs(Number(e.target.value))} className="w-full h-2 rounded-lg appearance-none cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>Note/Step Matrix</p>
|
<p className="text-xs mb-2" style={mutedStyle}>Note/Step Matrix</p>
|
||||||
<div
|
<div className="rounded-md border overflow-auto" style={{ borderColor: "var(--border-primary)", maxHeight: "340px" }}>
|
||||||
className="rounded-md border overflow-auto"
|
|
||||||
style={{ borderColor: "var(--border-primary)", maxHeight: "280px" }}
|
|
||||||
>
|
|
||||||
<table className="min-w-max border-separate border-spacing-0 text-xs">
|
<table className="min-w-max border-separate border-spacing-0 text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th className="sticky top-0 left-0 z-20 px-2 py-1.5 text-left border-b border-r" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>Note \\ Step</th>
|
||||||
className="sticky top-0 left-0 z-20 px-2 py-1.5 text-left border-b border-r"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
Note \ Step
|
|
||||||
</th>
|
|
||||||
{steps.map((_, stepIdx) => (
|
{steps.map((_, stepIdx) => (
|
||||||
<th
|
<th
|
||||||
key={stepIdx}
|
key={stepIdx}
|
||||||
className="sticky top-0 z-10 px-2 py-1.5 text-center border-b border-r"
|
className="sticky top-0 z-10 px-2 py-1.5 text-center border-b border-r"
|
||||||
style={{
|
style={{
|
||||||
minWidth: "36px",
|
minWidth: "40px",
|
||||||
backgroundColor: currentStep === stepIdx ? "rgba(116,184,22,0.2)" : "var(--bg-primary)",
|
backgroundColor: currentStep === stepIdx ? "rgba(116,184,22,0.2)" : "var(--bg-primary)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
color: currentStep === stepIdx ? "var(--accent)" : "var(--text-muted)",
|
color: currentStep === stepIdx ? "var(--accent)" : "var(--text-muted)",
|
||||||
@@ -524,45 +440,41 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Array.from({ length: gridNoteCount }, (_, noteIdx) => (
|
{Array.from({ length: gridNoteCount }, (_, noteIdx) => (
|
||||||
<tr
|
<tr key={noteIdx}>
|
||||||
key={noteIdx}
|
<th className="sticky left-0 z-[1] px-2 py-1.5 text-left border-b border-r" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>{NOTE_LABELS[noteIdx]}</th>
|
||||||
>
|
|
||||||
<th
|
|
||||||
className="sticky left-0 z-[1] px-2 py-1.5 text-left border-b border-r"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{NOTE_LABELS[noteIdx]}
|
|
||||||
</th>
|
|
||||||
{steps.map((stepValue, stepIdx) => {
|
{steps.map((stepValue, stepIdx) => {
|
||||||
const enabled = Boolean(stepValue & (1 << noteIdx));
|
const enabled = Boolean(stepValue & (1 << noteIdx));
|
||||||
const isCurrent = currentStep === stepIdx;
|
const isCurrent = currentStep === stepIdx;
|
||||||
|
const assignedBell = Number(noteAssignments[noteIdx] || 0);
|
||||||
|
const dotLabel = assignedBell > 0 ? assignedBell : noteIdx + 1;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={`${noteIdx}-${stepIdx}`}
|
key={`${noteIdx}-${stepIdx}`}
|
||||||
className="border-b border-r"
|
className="border-b border-r"
|
||||||
style={{
|
style={{
|
||||||
width: "36px",
|
width: "40px",
|
||||||
height: "36px",
|
height: "40px",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.06)" : "transparent",
|
backgroundColor: isCurrent ? "rgba(116,184,22,0.06)" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="w-full h-full flex items-center justify-center" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
className="w-full h-full flex items-center justify-center"
|
className="flex items-center justify-center text-[10px] font-semibold"
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
width: "54%",
|
width: "68%",
|
||||||
height: "54%",
|
height: "68%",
|
||||||
borderRadius: "9999px",
|
borderRadius: "9999px",
|
||||||
backgroundColor: "var(--btn-primary)",
|
backgroundColor: "var(--btn-primary)",
|
||||||
|
color: "var(--text-white)",
|
||||||
opacity: enabled ? 1 : 0,
|
opacity: enabled ? 1 : 0,
|
||||||
transform: enabled ? "scale(1)" : "scale(0.4)",
|
transform: enabled ? "scale(1)" : "scale(0.4)",
|
||||||
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
|
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
|
||||||
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{enabled ? dotLabel : ""}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -574,77 +486,17 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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/step)
|
|
||||||
</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 && (
|
{!hasSpeedInfo && (
|
||||||
<p className="text-xs mt-1.5" style={{ color: "var(--warning, #f59e0b)" }}>
|
<p className="text-xs mt-1.5" style={{ color: "var(--warning, #f59e0b)" }}>
|
||||||
No MIN/MAX speed set for this melody — using linear fallback.
|
No MIN/MAX speed set for this melody - using linear fallback.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tone Length Slider */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
|
||||||
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
|
||||||
{toneLengthMs} ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="20"
|
|
||||||
max="400"
|
|
||||||
step="10"
|
|
||||||
value={toneLengthMs}
|
|
||||||
onChange={(e) => setToneLengthMs(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>Short (20 ms)</span>
|
|
||||||
<span>Long (400 ms)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div
|
<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)" }}>
|
||||||
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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user