/* ============================================================================ * Kdrama by DSK — Interfaz (React) * Elegante y emotiva · i18n ES/EN · tema claro/oscuro · ruido ambiente. * ========================================================================== */ const { useState, useEffect, useRef, useCallback } = React; const STORE_KEY = "kdrama-settings-v1"; const SAVED_KEY = "kdrama-saved-v1"; const DEFAULTS = { instrument: "piano", engineMode: "fm", sfzInstrument: null, octaveOffset: 0, mood: "romance", complexity: 4, temperature: 0.35, tempoScale: 1.0, reverb: 0.45, width: 0.35, delay: 0.0, lofi: 0.0, noiseType: "off", noiseVolume: 0.5, outputGain: 1.5, playbackMode: "songs", pianoRoll: true, padEnabled: false, padVolume: 0.5, padTimbre: "warm", gradualFade: true, theme: "dark", lang: null, // tema oscuro por defecto · lang null → autodetect // estado plegado de los paneles (luna creciente = plegado, llena = abierto) secInstrument: true, secMode: true, secAdvanced: false, secAmbient: false, secSleep: false, }; function loadSettings() { try { const raw = localStorage.getItem(STORE_KEY); if (raw) return { ...DEFAULTS, ...JSON.parse(raw) }; } catch (e) {} return { ...DEFAULTS }; } /* ------------------------------------------------------ Nanas guardadas */ function loadSavedList() { try { const raw = localStorage.getItem(SAVED_KEY); if (raw) return JSON.parse(raw); } catch (e) {} return []; } function saveSavedList(list) { try { localStorage.setItem(SAVED_KEY, JSON.stringify(list)); } catch (e) {} } function applyTheme(theme) { const root = document.documentElement; if (theme === "light" || theme === "dark") root.dataset.theme = theme; else delete root.dataset.theme; // auto → lo decide @media } /* ---------------------------------------------------------------- ModalUI */ function ModalUI({ modal, onClose }) { const cardRef = useRef(null); useEffect(() => { if (modal && cardRef.current) cardRef.current.scrollTop = 0; }, [modal]); if (!modal) return null; return ReactDOM.createPortal(
e.stopPropagation()} role="dialog" aria-modal="true"> {modal.icon ?
{modal.icon}
: null}

{modal.title}

{modal.body ?

{modal.body}

: null} {modal.content ?
{modal.content}
: null}
{(modal.actions || [{ label: modal.okLabel || "OK", primary: true, onClick: onClose }]).map((a, i) => ( ))}
, document.body ); } /* ------------------------------------------------------------- Iconografía */ const Moon = ({ s = 26 }) => ( ); // Luna llena → panel ABIERTO · luna creciente → panel PLEGADO. const MoonFull = ({ s = 20 }) => ( ); const MoonCrescent = ({ s = 20 }) => ( ); /* ----------------------------------------------------- Sección plegable */ function Section({ title, open, onToggle, soft, children }) { return (
{children}
); } const Play = ({ s = 30 }) => ( ); const Pause = ({ s = 30 }) => ( ); const Dot = ({ s = 14 }) => ( ); const Gear = ({ s = 20 }) => ( ); const Dice = ({ s = 20 }) => ( ); const Bookmark = ({ s = 20 }) => ( ); const FolderIcon = ({ s = 20 }) => ( ); const PencilIcon = ({ s = 18 }) => ( ); const TrashIcon = ({ s = 18 }) => ( ); /* --------------------------------------------------------- Segmented pick */ function Segmented({ options, value, onChange }) { return (
{options.map((o) => ( ))}
); } /* --------------------------------------------------------------- Slider UI */ // Doble clic/doble tap en la etiqueta restablece el valor por defecto. function Slider({ label, valueLabel, min, max, step, value, onChange, leftCap, rightCap, defaultValue }) { const pct = ((value - min) / (max - min)) * 100; const reset = defaultValue != null ? () => onChange(defaultValue) : undefined; return (
{label} {valueLabel}
onChange(parseFloat(e.target.value))} style={{ "--pct": pct + "%" }} /> {(leftCap || rightCap) && (
{leftCap}{rightCap}
)}
); } /* --------------------------------------------------------------- Toggle UI */ // Doble clic/doble tap en la etiqueta restablece el valor por defecto. function Toggle({ label, sub, checked, onChange, defaultChecked }) { const reset = defaultChecked != null ? (e) => { e.stopPropagation(); onChange(defaultChecked); } : undefined; return ( ); } /* --------------------------------------------------------- Orbe / visual */ function Orb({ active, playing, levelRef, onToggle }) { const ref = useRef(null); const glowRef = useRef(null); useEffect(() => { let raf; const loop = () => { const lvl = levelRef.current || 0; const scale = 1 + lvl * 0.34; if (ref.current) ref.current.style.transform = `scale(${scale.toFixed(3)})`; if (glowRef.current) glowRef.current.style.opacity = (0.45 + lvl * 0.55).toFixed(3); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, []); return (
); } function fmtTime(s) { const m = Math.floor(s / 60), ss = Math.floor(s % 60); return `${m}:${ss.toString().padStart(2, "0")}`; } // Nombre visible de un instrumento SFZ en el idioma activo. "name" en el // manifest puede ser un string simple (mismo nombre en todos los idiomas) // o un objeto { es, en, ... } con una traducción por idioma. function sfzName(entry, lang) { if (!entry) return ""; const n = entry.name; if (n && typeof n === "object") return n[lang] || n.es || n.en || Object.values(n)[0] || entry.id; return n || entry.id; } function tsName() { const d = new Date(), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } /* --------------------------------------------------------- Piano roll */ /* Notas cayendo hacia una línea horizontal: por encima, lo que está por * sonar; al cruzar la línea, suenan; por debajo, se apagan enseguida. * Lee directamente synth.getVisNotes()/getVisNow(), sin estado propio. */ function PianoRoll({ synth, active }) { const canvasRef = useRef(null); useEffect(() => { if (!active || !synth) return; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); let raf, w = 0, h = 0, dpr = Math.min(2, window.devicePixelRatio || 1); const WINDOW_SEC = 4.2, PAST_SEC = 0.45; const COLORS = { melody: [255, 214, 150], chord: [150, 195, 255], bass: [190, 160, 255] }; const resize = () => { const rect = canvas.getBoundingClientRect(); if (rect.width < 2 || rect.height < 2) return; w = rect.width; h = rect.height; canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; const ro = new ResizeObserver(resize); ro.observe(canvas); resize(); const isDark = () => { const th = document.documentElement.dataset.theme; if (th === "dark") return true; if (th === "light") return false; return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; }; const draw = () => { raf = requestAnimationFrame(draw); if (w < 2 || h < 2) return; ctx.clearRect(0, 0, w, h); const lineY = h * 0.8; const notes = synth.getVisNotes(); const now = synth.getVisNow(); if (notes.length) { let lo = 200, hi = -1; for (const n of notes) { if (n.note < lo) lo = n.note; if (n.note > hi) hi = n.note; } if (hi >= lo) { lo -= 2; hi += 2; const span = Math.max(1, hi - lo); const yOf = (dt) => (dt >= 0 ? lineY * (1 - dt / WINDOW_SEC) : lineY + (h - lineY) * Math.min(1, -dt / PAST_SEC)); for (const n of notes) { const dtBottom = n.start - now; const dtTop = dtBottom + n.dur; if (dtBottom > WINDOW_SEC || dtTop < -PAST_SEC) continue; const yTop = yOf(Math.min(WINDOW_SEC, dtTop)); const yBottom = yOf(Math.max(-PAST_SEC, dtBottom)); const alpha = dtBottom < 0 ? Math.max(0, 1 + dtBottom / PAST_SEC) : 1; const x = ((n.note - lo) / span) * (w - 14) + 7; const rw = n.track === "melody" ? 8 : 5; const col = COLORS[n.track] || COLORS.melody; ctx.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${(0.85 * alpha).toFixed(3)})`; const rh = Math.max(4, yBottom - yTop); ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x - rw / 2, yTop, rw, rh, Math.min(3, rw / 2)); else ctx.rect(x - rw / 2, yTop, rw, rh); ctx.fill(); } } } ctx.strokeStyle = isDark() ? "rgba(255,255,255,0.22)" : "rgba(50,40,80,0.22)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, lineY); ctx.lineTo(w, lineY); ctx.stroke(); }; raf = requestAnimationFrame(draw); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [active, synth]); if (!active) return null; return