398 lines
19 KiB
TypeScript
398 lines
19 KiB
TypeScript
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { geminiService, decodeAudioData } from '../services/geminiService';
|
|
import AudioRecorder from '../components/AudioRecorder';
|
|
import { processAndDownloadAudio } from '../utils/audioUtils';
|
|
import { Scenario, PronunciationFeedback, Language } from '../types';
|
|
import { Mic, Volume2, ChevronRight, Award, AlertCircle, CheckCircle, User, Bot, ArrowLeft, Download, ToggleLeft, ToggleRight, PanelRightClose, PanelRightOpen, X } from 'lucide-react';
|
|
import { translations, getScenarios } from '../utils/localization';
|
|
|
|
interface SpeakingPracticeViewProps {
|
|
language: Language;
|
|
}
|
|
|
|
const SpeakingPracticeView: React.FC<SpeakingPracticeViewProps> = ({ language }) => {
|
|
const t = translations[language].speaking;
|
|
const tRecorder = translations[language].recorder;
|
|
|
|
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
|
|
const [history, setHistory] = useState<{role: string, text: string, translation?: string}[]>([]);
|
|
const [feedback, setFeedback] = useState<PronunciationFeedback | null>(null);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [isPlayingTTS, setIsPlayingTTS] = useState(false);
|
|
const [lastAudioUrl, setLastAudioUrl] = useState<string | null>(null);
|
|
const [showTranslation, setShowTranslation] = useState(false);
|
|
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); // New state for mobile feedback drawer
|
|
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
|
|
// Reset flow if language changes, to avoid mismatched text
|
|
useEffect(() => {
|
|
reset();
|
|
}, [language]);
|
|
|
|
const startScenario = (scenario: Scenario) => {
|
|
setActiveScenario(scenario);
|
|
setHistory([{
|
|
role: 'model',
|
|
text: scenario.initialMessage,
|
|
translation: scenario.initialTranslation
|
|
}]);
|
|
setFeedback(null);
|
|
playTTS(scenario.initialMessage);
|
|
};
|
|
|
|
const playTTS = async (text: string) => {
|
|
try {
|
|
setIsPlayingTTS(true);
|
|
const audioBase64 = await geminiService.generateSpeech(text);
|
|
if (audioBase64) {
|
|
setLastAudioUrl(audioBase64);
|
|
if (!audioContextRef.current) {
|
|
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
}
|
|
const buffer = await decodeAudioData(audioBase64, audioContextRef.current);
|
|
const source = audioContextRef.current.createBufferSource();
|
|
source.buffer = buffer;
|
|
source.connect(audioContextRef.current.destination);
|
|
source.onended = () => setIsPlayingTTS(false);
|
|
source.start();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
setIsPlayingTTS(false);
|
|
}
|
|
};
|
|
|
|
const downloadTTS = async (text: string) => {
|
|
try {
|
|
if (lastAudioUrl && history[history.length - 1]?.text === text) {
|
|
// If the last generated audio matches the current requested text, use cached
|
|
processAndDownloadAudio(lastAudioUrl, `sakura_roleplay_${Date.now()}.wav`);
|
|
return;
|
|
}
|
|
// Otherwise generate
|
|
const audioBase64 = await geminiService.generateSpeech(text);
|
|
if (audioBase64) {
|
|
processAndDownloadAudio(audioBase64, `sakura_roleplay_${Date.now()}.wav`);
|
|
}
|
|
} catch (e) {
|
|
console.error("Download failed", e);
|
|
}
|
|
};
|
|
|
|
const handleAudioInput = async (base64Audio: string) => {
|
|
if (!activeScenario) return;
|
|
|
|
setIsProcessing(true);
|
|
|
|
const historyText = history.slice(-4).map(h => `${h.role}: ${h.text}`).join('\n');
|
|
|
|
try {
|
|
const result = await geminiService.analyzeSpeakingPerformance(
|
|
base64Audio,
|
|
`Roleplay as ${activeScenario.role} in context: ${activeScenario.description}`,
|
|
historyText,
|
|
language
|
|
);
|
|
|
|
if (result) {
|
|
setFeedback(result);
|
|
setHistory(prev => [
|
|
...prev,
|
|
{ role: 'user', text: result.transcription },
|
|
{ role: 'model', text: result.response, translation: result.translation }
|
|
]);
|
|
|
|
setIsFeedbackOpen(true); // Open feedback automatically on new input
|
|
await playTTS(result.response);
|
|
}
|
|
} catch (e) {
|
|
console.error("Analysis failed", e);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
const reset = () => {
|
|
setActiveScenario(null);
|
|
setHistory([]);
|
|
setFeedback(null);
|
|
setLastAudioUrl(null);
|
|
setShowTranslation(false);
|
|
setIsFeedbackOpen(false);
|
|
};
|
|
|
|
// Initial View: Scenario Selection
|
|
if (!activeScenario) {
|
|
const scenarios = getScenarios(language);
|
|
|
|
return (
|
|
<div className="h-full p-6 md:p-10 overflow-y-auto bg-slate-50/50">
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="mb-10 animate-fade-in-up">
|
|
<h2 className="text-4xl font-extrabold text-slate-800 mb-3 tracking-tight">{t.title}</h2>
|
|
<p className="text-lg text-slate-500 max-w-2xl">{t.subtitle}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-6">
|
|
{scenarios.map((scenario, index) => (
|
|
<button
|
|
key={scenario.id}
|
|
onClick={() => startScenario(scenario)}
|
|
style={{ animationDelay: `${index * 100}ms` }}
|
|
className="animate-fade-in-up relative overflow-hidden bg-white p-6 rounded-3xl shadow-sm hover:shadow-xl border border-slate-100 hover:border-indigo-200 transition-all duration-300 group text-left hover:-translate-y-1"
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-slate-50 to-slate-100 rounded-bl-full -mr-8 -mt-8 z-0 group-hover:scale-110 transition-transform duration-500" />
|
|
|
|
<div className="relative z-10 flex items-start">
|
|
<div className="w-20 h-20 bg-white rounded-2xl shadow-lg flex items-center justify-center text-5xl mr-6 border border-slate-50 group-hover:rotate-12 transition-transform duration-300">
|
|
{scenario.icon}
|
|
</div>
|
|
<div className="flex-1 pt-1">
|
|
<h3 className="text-xl font-bold text-slate-800 mb-2 group-hover:text-indigo-600 transition-colors">{scenario.title}</h3>
|
|
<p className="text-slate-500 text-sm mb-4 leading-relaxed">{scenario.description}</p>
|
|
<div className="inline-flex items-center px-4 py-2 bg-indigo-50 text-indigo-600 rounded-full text-sm font-bold group-hover:bg-indigo-600 group-hover:text-white transition-colors duration-300">
|
|
{t.start} <ChevronRight size={16} className="ml-1 group-hover:translate-x-1 transition-transform" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Feedback Content Component
|
|
const FeedbackContent = () => (
|
|
<div className="h-full flex flex-col">
|
|
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
|
|
<h3 className="text-lg font-extrabold text-slate-800 flex items-center gap-2">
|
|
<Award className="text-amber-500" fill="currentColor" />
|
|
{t.feedbackTitle}
|
|
</h3>
|
|
<button onClick={() => setIsFeedbackOpen(false)} className="md:hidden text-slate-400">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{feedback ? (
|
|
<div className="space-y-6 animate-slide-in-right">
|
|
|
|
{/* Score Card */}
|
|
<div className="bg-white rounded-3xl p-6 flex flex-col items-center shadow-sm border border-slate-100 relative overflow-hidden group">
|
|
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-500 group-hover:h-full group-hover:opacity-5 transition-all duration-500"></div>
|
|
<div className="relative w-28 h-28 flex items-center justify-center mb-2">
|
|
<svg className="transform -rotate-90 w-28 h-28">
|
|
<circle cx="56" cy="56" r="48" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-slate-100" />
|
|
<circle cx="56" cy="56" r="48" stroke="currentColor" strokeWidth="8" fill="transparent"
|
|
strokeDasharray={301.6}
|
|
strokeDashoffset={301.6 - (301.6 * feedback.score) / 100}
|
|
strokeLinecap="round"
|
|
className={`${feedback.score > 80 ? 'text-green-500' : feedback.score > 60 ? 'text-amber-500' : 'text-red-500'} transition-all duration-1000 ease-out`}
|
|
/>
|
|
</svg>
|
|
<div className="absolute flex flex-col items-center animate-scale-in">
|
|
<span className="text-3xl font-black text-slate-800">{feedback.score}</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">{t.score}</span>
|
|
</div>
|
|
|
|
{/* Issues List */}
|
|
<div className="bg-white rounded-2xl p-5 border border-slate-100 shadow-sm animate-fade-in-up delay-100">
|
|
<h4 className="text-xs font-bold text-slate-400 mb-4 uppercase tracking-wider flex items-center gap-2">
|
|
<AlertCircle size={14} className="text-red-500" /> {t.toImprove}
|
|
</h4>
|
|
{feedback.pronunciationIssues.length > 0 ? (
|
|
<ul className="space-y-2">
|
|
{feedback.pronunciationIssues.map((issue, i) => (
|
|
<li key={i} className="text-sm text-slate-700 bg-red-50/50 p-3 rounded-lg border border-red-100 flex items-start gap-2">
|
|
<span className="text-red-400 mt-0.5">•</span> {issue}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-sm font-medium text-green-600 bg-green-50 p-4 rounded-xl flex items-center gap-2 animate-pulse">
|
|
<CheckCircle size={16} /> {t.perfect}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Advice */}
|
|
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-5 text-white shadow-lg shadow-indigo-200 animate-fade-in-up delay-200 hover:shadow-xl transition-shadow">
|
|
<h4 className="text-xs font-bold text-indigo-200 mb-3 uppercase tracking-wider flex items-center gap-2">
|
|
<CheckCircle size={14} /> {t.advice}
|
|
</h4>
|
|
<p className="text-sm font-medium leading-relaxed opacity-95">
|
|
{feedback.advice}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Metadata */}
|
|
<div className="space-y-2 animate-fade-in-up delay-300">
|
|
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
<h4 className="text-[10px] font-bold text-slate-400 mb-1 uppercase">{t.transcription}</h4>
|
|
<p className="text-sm text-slate-600 italic font-serif">"{feedback.transcription}"</p>
|
|
</div>
|
|
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
<h4 className="text-[10px] font-bold text-slate-400 mb-1 uppercase">{t.meaning}</h4>
|
|
<p className="text-sm text-slate-600">"{feedback.translation}"</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 p-8 animate-fade-in">
|
|
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6 shadow-inner animate-pulse">
|
|
<Mic size={32} className="text-slate-300" />
|
|
</div>
|
|
<p className="text-sm text-center font-medium leading-relaxed max-w-[200px]">{t.emptyFeedback}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Active Conversation View
|
|
return (
|
|
<div className="h-full flex flex-row bg-slate-50 animate-fade-in relative overflow-hidden">
|
|
|
|
{/* Left: Conversation Area */}
|
|
<div className="flex-1 flex flex-col h-full min-w-0 border-r border-slate-200 bg-white/50 relative">
|
|
{/* Header */}
|
|
<div className="p-4 bg-white/80 backdrop-blur border-b border-slate-100 flex items-center justify-between shadow-sm z-10 sticky top-0">
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<button
|
|
onClick={reset}
|
|
className="p-2 rounded-full hover:bg-slate-100 text-slate-500 transition-colors hover:scale-110 flex-shrink-0"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<div className="flex items-center gap-3 animate-slide-in-right min-w-0">
|
|
<span className="text-2xl bg-slate-100 w-10 h-10 flex items-center justify-center rounded-full shadow-inner flex-shrink-0">{activeScenario.icon}</span>
|
|
<div className="truncate">
|
|
<h3 className="font-bold text-slate-800 truncate">{activeScenario.title}</h3>
|
|
<p className="text-xs text-slate-500 font-medium uppercase tracking-wider truncate">{t.roleplay}: {activeScenario.role}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button
|
|
onClick={() => setShowTranslation(!showTranslation)}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-bold bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 transition-all"
|
|
>
|
|
{showTranslation ? <ToggleRight size={20} className="text-indigo-500" /> : <ToggleLeft size={20} />}
|
|
<span className="hidden sm:inline">{t.translation}</span>
|
|
</button>
|
|
|
|
{/* Feedback Toggle (Mobile) */}
|
|
<button
|
|
onClick={() => setIsFeedbackOpen(!isFeedbackOpen)}
|
|
className={`md:hidden p-2 rounded-lg border transition-colors flex items-center gap-2 text-sm font-medium ${
|
|
isFeedbackOpen
|
|
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{isFeedbackOpen ? <PanelRightClose size={18} /> : <PanelRightOpen size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 space-y-8 pb-24">
|
|
{history.map((msg, idx) => {
|
|
const isUser = msg.role === 'user';
|
|
return (
|
|
<div key={idx} className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in-up`}>
|
|
<div className={`flex max-w-[85%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
|
<div className={`w-10 h-10 rounded-2xl flex-shrink-0 flex items-center justify-center shadow-sm transition-transform hover:scale-110 ${isUser ? 'bg-indigo-600' : 'bg-pink-500'}`}>
|
|
{isUser ? <User size={18} className="text-white" /> : <Bot size={18} className="text-white" />}
|
|
</div>
|
|
<div className={`p-5 rounded-3xl shadow-sm transition-all hover:shadow-md ${isUser ? 'bg-indigo-600 text-white rounded-tr-sm' : 'bg-white text-slate-800 border border-slate-100 rounded-tl-sm'}`}>
|
|
<p className="text-lg leading-relaxed font-medium">{msg.text}</p>
|
|
{showTranslation && msg.translation && (
|
|
<p className={`text-sm mt-2 pt-2 border-t ${isUser ? 'border-white/20 text-indigo-100' : 'border-slate-100 text-slate-500'} italic`}>
|
|
{msg.translation}
|
|
</p>
|
|
)}
|
|
{!isUser && (
|
|
<div className="flex gap-2 mt-3">
|
|
<button
|
|
onClick={() => playTTS(msg.text)}
|
|
className="px-3 py-1 bg-pink-50 hover:bg-pink-100 text-pink-600 rounded-full flex items-center gap-1 text-xs font-bold transition-colors"
|
|
>
|
|
<Volume2 size={12} /> {t.replay}
|
|
</button>
|
|
<button
|
|
onClick={() => downloadTTS(msg.text)}
|
|
className="px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-500 rounded-full flex items-center gap-1 text-xs font-bold transition-colors"
|
|
title="Download"
|
|
>
|
|
<Download size={12} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{isProcessing && (
|
|
<div className="flex justify-center py-8">
|
|
<div className="flex items-center gap-3 px-6 py-3 bg-indigo-50 rounded-full text-indigo-600 font-bold text-sm animate-pulse shadow-inner">
|
|
<Mic size={18} className="animate-bounce" /> {t.listening}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Interaction Area */}
|
|
<div className="p-6 bg-white border-t border-slate-100 flex flex-col justify-center items-center relative shadow-[0_-4px_20px_rgba(0,0,0,0.02)] z-20 pb-[env(safe-area-inset-bottom)]">
|
|
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
|
|
{isProcessing && <div className="h-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 animate-indeterminate" />}
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-4 w-full max-w-md">
|
|
<div className="transform hover:scale-110 transition-transform duration-300">
|
|
<AudioRecorder
|
|
onAudioCaptured={handleAudioInput}
|
|
disabled={isProcessing || isPlayingTTS}
|
|
titleStart={tRecorder.start}
|
|
titleStop={tRecorder.stop}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-slate-400 font-bold uppercase tracking-widest animate-pulse">
|
|
{isProcessing ? t.processing : t.tapSpeak}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Feedback Panel (Desktop) */}
|
|
<div className={`
|
|
hidden md:block w-80 lg:w-96 bg-white border-l border-slate-200 overflow-y-auto shadow-2xl z-30 transition-transform duration-500
|
|
${isFeedbackOpen ? 'translate-x-0' : 'translate-x-0'}
|
|
`}>
|
|
<FeedbackContent />
|
|
</div>
|
|
|
|
{/* Right: Feedback Drawer (Mobile) */}
|
|
<div className={`
|
|
fixed inset-y-0 right-0 z-50 w-full sm:w-96 bg-white shadow-2xl transform transition-transform duration-300 md:hidden
|
|
${isFeedbackOpen ? 'translate-x-0' : 'translate-x-full'}
|
|
`}>
|
|
<FeedbackContent />
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SpeakingPracticeView;
|