feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 09:05:07 +09:00
parent 7a591bb0f1
commit 0849c70644
5 changed files with 349 additions and 2 deletions

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
const STEM_ICONS = {
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
};
const StemModal = ({ stems, onClose }) => {
const [playingStem, setPlayingStem] = useState(null);
if (!stems || Object.keys(stems).length === 0) return null;
return (
<div className="ms-modal-overlay" onClick={onClose}>
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
<div className="ms-modal__header">
<h3 className="ms-modal__title">12 Stems</h3>
<span className="ms-modal__subtitle"> 스템을 개별 재생 다운로드할 있습니다</span>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<div className="ms-stem-grid">
{Object.entries(stems).map(([name, url]) => {
if (!url) return null;
const isPlaying = playingStem === name;
return (
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
<div className="ms-stem-card__actions">
<button
type="button"
className="ms-btn--icon"
onClick={() => setPlayingStem(isPlaying ? null : name)}
>
{isPlaying ? '■' : '▶'}
</button>
<a href={url} download className="ms-btn--icon" aria-label="다운로드"></a>
</div>
{isPlaying && (
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
)}
</div>
);
})}
</div>
<div className="ms-modal__actions">
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
</div>
</div>
</div>
);
};
export default StemModal;

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef, useState } from 'react';
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const handler = () => setCurrentTime(el.currentTime);
el.addEventListener('timeupdate', handler);
return () => el.removeEventListener('timeupdate', handler);
}, []);
if (!alignedWords || alignedWords.length === 0) return null;
return (
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
<div className="ms-synced-player__header">
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<audio
ref={audioRef}
src={audioUrl}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
controls
className="ms-synced-player__audio"
/>
<div className="ms-synced-player__lyrics">
{alignedWords.map((word, idx) => {
const isActive = currentTime >= word.startS && currentTime < word.endS;
const isPast = currentTime >= word.endS;
return (
<span
key={idx}
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
>
{word.word}{' '}
</span>
);
})}
</div>
</div>
);
};
export default SyncedLyricsPlayer;