606 lines
24 KiB
TypeScript
Executable File
606 lines
24 KiB
TypeScript
Executable File
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { generateLyric, generateSong, waitForSongComplete } from '../../utils/api';
|
|
import { LANGUAGE_MAP } from '../../types/api';
|
|
|
|
interface BusinessInfo {
|
|
customer_name: string;
|
|
region: string;
|
|
detail_region_info: string;
|
|
}
|
|
|
|
interface SoundStudioContentProps {
|
|
onBack: () => void;
|
|
onNext: () => void;
|
|
businessInfo?: BusinessInfo;
|
|
}
|
|
|
|
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
|
|
|
// localStorage에 저장할 데이터 구조
|
|
interface SavedGenerationState {
|
|
taskId: string;
|
|
lyrics: string;
|
|
status: GenerationStatus;
|
|
timestamp: number;
|
|
}
|
|
|
|
const STORAGE_KEY = 'castad_song_generation';
|
|
const STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
|
|
const MAX_RETRY_COUNT = 3; // 최대 재시도 횟수
|
|
|
|
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => {
|
|
const [selectedType, setSelectedType] = useState('보컬');
|
|
const [selectedLang, setSelectedLang] = useState('한국어');
|
|
const [selectedGenre, setSelectedGenre] = useState('AI 추천');
|
|
const [progress, setProgress] = useState(0);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [status, setStatus] = useState<GenerationStatus>('idle');
|
|
const [showLyrics, setShowLyrics] = useState(false);
|
|
const [lyrics, setLyrics] = useState('');
|
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [statusMessage, setStatusMessage] = useState('');
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
|
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
// localStorage에 상태 저장
|
|
const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
|
|
const data: SavedGenerationState = {
|
|
taskId,
|
|
lyrics: currentLyrics,
|
|
status: currentStatus,
|
|
timestamp: Date.now(),
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
};
|
|
|
|
// localStorage에서 상태 제거
|
|
const clearStorage = () => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
};
|
|
|
|
// localStorage에서 상태 복구
|
|
const loadFromStorage = (): SavedGenerationState | null => {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (!saved) return null;
|
|
|
|
const data: SavedGenerationState = JSON.parse(saved);
|
|
|
|
// 만료된 데이터인지 확인 (30분)
|
|
if (Date.now() - data.timestamp > STORAGE_EXPIRY) {
|
|
clearStorage();
|
|
return null;
|
|
}
|
|
|
|
return data;
|
|
} catch {
|
|
clearStorage();
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// 컴포넌트 마운트 시 저장된 상태 복구
|
|
useEffect(() => {
|
|
const savedState = loadFromStorage();
|
|
if (savedState && (savedState.status === 'polling' || savedState.status === 'generating_song')) {
|
|
// 저장된 가사가 있으면 표시
|
|
if (savedState.lyrics) {
|
|
setLyrics(savedState.lyrics);
|
|
setShowLyrics(true);
|
|
}
|
|
|
|
// 폴링 상태 복구
|
|
setStatus('polling');
|
|
setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)');
|
|
|
|
// 폴링 재개 (저장된 가사와 함께)
|
|
resumePolling(savedState.taskId, savedState.lyrics, 0);
|
|
}
|
|
}, []);
|
|
|
|
// 폴링 재개 함수 (타임아웃 시 재생성)
|
|
const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => {
|
|
try {
|
|
const downloadResponse = await waitForSongComplete(
|
|
taskId,
|
|
(pollStatus) => {
|
|
if (pollStatus === 'pending') {
|
|
setStatusMessage('대기 중...');
|
|
} else if (pollStatus === 'processing') {
|
|
setStatusMessage('음악을 처리하고 있습니다...');
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!downloadResponse.success) {
|
|
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.');
|
|
}
|
|
|
|
setAudioUrl(downloadResponse.file_url);
|
|
setStatus('complete');
|
|
setStatusMessage('');
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
|
|
} catch (error) {
|
|
console.error('Polling failed:', error);
|
|
|
|
// 타임아웃인 경우 재생성 시도
|
|
if (error instanceof Error && error.message === 'TIMEOUT') {
|
|
if (currentRetryCount < MAX_RETRY_COUNT) {
|
|
const newRetryCount = currentRetryCount + 1;
|
|
setRetryCount(newRetryCount);
|
|
setStatusMessage(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`);
|
|
|
|
// 새로운 노래 생성 요청
|
|
await regenerateSongOnly(currentLyrics, newRetryCount);
|
|
} else {
|
|
setStatus('error');
|
|
setErrorMessage('여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.');
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
}
|
|
} else {
|
|
setStatus('error');
|
|
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
}
|
|
}
|
|
};
|
|
|
|
// 노래만 재생성 (가사는 유지)
|
|
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
|
|
if (!businessInfo) return;
|
|
|
|
try {
|
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
|
const genreMap: Record<string, string> = {
|
|
'AI 추천': 'pop',
|
|
'로파이': 'lofi',
|
|
'힙합': 'hip-hop',
|
|
'어쿠스틱': 'acoustic',
|
|
};
|
|
|
|
const songResponse = await generateSong({
|
|
genre: genreMap[selectedGenre] || 'pop',
|
|
language,
|
|
lyrics: currentLyrics,
|
|
});
|
|
|
|
if (!songResponse.success) {
|
|
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
|
}
|
|
|
|
// 새 task_id로 저장
|
|
saveToStorage(songResponse.task_id, currentLyrics, 'polling');
|
|
|
|
// 폴링 재개
|
|
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount);
|
|
|
|
} catch (error) {
|
|
console.error('Song regeneration failed:', error);
|
|
setStatus('error');
|
|
setErrorMessage(error instanceof Error ? error.message : '음악 재생성 중 오류가 발생했습니다.');
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
}
|
|
};
|
|
|
|
const handleMove = (clientX: number) => {
|
|
if (!progressBarRef.current || !audioRef.current) return;
|
|
const rect = progressBarRef.current.getBoundingClientRect();
|
|
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
const newTime = (newProgress / 100) * duration;
|
|
audioRef.current.currentTime = newTime;
|
|
setProgress(newProgress);
|
|
setCurrentTime(newTime);
|
|
};
|
|
|
|
const onMouseDown = (e: React.MouseEvent) => {
|
|
if (!audioUrl) return;
|
|
setIsDragging(true);
|
|
handleMove(e.clientX);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
if (isDragging) handleMove(e.clientX);
|
|
};
|
|
const onMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
if (isDragging) {
|
|
window.addEventListener('mousemove', onMouseMove);
|
|
window.addEventListener('mouseup', onMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', onMouseMove);
|
|
window.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
}, [isDragging]);
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(audio.currentTime);
|
|
if (audio.duration > 0) {
|
|
setProgress((audio.currentTime / audio.duration) * 100);
|
|
}
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setDuration(audio.duration);
|
|
};
|
|
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
setProgress(0);
|
|
setCurrentTime(0);
|
|
};
|
|
|
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
audio.addEventListener('ended', handleEnded);
|
|
|
|
return () => {
|
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
audio.removeEventListener('ended', handleEnded);
|
|
};
|
|
}, [audioUrl]);
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const togglePlayPause = () => {
|
|
if (!audioRef.current) return;
|
|
if (isPlaying) {
|
|
audioRef.current.pause();
|
|
} else {
|
|
audioRef.current.play();
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
};
|
|
|
|
const handleGenerateMusic = async () => {
|
|
if (!businessInfo) {
|
|
setErrorMessage('비즈니스 정보가 없습니다. 다시 시도해주세요.');
|
|
return;
|
|
}
|
|
|
|
setStatus('generating_lyric');
|
|
setErrorMessage(null);
|
|
setStatusMessage('가사를 생성하고 있습니다...');
|
|
|
|
try {
|
|
// Step 1: Generate lyrics
|
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
|
let lyricResponse = await generateLyric({
|
|
customer_name: businessInfo.customer_name,
|
|
detail_region_info: businessInfo.detail_region_info,
|
|
language,
|
|
region: businessInfo.region,
|
|
});
|
|
|
|
// Retry if the response contains error message
|
|
let retryCount = 0;
|
|
while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) {
|
|
retryCount++;
|
|
setStatusMessage(`가사 재생성 중... (${retryCount}/3)`);
|
|
lyricResponse = await generateLyric({
|
|
customer_name: businessInfo.customer_name,
|
|
detail_region_info: businessInfo.detail_region_info,
|
|
language,
|
|
region: businessInfo.region,
|
|
});
|
|
}
|
|
|
|
if (!lyricResponse.success) {
|
|
throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.');
|
|
}
|
|
|
|
// 3번 재시도 후에도 여전히 에러 메시지가 포함되어 있으면 가사 카드를 표시하지 않고 에러 처리
|
|
if (lyricResponse.lyric.includes("I'm sorry")) {
|
|
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
|
|
}
|
|
|
|
setLyrics(lyricResponse.lyric);
|
|
setShowLyrics(true);
|
|
|
|
// Step 2: Generate song
|
|
setStatus('generating_song');
|
|
setStatusMessage('음악을 생성하고 있습니다...');
|
|
|
|
const genreMap: Record<string, string> = {
|
|
'AI 추천': 'pop',
|
|
'로파이': 'lofi',
|
|
'힙합': 'hip-hop',
|
|
'어쿠스틱': 'acoustic',
|
|
};
|
|
|
|
const songResponse = await generateSong({
|
|
genre: genreMap[selectedGenre] || 'pop',
|
|
language,
|
|
lyrics: lyricResponse.lyric,
|
|
});
|
|
|
|
if (!songResponse.success) {
|
|
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
|
}
|
|
|
|
// Step 3: Poll for completion - 상태 저장
|
|
setStatus('polling');
|
|
setStatusMessage('음악을 처리하고 있습니다...');
|
|
saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling');
|
|
|
|
// 폴링 시작 (타임아웃 시 자동 재생성)
|
|
await resumePolling(songResponse.task_id, lyricResponse.lyric, 0);
|
|
|
|
} catch (error) {
|
|
console.error('Music generation failed:', error);
|
|
setStatus('error');
|
|
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
}
|
|
};
|
|
|
|
const handleRegenerate = async () => {
|
|
setShowLyrics(false);
|
|
setAudioUrl(null);
|
|
setLyrics('');
|
|
setProgress(0);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsPlaying(false);
|
|
setRetryCount(0);
|
|
clearStorage();
|
|
await handleGenerateMusic();
|
|
};
|
|
|
|
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Hidden audio element */}
|
|
{audioUrl && (
|
|
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
|
)}
|
|
|
|
<div className="flex-shrink-0 p-3 sm:p-4 md:p-6 pb-0">
|
|
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
뒤로가기
|
|
</button>
|
|
</div>
|
|
|
|
<div className="hidden sm:block text-center mb-2 md:mb-4">
|
|
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight">사운드 스튜디오</h1>
|
|
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light">
|
|
쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 px-3 sm:px-4 md:px-6 min-h-0 overflow-hidden">
|
|
<div className="flex flex-col md:flex-row gap-2 sm:gap-3 md:gap-4 max-w-6xl mx-auto w-full justify-center h-full">
|
|
{/* Left Card: Audio Style */}
|
|
<div
|
|
className={`bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col transition-all duration-700 ease-out overflow-hidden ${
|
|
showLyrics ? 'md:flex-[1.5]' : 'flex-1 md:max-w-xl'
|
|
}`}
|
|
>
|
|
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0">오디오 스타일</h3>
|
|
|
|
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
|
<div>
|
|
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">AI 사운드 유형 선택</p>
|
|
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
|
{['보컬', '성우 내레이션', '배경음악'].map(type => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setSelectedType(type)}
|
|
disabled={isGenerating}
|
|
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] md:text-[10px] font-bold transition-all ${selectedType === type ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
{type}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">언어 선택</p>
|
|
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
|
{[
|
|
{ label: '한국어', flag: '🇰🇷' },
|
|
{ label: 'English', flag: '🇺🇸' },
|
|
{ label: '中文', flag: '🇨🇳' },
|
|
{ label: '日本語', flag: '🇯🇵' },
|
|
{ label: 'ไทย', flag: '🇹🇭' },
|
|
{ label: 'Tiếng Việt', flag: '🇻🇳' }
|
|
].map(lang => (
|
|
<button
|
|
key={lang.label}
|
|
onClick={() => setSelectedLang(lang.label)}
|
|
disabled={isGenerating}
|
|
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] font-bold flex flex-col items-center gap-0.5 transition-all ${selectedLang === lang.label ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<span className="text-xs sm:text-sm">{lang.flag}</span>
|
|
<span>{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">음악 장르 선택</p>
|
|
<select
|
|
value={selectedGenre}
|
|
onChange={(e) => setSelectedGenre(e.target.value)}
|
|
disabled={isGenerating}
|
|
className={`w-full py-1.5 sm:py-2 px-2 sm:px-3 rounded-lg bg-[#121a1d] border border-gray-800 text-[8px] sm:text-[9px] md:text-[10px] text-gray-300 focus:outline-none focus:border-[#a6ffea] ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<option>AI 추천</option>
|
|
<option>로파이</option>
|
|
<option>힙합</option>
|
|
<option>어쿠스틱</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{errorMessage && (
|
|
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-[9px] sm:text-[10px] shrink-0">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status Message */}
|
|
{isGenerating && statusMessage && (
|
|
<div className="mt-2 p-2 bg-[#a6ffea]/10 border border-[#a6ffea]/30 rounded-lg text-[#a6ffea] text-[9px] sm:text-[10px] flex items-center gap-2 shrink-0">
|
|
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
</svg>
|
|
{statusMessage}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-2 sm:mt-3 shrink-0">
|
|
<button
|
|
onClick={handleGenerateMusic}
|
|
disabled={isGenerating}
|
|
className={`w-full py-2 sm:py-2.5 md:py-3 font-bold rounded-xl transition-all transform active:scale-[0.98] text-[10px] sm:text-xs flex items-center justify-center gap-2 ${
|
|
isGenerating
|
|
? 'bg-[#a6ffea]/50 text-[#121a1d]/50 cursor-not-allowed'
|
|
: 'bg-[#a6ffea] text-[#121a1d] hover:bg-[#8affda]'
|
|
}`}
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
</svg>
|
|
생성 중...
|
|
</>
|
|
) : (
|
|
'음악 생성'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Card: Lyrics - 애니메이션으로 나타남 */}
|
|
{showLyrics && (
|
|
<div
|
|
className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col flex-1 animate-slide-in overflow-hidden"
|
|
style={{ minWidth: '240px' }}
|
|
>
|
|
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-0.5 sm:mb-1 tracking-wide shrink-0">가사</h3>
|
|
<p className="text-gray-500 text-[8px] sm:text-[9px] mb-2 sm:mb-3 shrink-0">가사 영역을 선택해서 수정 가능해요</p>
|
|
|
|
<div className="flex-1 bg-[#121a1d]/50 rounded-lg sm:rounded-xl border border-white/5 p-2 sm:p-3 flex flex-col gap-2 min-h-0 overflow-hidden">
|
|
{/* Interactive Player Bar */}
|
|
<div className="bg-black/40 rounded-full p-1.5 sm:p-2 flex items-center gap-2 shrink-0">
|
|
<button
|
|
onClick={togglePlayPause}
|
|
disabled={!audioUrl}
|
|
className={`text-[#a6ffea] flex-shrink-0 transition-transform hover:scale-110 ${!audioUrl ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
{isPlaying ? (
|
|
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
)}
|
|
</button>
|
|
|
|
<div
|
|
ref={progressBarRef}
|
|
onMouseDown={onMouseDown}
|
|
className={`flex-1 h-1 bg-gray-800 rounded-full relative group ${audioUrl ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 h-full bg-[#a6ffea] rounded-full"
|
|
style={{ width: `${progress}%` }}
|
|
></div>
|
|
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none"
|
|
style={{ left: `${progress}%` }}
|
|
>
|
|
<div className="absolute w-3 h-3 sm:w-4 sm:h-4 bg-[#a6ffea] rounded-full opacity-20 animate-pulse"></div>
|
|
<div className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-[#a6ffea] rounded-full shadow-[0_0_10px_rgba(166,255,234,0.6)] border border-[#121a1d]/20"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="text-[8px] sm:text-[9px] text-gray-500 font-mono w-12 sm:w-14 text-right">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Editable Lyrics */}
|
|
<textarea
|
|
value={lyrics}
|
|
onChange={(e) => setLyrics(e.target.value)}
|
|
className="flex-1 overflow-y-auto text-[9px] sm:text-[10px] text-gray-400 bg-transparent resize-none focus:outline-none focus:text-gray-300 pr-2 custom-scrollbar leading-relaxed min-h-0"
|
|
placeholder="가사가 여기에 표시됩니다..."
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleRegenerate}
|
|
disabled={isGenerating}
|
|
className={`mt-2 sm:mt-3 w-full py-2 sm:py-2.5 bg-[#a6ffea]/20 text-[#a6ffea] border border-[#a6ffea]/30 font-bold rounded-lg sm:rounded-xl hover:bg-[#a6ffea]/30 transition-all text-[9px] sm:text-[10px] md:text-xs shrink-0 ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
재생성
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-shrink-0 flex justify-center py-2 sm:py-3 md:py-4 px-3 sm:px-4">
|
|
<button
|
|
onClick={onNext}
|
|
disabled={status !== 'complete'}
|
|
className={`font-bold py-2 sm:py-2.5 md:py-3 px-6 sm:px-10 md:px-14 rounded-full transition-all transform active:scale-95 shadow-2xl text-[9px] sm:text-[10px] md:text-xs tracking-wide ${
|
|
status === 'complete'
|
|
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
|
|
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
|
|
}`}
|
|
>
|
|
영상 생성하기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SoundStudioContent;
|