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:
22
src/api.js
22
src/api.js
@@ -341,6 +341,28 @@ export function generateCoverImage(payload) {
|
|||||||
return apiPost('/api/music/cover-image', payload);
|
return apiPost('/api/music/cover-image', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 2 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function convertToWav(payload) {
|
||||||
|
return apiPost('/api/music/wav', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function splitStems(payload) {
|
||||||
|
return apiPost('/api/music/stem-split', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||||
|
export function getTimestampedLyrics(taskId, sunoId) {
|
||||||
|
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/style-boost body: { content }
|
||||||
|
export function generateStyleBoost(content) {
|
||||||
|
return apiPost('/api/music/style-boost', { content });
|
||||||
|
}
|
||||||
|
|
||||||
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/lotto/stats/performance
|
// GET /api/lotto/stats/performance
|
||||||
|
|||||||
@@ -2535,3 +2535,38 @@
|
|||||||
display: block; padding: 8px; text-align: center;
|
display: block; padding: 8px; text-align: center;
|
||||||
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
|
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Stem Modal ─────────────────────────────────────────── */
|
||||||
|
.ms-modal--wide { max-width: 680px; }
|
||||||
|
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||||
|
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.ms-stem-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
padding: 12px 8px; border-radius: 10px;
|
||||||
|
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||||
|
.ms-stem-card__icon { font-size: 1.4rem; }
|
||||||
|
.ms-stem-card__name {
|
||||||
|
font-family: 'Courier Prime', monospace; font-size: 0.72rem;
|
||||||
|
color: var(--ms-muted); text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.ms-stem-card__actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
/* ── Synced Lyrics Player ───────────────────────────────── */
|
||||||
|
.ms-synced-player {
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 12px; padding: 16px; margin-top: 12px;
|
||||||
|
}
|
||||||
|
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||||
|
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
|
||||||
|
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
|
||||||
|
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
|
||||||
|
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
|
||||||
|
.ms-synced-word.is-past { color: var(--ms-muted); }
|
||||||
|
|
||||||
|
/* ── Style Boost Button ─────────────────────────────────── */
|
||||||
|
.ms-style-boost-btn { margin-left: auto; }
|
||||||
|
.ms-style-boost-btn.is-loading { opacity: 0.6; }
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
extendMusicTrack,
|
extendMusicTrack,
|
||||||
removeVocals,
|
removeVocals,
|
||||||
generateCoverImage,
|
generateCoverImage,
|
||||||
|
convertToWav,
|
||||||
|
splitStems,
|
||||||
|
getTimestampedLyrics,
|
||||||
|
generateStyleBoost,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './MusicStudio.css';
|
import './MusicStudio.css';
|
||||||
import AudioPlayer from './components/AudioPlayer';
|
import AudioPlayer from './components/AudioPlayer';
|
||||||
@@ -17,6 +21,8 @@ import { fmtTime } from './components/AudioPlayer';
|
|||||||
import CreditsBadge from './components/CreditsBadge';
|
import CreditsBadge from './components/CreditsBadge';
|
||||||
import CoverArtModal from './components/CoverArtModal';
|
import CoverArtModal from './components/CoverArtModal';
|
||||||
import LyricsTab from './components/LyricsTab';
|
import LyricsTab from './components/LyricsTab';
|
||||||
|
import StemModal from './components/StemModal';
|
||||||
|
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
데이터 상수
|
데이터 상수
|
||||||
@@ -327,7 +333,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
|||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Library Card
|
Library Card
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
|
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating }) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const genre = GENRES.find((g) => g.id === track.genre);
|
const genre = GENRES.find((g) => g.id === track.genre);
|
||||||
const totalSec = track.duration_sec ?? null;
|
const totalSec = track.duration_sec ?? null;
|
||||||
@@ -407,6 +413,12 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
|||||||
<div className="ms-more-menu__dropdown">
|
<div className="ms-more-menu__dropdown">
|
||||||
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
||||||
disabled={isGenerating}>🖼 Cover Art</button>
|
disabled={isGenerating}>🖼 Cover Art</button>
|
||||||
|
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>📀 WAV Download</button>
|
||||||
|
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
|
||||||
|
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +441,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
|||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Library Section
|
Library Section
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, isGenerating, loading }) => {
|
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, isGenerating, loading }) => {
|
||||||
const [playingId, setPlayingId] = useState(null);
|
const [playingId, setPlayingId] = useState(null);
|
||||||
|
|
||||||
const handlePlay = (track) => {
|
const handlePlay = (track) => {
|
||||||
@@ -479,6 +491,9 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
|||||||
onExtend={onExtend}
|
onExtend={onExtend}
|
||||||
onVocalRemoval={onVocalRemoval}
|
onVocalRemoval={onVocalRemoval}
|
||||||
onCoverArt={onCoverArt}
|
onCoverArt={onCoverArt}
|
||||||
|
onWavConvert={onWavConvert}
|
||||||
|
onStemSplit={onStemSplit}
|
||||||
|
onSyncedLyrics={onSyncedLyrics}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -525,6 +540,11 @@ export default function MusicStudio() {
|
|||||||
/* ── CoverArt 상태 ── */
|
/* ── CoverArt 상태 ── */
|
||||||
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
|
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
|
||||||
|
|
||||||
|
/* ── Phase 2 상태 ── */
|
||||||
|
const [stemModal, setStemModal] = useState(null); // { stems: {} }
|
||||||
|
const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
|
||||||
|
const [styleBoostLoading, setStyleBoostLoading] = useState(false);
|
||||||
|
|
||||||
/* ── 생성 상태 ── */
|
/* ── 생성 상태 ── */
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [genProgress, setGenProgress] = useState(0);
|
const [genProgress, setGenProgress] = useState(0);
|
||||||
@@ -871,6 +891,141 @@ export default function MusicStudio() {
|
|||||||
setCoverArtModal(null);
|
setCoverArtModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── WAV 변환 핸들러 ── */
|
||||||
|
const handleWavConvert = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('WAV 변환 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await convertToWav({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
suno_id: track.suno_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
setGenStep('WAV 변환 처리 중…');
|
||||||
|
setGenProgress(5);
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await getMusicStatus(res.task_id);
|
||||||
|
setGenProgress(status.progress ?? 0);
|
||||||
|
setGenStep(status.message ?? '처리 중…');
|
||||||
|
if (status.status === 'succeeded') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
const wavUrl = status.audio_url;
|
||||||
|
if (wavUrl) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = wavUrl;
|
||||||
|
a.download = `${track.title || 'track'}.wav`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
setGenStep('WAV 변환 완료!');
|
||||||
|
} else if (status.status === 'failed') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('WAV 변환 상태 조회 실패');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('WAV 변환에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 12스템 분리 핸들러 ── */
|
||||||
|
const handleStemSplit = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('12스템 분리 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await splitStems({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
suno_id: track.suno_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
setGenStep('12스템 분리 처리 중 (약 2~3분)…');
|
||||||
|
setGenProgress(5);
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await getMusicStatus(res.task_id);
|
||||||
|
setGenProgress(status.progress ?? 0);
|
||||||
|
setGenStep(status.message ?? '처리 중…');
|
||||||
|
if (status.status === 'succeeded') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
const stems = JSON.parse(status.audio_url || '{}');
|
||||||
|
setStemModal({ stems });
|
||||||
|
} else if (status.status === 'failed') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError(`스템 분리 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('스템 분리 상태 조회 실패');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('12스템 분리에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 타임스탬프 가사 핸들러 ── */
|
||||||
|
const handleSyncedLyrics = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id) return;
|
||||||
|
try {
|
||||||
|
const result = await getTimestampedLyrics(track.task_id, track.suno_id);
|
||||||
|
if (result?.alignedWords || result?.aligned_words) {
|
||||||
|
setSyncedLyrics({
|
||||||
|
audioUrl: track.audio_url,
|
||||||
|
words: result.alignedWords || result.aligned_words,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setGenError('타임스탬프 가사 조회에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 스타일 부스트 핸들러 ── */
|
||||||
|
const handleStyleBoost = async () => {
|
||||||
|
if (!genre || styleBoostLoading) return;
|
||||||
|
setStyleBoostLoading(true);
|
||||||
|
try {
|
||||||
|
const content = [
|
||||||
|
GENRES.find(g => g.id === genre)?.label,
|
||||||
|
...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
|
||||||
|
].join(', ');
|
||||||
|
const result = await generateStyleBoost(content);
|
||||||
|
if (result?.result) {
|
||||||
|
setPrompt(result.result);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
finally { setStyleBoostLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const handleNewTrack = () => {
|
const handleNewTrack = () => {
|
||||||
setTrack(null);
|
setTrack(null);
|
||||||
setGenProgress(0);
|
setGenProgress(0);
|
||||||
@@ -939,6 +1094,9 @@ export default function MusicStudio() {
|
|||||||
onExtend={handleExtend}
|
onExtend={handleExtend}
|
||||||
onVocalRemoval={handleVocalRemoval}
|
onVocalRemoval={handleVocalRemoval}
|
||||||
onCoverArt={handleCoverArt}
|
onCoverArt={handleCoverArt}
|
||||||
|
onWavConvert={handleWavConvert}
|
||||||
|
onStemSplit={handleStemSplit}
|
||||||
|
onSyncedLyrics={handleSyncedLyrics}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1018,6 +1176,17 @@ export default function MusicStudio() {
|
|||||||
<span className="ms-section__step">01</span>
|
<span className="ms-section__step">01</span>
|
||||||
<h2 className="ms-section__title">Genre</h2>
|
<h2 className="ms-section__title">Genre</h2>
|
||||||
<span className="ms-section__hint">장르를 선택하세요</span>
|
<span className="ms-section__hint">장르를 선택하세요</span>
|
||||||
|
{provider === 'suno' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
|
||||||
|
onClick={handleStyleBoost}
|
||||||
|
disabled={styleBoostLoading || !genre}
|
||||||
|
title="현재 설정으로 최적 스타일 프롬프트 생성"
|
||||||
|
>
|
||||||
|
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
||||||
@@ -1520,6 +1689,21 @@ export default function MusicStudio() {
|
|||||||
onClose={() => setCoverArtModal(null)}
|
onClose={() => setCoverArtModal(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Stem Modal ═══ */}
|
||||||
|
{stemModal && (
|
||||||
|
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Synced Lyrics Player ═══ */}
|
||||||
|
{syncedLyrics && (
|
||||||
|
<SyncedLyricsPlayer
|
||||||
|
audioUrl={syncedLyrics.audioUrl}
|
||||||
|
alignedWords={syncedLyrics.words}
|
||||||
|
onClose={() => setSyncedLyrics(null)}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/pages/music/components/StemModal.jsx
Normal file
55
src/pages/music/components/StemModal.jsx
Normal 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;
|
||||||
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal 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;
|
||||||
Reference in New Issue
Block a user