/* ============================================================================
* 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 (
);
}
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 ;
}
/* --------------------------------------------------- Atardecer con pétalos */
/* Canvas animado de fondo: un atardecer cálido con pétalos de cerezo que
* caen y se mecen suavemente. Se regenera al redimensionar y persiste al
* plegar/expandir paneles. Los paneles son opacos, así que el cielo solo
* asoma en los huecos — nunca tras los botones. */
function drawPetal(ctx, x, y, size, angle, hue, light, alpha) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const s = size;
const g = ctx.createLinearGradient(0, -s, 0, s);
g.addColorStop(0, `hsla(${hue}, 75%, ${Math.round(light * 88)}%, ${alpha})`);
g.addColorStop(1, `hsla(${hue}, 65%, ${Math.round(light * 70)}%, ${alpha * 0.9})`);
ctx.fillStyle = g;
ctx.beginPath();
// Pétalo de cerezo: lágrima ancha con una pequeña muesca en la punta.
ctx.moveTo(0, -s);
ctx.bezierCurveTo(s * 0.18, -s * 0.85, s * 0.8, -s * 0.7, s * 0.85, -s * 0.08);
ctx.quadraticCurveTo(s * 0.9, s * 0.45, s * 0.45, s * 0.8);
ctx.quadraticCurveTo(0, s * 1.05, -s * 0.45, s * 0.8);
ctx.quadraticCurveTo(-s * 0.9, s * 0.45, -s * 0.85, -s * 0.08);
ctx.bezierCurveTo(-s * 0.8, -s * 0.7, -s * 0.18, -s * 0.85, 0, -s);
ctx.fill();
ctx.restore();
}
function SkyCanvas() {
const canvasRef = useRef(null);
const petalsRef = useRef([]); // persisten entre redimensionados (no se regeneran al plegar)
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
let raf, w = 0, h = 0, dpr = Math.min(2, window.devicePixelRatio || 1);
let t0 = performance.now();
// Pétalos en coordenadas NORMALIZADAS (0..1): sobreviven a cualquier
// cambio de tamaño, así que plegar/expandir no los borra ni reinicia.
const genPetals = (count) => {
const arr = [];
for (let i = 0; i < count; i++) {
arr.push({
nx: Math.random(), ny: Math.random(),
size: 3 + Math.random() * 4.5, // px
fall: 0.012 + Math.random() * 0.028, // velocidad de caída (norm./s)
sway: 0.5 + Math.random() * 1.2, // amplitud de balanceo
swaySpeed: 0.4 + Math.random() * 0.9,
phase: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 2.2, // rotación (rad/s)
angle: Math.random() * Math.PI * 2,
hue: 332 + Math.random() * 22, // rosa (cerezo)
light: 0.68 + Math.random() * 0.28,
alpha: 0.45 + Math.random() * 0.45,
});
}
return arr;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const nw = rect.width, nh = rect.height;
if (nw < 2 || nh < 2) return; // ignora medidas degeneradas
// Evita re-escalar el lienzo por cambios sub-pixel (suaviza el plegado).
if (Math.abs(nw - w) < 1 && Math.abs(nh - h) < 1 && petalsRef.current.length) return;
w = nw; h = nh;
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 target = Math.max(12, Math.round((w * h) / 11000));
// Solo (re)genera si no hay pétalos o el área cambió mucho (no al plegar).
if (!petalsRef.current.length || Math.abs(petalsRef.current.length - target) > target * 0.6) {
petalsRef.current = genPetals(target);
}
};
const draw = (now) => {
const dt = Math.min(0.05, (now - t0) / 1000); t0 = now;
raf = requestAnimationFrame(draw); // sigue vivo pase lo que pase
if (w < 2 || h < 2) return; // sin tamaño válido: no borra nada
ctx.clearRect(0, 0, w, h);
// Pétalos cayendo.
for (const p of petalsRef.current) {
p.ny += p.fall * dt;
p.phase += p.swaySpeed * dt;
p.angle += p.spin * dt;
if (p.ny > 1.06) { p.ny = -0.06; p.nx = Math.random(); }
const x = (p.nx + Math.sin(p.phase) * 0.035) * w;
const y = p.ny * h;
drawPetal(ctx, x, y, p.size, p.angle, p.hue, p.light, p.alpha);
}
};
resize();
// ResizeObserver con debounce por rAF: una sola medición por frame durante
// la animación de plegado → transición fluida, sin tirones.
let roScheduled = false;
const ro = new ResizeObserver(() => {
if (roScheduled) return;
roScheduled = true;
requestAnimationFrame(() => { roScheduled = false; resize(); });
});
ro.observe(canvas);
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, []);
return ;
}
/* --------------------------------------------------------------------- App */
function App() {
const engineRef = useRef(null);
const synthRef = useRef(null);
const levelRef = useRef(0);
if (!engineRef.current) {
engineRef.current = new LullabyEngine();
synthRef.current = new SynthEngine(engineRef.current);
}
const init0 = useRef(loadSettings()).current;
const [lang, setLang] = useState(init0.lang || window.I18N.detect());
const [theme, setTheme] = useState(init0.theme);
const [instrument, setInstrument] = useState(init0.instrument);
const [engineMode, setEngineMode] = useState(init0.engineMode);
const [sfzInstrument, setSfzInstrument] = useState(init0.sfzInstrument);
const [sfzList, setSfzList] = useState([]);
const [octaveOffset, setOctaveOffset] = useState(init0.octaveOffset);
const [mood, setMood] = useState(init0.mood);
const [complexity, setComplexity] = useState(init0.complexity);
const [temperature, setTemperature] = useState(init0.temperature);
const [tempoScale, setTempoScale] = useState(init0.tempoScale);
const [reverb, setReverb] = useState(init0.reverb);
const [width, setWidth] = useState(init0.width);
const [delay, setDelay] = useState(init0.delay);
const [lofi, setLofi] = useState(init0.lofi);
const [noiseType, setNoiseType] = useState(init0.noiseType);
const [noiseVolume, setNoiseVolume] = useState(init0.noiseVolume);
const [outputGain, setOutputGain] = useState(init0.outputGain);
const [gradualFade, setGradualFade] = useState(init0.gradualFade);
const [playbackMode, setPlaybackMode] = useState(init0.playbackMode);
const [pianoRoll, setPianoRoll] = useState(init0.pianoRoll);
const [padEnabled, setPadEnabled] = useState(init0.padEnabled);
const [padVolume, setPadVolume] = useState(init0.padVolume);
const [padTimbre, setPadTimbre] = useState(init0.padTimbre);
const [songTick, setSongTick] = useState(0); // fuerza repintado al rotar el modo (Mix/nanas)
const [heldMode, setHeldMode] = useState(null); // modo con pulsación larga en curso
const holdRef = useRef({ timer: null, fired: false });
const [screensaver, setScreensaver] = useState(false); // modo salvapantallas (cielo a pantalla completa)
const brandHold = useRef(null);
const wakeRef = useRef(null);
const ssEnterRef = useRef(0); // momento de entrada al salvapantallas (anti-toque-fantasma)
const cxRegenRef = useRef(null); // debounce: regenerar nana al cambiar complejidad
// Paneles plegables (luna llena = abierto, creciente = plegado)
const [secInstrument, setSecInstrument] = useState(init0.secInstrument);
const [secMode, setSecMode] = useState(init0.secMode);
const [secAdvanced, setSecAdvanced] = useState(init0.secAdvanced);
const [secAmbient, setSecAmbient] = useState(init0.secAmbient);
const [secSleep, setSecSleep] = useState(init0.secSleep);
const [playing, setPlaying] = useState(false);
const [timerMin, setTimerMin] = useState(null);
const [timeLeft, setTimeLeft] = useState(0);
const [recording, setRecording] = useState(false);
const [recDur, setRecDur] = useState(60);
const [modal, setModal] = useState(null);
const t = useCallback((k) => window.I18N.t(lang, k), [lang]);
const closeModal = useCallback(() => setModal(null), []);
/* aplicar tema + idioma al documento */
useEffect(() => { applyTheme(theme); }, [theme]);
useEffect(() => { document.documentElement.lang = lang; }, [lang]);
/* persistencia */
useEffect(() => {
const data = { instrument, engineMode, sfzInstrument, octaveOffset, mood, complexity, temperature, tempoScale, reverb, width, delay, lofi, noiseType, noiseVolume, outputGain, playbackMode, pianoRoll, padEnabled, padVolume, padTimbre, gradualFade, theme, lang, secInstrument, secMode, secAdvanced, secAmbient, secSleep };
try { localStorage.setItem(STORE_KEY, JSON.stringify(data)); } catch (e) {}
}, [instrument, engineMode, sfzInstrument, octaveOffset, mood, complexity, temperature, tempoScale, reverb, width, delay, lofi, noiseType, noiseVolume, outputGain, playbackMode, pianoRoll, padEnabled, padVolume, padTimbre, gradualFade, theme, lang, secInstrument, secMode, secAdvanced, secAmbient, secSleep]);
/* sincronizar con motores */
useEffect(() => { synthRef.current.setInstrument(instrument); }, [instrument]);
useEffect(() => { synthRef.current.setEngineMode(engineMode); }, [engineMode]);
useEffect(() => { synthRef.current.setOctaveOffset(octaveOffset); }, [octaveOffset]);
// Carga el índice de instrumentos SFZ (assets/sfz/manifest.json) la primera
// vez que se entra en modo SFZ. Si no hay ninguno elegido todavía, se
// preselecciona el primero de la lista.
useEffect(() => {
if (engineMode !== "sfz") return;
let alive = true;
synthRef.current.loadSfzManifest().then((list) => {
if (!alive) return;
setSfzList(list);
setSfzInstrument((cur) => cur || (list[0] && list[0].id) || null);
});
return () => { alive = false; };
}, [engineMode]);
useEffect(() => {
if (engineMode === "sfz" && sfzInstrument) synthRef.current.setSfzInstrument(sfzInstrument);
}, [engineMode, sfzInstrument]);
// Sincroniza el modo guardado UNA vez al arrancar. Los cambios posteriores
// los gestiona pickMode (que siempre genera una nana nueva).
useEffect(() => { engineRef.current.setMood(mood); }, []);
// La complejidad afecta a la COMPOSICIÓN. En continuo se nota al vuelo; en
// modo canción regenera la nana (con debounce para no rehacerla en cada paso).
useEffect(() => {
engineRef.current.setComplexity(complexity);
if (playing && playbackMode === "songs") {
clearTimeout(cxRegenRef.current);
cxRegenRef.current = setTimeout(() => synthRef.current.restartSong(), 450);
}
}, [complexity]);
useEffect(() => { synthRef.current.setTemperature(temperature); }, [temperature]);
useEffect(() => { synthRef.current.setTempoScale(tempoScale); }, [tempoScale]);
useEffect(() => { synthRef.current.setReverb(reverb); }, [reverb]);
useEffect(() => { synthRef.current.setWidth(width); }, [width]);
useEffect(() => { synthRef.current.setDelay(delay); }, [delay]);
useEffect(() => { synthRef.current.setLofi(lofi); }, [lofi]);
useEffect(() => { synthRef.current.setNoiseVolume(noiseVolume); }, [noiseVolume]);
useEffect(() => { synthRef.current.setOutputGain(outputGain); }, [outputGain]);
useEffect(() => { synthRef.current.setPlaybackMode(playbackMode); }, [playbackMode]);
useEffect(() => { synthRef.current.setPadEnabled(padEnabled); }, [padEnabled]);
useEffect(() => { synthRef.current.setPadVolume(padVolume); }, [padVolume]);
useEffect(() => { synthRef.current.setPadTimbre(padTimbre); }, [padTimbre]);
/* la rotación de modo (Mix / fin de nana) avisa por callback → repinta */
useEffect(() => { synthRef.current.onShuffle = () => setSongTick((x) => x + 1); }, []);
/* visualizador */
useEffect(() => {
let raf;
const loop = () => {
const active = playing || noiseType !== "off";
levelRef.current = active ? synthRef.current.getLevel() : levelRef.current * 0.9;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [playing, noiseType]);
/* cuenta atrás del sleep timer */
useEffect(() => {
if (!timerMin || !playing) return;
let left = timerMin * 60; setTimeLeft(left);
const id = setInterval(() => { left -= 1; setTimeLeft(left); if (left <= 0) clearInterval(id); }, 1000);
return () => clearInterval(id);
}, [timerMin, playing]);
/* Pista visual al cargar: micro-scroll de ida y vuelta para que el usuario
* descubra que hay más opciones debajo del pliegue. */
const ctrlRef = useRef({});
useEffect(() => {
const scroller = document.scrollingElement || document.documentElement;
const t1 = setTimeout(() => {
try {
scroller.scrollTo({ top: 96, behavior: "smooth" });
setTimeout(() => scroller.scrollTo({ top: 0, behavior: "smooth" }), 650);
} catch (e) {}
}, 700);
// Puente para que la notificación nativa de Android controle la app.
window.KdramaControls = {
toggle: () => ctrlRef.current.toggle && ctrlRef.current.toggle(),
setTimer: (m) => ctrlRef.current.setTimer && ctrlRef.current.setTimer(m),
};
return () => clearTimeout(t1);
}, []);
/* Empuja el estado a la notificación persistente de Android (si existe). */
useEffect(() => {
const m = window.AndroidMedia;
if (!m || !m.update) return;
const am = mood === "mix" ? engineRef.current.activeMood() : mood;
const sfzEntry = engineMode === "sfz" ? sfzList.find((e) => e.id === sfzInstrument) : null;
const instName = engineMode === "sfz" ? sfzName(sfzEntry, lang) : window.I18N.t(lang, "inst_" + instrument);
const title = playing ? instName + " · " + window.I18N.t(lang, am) : "Kdrama by DSK";
try {
m.update(JSON.stringify({
playing, title,
timerActive: !!(timerMin && playing),
timeLeft: timerMin && playing ? timeLeft : 0,
}));
} catch (e) {}
}, [playing, timerMin, timeLeft, instrument, engineMode, sfzInstrument, sfzList, mood, lang, songTick]);
const togglePlay = () => {
const s = synthRef.current;
if (playing) { s.stop(); setPlaying(false); setTimerMin(null); }
else {
s.start(); s.setTemperature(temperature); s.setReverb(reverb);
setPlaying(true);
if (timerMin) s.setSleepTimer(timerMin, handleTimerEnd, gradualFade);
}
};
const handleTimerEnd = () => {
setPlaying(false); setTimerMin(null); setTimeLeft(0);
setModal({ icon: , title: t("done_title"), body: t("done_body"), okLabel: t("close") });
};
const pickTimer = (min) => {
if (timerMin === min) { setTimerMin(null); setTimeLeft(0); synthRef.current.clearSleepTimer(); return; }
setTimerMin(min);
if (playing) synthRef.current.setSleepTimer(min, handleTimerEnd, gradualFade);
};
const toggleGradual = (v) => {
setGradualFade(v);
if (playing && timerMin) synthRef.current.setSleepTimer(timerMin, handleTimerEnd, v);
};
/* ---- Modo: CUALQUIER pulsación (mismo o distinto modo) genera nana nueva ---- */
const pickMode = (value) => {
setMood(value);
engineRef.current.setMood(value); // reinicia el motor → material nuevo
if (playing) {
if (playbackMode === "songs") synthRef.current.restartSong();
else synthRef.current.smoothModeTransition(); // disimula el salto en flujo continuo
}
setSongTick((x) => x + 1);
};
/* ---- Pulsación larga (≥2 s) sobre un modo → guardar MIDI de la canción ----
* Tap corto = seleccionar/rebarajar. Mantener pulsado = exportar MIDI de lo
* que suena (continuo: últimos 2 min · canciones: la nana entera al acabar). */
const onModeDown = (value) => () => {
holdRef.current.fired = false;
setHeldMode(value);
holdRef.current.timer = setTimeout(() => {
holdRef.current.fired = true;
holdRef.current.timer = null;
setHeldMode(null);
triggerMidiSave();
}, 2000);
};
const onModeUp = (value) => () => {
if (holdRef.current.timer) { clearTimeout(holdRef.current.timer); holdRef.current.timer = null; }
setHeldMode(null);
if (!holdRef.current.fired) pickMode(value); // fue un tap corto
holdRef.current.fired = false;
};
const onModeCancel = () => {
if (holdRef.current.timer) { clearTimeout(holdRef.current.timer); holdRef.current.timer = null; }
setHeldMode(null);
holdRef.current.fired = false;
};
/* ---- Modo salvapantallas: mantener pulsado el nombre 2 s → solo el cielo ---- */
const enterScreensaver = () => {
setScreensaver(true);
ssEnterRef.current = Date.now();
// Mantén la pantalla encendida mientras dure (si el navegador lo permite).
try {
if (navigator.wakeLock && navigator.wakeLock.request) {
navigator.wakeLock.request("screen").then((wl) => { wakeRef.current = wl; }).catch(() => {});
}
} catch (e) {}
};
const exitScreensaver = (e) => {
if (e) { e.stopPropagation(); e.preventDefault(); }
// Ignora el propio gesto de entrada (no salir nada más aparecer).
if (Date.now() - ssEnterRef.current < 500) return;
setScreensaver(false);
try { if (wakeRef.current) { wakeRef.current.release(); wakeRef.current = null; } } catch (e2) {}
};
const onBrandDown = () => {
if (brandHold.current) clearTimeout(brandHold.current);
brandHold.current = setTimeout(() => { brandHold.current = null; enterScreensaver(); }, 2000);
};
const onBrandUp = () => {
if (brandHold.current) { clearTimeout(brandHold.current); brandHold.current = null; }
};
const triggerMidiSave = () => {
const status = synthRef.current.saveCurrentMidi((blob) => { if (blob) offerMidiDownload(blob); });
if (status === null) {
setModal({ icon: , title: t("midi_title"), body: t("midi_need_play"), okLabel: t("close") });
}
// status "ready" → offerMidiDownload ya abre el modal de guardado.
};
const offerMidiDownload = (blob) => {
const name = `kdrama_bydsk_${tsName()}.mid`;
const dl = window.AndroidDownloader;
if (dl && (dl.saveMidiFile || dl.saveBase64File || dl.saveFile)) {
const reader = new FileReader();
reader.onloadend = () => {
try {
if (dl.saveMidiFile) dl.saveMidiFile(reader.result);
else if (dl.saveBase64File) dl.saveBase64File(reader.result, name, "audio/midi");
else if (dl.saveFile) dl.saveFile(reader.result, name);
} catch (e) {}
};
reader.readAsDataURL(blob);
setModal({ icon: , title: t("midi_saved_title"), body: t("midi_saved_body"),
actions: [{ label: t("ok"), onClick: closeModal }] });
return;
}
const url = URL.createObjectURL(blob);
setModal({
icon: , title: t("midi_saved_title"), body: t("midi_saved_body"),
content: {t("midi_download")},
actions: [{ label: t("close"), onClick: closeModal }],
});
};
/* ---- Guardar / abrir nanas ---- */
const openSaveModal = () => {
if (!synthRef.current.hasSongToSave()) {
setModal({ icon: , title: t("save_title"), body: t("save_need_song"), okLabel: t("close") });
return;
}
const defaultName = `${t(mood === "mix" ? "mix" : mood)} · ${tsName()}`;
setModal({
icon: , title: t("save_title"),
content: { closeModal(); doSaveSong(name); }} />,
actions: [{ label: t("cancel"), onClick: closeModal }],
});
};
const doSaveSong = (name) => {
const snap = synthRef.current.exportSongSnapshot();
if (!snap) return;
const entry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 7),
name: (name && name.trim()) || `${t(mood === "mix" ? "mix" : mood)} · ${tsName()}`,
ts: Date.now(),
snapshot: snap,
};
const list = loadSavedList();
list.unshift(entry);
saveSavedList(list);
setModal({ icon: , title: t("save_done_title"), body: t("save_done_body"), okLabel: t("ok") });
};
const openSavedList = () => {
setModal({
icon: , title: t("saved_list_title"),
content: ,
actions: [{ label: t("close"), onClick: closeModal }],
});
};
const renameSaved = (id, name) => {
const list = loadSavedList();
const idx = list.findIndex((e) => e.id === id);
if (idx === -1) return;
const trimmed = (name || "").trim();
if (trimmed) list[idx] = { ...list[idx], name: trimmed };
saveSavedList(list);
};
const openRenameModal = (entry) => {
setModal({
icon: , title: t("saved_rename_title"),
content: { renameSaved(entry.id, name); openSavedList(); }} />,
actions: [{ label: t("cancel"), onClick: openSavedList }],
});
};
const confirmDeleteSaved = (id) => {
setModal({
icon: , title: t("saved_delete_confirm_title"), body: t("saved_delete_confirm_body"),
actions: [
{ label: t("cancel"), onClick: openSavedList, close: false },
{
label: t("saved_delete_yes"), primary: true, close: false,
onClick: () => { saveSavedList(loadSavedList().filter((e) => e.id !== id)); openSavedList(); },
},
],
});
};
const playSavedEntry = (entry) => {
closeModal();
const snap = entry.snapshot;
const s = synthRef.current;
if (playbackMode !== "songs") { setPlaybackMode("songs"); s.setPlaybackMode("songs"); }
const wasPlaying = playing;
s.loadSongSnapshot(snap);
if (snap.instrument) setInstrument(snap.instrument);
if (snap.engineMode) setEngineMode(snap.engineMode);
if (snap.sfzInstrument) setSfzInstrument(snap.sfzInstrument);
if (snap.octaveOffset != null) setOctaveOffset(snap.octaveOffset);
if (snap.temperature != null) setTemperature(snap.temperature);
if (snap.tempoScale != null) setTempoScale(snap.tempoScale);
if (snap.reverb != null) setReverb(snap.reverb);
if (snap.width != null) setWidth(snap.width);
if (snap.delayAmt != null) setDelay(snap.delayAmt);
if (snap.lofi != null) setLofi(snap.lofi);
if (snap.padEnabled != null) setPadEnabled(snap.padEnabled);
if (snap.padVolume != null) setPadVolume(snap.padVolume);
if (snap.padTimbre != null) setPadTimbre(snap.padTimbre);
if (!wasPlaying) {
s.start(); setPlaying(true);
if (timerMin) s.setSleepTimer(timerMin, handleTimerEnd, gradualFade);
}
};
/* ---- Ruido ambiente ---- */
const pickNoise = (type) => {
const next = noiseType === type ? "off" : type;
setNoiseType(next);
synthRef.current.setNoiseType(next);
synthRef.current.setNoiseVolume(noiseVolume);
};
/* ---- Grabación ---- */
const openRecordModal = () => {
if (recording) return;
setModal({
icon: , title: t("rec_title"),
content: { closeModal(); beginRecording(dur); }} />,
actions: [{ label: t("cancel"), onClick: closeModal }],
});
};
const beginRecording = (dur) => {
const s = synthRef.current;
if (!playing) { s.start(); s.setTemperature(temperature); s.setReverb(reverb); setPlaying(true); }
setRecording(true);
s.startRecording(dur, (blob) => { setRecording(false); if (blob) offerDownload(blob); });
};
const stopRecordingNow = () => {
const blob = synthRef.current.stopRecording();
setRecording(false);
if (blob) offerDownload(blob);
};
const offerDownload = (blob) => {
const ext = synthRef.current.lastExt || "wav";
// App Android (WebView): guardar directo en Descargas con el bridge nativo.
const dl = window.AndroidDownloader;
if (dl && (dl.saveMp3File || dl.saveWavFile)) {
const reader = new FileReader();
reader.onloadend = () => {
try {
if (ext === "mp3" && dl.saveMp3File) dl.saveMp3File(reader.result);
else if (dl.saveWavFile) dl.saveWavFile(reader.result);
else if (dl.saveMp3File) dl.saveMp3File(reader.result);
} catch (e) {}
};
reader.readAsDataURL(blob); // data:audio/...;base64,XXXX (el bridge corta tras la coma)
setModal({
icon: , title: t("saved_title"),
body: t("saved_body") + (ext === "wav" ? " (.wav)" : " (.mp3)"),
actions: [{ label: t("ok"), onClick: closeModal }],
});
return;
}
// Navegador web: descarga clásica.
const url = URL.createObjectURL(blob);
const name = `kdrama_bydsk_${tsName()}.${ext}`;
setModal({
icon: , title: t("saved_title"),
body: t("saved_body") + (ext === "wav" ? " (.wav)" : " (.mp3)"),
content: {t("download")} .{ext},
actions: [{ label: t("close"), onClick: closeModal }],
});
};
/* ---- Aleatorio coherente: nueva atmósfera de un toque ---- */
const randomize = () => {
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const rng = (a, b) => a + Math.random() * (b - a);
const ins = pick(synthRef.current.instrumentOrder);
const md = pick(["hope", "romance", "sad", "drama"]);
// Complejidad sesgada hacia lo tranquilo (nanas): 2..7 con peso bajo.
const cx = Math.round(rng(2, 7));
// Temperatura y reverb correlacionadas: cuanto más "dormido", más envolvente.
const tp = +rng(0.2, 0.8).toFixed(2);
const rv = +Math.min(0.85, Math.max(0.15, (1 - tp) * 0.7 + rng(-0.1, 0.1))).toFixed(2);
const ts = +rng(0.82, 1.05).toFixed(2); // tempo sutil
const wd = +rng(0.2, 0.7).toFixed(2); // amplitud
const dl = +rng(0.0, 0.4).toFixed(2); // eco de ensueño contenido
const lf = +rng(0.0, 0.5).toFixed(2); // toque lofi opcional
// El sonido ambiente queda EXCLUIDO del aleatorio: siempre apagado.
const nt = "off";
setInstrument(ins); setMood(md); setComplexity(cx);
setTemperature(tp); setTempoScale(ts); setReverb(rv); setWidth(wd); setDelay(dl); setLofi(lf);
setNoiseType(nt);
const s = synthRef.current;
s.setInstrument(ins);
engineRef.current.setMood(md); engineRef.current.setComplexity(cx);
s.setTemperature(tp); s.setTempoScale(ts); s.setReverb(rv); s.setWidth(wd); s.setDelay(dl); s.setLofi(lf);
s.setNoiseType(nt);
if (!playing) { s.start(); setPlaying(true); if (timerMin) s.setSleepTimer(timerMin, handleTimerEnd, gradualFade); }
else if (playbackMode === "songs") s.restartSong(); // nueva atmósfera → nueva nana
else s.smoothModeTransition(); // disimula el salto en flujo continuo
};
const openOptions = () => {
setModal({
kind: "options",
icon: , title: t("options"),
content: ,
actions: [{ label: t("close"), onClick: closeModal }],
});
};
// El modal de Opciones queda "vivo" mientras está abierto: los botones de
// octava son un contador que se pulsa varias veces seguidas y necesita ver
// el valor moverse al instante, no solo al reabrir el panel (modal.content
// es un elemento ya creado — sin esto se quedaría con las props del
// momento en que se abrió, ver openOptions).
useEffect(() => {
setModal((m) => (m && m.kind === "options") ? {
...m,
content: ,
} : m);
}, [octaveOffset, engineMode, sfzInstrument, theme, lang]);
const confirmReset = () => {
setModal({
icon: , title: t("reset_confirm_title"), body: t("reset_confirm_body"),
actions: [
{ label: t("cancel"), onClick: closeModal },
{ label: t("reset_yes"), primary: true, onClick: doReset },
],
});
};
const doReset = () => {
synthRef.current.stop();
synthRef.current.setNoiseType("off");
try { localStorage.removeItem(STORE_KEY); } catch (e) {}
setPlaying(false); setTimerMin(null); setTimeLeft(0);
setInstrument(DEFAULTS.instrument); setEngineMode(DEFAULTS.engineMode);
setSfzInstrument(DEFAULTS.sfzInstrument); setSfzList([]);
setOctaveOffset(DEFAULTS.octaveOffset);
setMood(DEFAULTS.mood);
setComplexity(DEFAULTS.complexity); setTemperature(DEFAULTS.temperature);
setTempoScale(DEFAULTS.tempoScale);
setReverb(DEFAULTS.reverb); setWidth(DEFAULTS.width); setDelay(DEFAULTS.delay); setLofi(DEFAULTS.lofi);
setNoiseType(DEFAULTS.noiseType);
setNoiseVolume(DEFAULTS.noiseVolume); setOutputGain(DEFAULTS.outputGain); setGradualFade(DEFAULTS.gradualFade);
setPlaybackMode(DEFAULTS.playbackMode); setPianoRoll(DEFAULTS.pianoRoll);
setPadEnabled(DEFAULTS.padEnabled);
setPadVolume(DEFAULTS.padVolume);
setPadTimbre(DEFAULTS.padTimbre);
synthRef.current.setPlaybackMode(DEFAULTS.playbackMode);
// Reinicia también el motor → la próxima nana se regenera desde los valores por defecto.
engineRef.current.setMood(DEFAULTS.mood);
engineRef.current.setComplexity(DEFAULTS.complexity);
setSecInstrument(DEFAULTS.secInstrument); setSecMode(DEFAULTS.secMode);
setSecAdvanced(DEFAULTS.secAdvanced); setSecAmbient(DEFAULTS.secAmbient); setSecSleep(DEFAULTS.secSleep);
setTheme(DEFAULTS.theme);
setLang(window.I18N.detect());
};
const fmOrder = synthRef.current.instrumentOrder;
const fmPills = fmOrder.slice(0, 5);
const fmOthers = fmOrder.slice(5);
const sfzPills = sfzList.slice(0, 5);
const sfzOthers = sfzList.slice(5);
const sfzEntry = sfzList.find((e) => e.id === sfzInstrument);
const sfzDisplayName = sfzName(sfzEntry, lang);
const openInstrumentPicker = (kind) => {
const items = kind === "fm"
? fmOthers.map((k) => ({ id: k, name: t("inst_" + k) }))
: sfzOthers.map((e) => ({ id: e.id, name: sfzName(e, lang) }));
const current = kind === "fm" ? instrument : sfzInstrument;
setModal({
icon: , title: t("inst_picker_title"),
content: (
{items.map((it) => (
))}
),
actions: [{ label: t("close"), onClick: closeModal }],
});
};
const tempLabel = temperature < 0.34 ? t("temp_soft") : temperature > 0.66 ? t("temp_bright") : t("temp_mid");
const tempoLabel = tempoScale < 0.9 ? t("tempo_v_slow") : tempoScale > 1.04 ? t("tempo_v_fast") : t("tempo_v_mid");
const reverbLabel = reverb < 0.25 ? t("rev_dry") : reverb > 0.6 ? t("rev_hall") : t("rev_mid");
const activeMood = mood === "mix" ? engineRef.current.activeMood() : mood;
const moodLabel = mood === "mix" ? (t("mix") + " · " + t(activeMood)) : t(mood);
const statusLabel = playing ? (recording ? t("st_recording") : t("st_playing")) : t("st_paused");
const noiseOptions = [
{ value: "rain", label: t("noise_rain") },
{ value: "white", label: t("noise_white") },
{ value: "pink", label: t("noise_pink") },
{ value: "brown", label: t("noise_brown") },
{ value: "ocean", label: t("noise_ocean") },
{ value: "wind", label: t("noise_wind") },
{ value: "delta", label: t("noise_delta") },
{ value: "theta", label: t("noise_theta") },
];
// Mantener el puente nativo apuntando a los manejadores actuales.
ctrlRef.current.toggle = togglePlay;
ctrlRef.current.setTimer = (m) => {
if (!m) { setTimerMin(null); setTimeLeft(0); synthRef.current.clearSleepTimer(); return; }
setTimerMin(m);
if (playing) synthRef.current.setSleepTimer(m, handleTimerEnd, gradualFade);
};
return (
{playing
? {engineMode === "sfz" ? sfzDisplayName : t("inst_" + instrument)} · {moodLabel}
: {t("hint")}}
{timerMin && playing ? {fmtTime(timeLeft)} : null}
setSecInstrument((v) => !v)}>
{t("engine")}
{engineMode === "fm" ? (
{fmPills.map((k) => (
))}
{fmOthers.length > 0 && (
)}
) : (
{sfzList.length === 0 ? (
{t("sfz_empty")}
) : (
{sfzPills.map((e) => (
))}
{sfzOthers.length > 0 && (
)}
)}
)}
setSecMode((v) => !v)}>
{[
{ value: "hope", label: t("hope"), sub: t("hope_sub") },
{ value: "romance", label: t("romance"), sub: t("romance_sub") },
{ value: "sad", label: t("sad"), sub: t("sad_sub") },
{ value: "drama", label: t("drama"), sub: t("drama_sub") },
].map((o) => (
))}
{t("playback")}
setSecAdvanced((v) => !v)}>
{padEnabled && (
<>
{t("pad_timbre")}
>
)}
setSecAmbient((v) => !v)} soft>
{noiseOptions.map((o) => (
))}
{noiseType !== "off" && (
)}
setSecSleep((v) => !v)} soft>
{[15, 30, 60].map((m) => (
))}
{screensaver && ReactDOM.createPortal(
,
document.body
)}
);
}
/* --------------------------------------------------- Configurar grabación */
function RecordSetup({ t, recDur, setRecDur, onStart }) {
const [val, setVal] = useState(recDur);
const presets = [30, 60, 120, 300];
return (
{t("rec_help")}
{presets.map((p) => (
))}
{ const v = parseInt(e.target.value || "0", 10); setVal(v); setRecDur(v); }} />
{t("rec_seconds")}
);
}
/* ----------------------------------------------- Guardar nana (nombre) */
function SaveSetup({ t, defaultName, onSave }) {
const [name, setName] = useState(defaultName);
return (
);
}
/* ------------------------------------------------------- Mis nanas (lista) */
function SavedList({ t, onPlay, onAskDelete, onRename }) {
const [list] = useState(() => loadSavedList());
if (!list.length) return {t("saved_empty")}
;
return (
{list.map((e) => (
{e.name}
{new Date(e.ts).toLocaleDateString()}
))}
);
}
/* ------------------------------------------------------- Panel de opciones */
function OptionsPanel({ t, theme, setTheme, octaveOffset, setOctaveOffset, baseOctave, lang, setLang, onReset }) {
const effective = (baseOctave || 0) + octaveOffset;
const effectiveLabel = effective > 0 ? "+" + effective : String(effective);
return (
{t("theme")}
setOctaveOffset(DEFAULTS.octaveOffset)}>{t("octave")}
{effectiveLabel}
{t("octave_sub")}
{t("language")}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();