import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import PlaybackModal from "./PlaybackModal";
import BinaryTableModal from "./BinaryTableModal";
function fallbackCopy(text, onSuccess) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
document.body.removeChild(ta);
}
function copyText(text, onSuccess) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
} else {
fallbackCopy(text, onSuccess);
}
}
import {
getLocalizedValue,
getLanguageName,
normalizeColor,
formatDuration,
} from "./melodyUtils";
function formatBpm(ms) {
const value = Number(ms);
if (!value || value <= 0) return null;
return Math.round(60000 / value);
}
function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
if (minSpeed == null || maxSpeed == null) return null;
const p = Math.max(0, Math.min(100, Number(percent || 0)));
const t = p / 100;
const a = Number(minSpeed);
const b = Number(maxSpeed);
if (a <= 0 || b <= 0) return Math.round(a + (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 Field({ label, children }) {
return (
{label}
{children || "-"}
);
}
function UrlField({ label, value }) {
const [copied, setCopied] = useState(false);
return (
{label}
{value}
);
}
export default function MelodyDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("melodies", "edit");
const [melody, setMelody] = useState(null);
const [files, setFiles] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showDelete, setShowDelete] = useState(false);
const [showUnpublish, setShowUnpublish] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null);
const [builtMelody, setBuiltMelody] = useState(null);
const [codeCopied, setCodeCopied] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [showPlayback, setShowPlayback] = useState(false);
const [showBinaryView, setShowBinaryView] = useState(false);
const [offlineSaving, setOfflineSaving] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
setDisplayLang(ms.primary_language || "en");
});
}, []);
useEffect(() => {
loadData();
}, [id]);
const loadData = async () => {
setLoading(true);
try {
const [m, f] = await Promise.all([
api.get(`/melodies/${id}`),
api.get(`/melodies/${id}/files`),
]);
setMelody(m);
setFiles(f);
// Load built melody assignment (non-fatal if it fails)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
setBuiltMelody(bm || null);
} catch {
setBuiltMelody(null);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
try {
await api.delete(`/melodies/${id}`);
navigate("/melodies");
} catch (err) {
setError(err.message);
setShowDelete(false);
}
};
const handlePublish = async () => {
setActionLoading(true);
try {
await api.post(`/melodies/${id}/publish`);
await loadData();
} catch (err) {
setError(err.message);
} finally {
setActionLoading(false);
}
};
const handleUnpublish = async () => {
setActionLoading(true);
try {
await api.post(`/melodies/${id}/unpublish`);
setShowUnpublish(false);
await loadData();
} catch (err) {
setError(err.message);
setShowUnpublish(false);
} finally {
setActionLoading(false);
}
};
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) {
return Loading...
;
}
if (error) {
return (
{error}
);
}
if (!melody) return null;
const info = melody.information || {};
const settings = melody.default_settings || {};
const speedMs = mapPercentageToStepDelay(settings.speed, info.minSpeed, info.maxSpeed);
const speedBpm = formatBpm(speedMs);
const minBpm = formatBpm(info.minSpeed);
const maxBpm = formatBpm(info.maxSpeed);
const missingArchetype = Boolean(melody.pid) && !builtMelody?.id;
const languages = melodySettings?.available_languages || ["en"];
const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
const badgeStyle = (active) => ({
backgroundColor: active ? "var(--success-bg)" : "var(--bg-card-hover)",
color: active ? "var(--success-text)" : "var(--text-muted)",
});
return (
{displayName}
{melody.status === "published" ? "LIVE" : "DRAFT"}
{languages.length > 1 && (
)}
{canEdit && (
{melody.status === "draft" ? (
) : (
)}
)}
{/* Left column */}
{/* Melody Information */}
Melody Information
{info.color ? (
{info.color}
) : (
"-"
)}
{info.melodyTone}
{info.steps}
{info.minSpeed ? (
{minBpm} bpm · {info.minSpeed} ms
) : "-"}
{info.maxSpeed ? (
{maxBpm} bpm · {info.maxSpeed} ms
) : "-"}
{info.totalActiveBells ?? "-"}
{getLocalizedValue(info.description, displayLang)}
{info.customTags?.length > 0 ? (
{info.customTags.map((tag) => (
{tag}
))}
) : (
"-"
)}
{/* Identifiers */}
Identifiers
{melody.id}
{melody.pid}
{melody.url && (
)}
{/* Right column */}
{/* Default Settings */}
Default Settings
{settings.speed != null ? (
{settings.speed}%{speedBpm ? {` · ${speedBpm} bpm`} : ""}{speedMs ? {` · ${speedMs} ms`} : ""}
) : "-"}
{formatDuration(settings.duration)}
{settings.totalRunDuration}
{settings.pauseDuration}
{settings.infiniteLoop ? "Yes" : "No"}
{settings.echoRing?.length > 0
? settings.echoRing.join(", ")
: "-"}
- Note Assignments
-
{settings.noteAssignments?.length > 0 ? (
{settings.noteAssignments.map((assignedBell, noteIdx) => {
return (
{String.fromCharCode(65 + noteIdx)}
{assignedBell > 0 ? assignedBell : "-"}
);
})}
) : (
-
)}
Top = Note, Bottom = Assigned Bell
{/* Files */}
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
{builtMelody?.progmem_code && (
Firmware Code
PROGMEM code for built-in firmware playback · PID: {builtMelody.pid}
{builtMelody.progmem_code}
)}
{/* Metadata section */}
{melody.metadata && (
History
{melody.metadata.dateCreated && (
{new Date(melody.metadata.dateCreated).toLocaleString()}
)}
{melody.metadata.createdBy && (
{melody.metadata.createdBy}
)}
{melody.metadata.dateEdited && (
{new Date(melody.metadata.dateEdited).toLocaleString()}
)}
{melody.metadata.lastEditedBy && (
{melody.metadata.lastEditedBy}
)}
)}
{/* Admin Notes section */}
Admin Notes
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
{melody.metadata.adminNotes.map((note, i) => (
{note}
))}
) : (
No admin notes yet. Edit this melody to add notes.
)}
setShowPlayback(false)}
/>
setShowBinaryView(false)}
/>
setShowSpeedCalc(false)}
onSaved={() => {
setShowSpeedCalc(false);
loadData();
}}
/>
setShowDelete(false)}
/>
setShowUnpublish(false)}
/>
);
}