Files
ai-app-skr/views/SpeakingPracticeView.tsx
2025-11-22 16:38:53 +08:00

396 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 || 0)) / 100}
strokeLinecap="round"
className={`${(feedback.score || 0) > 80 ? 'text-green-500' : (feedback.score || 0) > 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 || 0}</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 && 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;