CODEX - Added Modal Playback for Archetypes
This commit is contained in:
@@ -47,6 +47,13 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
|
|||||||
return Math.round(a * Math.pow(b / a, t));
|
return Math.round(a * Math.pow(b / a, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFileUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") return null;
|
||||||
|
if (url.startsWith("http") || url.startsWith("/api")) return url;
|
||||||
|
if (url.startsWith("/")) return `/api${url}`;
|
||||||
|
return `/api/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
function hueForDepth(index, count) {
|
function hueForDepth(index, count) {
|
||||||
const safeCount = Math.max(1, count);
|
const safeCount = Math.max(1, count);
|
||||||
const t = Math.max(0, Math.min(1, index / safeCount));
|
const t = Math.max(0, Math.min(1, index / safeCount));
|
||||||
@@ -121,6 +128,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 [offlineSaving, setOfflineSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
@@ -192,6 +200,32 @@ export default function MelodyDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleAvailableOffline = async (nextValue) => {
|
||||||
|
if (!canEdit || !melody) return;
|
||||||
|
setOfflineSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
information: { ...(melody.information || {}), available_offline: nextValue },
|
||||||
|
default_settings: melody.default_settings || {},
|
||||||
|
type: melody.type || "all",
|
||||||
|
uid: melody.uid || "",
|
||||||
|
pid: melody.pid || "",
|
||||||
|
metadata: melody.metadata || {},
|
||||||
|
};
|
||||||
|
if (melody.url) body.url = melody.url;
|
||||||
|
await api.put(`/melodies/${id}`, body);
|
||||||
|
setMelody((prev) => ({
|
||||||
|
...prev,
|
||||||
|
information: { ...(prev?.information || {}), available_offline: nextValue },
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setOfflineSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
}
|
}
|
||||||
@@ -490,10 +524,24 @@ export default function MelodyDetail() {
|
|||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
|
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
|
<Field label="Available as Built-In">
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(info.available_offline)}
|
||||||
|
disabled={!canEdit || offlineSaving}
|
||||||
|
onChange={(e) => handleToggleAvailableOffline(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded"
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{info.available_offline ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
<Field label="Binary File">
|
<Field label="Binary File">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Prefer the uploaded file URL, fall back to melody.url (legacy/firebase storage URL)
|
// Prefer the uploaded file URL, fall back to melody.url (legacy/firebase storage URL)
|
||||||
const binaryUrl = files.binary_url || melody.url || null;
|
const binaryUrl = normalizeFileUrl(files.binary_url || melody.url || null);
|
||||||
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>Not uploaded</span>;
|
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>Not uploaded</span>;
|
||||||
|
|
||||||
const binaryPid = builtMelody?.pid || melody.pid || "binary";
|
const binaryPid = builtMelody?.pid || melody.pid || "binary";
|
||||||
@@ -539,15 +587,26 @@ export default function MelodyDetail() {
|
|||||||
{builtMelody?.name ? (
|
{builtMelody?.name ? (
|
||||||
<strong style={{ color: "var(--text-heading)" }}>{builtMelody.name}</strong>
|
<strong style={{ color: "var(--text-heading)" }}>{builtMelody.name}</strong>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
href={binaryUrl}
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="underline"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
{downloadName}
|
{downloadName}
|
||||||
</a>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPlayback(true)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
{!files.binary_url && melody.url && (
|
{!files.binary_url && melody.url && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
via URL
|
via URL
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const defaultInfo = {
|
|||||||
steps: 0,
|
steps: 0,
|
||||||
color: "",
|
color: "",
|
||||||
isTrueRing: false,
|
isTrueRing: false,
|
||||||
|
available_offline: false,
|
||||||
previewURL: "",
|
previewURL: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +70,13 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
|
|||||||
return Math.round(a * Math.pow(b / a, t));
|
return Math.round(a * Math.pow(b / a, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFileUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") return null;
|
||||||
|
if (url.startsWith("http") || url.startsWith("/api")) return url;
|
||||||
|
if (url.startsWith("/")) return `/api${url}`;
|
||||||
|
return `/api/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MelodyForm() {
|
export default function MelodyForm() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
@@ -717,26 +725,53 @@ export default function MelodyForm() {
|
|||||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="available-offline"
|
||||||
|
checked={Boolean(information.available_offline)}
|
||||||
|
onChange={(e) => updateInfo("available_offline", e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="available-offline" className="text-sm font-medium" style={labelStyle}>
|
||||||
|
Available as Built-In
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<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>
|
||||||
{(() => {
|
{(() => {
|
||||||
const binaryUrl = existingFiles.binary_url || url || null;
|
const binaryUrl = normalizeFileUrl(existingFiles.binary_url || url || null);
|
||||||
const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download");
|
const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download");
|
||||||
const binaryName = resolveFilename(binaryUrl, fallback);
|
const binaryName = resolveFilename(binaryUrl, fallback);
|
||||||
return (
|
return (
|
||||||
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
|
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
|
||||||
{binaryUrl ? (
|
{binaryUrl ? (
|
||||||
<a
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
href={binaryUrl}
|
|
||||||
onClick={(e) => downloadExistingFile(binaryUrl, binaryName, e)}
|
|
||||||
className="underline"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
{binaryName}
|
{binaryName}
|
||||||
</a>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>No binary uploaded</span>
|
<span>No binary uploaded</span>
|
||||||
)}
|
)}
|
||||||
|
{binaryUrl && (
|
||||||
|
<div className="mt-1 inline-flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => downloadExistingFile(binaryUrl, binaryName, e)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPlayback(true)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-0.5">{information.totalNotes ?? 0} active notes</div>
|
<div className="mt-0.5">{information.totalNotes ?? 0} active notes</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -908,15 +943,17 @@ export default function MelodyForm() {
|
|||||||
multiline={translationModal.multiline}
|
multiline={translationModal.multiline}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PlaybackModal
|
||||||
|
open={showPlayback}
|
||||||
|
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={() => setShowPlayback(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<>
|
<>
|
||||||
<PlaybackModal
|
|
||||||
open={showPlayback}
|
|
||||||
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
|
|
||||||
builtMelody={builtMelody}
|
|
||||||
archetypeCsv={information.archetype_csv || null}
|
|
||||||
onClose={() => setShowPlayback(false)}
|
|
||||||
/>
|
|
||||||
<SpeedCalculatorModal
|
<SpeedCalculatorModal
|
||||||
open={showSpeedCalc}
|
open={showSpeedCalc}
|
||||||
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
|
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
|
||||||
|
|||||||
@@ -4,6 +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 {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -39,6 +40,7 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
||||||
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
||||||
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
||||||
|
{ key: "builtIn", label: "Built-in", defaultOn: false },
|
||||||
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
||||||
{ key: "dateCreated", label: "Date Created", defaultOn: false },
|
{ key: "dateCreated", label: "Date Created", defaultOn: false },
|
||||||
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
|
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
|
||||||
@@ -169,6 +171,106 @@ function getBinaryFilename(row) {
|
|||||||
return row?.pid ? `${row.pid}.bsm` : "melody.bsm";
|
return row?.pid ? `${row.pid}.bsm` : "melody.bsm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSafeCppSymbol(input, fallback = "melody") {
|
||||||
|
const base = String(input || "").trim().toLowerCase();
|
||||||
|
const cleaned = base.replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
||||||
|
const symbol = cleaned || fallback;
|
||||||
|
return /^[a-z_]/.test(symbol) ? symbol : `m_${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCppString(value) {
|
||||||
|
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStepTokenToMask(token) {
|
||||||
|
const raw = String(token || "").trim();
|
||||||
|
if (!raw) return 0;
|
||||||
|
if (raw.startsWith("0x") || raw.startsWith("0X")) {
|
||||||
|
const parsed = Number.parseInt(raw, 16);
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed & 0xffff;
|
||||||
|
}
|
||||||
|
if (raw.includes("+")) {
|
||||||
|
return raw
|
||||||
|
.split("+")
|
||||||
|
.map((part) => Number.parseInt(part.trim(), 10))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0)
|
||||||
|
.reduce((mask, bell) => {
|
||||||
|
if (bell > 16) return mask;
|
||||||
|
return mask | (1 << (bell - 1));
|
||||||
|
}, 0) & 0xffff;
|
||||||
|
}
|
||||||
|
const n = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(n) || n < 0) return 0;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
if (n <= 16) return (1 << (n - 1)) & 0xffff;
|
||||||
|
return n & 0xffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArchetypeCsv(archetypeCsv) {
|
||||||
|
if (!archetypeCsv || typeof archetypeCsv !== "string") return [];
|
||||||
|
return archetypeCsv
|
||||||
|
.split(",")
|
||||||
|
.map((step) => step.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(parseStepTokenToMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHex16(value) {
|
||||||
|
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOfflineCppCode(rows) {
|
||||||
|
const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline));
|
||||||
|
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrays = [];
|
||||||
|
const libraryEntries = [];
|
||||||
|
|
||||||
|
for (const row of selected) {
|
||||||
|
const info = row?.information || {};
|
||||||
|
const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody"));
|
||||||
|
const uid = row?.uid || "";
|
||||||
|
const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`;
|
||||||
|
const steps = parseArchetypeCsv(info.archetype_csv);
|
||||||
|
const stepCount = Number(info.steps || 0);
|
||||||
|
|
||||||
|
arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`);
|
||||||
|
arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`);
|
||||||
|
if (steps.length === 0) {
|
||||||
|
arrays.push(" // No archetype_csv step data found");
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < steps.length; i += 8) {
|
||||||
|
const chunk = steps.slice(i, i + 8).map(formatHex16).join(", ");
|
||||||
|
arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arrays.push("};");
|
||||||
|
arrays.push("");
|
||||||
|
|
||||||
|
libraryEntries.push(" {");
|
||||||
|
libraryEntries.push(` "${escapeCppString(displayName)}",`);
|
||||||
|
libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`);
|
||||||
|
libraryEntries.push(` ${symbol},`);
|
||||||
|
libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`);
|
||||||
|
libraryEntries.push(" }");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`// Generated: ${generatedAt}`,
|
||||||
|
"",
|
||||||
|
...arrays,
|
||||||
|
"// --- Add or replace your MELODY_LIBRARY[] with this: ---",
|
||||||
|
"const MelodyInfo MELODY_LIBRARY[] = {",
|
||||||
|
libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"),
|
||||||
|
"};",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export default function MelodyList() {
|
export default function MelodyList() {
|
||||||
const [melodies, setMelodies] = useState([]);
|
const [melodies, setMelodies] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -189,6 +291,9 @@ export default function MelodyList() {
|
|||||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||||
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||||
|
const [showOfflineModal, setShowOfflineModal] = useState(false);
|
||||||
|
const [builtInSavingId, setBuiltInSavingId] = useState(null);
|
||||||
|
const [viewRow, setViewRow] = useState(null);
|
||||||
const columnPickerRef = useRef(null);
|
const columnPickerRef = useRef(null);
|
||||||
const creatorPickerRef = useRef(null);
|
const creatorPickerRef = useRef(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -323,6 +428,41 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBinaryView = (e, row) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setViewRow(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBuiltInState = async (e, row, nextValue) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!canEdit) return;
|
||||||
|
setBuiltInSavingId(row.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
information: { ...(row.information || {}), available_offline: nextValue },
|
||||||
|
default_settings: row.default_settings || {},
|
||||||
|
type: row.type || "all",
|
||||||
|
uid: row.uid || "",
|
||||||
|
pid: row.pid || "",
|
||||||
|
metadata: row.metadata || {},
|
||||||
|
};
|
||||||
|
if (row.url) body.url = row.url;
|
||||||
|
await api.put(`/melodies/${row.id}`, body);
|
||||||
|
setMelodies((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === row.id
|
||||||
|
? { ...m, information: { ...(m.information || {}), available_offline: nextValue } }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBuiltInSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleColumn = (key) => {
|
const toggleColumn = (key) => {
|
||||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||||
if (col?.alwaysOn) return;
|
if (col?.alwaysOn) return;
|
||||||
@@ -403,6 +543,8 @@ export default function MelodyList() {
|
|||||||
});
|
});
|
||||||
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
|
||||||
|
|
||||||
const handleSortClick = (columnKey) => {
|
const handleSortClick = (columnKey) => {
|
||||||
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
|
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
|
||||||
if (!nextSortKey) return;
|
if (!nextSortKey) return;
|
||||||
@@ -634,6 +776,36 @@ export default function MelodyList() {
|
|||||||
) : (
|
) : (
|
||||||
"-"
|
"-"
|
||||||
);
|
);
|
||||||
|
case "builtIn": {
|
||||||
|
const enabled = Boolean(info.available_offline);
|
||||||
|
const saving = builtInSavingId === row.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => updateBuiltInState(e, row, !enabled)}
|
||||||
|
disabled={!canEdit || saving}
|
||||||
|
className="inline-flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ background: "none", border: "none", padding: 0, color: "var(--text-secondary)" }}
|
||||||
|
title={canEdit ? "Click to toggle built-in availability" : "Built-in availability"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded-full border inline-flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
borderColor: enabled ? "rgba(34,197,94,0.7)" : "var(--border-primary)",
|
||||||
|
backgroundColor: enabled ? "rgba(34,197,94,0.15)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: enabled ? "#22c55e" : "transparent" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: enabled ? "var(--success-text)" : "var(--text-muted)" }}>
|
||||||
|
{enabled ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
case "binaryFile": {
|
case "binaryFile": {
|
||||||
const binaryUrl = getBinaryUrl(row);
|
const binaryUrl = getBinaryUrl(row);
|
||||||
const filename = getBinaryFilename(row);
|
const filename = getBinaryFilename(row);
|
||||||
@@ -648,15 +820,26 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-col">
|
<span className="inline-flex flex-col">
|
||||||
<button
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{filename || "binary.bsm"}</span>
|
||||||
type="button"
|
<span className="mt-1 inline-flex gap-1.5">
|
||||||
onClick={(e) => downloadBinary(e, row)}
|
<button
|
||||||
className="underline text-xs text-left"
|
type="button"
|
||||||
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
onClick={(e) => downloadBinary(e, row)}
|
||||||
title={binaryUrl}
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
>
|
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
|
||||||
{filename || "Click to Download"}
|
title={binaryUrl}
|
||||||
</button>
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => openBinaryView(e, row)}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -902,9 +1085,25 @@ export default function MelodyList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
|
{canEdit && (
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOfflineModal(true)}
|
||||||
|
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Build Offline List
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1066,6 +1265,76 @@ export default function MelodyList() {
|
|||||||
onConfirm={handleUnpublish}
|
onConfirm={handleUnpublish}
|
||||||
onCancel={() => setUnpublishTarget(null)}
|
onCancel={() => setUnpublishTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showOfflineModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
|
||||||
|
onClick={() => setShowOfflineModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-5xl max-h-[85vh] rounded-lg border shadow-xl flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Offline Built-In Code</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Includes melodies where Built-in = Yes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(offlineCode);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOfflineModal(false)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 overflow-auto">
|
||||||
|
<pre
|
||||||
|
className="text-xs rounded-lg p-4 overflow-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
maxHeight: "62vh",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{offlineCode}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackModal
|
||||||
|
open={!!viewRow}
|
||||||
|
melody={viewRow || null}
|
||||||
|
builtMelody={null}
|
||||||
|
files={viewRow ? { binary_url: getBinaryUrl(viewRow) } : null}
|
||||||
|
archetypeCsv={viewRow?.information?.archetype_csv || null}
|
||||||
|
onClose={() => setViewRow(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ function parseStepsString(stepsStr) {
|
|||||||
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
|
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlaybackUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") return null;
|
||||||
|
if (url.startsWith("http") || url.startsWith("/api")) return url;
|
||||||
|
if (url.startsWith("/")) return `/api${url}`;
|
||||||
|
return `/api/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function decodeBsmBinary(url) {
|
async function decodeBsmBinary(url) {
|
||||||
// Try with auth token first (for our API endpoints), then without (for Firebase URLs)
|
// 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");
|
||||||
@@ -143,6 +150,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
const [currentStep, setCurrentStep] = useState(-1);
|
const [currentStep, setCurrentStep] = useState(-1);
|
||||||
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);
|
||||||
|
|
||||||
// activeBells: Set of bell numbers currently lit (for flash effect)
|
// activeBells: Set of bell numbers currently lit (for flash effect)
|
||||||
const [activeBells, setActiveBells] = useState(new Set());
|
const [activeBells, setActiveBells] = useState(new Set());
|
||||||
@@ -153,6 +161,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
const speedMsRef = useRef(500);
|
const speedMsRef = useRef(500);
|
||||||
const toneLengthRef = useRef(80);
|
const toneLengthRef = useRef(80);
|
||||||
const noteAssignmentsRef = useRef(noteAssignments);
|
const noteAssignmentsRef = useRef(noteAssignments);
|
||||||
|
const loopEnabledRef = useRef(true);
|
||||||
|
|
||||||
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
|
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
|
||||||
|
|
||||||
@@ -160,6 +169,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
useEffect(() => { 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]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]);
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
if (playbackRef.current) {
|
if (playbackRef.current) {
|
||||||
@@ -180,11 +190,39 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
setCurrentStep(-1);
|
setCurrentStep(-1);
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
setSpeedPercent(50);
|
setSpeedPercent(50);
|
||||||
|
setLoopEnabled(true);
|
||||||
setActiveBells(new Set());
|
setActiveBells(new Set());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const binaryUrlCandidate = builtMelody?.binary_url
|
||||||
|
? `/api${builtMelody.binary_url}`
|
||||||
|
: files?.binary_url || melody?.url || null;
|
||||||
|
const binaryUrl = normalizePlaybackUrl(binaryUrlCandidate);
|
||||||
const csv = archetypeCsv || info.archetype_csv || null;
|
const csv = archetypeCsv || info.archetype_csv || null;
|
||||||
|
|
||||||
|
if (binaryUrl) {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError("");
|
||||||
|
decodeBsmBinary(binaryUrl)
|
||||||
|
.then((decoded) => {
|
||||||
|
setSteps(decoded);
|
||||||
|
stepsRef.current = decoded;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (csv) {
|
||||||
|
const parsed = parseStepsString(csv);
|
||||||
|
setSteps(parsed);
|
||||||
|
stepsRef.current = parsed;
|
||||||
|
setLoadError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (csv) {
|
if (csv) {
|
||||||
const parsed = parseStepsString(csv);
|
const parsed = parseStepsString(csv);
|
||||||
setSteps(parsed);
|
setSteps(parsed);
|
||||||
@@ -193,25 +231,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to binary
|
setLoadError("No binary or archetype data available for this melody.");
|
||||||
const binaryUrl = builtMelody?.binary_url
|
|
||||||
? `/api${builtMelody.binary_url}`
|
|
||||||
: files?.binary_url || melody?.url || null;
|
|
||||||
|
|
||||||
if (!binaryUrl) {
|
|
||||||
setLoadError("No binary or archetype data available for this melody.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError("");
|
|
||||||
decodeBsmBinary(binaryUrl)
|
|
||||||
.then((decoded) => {
|
|
||||||
setSteps(decoded);
|
|
||||||
stepsRef.current = decoded;
|
|
||||||
})
|
|
||||||
.catch((err) => setLoadError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const ensureAudioCtx = () => {
|
const ensureAudioCtx = () => {
|
||||||
@@ -255,11 +275,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
// Schedule next step after step interval
|
// Schedule next step after step interval
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const next = playFrom + 1;
|
const next = playFrom + 1;
|
||||||
scheduleStep(next >= stepsRef.current.length ? 0 : next);
|
if (next >= stepsRef.current.length) {
|
||||||
|
if (loopEnabledRef.current) {
|
||||||
|
scheduleStep(0);
|
||||||
|
} else {
|
||||||
|
stopPlayback();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleStep(next);
|
||||||
}, speedMsRef.current);
|
}, speedMsRef.current);
|
||||||
|
|
||||||
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
|
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (!stepsRef.current.length) return;
|
if (!stepsRef.current.length) return;
|
||||||
@@ -286,6 +314,18 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
|
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
|
||||||
|
|
||||||
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
|
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
|
||||||
|
const detectedNoteCount = steps.reduce((max, stepValue) => {
|
||||||
|
let highest = 0;
|
||||||
|
for (let bit = 15; bit >= 0; bit--) {
|
||||||
|
if (stepValue & (1 << bit)) {
|
||||||
|
highest = bit + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(max, highest);
|
||||||
|
}, 0);
|
||||||
|
const configuredNoteCount = Number(info.totalNotes || noteAssignments.length || 0);
|
||||||
|
const gridNoteCount = Math.max(1, Math.min(16, configuredNoteCount || detectedNoteCount || 1));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -439,7 +479,99 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs" style={mutedStyle}>Loops continuously</span>
|
<label className="inline-flex items-center gap-2 text-xs" style={mutedStyle}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={loopEnabled}
|
||||||
|
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded"
|
||||||
|
/>
|
||||||
|
Loop
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps matrix */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-2" style={mutedStyle}>Note/Step Matrix</p>
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
<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: "36px",
|
||||||
|
backgroundColor: currentStep === stepIdx ? "rgba(116,184,22,0.2)" : "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: currentStep === stepIdx ? "var(--accent)" : "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));
|
||||||
|
const isCurrent = currentStep === stepIdx;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${noteIdx}-${stepIdx}`}
|
||||||
|
className="border-b border-r"
|
||||||
|
style={{
|
||||||
|
width: "36px",
|
||||||
|
height: "36px",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
backgroundColor: isCurrent ? "rgba(116,184,22,0.06)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-full h-full flex items-center justify-center"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "54%",
|
||||||
|
height: "54%",
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Speed Slider */}
|
{/* Speed Slider */}
|
||||||
|
|||||||
Reference in New Issue
Block a user