import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Play, Pause, Download, Music, Type, Check, Video, Mic2, RefreshCw, Save, SkipForward, Rewind, Sparkles, Image as ImageIcon, Settings, Sliders, MoveVertical, Type as TypeIcon, Circle, BarChart2, Clock } from 'lucide-react'; const KaraokeApp = () => { // --- State Management --- const [step, setStep] = useState(1); // 1: Setup, 2: Sync, 3: Preview/Export const [audioFile, setAudioFile] = useState(null); const [audioUrl, setAudioUrl] = useState(null); // Background State (Now supports multiple) const [bgFiles, setBgFiles] = useState([]); const [bgUrls, setBgUrls] = useState([]); const [lyricsText, setLyricsText] = useState("ช้าง ช้าง ช้าง\nน้องเคยเห็นช้างหรือเปล่า\nช้างมันตัวโตไม่เบา\nจมูกยาวๆ เรียกว่างวง\nมีเขี้ยวใต้งวง เรียกว่างา\nมีหู มีตา หางยาว"); const [parsedLyrics, setParsedLyrics] = useState([]); const [syncData, setSyncData] = useState([]); // Customization Settings const [styles, setStyles] = useState({ fontSize: 80, fontFamily: 'Kanit', textColor: '#ffffff', highlightColor1: '#f472b6', // Pink highlightColor2: '#fbbf24', // Amber showVisualizer: true, visualizerType: 'bar', // 'bar' or 'circle' showParticles: true, overlayOpacity: 0.5, verticalOffset: 0, strokeWidth: 6, slideshowSpeed: 5 // Seconds per slide }); const fontOptions = [ { name: 'Kanit', label: 'Kanit (มาตรฐาน)' }, { name: 'Mali', label: 'Mali (ลายมือน่ารัก)' }, { name: 'Sarabun', label: 'Sarabun (ทางการ)' }, { name: 'Charm', label: 'Charm (ไทยวิจิตร)' }, { name: 'Chakra Petch', label: 'Chakra Petch (ล้ำยุค)' } ]; // Syncing State const [isPlaying, setIsPlaying] = useState(false); const [currentLineIndex, setCurrentLineIndex] = useState(-1); const audioRef = useRef(null); // Images Ref (Store loaded HTMLImageElements) const bgImagesRef = useRef([]); // Audio Analysis const audioContextRef = useRef(null); const analyserRef = useRef(null); // Canvas / Export const canvasRef = useRef(null); const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const [countdown, setCountdown] = useState(0); // Load Fonts useEffect(() => { const link = document.createElement('link'); link.href = 'https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;700&family=Charm:wght@400;700&family=Kanit:wght@400;900&family=Mali:wght@400;700&family=Sarabun:wght@400;800&display=swap'; link.rel = 'stylesheet'; document.head.appendChild(link); return () => document.head.removeChild(link); }, []); // Preload Images when URLs change useEffect(() => { if (bgUrls.length > 0) { bgImagesRef.current = bgUrls.map(url => { const img = new Image(); img.src = url; return img; }); } else { bgImagesRef.current = []; } }, [bgUrls]); // --- Audio Setup --- const setupAudioContext = () => { if (!audioContextRef.current && audioRef.current) { const AudioContext = window.AudioContext || window.webkitAudioContext; const ctx = new AudioContext(); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; try { const source = ctx.createMediaElementSource(audioRef.current); source.connect(analyser); analyser.connect(ctx.destination); audioContextRef.current = ctx; analyserRef.current = analyser; } catch (e) { console.error("Audio Context Error:", e); } } if (audioContextRef.current && audioContextRef.current.state === 'suspended') { audioContextRef.current.resume(); } }; // --- Step 1: Logic --- const handleFileUpload = (e) => { const file = e.target.files[0]; if (file) { setAudioFile(file); setAudioUrl(URL.createObjectURL(file)); if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } } }; const handleBgUpload = (e) => { const files = Array.from(e.target.files); if (files.length > 0) { setBgFiles(files); const urls = files.map(file => URL.createObjectURL(file)); setBgUrls(urls); } }; const handleStartSync = () => { const lines = lyricsText.split('\n').filter(line => line.trim() !== ''); setParsedLyrics(lines); setSyncData(lines.map(line => ({ text: line, startTime: null, endTime: null }))); setCurrentLineIndex(-1); setStep(2); setTimeout(setupAudioContext, 100); }; // --- Step 2: Logic (Syncing) --- const togglePlay = () => { if (!audioRef.current) return; setupAudioContext(); if (isPlaying) audioRef.current.pause(); else audioRef.current.play(); setIsPlaying(!isPlaying); }; const recordTiming = () => { if (!isPlaying || currentLineIndex >= parsedLyrics.length) return; const currentTime = audioRef.current.currentTime; // Set End Time for previous line if (currentLineIndex >= 0) { setSyncData(prev => { const newData = [...prev]; if (newData[currentLineIndex] && newData[currentLineIndex].endTime === null) { newData[currentLineIndex].endTime = currentTime; } return newData; }); } // Set Start Time for next line const nextIndex = currentLineIndex + 1; if (nextIndex < parsedLyrics.length) { setSyncData(prev => { const newData = [...prev]; newData[nextIndex].startTime = currentTime; return newData; }); setCurrentLineIndex(nextIndex); } else { setCurrentLineIndex(nextIndex); } }; // Sync Shortcuts useEffect(() => { const handleKeyDown = (e) => { if (step === 2 && (e.code === 'Space' || e.code === 'Enter')) { e.preventDefault(); if (!isPlaying && currentLineIndex === -1) togglePlay(); else recordTiming(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [step, isPlaying, currentLineIndex, parsedLyrics]); const resetSync = () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; } setIsPlaying(false); setCurrentLineIndex(-1); setSyncData(parsedLyrics.map(line => ({ text: line, startTime: null, endTime: null }))); }; const finishSync = () => { if (syncData.length > 0 && currentLineIndex > 0) { setSyncData(prev => { const newData = [...prev]; const lastIdx = Math.min(currentLineIndex, newData.length - 1); if (!newData[lastIdx].endTime) { newData[lastIdx].endTime = audioRef.current ? audioRef.current.duration : newData[lastIdx].startTime + 5; } return newData; }); } if (audioRef.current) { audioRef.current.pause(); setIsPlaying(false); } setStep(3); }; // --- Step 3: Rendering Logic --- // Particles const particles = useMemo(() => { return Array.from({ length: 60 }).map(() => ({ x: Math.random() * 1920, y: Math.random() * 1080, size: Math.random() * 3 + 1, speed: Math.random() * 0.8 + 0.1, opacity: Math.random() * 0.5 + 0.1 })); }, []); useEffect(() => { if (step !== 3 || !canvasRef.current) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); let animationFrameId; const bufferLength = analyserRef.current ? analyserRef.current.frequencyBinCount : 0; const dataArray = new Uint8Array(bufferLength); const render = () => { if (!audioRef.current) return; const currentTime = audioRef.current.currentTime; if (analyserRef.current) analyserRef.current.getByteFrequencyData(dataArray); // 1. Background (Slideshow Logic) if (bgImagesRef.current.length > 0) { // Calculate which image to show based on time const slideDuration = styles.slideshowSpeed; const imgIndex = Math.floor(currentTime / slideDuration) % bgImagesRef.current.length; const img = bgImagesRef.current[imgIndex]; if (img && img.complete) { const scale = Math.max(canvas.width / img.width, canvas.height / img.height); const x = (canvas.width / 2) - (img.width / 2) * scale; const y = (canvas.height / 2) - (img.height / 2) * scale; ctx.drawImage(img, x, y, img.width * scale, img.height * scale); } // Overlay ctx.fillStyle = `rgba(0, 0, 0, ${styles.overlayOpacity})`; ctx.fillRect(0, 0, canvas.width, canvas.height); } else { // Default Gradient const gradient = ctx.createRadialGradient(960, 540, 0, 960, 540, 1000); gradient.addColorStop(0, '#312e81'); gradient.addColorStop(1, '#0f172a'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); } // 2. Particles if (styles.showParticles) { ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; particles.forEach(p => { p.y -= p.speed; if (p.y < 0) p.y = canvas.height; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); }); } // 3. Visualizer if (styles.showVisualizer && analyserRef.current) { if (styles.visualizerType === 'circle') { // CIRCULAR VISUALIZER const centerX = canvas.width / 2; const centerY = canvas.height / 2 + styles.verticalOffset; const radius = 350; ctx.beginPath(); for (let i = 0; i < bufferLength; i++) { const barHeight = (dataArray[i] / 255) * 150; const rad = (i / bufferLength) * Math.PI * 2; const x = centerX + Math.cos(rad) * (radius + barHeight); const y = centerY + Math.sin(rad) * (radius + barHeight); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.lineWidth = 4; ctx.strokeStyle = `rgba(255, 255, 255, 0.2)`; ctx.stroke(); ctx.beginPath(); for (let i = 0; i < bufferLength; i+=2) { const barHeight = (dataArray[i] / 255) * 200; const rad = (i / bufferLength) * Math.PI * 2; const x = centerX + Math.cos(rad) * (radius + barHeight + 20); const y = centerY + Math.sin(rad) * (radius + barHeight + 20); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.strokeStyle = styles.highlightColor1; ctx.lineWidth = 2; ctx.stroke(); } else { // BAR VISUALIZER const barWidth = (canvas.width / bufferLength) * 2.5; let barX = 0; for (let i = 0; i < bufferLength; i++) { const barHeight = (dataArray[i] / 255) * 250; const barGrad = ctx.createLinearGradient(0, canvas.height - barHeight, 0, canvas.height); barGrad.addColorStop(0, styles.highlightColor1); barGrad.addColorStop(1, styles.highlightColor2); ctx.fillStyle = barGrad; ctx.fillRect(barX, canvas.height - barHeight, barWidth, barHeight); barX += barWidth + 1; } } } // 4. Lyrics const activeIdx = syncData.findIndex(line => line.startTime <= currentTime && (line.endTime === null || line.endTime > currentTime) ); let displayIdx = activeIdx; if (displayIdx === -1) { displayIdx = syncData.findIndex(line => line.startTime > currentTime); if (displayIdx === -1) displayIdx = syncData.length; displayIdx = displayIdx - 1; } const centerX = canvas.width / 2; const centerY = (canvas.height / 2) + styles.verticalOffset; const lineHeight = styles.fontSize * 1.6; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `bold ${styles.fontSize}px "${styles.fontFamily}", sans-serif`; ctx.lineWidth = styles.strokeWidth; ctx.lineJoin = 'round'; const drawLine = (text, x, y, type, progress = 0) => { if (type === 'active') { // Outline ctx.strokeStyle = 'black'; ctx.strokeText(text, x, y); // Base Text ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.fillText(text, x, y); // Karaoke Wipe ctx.save(); const textWidth = ctx.measureText(text).width; const startX = x - (textWidth / 2); ctx.beginPath(); ctx.rect(startX - 10, y - styles.fontSize, (textWidth * progress) + 10, styles.fontSize * 2); ctx.clip(); // Gradient Fill const textGrad = ctx.createLinearGradient(startX, y, startX + textWidth, y); textGrad.addColorStop(0, styles.highlightColor1); textGrad.addColorStop(1, styles.highlightColor2); ctx.fillStyle = textGrad; ctx.fillText(text, x, y); // Glow ctx.shadowColor = styles.highlightColor1; ctx.shadowBlur = 15; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore(); ctx.shadowBlur = 0; } else if (type === 'past') { ctx.font = `bold ${styles.fontSize * 0.8}px "${styles.fontFamily}", sans-serif`; ctx.fillStyle = styles.textColor; ctx.globalAlpha = 0.3; ctx.strokeStyle = 'black'; ctx.lineWidth = styles.strokeWidth * 0.8; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.globalAlpha = 1; } else { // Future ctx.font = `bold ${styles.fontSize * 0.8}px "${styles.fontFamily}", sans-serif`; ctx.fillStyle = styles.textColor; ctx.globalAlpha = 0.1; ctx.fillText(text, x, y); ctx.globalAlpha = 1; } }; if (syncData[displayIdx - 1]) drawLine(syncData[displayIdx - 1].text, centerX, centerY - lineHeight, 'past'); if (syncData[displayIdx]) { const line = syncData[displayIdx]; let progress = 0; if (activeIdx === displayIdx) { const duration = (line.endTime || (line.startTime + 5)) - line.startTime; const elapsed = currentTime - line.startTime; progress = Math.min(1, Math.max(0, elapsed / duration)); } else if (currentTime > (line.endTime || 0)) progress = 1; drawLine(line.text, centerX, centerY, activeIdx === displayIdx ? 'active' : (currentTime > line.startTime ? 'past' : 'future'), progress); } else if (displayIdx === -1 && syncData[0]) { drawLine(syncData[0].text, centerX, centerY, 'future'); } if (syncData[displayIdx + 1]) drawLine(syncData[displayIdx + 1].text, centerX, centerY + lineHeight, 'future'); animationFrameId = requestAnimationFrame(render); }; render(); return () => cancelAnimationFrame(animationFrameId); }, [step, isPlaying, syncData, particles, styles, bgUrls]); // Recording Logic (Same as before) const startRecordingProcess = () => { setCountdown(3); let count = 3; const timer = setInterval(() => { count--; setCountdown(count); if (count === 0) { clearInterval(timer); startActualRecording(); } }, 1000); }; const startActualRecording = () => { if (!canvasRef.current || !audioRef.current) return; setupAudioContext(); const canvasStream = canvasRef.current.captureStream(30); let finalStream = canvasStream; try { const audioStream = audioRef.current.mozCaptureStream ? audioRef.current.mozCaptureStream() : audioRef.current.captureStream(); if (audioStream) { const audioTracks = audioStream.getAudioTracks(); if (audioTracks.length > 0) finalStream.addTrack(audioTracks[0]); } } catch (e) { console.warn("Audio capture limited"); } const recorder = new MediaRecorder(finalStream, { mimeType: 'video/webm; codecs=vp9' }); mediaRecorderRef.current = recorder; chunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'karaoke-slideshow.webm'; a.click(); setIsRecording(false); }; recorder.start(); setIsRecording(true); audioRef.current.currentTime = 0; audioRef.current.play(); setIsPlaying(true); }; const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); audioRef.current.pause(); setIsPlaying(false); } }; // --- UI Components --- return (