Spaces:
Running
Running
Ananya Sridhar
Add dark mode: shared theme system, toggle in sidebar, applied to Sidebar/TherapistInput/PracticeScreen/ResultScreen/CharacterSelect
1896040 | import { useState, useEffect, useRef } from "react"; | |
| import { CHARACTERS } from "../assets/characters"; | |
| import CharacterBackdrop from "./CharacterBackdrop"; | |
| import { useAudio } from "../hooks/useAudio"; | |
| import { evaluateAttempt } from "../utils/api"; | |
| import { friendlyPhoneme, phonemeExample } from "../utils/phonemeMap"; | |
| import { displayPhoneme } from "../utils/phonemeMapIndic"; | |
| import { t } from "../utils/i18n"; | |
| import { getTheme, getSurface } from "../utils/themes"; | |
| export default function PracticeScreen({ character, language = "english", wordData, sessionId, attemptNumber, attemptHistory = [], onResult, onSwitchCharacter, darkMode }) { | |
| const [phase, setPhase] = useState("listen"); | |
| const [playingChar, setPlayingChar] = useState(false); | |
| const [playingChild, setPlayingChild] = useState(false); | |
| const { isRecording, audioBlob, audioUrl, startRecording, stopRecording, reset } = useAudio(); | |
| const char = CHARACTERS[character]; | |
| const th = getTheme(character, darkMode); | |
| const [showSwitcher, setShowSwitcher] = useState(false); | |
| const [imageIndex, setImageIndex] = useState(0); | |
| useEffect(() => { document.body.style.background = th.bg; document.body.style.transition = "background 0.5s ease"; }, [th.bg]); | |
| const playWord = async (speed = 1.0) => { | |
| setPlayingChar(true); | |
| try { | |
| const form = new FormData(); | |
| form.append("word", wordData.word); | |
| form.append("speed", String(speed)); | |
| form.append("language", language); | |
| form.append("character", character); | |
| const res = await fetch("https://anabaena-vaaksiddhi.hf.space/speak/word", { method: "POST", body: form }); | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const audio = new Audio(url); | |
| audio.play(); | |
| audio.onended = () => setPlayingChar(false); | |
| } catch { setPlayingChar(false); } | |
| }; | |
| const playChildAudio = () => { | |
| if (!audioUrl) return; | |
| setPlayingChild(true); | |
| const audio = new Audio(audioUrl); | |
| audio.play(); | |
| audio.onended = () => setPlayingChild(false); | |
| }; | |
| const handleRecord = async () => { | |
| setPhase("record"); | |
| await startRecording(); | |
| }; | |
| const handleSubmit = async () => { | |
| if (!audioBlob) return; | |
| setPhase("loading"); | |
| try { | |
| const result = await evaluateAttempt({ | |
| audio: audioBlob, | |
| targetWord: wordData.word, | |
| character, | |
| language: wordData.language || "english", | |
| condition: "autism", | |
| attemptNumber, | |
| sessionId, | |
| attemptHistory, | |
| }); | |
| const newHistory = [...attemptHistory, result]; | |
| onResult({ ...result, attemptNumber, attemptHistory: newHistory, childAudioUrl: audioUrl }); | |
| } catch (err) { | |
| console.error(err); | |
| setPhase("record"); | |
| } | |
| }; | |
| const imageUrl = wordData?.image_base64 ? `data:image/png;base64,${wordData.image_base64}` : null; | |
| return ( | |
| <div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 20px", position: "relative" }}> | |
| <CharacterBackdrop character={character || "BOLT"} /> | |
| <div style={{ width: "100%", maxWidth: "480px", display: "flex", flexDirection: "column", gap: "16px", position: "relative", zIndex: 1 }}> | |
| <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: "10px" }}> | |
| <img src={char.image} alt={char.name} style={{ width: "40px", height: "40px", objectFit: "contain" }} /> | |
| <span style={{ color: th.text, fontWeight: 800, fontSize: "0.95rem", fontFamily: "Nunito, sans-serif" }}>{char.name}</span> | |
| </div> | |
| <div style={{ display: "flex", gap: "8px", alignItems: "center" }}> | |
| <button onClick={() => setShowSwitcher(!showSwitcher)} style={{ background: th.card, border: `1.5px solid ${th.accent}44`, borderRadius: "10px", padding: "5px 12px", color: th.sub, fontSize: "0.7rem", fontWeight: 700, cursor: "pointer", fontFamily: "Nunito, sans-serif" }}>Switch</button> | |
| <div style={{ background: th.card, border: `1px solid ${th.accent}44`, borderRadius: "20px", padding: "4px 14px", fontSize: "0.75rem", color: th.sub, fontWeight: 700 }}>Attempt {attemptNumber}</div> | |
| </div> | |
| </div> | |
| {showSwitcher && ( | |
| <div style={{ background: getSurface(darkMode, 0.9), border: `1.5px solid ${th.accent}33`, borderRadius: "16px", padding: "14px", boxShadow: `0 4px 20px ${th.accent}18` }}> | |
| <p style={{ color: th.sub, fontSize: "0.65rem", fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", margin: "0 0 10px 0" }}>Switch character</p> | |
| <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}> | |
| {Object.values(CHARACTERS).map(c => ( | |
| <button key={c.id} onClick={() => { onSwitchCharacter(c.id); setShowSwitcher(false); }} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", background: c.id === character ? getTheme(c.id, darkMode).card : "transparent", border: `1.5px solid ${c.id === character ? getTheme(c.id, darkMode).accent : "rgba(0,0,0,0.08)"}`, borderRadius: "10px", padding: "8px 10px", cursor: "pointer" }}> | |
| <img src={c.image} alt={c.name} style={{ width: "32px", height: "32px", objectFit: "contain" }} /> | |
| <span style={{ fontSize: "0.62rem", fontWeight: 700, color: getTheme(c.id, darkMode).text, fontFamily: "Nunito, sans-serif" }}>{c.name}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div style={{ background: getSurface(darkMode, 0.7), border: `1.5px solid ${th.accent}33`, borderRadius: "24px", padding: "28px 24px", display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", boxShadow: `0 4px 24px ${th.accent}18` }}> | |
| {(() => { | |
| const imgs = wordData?.images?.length > 0 ? wordData.images : imageUrl ? [{ label: wordData?.word, image_base64: wordData?.image_base64 }] : []; | |
| const idx = Math.min(imageIndex, Math.max(imgs.length - 1, 0)); | |
| const current = imgs[idx]; | |
| return ( | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "10px", width: "100%" }}> | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", minHeight: "180px" }}> | |
| {current?.pair ? ( | |
| <> | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "6px" }}> | |
| <img src={"data:image/png;base64," + current.image_base64} alt={current.label_1} style={{ width: "120px", height: "120px", objectFit: "contain", borderRadius: "12px" }} /> | |
| <span style={{ color: th.sub, fontSize: "0.7rem", fontWeight: 700, textTransform: "capitalize", fontFamily: "Nunito, sans-serif" }}>{current.label_1}</span> | |
| </div> | |
| <span style={{ color: th.accent, fontSize: "1.4rem", fontWeight: 900 }}>+</span> | |
| {current.image_base64_2 && ( | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "6px" }}> | |
| <img src={"data:image/png;base64," + current.image_base64_2} alt={current.label_2} style={{ width: "120px", height: "120px", objectFit: "contain", borderRadius: "12px" }} /> | |
| <span style={{ color: th.sub, fontSize: "0.7rem", fontWeight: 700, textTransform: "capitalize", fontFamily: "Nunito, sans-serif" }}>{current.label_2}</span> | |
| </div> | |
| )} | |
| </> | |
| ) : current ? ( | |
| <img src={"data:image/png;base64," + current.image_base64} alt={current.label} style={{ width: "180px", height: "180px", objectFit: "contain", borderRadius: "16px" }} /> | |
| ) : ( | |
| <div style={{ width: "140px", height: "140px", background: th.card, borderRadius: "16px", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "3rem" }}>πΌοΈ</div> | |
| )} | |
| </div> | |
| {current && !current.pair && <p style={{ color: th.sub, fontSize: "0.72rem", fontWeight: 700, margin: 0, textTransform: "capitalize", fontFamily: "Nunito, sans-serif" }}>{current.label}</p>} | |
| {imgs.length > 1 && ( | |
| <div style={{ display: "flex", alignItems: "center", gap: "10px" }}> | |
| <button onClick={() => setImageIndex(i => Math.max(0, i - 1))} disabled={idx === 0} style={{ width: "30px", height: "30px", borderRadius: "50%", border: `1.5px solid ${th.accent}44`, background: idx === 0 ? "transparent" : th.card, color: th.accent, cursor: idx === 0 ? "not-allowed" : "pointer", opacity: idx === 0 ? 0.3 : 1, fontSize: "1rem", display: "flex", alignItems: "center", justifyContent: "center" }}>β</button> | |
| <div style={{ display: "flex", gap: "5px" }}> | |
| {imgs.map((_, i) => ( | |
| <div key={i} onClick={() => setImageIndex(i)} style={{ width: i === idx ? "16px" : "7px", height: "7px", borderRadius: "4px", background: i === idx ? th.accent : `${th.accent}33`, transition: "all 0.2s", cursor: "pointer" }} /> | |
| ))} | |
| </div> | |
| <button onClick={() => setImageIndex(i => Math.min(imgs.length - 1, i + 1))} disabled={idx === imgs.length - 1} style={{ width: "30px", height: "30px", borderRadius: "50%", border: `1.5px solid ${th.accent}44`, background: idx === imgs.length - 1 ? "transparent" : th.card, color: th.accent, cursor: idx === imgs.length - 1 ? "not-allowed" : "pointer", opacity: idx === imgs.length - 1 ? 0.3 : 1, fontSize: "1rem", display: "flex", alignItems: "center", justifyContent: "center" }}>β</button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })()} | |
| <div style={{ textAlign: "center" }}> | |
| <p style={{ color: th.sub, fontSize: "0.65rem", letterSpacing: "0.12em", margin: "0 0 6px 0", fontWeight: 700, textTransform: "uppercase" }}>Target Word</p> | |
| <p style={{ fontFamily: "Nunito, sans-serif", fontSize: "2.4rem", fontWeight: 900, color: th.text, margin: 0 }}>{wordData?.word}</p> | |
| </div> | |
| <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", justifyContent: "center" }}> | |
| {(wordData?.phonemes || []).map((p, i) => ( | |
| <div key={i} title={phonemeExample(p)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "2px", background: th.card, border: `1px solid ${th.accent}55`, borderRadius: "10px", padding: "6px 10px", cursor: "default" }}> | |
| <span style={{ color: th.accent, fontFamily: "Nunito, sans-serif", fontSize: "0.95rem", fontWeight: 900, lineHeight: 1 }}> | |
| {language === "english" ? friendlyPhoneme(p) : (displayPhoneme(p, language)?.label || p)} | |
| </span> | |
| <span style={{ color: th.sub, fontFamily: "JetBrains Mono, monospace", fontSize: "0.55rem", opacity: 0.6 }}>{p}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <div style={{ display: "flex", gap: "10px" }}> | |
| <button id="hint-hear-voice" onClick={() => playWord(1.0)} disabled={playingChar} style={{ flex: 1, background: getSurface(darkMode, 0.7), border: `1.5px solid ${th.accent}44`, borderRadius: "14px", padding: "14px", cursor: "pointer", color: th.accent, fontWeight: 700, fontSize: "0.85rem", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", fontFamily: "Nunito, sans-serif" }}> | |
| π Normal | |
| </button> | |
| <button onClick={() => playWord(0.65)} disabled={playingChar} style={{ flex: 1, background: getSurface(darkMode, 0.7), border: `1.5px solid ${th.accent}44`, borderRadius: "14px", padding: "14px", cursor: "pointer", color: th.accent, fontWeight: 700, fontSize: "0.85rem", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", fontFamily: "Nunito, sans-serif" }}> | |
| π’ Slow | |
| </button> | |
| </div> | |
| {playingChar && <p style={{ color: th.sub, fontSize: "0.75rem", textAlign: "center", margin: "-8px 0 0 0" }}>Playing...</p>} | |
| {phase === "listen" && ( | |
| <button onClick={handleRecord} style={{ background: th.accent, border: "none", borderRadius: "18px", padding: "22px", fontFamily: "Nunito, sans-serif", fontSize: "1.15rem", fontWeight: 900, color: "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", boxShadow: `0 4px 20px ${th.accent}44` }}> | |
| <span style={{ fontSize: "1.4rem" }}>ποΈ</span> | |
| Now you try! | |
| </button> | |
| )} | |
| {phase === "record" && isRecording && ( | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px" }}> | |
| <div style={{ position: "relative", width: "88px", height: "88px" }}> | |
| <div style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "#FF6B6B", opacity: 0.25, animation: "ping 1.2s ease-in-out infinite" }} /> | |
| <button onClick={stopRecording} style={{ position: "relative", width: "88px", height: "88px", borderRadius: "50%", background: "#FF6B6B", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 4px 20px #FF6B6B44" }}> | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="white"> | |
| <path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2" fill="none" stroke="white" strokeWidth="2"/> | |
| <line x1="12" y1="19" x2="12" y2="23" stroke="white" strokeWidth="2"/> | |
| <line x1="8" y1="23" x2="16" y2="23" stroke="white" strokeWidth="2"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <p style={{ color: th.sub, fontSize: "0.85rem", fontWeight: 600 }}>Recording... tap to stop</p> | |
| </div> | |
| )} | |
| {phase === "record" && !isRecording && audioBlob && ( | |
| <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}> | |
| <div style={{ background: getSurface(darkMode, 0.7), border: `1.5px solid ${th.accent}33`, borderRadius: "16px", padding: "14px", display: "flex", gap: "10px" }}> | |
| <button onClick={playChildAudio} disabled={playingChild} style={{ flex: 1, background: "transparent", border: `1.5px solid ${th.accent}44`, borderRadius: "10px", padding: "10px", color: th.sub, fontSize: "0.8rem", cursor: "pointer", fontWeight: 700, fontFamily: "Nunito, sans-serif" }}> | |
| {playingChild ? "Playing..." : t(language, "hearYourself")} | |
| </button> | |
| <button onClick={() => playWord(1.0)} disabled={playingChar} style={{ flex: 1, background: "transparent", border: `1.5px solid ${th.accent}66`, borderRadius: "10px", padding: "10px", color: th.accent, fontSize: "0.8rem", cursor: "pointer", fontWeight: 700, fontFamily: "Nunito, sans-serif" }}> | |
| {playingChar ? "Playing..." : `π Hear ${char.name}`} | |
| </button> | |
| </div> | |
| <button onClick={handleSubmit} style={{ background: th.accent, border: "none", borderRadius: "16px", padding: "20px", fontFamily: "Nunito, sans-serif", fontSize: "1.1rem", fontWeight: 900, color: "#fff", cursor: "pointer", boxShadow: `0 4px 20px ${th.accent}44` }}> | |
| Check my answer! β¨ | |
| </button> | |
| <button id="hint-mic-button" onClick={() => { reset(); setPhase("record"); startRecording(); }} style={{ background: "transparent", border: `1.5px solid ${th.accent}44`, borderRadius: "12px", padding: "12px", color: th.sub, fontSize: "0.85rem", cursor: "pointer", fontFamily: "Nunito, sans-serif", fontWeight: 600 }}> | |
| Try again | |
| </button> | |
| </div> | |
| )} | |
| {phase === "loading" && ( | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", padding: "32px" }}> | |
| <div style={{ width: "44px", height: "44px", border: `3px solid ${th.accent}33`, borderTop: `3px solid ${th.accent}`, borderRadius: "50%", animation: "spin 0.8s linear infinite" }} /> | |
| <p style={{ color: th.sub, fontSize: "0.85rem", fontWeight: 600 }}>Analysing your voice...</p> | |
| </div> | |
| )} | |
| </div> | |
| <style>{` | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @keyframes ping { 75%, 100% { transform: scale(2.2); opacity: 0; } } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |