window.location.reload()}
/>
} />
}>
}
/>
}>
}
/>
}>
}
/>
}>
}
/>
}>
}
/>
}>
}
/>
}>
}
/>
} />
>
);
};
const App = () => (
);
export default App;
================================================
FILE: src/ErrorBoundary.jsx
================================================
import { useTranslation } from "react-i18next";
import ErrorBoundaryInner from "./ErrorBoundaryInner";
const ErrorBoundary = (props) => {
const { t } = useTranslation();
return ;
};
export default ErrorBoundary;
================================================
FILE: src/ErrorBoundaryInner.jsx
================================================
import PropTypes from "prop-types";
import React from "react";
import { FaGithub } from "react-icons/fa";
import { FiRefreshCw } from "react-icons/fi";
import { HiOutlineClipboardCopy } from "react-icons/hi";
import { Toaster } from "sonner";
import Container from "./ui/Container";
import openExternal from "./utils/openExternal";
import { sonnerSuccessToast } from "./utils/sonnerCustomToast";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("React Error Boundary caught an error:", error, errorInfo);
this.setState({ errorInfo });
}
handleCopy = () => {
const { error, errorInfo } = this.state;
const fullError = `Error log:\n
\`\`\`
App version: v${__APP_VERSION__}\n${error?.toString()}\n\nStack Trace:\n${errorInfo?.componentStack}
\`\`\`
`;
navigator.clipboard.writeText(fullError).then(() => {
sonnerSuccessToast(this.props.t("toast.appCrashCopySuccess"));
});
};
handleRefresh = () => location.reload();
render() {
const { hasError, error, errorInfo } = this.state;
const { t } = this.props;
if (hasError) {
return (
🚨 {t("appCrash.appCrashedTitle")}
{t("appCrash.appCrashedDesc")}
App version: v{__APP_VERSION__}
{"\n"}
{error?.toString()}
{"\n"}
{errorInfo?.componentStack}
{t("appCrash.copyBtn")}
openExternal(
"https://github.com/learnercraft/ispeakerreact/issues/new?template=bug_report.yml"
)
}
className="btn btn-info"
>
{t("appCrash.openGitHubBtn")}
{t("appCrash.refreshBtn")}
);
}
return this.props.children;
}
}
ErrorBoundary.propTypes = {
t: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
};
================================================
FILE: src/index.jsx
================================================
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "../i18n";
import App from "./App";
import "./styles/index.css";
const root = createRoot(document.getElementById("root"));
root.render(
);
================================================
FILE: src/components/Homepage.jsx
================================================
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Container from "../ui/Container";
import isElectron from "../utils/isElectron";
import Footer from "./general/Footer";
import LogoLightOrDark from "./general/LogoLightOrDark";
import TopNavBar from "./general/TopNavBar";
const Homepage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleNavigate = (path) => {
navigate(path);
};
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.home")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
const cardsInfo = [
{
title: `${t("navigation.sounds")}`,
description: `${t("homepage.soundDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/sound_menu_icon.svg`,
path: "sounds",
},
{
title: `${t("navigation.words")}`,
description: `${t("homepage.wordDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/word_menu_icon.svg`,
path: "words",
},
{
title: `${t("navigation.exercises")}`,
description: `${t("homepage.exerciseDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/exercise_menu_icon.svg`,
path: "exercises",
},
{
title: `${t("navigation.conversations")}`,
description: `${t("homepage.conversationDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/conversation_menu_icon.svg`,
path: "conversations",
},
{
title: `${t("navigation.exams")}`,
description: `${t("homepage.examDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/exam_menu_icon.svg`,
path: "exams",
},
{
title: `${t("navigation.settings")}`,
description: `${t("homepage.settingDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/settings_menu_icon.svg`,
path: "settings",
},
{
title: `${t("navigation.download")}`,
description: `${t("homepage.downloadDescription")}`,
icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/download_menu_icon.svg`,
path: "download",
hideForElectron: true,
},
];
useEffect(() => {
window.scrollTo(0, 0);
}, []);
return (
<>
iSpeakerReact
v{__APP_VERSION__}
{cardsInfo
.filter((card) => !(isElectron() && card.hideForElectron))
.map((card, idx) => (
{card.title}
{card.description}
handleNavigate(card.path)}
aria-label={`Open the ${card.title} section`}
>
{t("homepage.openBtn")}
))}
>
);
};
export default Homepage;
================================================
FILE: src/components/conversation_page/ConversationDetailPage.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoChevronBackOutline } from "react-icons/io5";
import { MdChecklist, MdHeadphones, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md";
import isElectron from "../../utils/isElectron";
import { useScrollTo } from "../../utils/useScrollTo";
import LoadingOverlay from "../general/LoadingOverlay";
import ListeningTab from "./ListeningTab";
import PracticeTab from "./PracticeTab";
import ReviewTab from "./ReviewTab";
import WatchAndStudyTab from "./WatchAndStudyTab";
const ConversationDetailPage = ({ id, accent, title, onBack }) => {
const { t } = useTranslation();
const { ref: scrollRef, scrollTo } = useScrollTo();
const [activeTab, setActiveTab] = useState("watchStudyTab");
const [loading, setLoading] = useState(true);
const [accentData, setAccentData] = useState(null);
const [videoUrl, setVideoUrl] = useState(null);
const [videoLoading, setVideoLoading] = useState(true);
const [port, setPort] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(
`${import.meta.env.BASE_URL}json/conversation_data.json`
);
const data = await response.json();
// Find the correct conversation data in the array based on the ID
const conversationData = data[id]?.[0];
if (conversationData) {
const accentData = conversationData[accent === "british" ? "BrE" : "AmE"];
setAccentData(accentData); // Set the accent-specific data
setLoading(false);
} else {
console.error("Conversation not found.");
setLoading(false);
return;
}
} catch (error) {
console.error("Error fetching data:", error);
alert(
"Error while loading the data for this section. Please check your Internet connection."
);
}
};
fetchData();
}, [id, accent]);
// Fetch the dynamic port if running in Electron
useEffect(() => {
const fetchPort = async () => {
if (window.electron?.ipcRenderer) {
const dynamicPort = await window.electron.ipcRenderer.invoke("get-port");
setPort(dynamicPort);
}
};
fetchPort();
}, []);
// Use offline file if running in Electron
useEffect(() => {
const fetchVideoUrl = async () => {
if (isElectron() && accentData && port) {
const accentVideoData = accent === "british" ? "GB" : "US";
const videoFileName = accentData.watch_and_study.offlineFile;
const folderName = `iSpeakerReact_ConversationVideos_${accentVideoData}`;
const videoStreamUrl = `http://localhost:${port}/video/${folderName}/${videoFileName}`;
try {
const response = await fetch(videoStreamUrl, { method: "HEAD" });
if (response.ok) {
setVideoUrl(videoStreamUrl);
} else {
throw new Error("Local video file not found");
}
} catch (error) {
console.warn("Falling back to Vimeo due to local video file not found:", error);
setVideoUrl(accentData.watch_and_study.videoLink);
}
setVideoLoading(false);
} else if (accentData && (!isElectron() || port !== null)) {
setVideoUrl(accentData.watch_and_study.videoLink);
setVideoLoading(false);
}
};
fetchVideoUrl();
}, [accentData, accent, port]);
let videoSubtitle = "";
let subtitleUrl = "";
if (accentData) {
videoSubtitle = accentData.watch_and_study.subtitle;
subtitleUrl = `${import.meta.env.BASE_URL}media/conversation/subtitles/${accent === "british" ? "gb" : "us"}/${videoSubtitle}`;
}
return (
<>
{t("conversationPage.topicHeading")} {t(title)}
{t("accent.accentSettings")}:{" "}
{t(accent === "british" ? "accent.accentBritish" : "accent.accentAmerican")}
{t("buttonConversationExam.conversationBackBtn")}
{loading || videoLoading ? (
) : (
<>
{activeTab === "watchStudyTab" && (
)}
{activeTab === "listenTab" && (
)}
{activeTab === "practiceTab" && (
)}
{activeTab === "reviewTab" && (
)}
>
)}
>
);
};
ConversationDetailPage.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
accent: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
};
export default ConversationDetailPage;
================================================
FILE: src/components/conversation_page/ConversationMenu.jsx
================================================
import PropTypes from "prop-types";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import Container from "../../ui/Container";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import isElectron from "../../utils/isElectron";
import AccentDropdown from "../general/AccentDropdown";
import LoadingOverlay from "../general/LoadingOverlay";
import TopNavBar from "../general/TopNavBar";
const ConversationDetailPage = lazy(() => import("./ConversationDetailPage"));
const ConversationListPage = () => {
const { t } = useTranslation();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedAccent, setSelectedAccent] = AccentLocalStorage();
const [selectedConversation, setSelectedConversation] = useState(null);
// Modal state
const modalRef = useRef(null);
const [modalInfo, setModalInfo] = useState(null);
// Handle showing the modal
const handleShowModal = (info) => {
setModalInfo(info);
if (modalRef.current) {
modalRef.current.showModal();
}
};
// Handle closing the modal
const handleCloseModal = () => {
if (modalRef.current) {
modalRef.current.close();
}
setModalInfo(null);
};
const handleSelectConversation = (id, title) => {
const selected = data.find((section) => section.titles.some((item) => item.id === id));
if (selected) {
setSelectedConversation({
id,
title,
heading: selected.heading,
});
}
};
const TooltipIcon = ({ info, onClick }) => (
<>
{/* Tooltip for larger screens */}
{/* Modal trigger button for small screens */}
>
);
TooltipIcon.propTypes = {
info: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const ConversationCard = ({ heading, titles, onShowModal }) => (
{t(heading)}
{titles.map(({ title, id, info }, index) => (
))}
);
ConversationCard.propTypes = {
heading: PropTypes.string.isRequired,
titles: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
info: PropTypes.string.isRequired,
})
).isRequired,
onShowModal: PropTypes.func.isRequired,
};
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// Fetch from network
const response = await fetch(
`${import.meta.env.BASE_URL}json/conversation_list.json`
);
const fetchedData = await response.json();
setData(fetchedData.conversationList);
setLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
alert(
"Error while loading the data for this section. Please check your Internet connection."
);
}
};
fetchData();
}, []);
useEffect(() => {
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
localStorage.setItem("ispeaker", JSON.stringify({ ...storedData, selectedAccent }));
}, [selectedAccent]);
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.conversations")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
return (
<>
{t("navigation.conversations")}
{selectedConversation ? (
}>
setSelectedConversation(null)}
/>
) : (
<>
{t("conversationPage.selectType")}
{loading ? (
) : (
{data.map((section, index) => (
))}
)}
{t("conversationPage.conversationModalInfo")}
{modalInfo}
{t("sound_page.closeBtn")}
>
)}
>
);
};
export default ConversationListPage;
================================================
FILE: src/components/conversation_page/ListeningTab.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
const ListeningTab = ({ sentences }) => {
const { t } = useTranslation();
const [currentAudio, setCurrentAudio] = useState(null);
const [playingIndex, setPlayingIndex] = useState(null);
const [loadingIndex, setLoadingIndex] = useState(null);
const handlePlayPause = (index, audioSrc) => {
if (loadingIndex === index) {
// Cancel the loading process if clicked again
if (abortController.current) {
abortController.current.abort();
}
setLoadingIndex(null);
return;
}
if (playingIndex === index) {
// Stop current audio if the same index is clicked
if (currentAudio) {
currentAudio.pause();
setPlayingIndex(null);
setCurrentAudio(null);
}
} else {
// Stop any currently playing audio
if (currentAudio) {
currentAudio.pause();
}
// Set loading state
setLoadingIndex(index);
// AbortController to manage cancellation
const controller = new AbortController();
abortController.current = controller;
const signal = controller.signal;
// Fetch the audio file
fetch(`${import.meta.env.BASE_URL}media/conversation/mp3/${audioSrc}.mp3`, { signal })
.then((response) => response.blob())
.then((audioBlob) => {
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Explicitly load the audio (for iOS < 17)
audio.load();
audio.oncanplaythrough = () => {
setLoadingIndex(null);
setCurrentAudio(audio);
setPlayingIndex(index);
audio.play();
};
audio.onended = () => {
setCurrentAudio(null);
setPlayingIndex(null);
URL.revokeObjectURL(audioUrl);
};
audio.onerror = () => {
setLoadingIndex(null);
setCurrentAudio(null);
setPlayingIndex(null);
console.log("Audio loading error");
sonnerErrorToast(t("toast.audioPlayFailed"));
};
setCurrentAudio(audio);
})
.catch((error) => {
if (error.name === "AbortError") {
console.log("Audio loading aborted");
} else {
console.error("Error loading audio:", error);
sonnerErrorToast(t("toast.audioPlayFailed"));
}
setLoadingIndex(null);
setCurrentAudio(null);
setPlayingIndex(null);
});
}
};
const abortController = useRef(null);
useEffect(() => {
return () => {
if (abortController.current) {
abortController.current.abort();
}
if (currentAudio) {
currentAudio.pause();
setCurrentAudio(null);
}
};
}, [currentAudio]);
return (
<>
{sentences.map((subtopic, index) => (
))}
>
);
};
ListeningTab.propTypes = {
sentences: PropTypes.array.isRequired,
};
export default ListeningTab;
================================================
FILE: src/components/conversation_page/PracticeTab.jsx
================================================
import { useEffect, useRef, useState } from "react";
import { BsFloppy, BsPlayCircle, BsRecordCircle, BsStopCircle, BsTrash } from "react-icons/bs";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import {
checkRecordingExists,
openDatabase,
playRecording,
saveRecording,
} from "../../utils/databaseOperations";
import isElectron from "../../utils/isElectron";
import {
sonnerErrorToast,
sonnerSuccessToast,
sonnerWarningToast,
} from "../../utils/sonnerCustomToast";
const PracticeTab = ({ accent, conversationId }) => {
const { t } = useTranslation();
const [textValue, setTextValue] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState(null);
const [isRecordingPlaying, setIsRecordingPlaying] = useState(false);
const [recordingExists, setRecordingExists] = useState(false);
const [currentAudioSource, setCurrentAudioSource] = useState(null); // For AudioContext source node
const [currentAudioElement, setCurrentAudioElement] = useState(null); // For Audio element (fallback)
const textAreaRef = useRef(null);
const textKey = `${accent}-${conversationId}-text`;
const recordingKey = `${accent}-conversation-${conversationId}`;
// Load saved text from IndexedDB
useEffect(() => {
// Load saved text data if exists
const loadText = async () => {
const db = await openDatabase();
const transaction = db.transaction(["conversation_data"]);
const store = transaction.objectStore("conversation_data");
const request = store.get(textKey);
request.onsuccess = () => {
if (request.result && request.result.text) {
setTextValue(request.result.text);
}
};
};
loadText();
}, [textKey]);
// Check if the recording exists in IndexedDB
useEffect(() => {
const checkIfRecordingExists = async () => {
const exists = await checkRecordingExists(recordingKey);
setRecordingExists(exists);
};
checkIfRecordingExists();
}, [recordingKey]);
// Auto-expand text area
const autoExpand = () => {
const textArea = textAreaRef.current;
if (textArea) {
textArea.style.height = "auto"; // Reset height to calculate new height
textArea.style.height = `${textArea.scrollHeight}px`; // Set height to the new calculated height
}
};
// Trigger auto-expand on text change
useEffect(() => {
autoExpand();
}, [textValue]);
// Save text to IndexedDB
const handleSaveText = async () => {
try {
const db = await openDatabase();
const transaction = db.transaction(["conversation_data"], "readwrite");
const store = transaction.objectStore("conversation_data");
const request = store.put({ id: textKey, text: textValue });
request.onsuccess = () => {
sonnerSuccessToast(t("toast.textSaveSuccess"));
};
request.onerror = (error) => {
isElectron() && window.electron.log("error", `Error saving text: ${error}`);
sonnerErrorToast(t("toast.textSaveFailed") + error.message);
};
} catch (error) {
console.error("Error saving text: ", error);
isElectron() && window.electron.log("error", `Error saving text: ${error}`);
}
};
// Clear text from IndexedDB
const handleClearText = async () => {
try {
const db = await openDatabase();
const transaction = db.transaction(["conversation_data"], "readwrite");
const store = transaction.objectStore("conversation_data");
const request = store.delete(textKey);
request.onsuccess = () => {
setTextValue("");
sonnerSuccessToast(t("toast.textClearSuccess"));
};
request.onerror = (error) => {
sonnerErrorToast(t("toast.textClearFailed") + error.message);
isElectron() && window.electron.log("error", `Error clearing text: ${error}`);
};
} catch (error) {
console.error("Error clearing text: ", error);
isElectron() && window.electron.log("error", `Error clearing text: ${error}`);
sonnerErrorToast(t("toast.textClearFailed") + error.message);
}
};
// Handle recording
const handleRecording = () => {
if (!isRecording) {
// Start recording
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
const recordOptions = {
audioBitsPerSecond: 128000,
};
const mediaRecorder = new MediaRecorder(stream, recordOptions);
let audioChunks = [];
mediaRecorder.start();
setIsRecording(true);
setMediaRecorder(mediaRecorder);
mediaRecorder.addEventListener("dataavailable", (event) => {
audioChunks.push(event.data);
if (mediaRecorder.state === "inactive") {
const audioBlob = new Blob(audioChunks, { type: event.data.type });
saveRecording(audioBlob, recordingKey, event.data.type);
sonnerSuccessToast(t("toast.recordingSuccess"));
isElectron() &&
window.electron.log("log", `Recording saved: ${recordingKey}`);
setRecordingExists(true);
audioChunks = [];
}
});
// Auto-stop after 15 minutes
setTimeout(
() => {
if (mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
sonnerWarningToast(t("toast.recordingExceeded"));
setIsRecording(false);
}
},
15 * 60 * 1000
);
})
.catch((error) => {
sonnerErrorToast(t("toast.recordingFailed") + error.message);
isElectron() && window.electron.log("error", `Recording failed: ${error}`);
});
} else {
// Stop recording
mediaRecorder.stop();
setIsRecording(false);
}
};
// Handle playback
const handlePlayRecording = async () => {
if (isRecordingPlaying) {
if (currentAudioSource) {
currentAudioSource.stop();
setCurrentAudioSource(null);
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
setCurrentAudioElement(null);
}
setIsRecordingPlaying(false);
} else {
playRecording(
recordingKey,
(audio, audioSource) => {
setIsRecordingPlaying(true);
if (audioSource) {
setCurrentAudioSource(audioSource);
} else {
setCurrentAudioElement(audio);
}
},
(error) => {
sonnerErrorToast(t("toast.playbackError") + error.message);
isElectron() && window.electron.log("error", `Error saving text: ${error}`);
setIsRecordingPlaying(false);
},
() => {
setIsRecordingPlaying(false);
setCurrentAudioSource(null);
setCurrentAudioElement(null);
}
);
}
};
return (
{t("tabConversationExam.practiceConversationText", { returnObjects: true }).map(
(text, index) => (
{text}
)
)}
{t("tabConversationExam.practiceConversationBox")}
{t("buttonConversationExam.saveBtn")}
{t("buttonConversationExam.clearBtn")}
{t("tabConversationExam.recordSectionText")}
{isRecording ? (
<>
{" "}
{t("buttonConversationExam.stopRecordBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.recordBtn")}
>
)}
{isRecordingPlaying ? (
<>
{" "}
{t("buttonConversationExam.stopPlayBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.playBtn")}
>
)}
);
};
PracticeTab.propTypes = {
accent: PropTypes.string.isRequired,
conversationId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
export default PracticeTab;
================================================
FILE: src/components/conversation_page/ReviewTab.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const ReviewTab = ({ reviews, accent, conversationId }) => {
const { t } = useTranslation();
const [reviewState, setReviewState] = useState({});
const reviewKey = `${accent}-${conversationId}-review`;
// Load saved review states from localStorage
useEffect(() => {
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
const savedReviews = storedData.conversationReview?.[accent] || {};
const initialReviewState = reviews.reduce((acc, review, index) => {
const key = `${reviewKey}${index + 1}`;
acc[index + 1] = savedReviews[key] || false;
return acc;
}, {});
setReviewState(initialReviewState);
}, [accent, conversationId, reviews, reviewKey]);
// Handle checkbox change
const handleCheckboxChange = (index) => {
const newReviewState = { ...reviewState, [index]: !reviewState[index] };
setReviewState(newReviewState);
// Load existing data from localStorage
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
const currentAccentData = storedData.conversationReview?.[accent] || {};
// Update the specific review state while preserving the rest of the data
const updatedReviews = {
...currentAccentData,
[`${reviewKey}${index}`]: newReviewState[index],
};
// Save updated data back to localStorage
localStorage.setItem(
"ispeaker",
JSON.stringify({
...storedData,
conversationReview: {
...storedData.conversationReview,
[accent]: updatedReviews,
},
})
);
sonnerSuccessToast(t("toast.reviewUpdated"));
};
return (
{reviews.map((review, index) => (
{t(review.text)}
handleCheckboxChange(index + 1)}
/>
))}
);
};
ReviewTab.propTypes = {
reviews: PropTypes.array.isRequired,
accent: PropTypes.string.isRequired,
conversationId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
export default ReviewTab;
================================================
FILE: src/components/conversation_page/WatchAndStudyTab.jsx
================================================
import { MediaPlayer, MediaProvider, Track } from "@vidstack/react";
import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import PropTypes from "prop-types";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import isElectron from "../../utils/isElectron";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
const WatchAndStudyTab = ({ videoUrl, subtitleUrl, dialog, skillCheckmark }) => {
const { t } = useTranslation();
const [highlightState, setHighlightState] = useState({});
const [iframeLoading, setiFrameLoading] = useState(true);
const { autoDetectedTheme } = useAutoDetectTheme();
const handleIframeLoad = () => setiFrameLoading(false);
// Handle checkbox change
const handleCheckboxChange = (index) => {
setHighlightState((prevState) => ({
...prevState,
[index]: !prevState[index],
}));
};
return (
{t("tabConversationExam.watchCard")}
{isElectron() && videoUrl && videoUrl.startsWith("http://localhost") ? (
) : (
<>
{iframeLoading && (
)}
>
)}
{isElectron() && !videoUrl.startsWith("http://localhost") ? (
{t("alert.alertOnlineVideo")}
) : (
""
)}
{t("tabConversationExam.studyCard")}
{t("tabConversationExam.studyExpandBtn")}
{dialog.map((line, index) => (
{line.speaker}: {" "}
highlightState[p1]
? `${p1 === "1" ? "bg-primary text-primary-content font-semibold" : "bg-secondary text-secondary-content font-semibold"}`
: ""
),
}}
>
))}
{skillCheckmark.map((skill, index) => (
{t(skill.label)}
handleCheckboxChange(index + 1)}
/>
))}
);
};
WatchAndStudyTab.propTypes = {
videoUrl: PropTypes.string.isRequired,
subtitleUrl: PropTypes.string.isRequired,
dialog: PropTypes.array.isRequired,
skillCheckmark: PropTypes.array.isRequired,
};
export default WatchAndStudyTab;
================================================
FILE: src/components/download_page/DownloadPage.jsx
================================================
import { useEffect } from "react";
import { useTranslation, Trans } from "react-i18next";
import Container from "../../ui/Container";
import Footer from "../general/Footer";
import TopNavBar from "../general/TopNavBar";
const DownloadPage = () => {
const { t } = useTranslation();
useEffect(() => {
window.scrollTo(0, 0);
}, []);
useEffect(() => {
document.title = `${t("navigation.download")} | iSpeakerReact v${__APP_VERSION__}`;
}, [t]);
const features = [
{
name: t("downloadPage.featureOfflineText"),
description: t("downloadPage.featureOfflineDesc"),
//icon: CloudArrowUpIcon,
},
{
name: t("downloadPage.featureVideoDownloadText"),
description: t("downloadPage.featureVideoDownloadDesc"),
//icon: LockClosedIcon,
},
{
name: t("downloadPage.featurePronunciationCheckerText"),
description: t("downloadPage.featurePronunciationCheckerDesc"),
desktopOnly: true,
},
{
name: t("downloadPage.featureSameContentText"),
description: t("downloadPage.featureSameContentDesc"),
//icon: LockClosedIcon,
},
];
const faqItems = [
{
questionKey: "faqSystemReqTitle",
answerKey: "faqSystemReqAnswer",
},
{
questionKey: "faqCheckUpdatesTitle",
answerKey: "faqCheckUpdatesAnswer",
},
{
questionKey: "faqIsFreeTitle",
answerKey: "faqIsFreeAnswer",
},
];
return (
<>
{/* Hero Section with Gradient */}
{t("downloadPage.titleMain")}
{t("downloadPage.descriptionMain")}
{t("downloadPage.availablePlatformText")}
{/* Pronunciation Checker Highlight */}
{t("downloadPage.newBadge")}
{t("downloadPage.desktopOnly")}
{t("downloadPage.featurePronunciationCheckerText")}
{t("downloadPage.featurePronunciationCheckerDesc")}
window.open(
"https://www.python.org/downloads/",
"_blank"
)
}
/>,
]}
/>
{/* Features */}
{t("downloadPage.featureMain")}
{features.map((feature) => (
{feature.name}
{feature.desktopOnly && (
{t("downloadPage.desktopOnly")}
)}
{" "}
{feature.description}
))}
{/* Screenshot on the right, vertically centered */}
{faqItems.map((item, idx) => (
))}
>
);
};
export default DownloadPage;
================================================
FILE: src/components/exam_page/ExamDetailPage.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoChevronBackOutline, IoInformationCircleOutline } from "react-icons/io5";
import { MdChecklist, MdHeadphones, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md";
import isElectron from "../../utils/isElectron";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import { useScrollTo } from "../../utils/useScrollTo";
import LoadingOverlay from "../general/LoadingOverlay";
import ListeningTab from "./ListeningTab";
import PracticeTab from "./PracticeTab";
import ReviewTab from "./ReviewTab";
import WatchAndStudyTab from "./WatchAndStudyTab";
const ExamDetailPage = ({ id, title, onBack, accent }) => {
const { t } = useTranslation();
const { ref: scrollRef, scrollTo } = useScrollTo();
const [activeTab, setActiveTab] = useState("watchStudyTab");
const [examData, setExamData] = useState(null);
const [loading, setLoading] = useState(true);
const [videoUrl, setVideoUrl] = useState(null);
const [videoLoading, setVideoLoading] = useState(true);
const [port, setPort] = useState(null);
const examMainInfoModal = useRef(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// If not in IndexedDB or running in Electron, fetch from the network
const response = await fetch(
`${import.meta.env.BASE_URL}json/examspeaking_data.json`
);
const data = await response.json();
setExamData(data);
setLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
alert(
"Error while loading the data for this section. Please check your Internet connection."
);
}
};
fetchData();
}, []);
// Fetch the dynamic port if running in Electron
useEffect(() => {
const fetchPort = async () => {
if (window.electron?.ipcRenderer) {
const dynamicPort = await window.electron.ipcRenderer.invoke("get-port");
setPort(dynamicPort);
}
};
fetchPort();
}, []);
// Use offline file if running in Electron
useEffect(() => {
const fetchVideoUrl = async () => {
if (isElectron() && examData && examData[id] && port) {
const videoFileName = examData[id].watch_and_study.offlineFile;
const folderName = "iSpeakerReact_ExamVideos";
const videoStreamUrl = `http://localhost:${port}/video/${folderName}/${videoFileName}`;
try {
// Make a HEAD request to check if the local video file exists
const response = await fetch(videoStreamUrl, { method: "HEAD" });
if (response.ok) {
// If the file exists, set the video URL to the local file
setVideoUrl(videoStreamUrl);
} else if (response.status === 404) {
// If the file doesn't exist, fall back to the Vimeo link
throw new Error("Local video file not found");
}
} catch (error) {
console.warn("Falling back to Vimeo due to local video file not found:", error);
// Fallback to Vimeo video link
setVideoUrl(examData[id].watch_and_study.videoLink);
}
setVideoLoading(false); // Video URL is now loaded (either local or Vimeo)
} else if (examData && examData[id] && (!isElectron() || port !== null)) {
// This is the web case where we simply use the Vimeo link
setVideoUrl(examData[id].watch_and_study.videoLink);
setVideoLoading(false); // Video URL for web (Vimeo or other) is set
}
};
fetchVideoUrl();
}, [examData, id, port]);
// Check if data is still loading
if (loading || videoLoading) {
return ;
}
// Check if examData is available
if (!examData || !examData[id]) {
return sonnerErrorToast(t("toast.loadingError"));
}
const examDetails = examData[id];
const examLocalizedDescArray = t(examDetails.description, { returnObjects: true });
const videoSubtitle = examData[id].watch_and_study.subtitle;
const subtitleUrl = `${import.meta.env.BASE_URL}media/exam/subtitles/${videoSubtitle}`;
return (
<>
{t("tabConversationExam.taskCard")}: {t(title)}
examMainInfoModal.current?.showModal()}
>
{t("accent.accentSettings")}:{" "}
{t(accent === "british" ? "accent.accentBritish" : "accent.accentAmerican")}
{" "}
{t("buttonConversationExam.examBackBtn")}
{activeTab === "watchStudyTab" && (
)}
{activeTab === "listenTab" && (
)}
{activeTab === "practiceTab" && (
)}
{activeTab === "reviewTab" && (
)}
{t("examPage.taskInfo")}
{examLocalizedDescArray.map((desc, index) => (
{desc}
))}
>
);
};
ExamDetailPage.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
title: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
accent: PropTypes.string.isRequired,
};
export default ExamDetailPage;
================================================
FILE: src/components/exam_page/ExamPage.jsx
================================================
import PropTypes from "prop-types";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import Container from "../../ui/Container";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import isElectron from "../../utils/isElectron";
import AccentDropdown from "../general/AccentDropdown";
import LoadingOverlay from "../general/LoadingOverlay";
import TopNavBar from "../general/TopNavBar";
const ExamDetailPage = lazy(() => import("./ExamDetailPage"));
const ExamPage = () => {
const { t } = useTranslation();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedAccent, setSelectedAccent] = AccentLocalStorage();
const [selectedExam, setSelectedExam] = useState(null);
// Ref and state for modal tooltip handling
const modalRef = useRef(null);
const [tooltipContent, setTooltipContent] = useState("");
const handleShowTooltip = (content) => {
setTooltipContent(content);
if (modalRef.current) {
modalRef.current.showModal();
}
};
const handleCloseTooltip = () => {
if (modalRef.current) {
modalRef.current.close();
}
setTooltipContent("");
};
const handleSelectExam = (id, title) => {
const selected = data.find((section) => section.titles.some((item) => item.id === id));
if (selected) {
setSelectedExam({
id,
title,
heading: selected.heading,
});
}
};
const TooltipIcon = ({ exam_popup }) => {
const lines = t(exam_popup, { returnObjects: true });
const tooltipArray = Array.isArray(lines) ? lines : [lines];
const tooltipText = tooltipArray.map((line, index) => {line}
);
return (
<>
{/* Modal trigger button for small screens */}
handleShowTooltip(tooltipText)}
>
>
);
};
TooltipIcon.propTypes = {
exam_popup: PropTypes.string.isRequired,
};
const ExamCard = ({ heading, titles }) => (
{t(heading)}
{titles.map(({ title, exam_popup, id }, index) => (
))}
);
ExamCard.propTypes = {
heading: PropTypes.string.isRequired,
titles: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
exam_popup: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
})
).isRequired,
};
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(
`${import.meta.env.BASE_URL}json/examspeaking_list.json`
);
const fetchedData = await response.json();
setData(fetchedData.examList);
setLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
alert(
"Error while loading the data for this section. Please check your Internet connection."
);
}
};
fetchData();
}, []);
useEffect(() => {
const savedSettings = JSON.parse(localStorage.getItem("ispeaker")) || {};
savedSettings.selectedAccent = selectedAccent;
localStorage.setItem("ispeaker", JSON.stringify(savedSettings));
}, [selectedAccent]);
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.exams")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
return (
<>
{t("navigation.exams")}
{selectedExam ? (
}>
setSelectedExam(null)}
/>
) : (
<>
{t("examPage.selectType")}
{loading ? (
) : (
{data.map((section, index) => (
))}
)}
{/* Tooltip Modal */}
{t("examPage.examModalInfo")}
{tooltipContent}
{t("sound_page.closeBtn")}
>
)}
>
);
};
export default ExamPage;
================================================
FILE: src/components/exam_page/ListeningTab.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
const ListeningTab = ({ subtopicsBre, subtopicsAme, currentAccent }) => {
const { t } = useTranslation();
const [playingIndex, setPlayingIndex] = useState(null);
const [currentAudio, setCurrentAudio] = useState(null);
const [loadingIndex, setLoadingIndex] = useState(null);
const subtopics = currentAccent === "american" ? subtopicsAme : subtopicsBre;
const handlePlayPause = (index, audioSrc) => {
if (loadingIndex === index) {
// Cancel the loading process if clicked again
if (abortController.current) {
abortController.current.abort();
}
setLoadingIndex(null);
return;
}
if (playingIndex === index) {
// Stop current audio if the same index is clicked
if (currentAudio) {
currentAudio.pause();
setPlayingIndex(null);
setCurrentAudio(null);
}
} else {
// Stop any currently playing audio
if (currentAudio) {
currentAudio.pause();
}
// Set loading state
setLoadingIndex(index);
// AbortController to manage cancellation
const controller = new AbortController();
abortController.current = controller;
const signal = controller.signal;
// Fetch the audio file
fetch(`${import.meta.env.BASE_URL}media/exam/mp3/${audioSrc}.mp3`, { signal })
.then((response) => response.blob())
.then((audioBlob) => {
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Explicitly load the audio (for iOS < 17)
audio.load();
audio.oncanplaythrough = () => {
setLoadingIndex(null);
setCurrentAudio(audio);
setPlayingIndex(index);
audio.play();
};
audio.onended = () => {
setCurrentAudio(null);
setPlayingIndex(null);
URL.revokeObjectURL(audioUrl);
};
audio.onerror = () => {
setLoadingIndex(null);
setCurrentAudio(null);
setPlayingIndex(null);
console.log("Audio loading error");
sonnerErrorToast(t("toast.audioPlayFailed"));
};
setCurrentAudio(audio);
})
.catch((error) => {
if (error.name === "AbortError") {
console.log("Audio loading aborted");
} else {
console.error("Error loading audio:", error);
sonnerErrorToast(t("toast.audioPlayFailed"));
}
setLoadingIndex(null);
setCurrentAudio(null);
setPlayingIndex(null);
});
}
};
const abortController = useRef(null);
useEffect(() => {
return () => {
if (abortController.current) {
abortController.current.abort();
}
if (currentAudio) {
currentAudio.pause();
setCurrentAudio(null);
}
};
}, [currentAudio]);
return (
<>
{subtopics.map((subtopic, topicIndex) => (
))}
>
);
};
ListeningTab.propTypes = {
subtopicsBre: PropTypes.array.isRequired,
subtopicsAme: PropTypes.array.isRequired,
currentAccent: PropTypes.string.isRequired,
};
export default ListeningTab;
================================================
FILE: src/components/exam_page/PracticeTab.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsFloppy, BsPlayCircle, BsRecordCircle, BsStopCircle, BsTrash } from "react-icons/bs";
import { IoCheckmark, IoCloseOutline } from "react-icons/io5";
import {
checkRecordingExists,
openDatabase,
playRecording,
saveRecording,
} from "../../utils/databaseOperations";
import isElectron from "../../utils/isElectron";
import {
sonnerErrorToast,
sonnerSuccessToast,
sonnerWarningToast,
} from "../../utils/sonnerCustomToast";
const PracticeTab = ({ accent, examId, taskData, tips }) => {
const { t } = useTranslation();
const [textValues, setTextValues] = useState(() => taskData.map(() => ""));
const [isRecording, setIsRecording] = useState(false);
const [isRecordingPlaying, setIsRecordingPlaying] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState(null);
const [recordingExists, setRecordingExists] = useState(() => taskData.map(() => false));
const [currentAudioSource, setCurrentAudioSource] = useState(null);
const [currentAudioElement, setCurrentAudioElement] = useState(null);
const [activeTaskIndex, setActiveTaskIndex] = useState(null);
const textAreaRefs = useRef([]);
const imageModalRef = useRef(null);
const [imageLoading, setImageLoading] = useState(false);
const [modalImage, setModalImage] = useState("");
// Load saved text from IndexedDB
useEffect(() => {
const loadTextData = async () => {
const db = await openDatabase();
taskData.forEach(async (_, index) => {
const textKey = `${accent}-${examId}-text-${index}`;
const transaction = db.transaction(["exam_data"], "readonly");
const store = transaction.objectStore("exam_data");
const request = store.get(textKey);
request.onsuccess = () => {
if (request.result && request.result.text) {
setTextValues((prev) => {
const updated = [...prev];
updated[index] = request.result.text;
return updated;
});
}
};
});
};
loadTextData();
}, [accent, examId, taskData]);
// Check if recordings exist in IndexedDB
useEffect(() => {
taskData.forEach(async (_, index) => {
const recordingKey = `${accent}-exam-${examId}-${index}`;
const exists = await checkRecordingExists(recordingKey);
setRecordingExists((prev) => {
const updated = [...prev];
updated[index] = exists;
return updated;
});
});
}, [accent, examId, taskData]);
// Auto-expand textarea
const autoExpand = (e) => {
const textArea = textAreaRefs.current[e.target.id];
if (textArea) {
textArea.style.height = "auto"; // Reset height to calculate new height
textArea.style.height = `${textArea.scrollHeight}px`; // Set height to the new calculated height
}
};
// Save text to IndexedDB
const handleSaveText = async (index) => {
const textKey = `${accent}-${examId}-text-${index}`;
try {
const db = await openDatabase();
const transaction = db.transaction(["exam_data"], "readwrite");
const store = transaction.objectStore("exam_data");
const request = store.put({ id: textKey, text: textValues[index] });
request.onsuccess = () => {
sonnerSuccessToast(t("toast.textSaveSuccess"));
};
request.onerror = (error) => {
console.error("Error saving text: ", error);
isElectron() && window.electron.log("error", `Error saving text: ${error}`);
sonnerErrorToast(t("toast.textSaveFailed") + error.message);
};
} catch (error) {
console.error("Error saving text: ", error);
isElectron() && window.electron.log("error", `Error saving text: ${error}`);
}
};
// Clear text from IndexedDB
const handleClearText = async (index) => {
const textKey = `${accent}-${examId}-text-${index}`;
try {
const db = await openDatabase();
const transaction = db.transaction(["exam_data"], "readwrite");
const store = transaction.objectStore("exam_data");
const request = store.delete(textKey);
request.onsuccess = () => {
setTextValues((prev) => {
const updated = [...prev];
updated[index] = "";
return updated;
});
console.log("Text cleared successfully.");
sonnerSuccessToast(t("toast.textClearSuccess"));
};
request.onerror = (error) => {
console.error("Error clearing text: ", error);
isElectron() && window.electron.log("error", `Error clearing text: ${error}`);
sonnerErrorToast(t("toast.textClearFailed") + error.message);
};
} catch (error) {
console.error("Error clearing text: ", error);
isElectron() && window.electron.log("error", `Error clearing text: ${error}`);
sonnerErrorToast(t("toast.textClearFailed") + error.message);
}
};
const handleRecording = (index) => {
if (!isRecording) {
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
const recordOptions = {
audioBitsPerSecond: 128000,
};
const mediaRecorder = new MediaRecorder(stream, recordOptions);
let audioChunks = [];
mediaRecorder.start();
setIsRecording(true);
setMediaRecorder(mediaRecorder);
setActiveTaskIndex(index);
mediaRecorder.addEventListener("dataavailable", (event) => {
audioChunks.push(event.data);
if (mediaRecorder.state === "inactive") {
const audioBlob = new Blob(audioChunks, { type: event.data.type });
const recordingKey = `${accent}-exam-${examId}-${index}`;
saveRecording(audioBlob, recordingKey, event.data.type);
sonnerSuccessToast(t("toast.recordingSuccess"));
isElectron() &&
window.electron.log("log", `Recording saved: ${recordingKey}`);
setRecordingExists((prev) => {
const updatedExists = [...prev];
updatedExists[index] = true;
return updatedExists;
});
audioChunks = [];
}
});
setTimeout(
() => {
if (mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
setIsRecording(false);
setActiveTaskIndex(null);
sonnerWarningToast(t("toast.recordingExceeded"));
}
},
15 * 60 * 1000
); // 15 minutes limit
})
.catch((error) => {
sonnerErrorToast(t("toast.recordingFailed") + error.message);
isElectron() && window.electron.log("error", `Recording failed: ${error}`);
});
} else {
mediaRecorder.stop();
setIsRecording(false);
setActiveTaskIndex(null);
}
};
const handlePlayRecording = (index) => {
if (isRecordingPlaying) {
if (currentAudioSource) {
currentAudioSource.stop();
setCurrentAudioSource(null);
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
setCurrentAudioElement(null);
}
setIsRecordingPlaying(false);
setActiveTaskIndex(null);
} else {
const recordingKey = `${accent}-exam-${examId}-${index}`;
playRecording(
recordingKey,
(audio, audioSource) => {
setIsRecordingPlaying(true);
setActiveTaskIndex(index);
if (audioSource) {
setCurrentAudioSource(audioSource);
} else {
setCurrentAudioElement(audio);
}
},
(error) => {
sonnerErrorToast(t("toast.playbackError") + error.message);
isElectron() && window.electron.log("error", `Error during playback: ${error}`);
setIsRecordingPlaying(false);
setActiveTaskIndex(null);
},
() => {
setIsRecordingPlaying(false);
setActiveTaskIndex(null);
setCurrentAudioSource(null);
setCurrentAudioElement(null);
}
);
}
};
const handleImageClick = (imageName) => {
const newImage = `${import.meta.env.BASE_URL}images/ispeaker/exam_images/fullsize/${imageName}.webp`;
// Only set loading if the image is different
if (modalImage !== newImage) {
setImageLoading(true);
setModalImage(newImage);
}
imageModalRef.current?.showModal();
};
const examTipDoLocalized = t(tips.dos, { returnObjects: true });
const examTipDontLocalized = t(tips.donts, { returnObjects: true });
return (
<>
{taskData.map((task, taskIndex) => {
const examLocalizedPara = t(task.para, { returnObjects: true });
const examLocalizedListItems =
task.listItems && t(task.listItems, { returnObjects: true });
return (
{t("tabConversationExam.taskCard")} {taskIndex + 1}
{task.images.length > 0 && (
{task.images.map((image, index) => (
handleImageClick(image)}
/>
))}
)}
{examLocalizedPara.map((paragraph, index) => (
{paragraph}
))}
{examLocalizedListItems.length > 0 && (
{examLocalizedListItems.map((item, index) => (
{item}
))}
)}
{t("tabConversationExam.practiceExamTextbox")}
handleSaveText(taskIndex)}
disabled={!textValues[taskIndex]}
>
{" "}
{t("buttonConversationExam.saveBtn")}
handleClearText(taskIndex)}
disabled={!textValues[taskIndex]}
>
{" "}
{t("buttonConversationExam.clearBtn")}
{t("tabConversationExam.recordSectionText")}
handleRecording(taskIndex)}
disabled={
isRecordingPlaying ||
(activeTaskIndex !== null &&
activeTaskIndex !== taskIndex)
}
>
{isRecording && activeTaskIndex === taskIndex ? (
<>
{" "}
{t("buttonConversationExam.stopRecordBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.recordBtn")}
>
)}
handlePlayRecording(taskIndex)}
disabled={
isRecording ||
(activeTaskIndex !== null &&
activeTaskIndex !== taskIndex) ||
!recordingExists[taskIndex]
}
>
{isRecordingPlaying && activeTaskIndex === taskIndex ? (
<>
{" "}
{t("buttonConversationExam.stopPlayBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.playBtn")}
>
)}
);
})}
{t("tabConversationExam.tipCardExam")}
{t("tabConversationExam.doCardExam")}
{examTipDoLocalized.map((tip, index) => (
))}
{t("tabConversationExam.dontsCardExam")}
{examTipDontLocalized.map((tip, index) => (
))}
{t("tabConversationExam.imageFullSizeModal")}
{imageLoading && ( // Show skeleton loader if image is loading
)}
{modalImage && (
setImageLoading(false)} // Stop loading when image is loaded
onError={() => setImageLoading(false)} // Handle errors
alt="Full size"
/>
)}
>
);
};
PracticeTab.propTypes = {
accent: PropTypes.string.isRequired,
examId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
taskData: PropTypes.array.isRequired,
tips: PropTypes.object.isRequired,
};
export default PracticeTab;
================================================
FILE: src/components/exam_page/ReviewTab.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const ReviewTab = ({ reviews, examId, accent }) => {
const { t } = useTranslation();
const [checkedReviews, setCheckedReviews] = useState(() => {
// Retrieve saved reviews from ispeaker -> examReview in localStorage
const savedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
return savedData.examReview?.[accent] || {};
});
const handleCheckboxChange = (index) => {
const key = `${examId}-${index}`;
setCheckedReviews((prev) => ({
...prev,
[key]: !prev[key],
}));
sonnerSuccessToast(t("toast.reviewUpdated"));
};
useEffect(() => {
// Retrieve the existing ispeaker data
const savedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
// Update the examReview section
savedData.examReview = savedData.examReview || {};
savedData.examReview[accent] = { ...checkedReviews };
// Save the updated ispeaker data back to localStorage
localStorage.setItem("ispeaker", JSON.stringify(savedData));
}, [checkedReviews, examId, accent]);
return (
{reviews.map((review, index) => (
{t(review.text)}
handleCheckboxChange(index)}
/>
))}
);
};
ReviewTab.propTypes = {
reviews: PropTypes.array.isRequired,
examId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
accent: PropTypes.string.isRequired,
};
export default ReviewTab;
================================================
FILE: src/components/exam_page/WatchAndStudyTab.jsx
================================================
import { MediaPlayer, MediaProvider, Track } from "@vidstack/react";
import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import PropTypes from "prop-types";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoCloseOutline, IoInformationCircleOutline } from "react-icons/io5";
import isElectron from "../../utils/isElectron";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) => {
const { t } = useTranslation();
const [modalImage, setModalImage] = useState("");
const [imageLoading, setImageLoading] = useState(false);
const imageModalRef = useRef(null);
const [iframeLoading, setiFrameLoading] = useState(true);
const { autoDetectedTheme } = useAutoDetectTheme();
const handleImageClick = (imageName) => {
const newImage = `${import.meta.env.BASE_URL}images/ispeaker/exam_images/fullsize/${imageName}.webp`;
// Only set loading if the image is different
if (modalImage !== newImage) {
setImageLoading(true);
setModalImage(newImage);
}
imageModalRef.current?.showModal();
};
const handleIframeLoad = () => setiFrameLoading(false);
// State for highlighting the dialog
const [highlightState, setHighlightState] = useState({
1: false,
2: false,
3: false,
4: false,
5: false,
6: false,
});
const handleCheckboxChange = (index) => {
setHighlightState((prevState) => ({
...prevState,
[index]: !prevState[index],
}));
};
const getHighlightClass = (index) => {
switch (index) {
case 1:
return "font-semibold bg-primary text-primary-content";
case 2:
return "font-semibold bg-secondary text-secondary-content";
case 3:
return "font-semibold bg-accent text-accent-content";
case 4:
return "font-semibold bg-info text-info-content";
case 5:
return "font-semibold bg-error text-error-content";
case 6:
return "font-semibold bg-fuchsia-600 text-white";
default:
return "";
}
};
const getCheckboxHighlightClass = (index) => {
switch (index) {
case 1:
return "checkbox-primary";
case 2:
return "checkbox-secondary";
case 3:
return "checkbox-accent";
case 4:
return "checkbox-info";
case 5:
return "checkbox-error";
case 6:
return "border-fuchsia-600 bg-fuchsia-600 checked:text-white checked:bg-fuchsia-600";
default:
return "";
}
};
const highlightDialog = (speech) => {
return speech.replace(/highlight-dialog-(\d+)/g, (match, p1) => {
const className = getHighlightClass(parseInt(p1, 10));
return highlightState[p1] ? `${className} ${match}` : `${match}`;
});
};
const examTaskQuestion = t(taskData.para, { returnObjects: true });
const examTaskList = taskData.listItems && t(taskData.listItems, { returnObjects: true });
return (
<>
{t("tabConversationExam.taskCard")}
{taskData.images.map((image, index) => (
handleImageClick(image)}
/>
))}
{examTaskQuestion.map((paragraph, index) => (
{paragraph}
))}
{examTaskList && (
{examTaskList.map((item, index) => (
{item}
))}
)}
{t("tabConversationExam.watchCard")}
{isElectron() &&
videoUrl &&
videoUrl.startsWith("http://localhost") ? (
) : (
<>
{iframeLoading && (
)}
>
)}
{isElectron() && !videoUrl.startsWith("http://localhost") ? (
{t("alert.alertOnlineVideo")}
) : (
""
)}
{t("tabConversationExam.studyCard")}
{t("tabConversationExam.studyExpandBtn")}
{dialog.map((line, index) => (
{line.speaker}: {" "}
))}
{skills.map((skill, index) => (
{t(skill.label)}
handleCheckboxChange(index + 1)}
/>
))}
{t("tabConversationExam.imageFullSizeModal")}
{imageLoading && ( // Show skeleton loader if image is loading
)}
{modalImage && (
setImageLoading(false)} // Stop loading when image is loaded
onError={() => setImageLoading(false)} // Handle errors
alt="Full size"
/>
)}
>
);
};
WatchAndStudyTab.propTypes = {
videoUrl: PropTypes.string.isRequired,
subtitleUrl: PropTypes.string.isRequired,
taskData: PropTypes.object.isRequired,
dialog: PropTypes.array.isRequired,
skills: PropTypes.array.isRequired,
};
export default WatchAndStudyTab;
================================================
FILE: src/components/exercise_page/DictationQuiz.jsx
================================================
import _ from "lodash";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiOutlineCheckCircle, AiOutlineCloseCircle } from "react-icons/ai";
import { IoInformationCircleOutline, IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import useCountdownTimer from "../../utils/useCountdownTimer";
const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answer, setAnswer] = useState("");
const [showValidation, setShowValidation] = useState(false);
const [validationVariant, setValidationVariant] = useState("danger");
const [validationMessage, setValidationMessage] = useState("");
const [isTextboxDisabled, setIsTextboxDisabled] = useState(false);
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false);
const [hasAnswered, setHasAnswered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const audioRef = useRef(null);
const { t } = useTranslation();
const filterAndShuffleQuiz = (quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
return _.shuffle(uniqueQuiz);
};
useEffect(() => {
if (quiz?.length > 0) {
setShuffledQuiz(filterAndShuffleQuiz([...quiz]));
}
return () => {
// Cleanup: Stop audio when component unmounts
stopAudio();
};
}, [quiz]);
const stopAudio = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0; // Reset the audio to the start
audioRef.current.removeAttribute("src"); // Remove the audio source attribute
audioRef.current.load(); // Reload the audio element to reset the state
setIsPlaying(false); // Reset the playing state
setIsLoading(false);
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!shuffledQuiz[currentQuestionIndex]) return;
const textboxWord = shuffledQuiz[currentQuestionIndex].words.find((word) => word.textbox);
if (!textboxWord) {
console.error("No textbox found in the current question.");
return;
}
const correctAnswer = textboxWord.textbox.toLowerCase();
const isCorrect = answer.trim().toLowerCase() === correctAnswer;
setIsTextboxDisabled(true);
setIsSubmitButtonEnabled(true);
setHasAnswered(true);
setShowValidation(true);
setValidationVariant(isCorrect ? "success" : "danger");
setValidationMessage(isCorrect ? "" : correctAnswer);
onAnswer(isCorrect, "single");
};
const nextQuestion = () => {
if ((!hasAnswered && answer.trim() === "") || !hasAnswered) {
onAnswer(false, "single");
}
stopAudio();
if (currentQuestionIndex < shuffledQuiz.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
setAnswer("");
setIsTextboxDisabled(false);
setShowValidation(false);
setIsSubmitButtonEnabled(false);
setHasAnswered(false); // Reset for the next question
} else {
onQuit(); // Notify parent that the quiz is finished
stopAudio();
clearTimer();
}
};
const handleNext = () => {
nextQuestion(); // Move to the next question
};
const handleQuit = () => {
onQuit();
stopAudio();
clearTimer();
};
const handleAudioPlay = () => {
if (!shuffledQuiz[currentQuestionIndex]) return; // Safeguard against undefined access
if (isPlaying) {
// Stop the current audio if it's playing
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
return;
}
// Determine the exercise type for the current question
const exerciseType = shuffledQuiz[currentQuestionIndex]?.type;
const audio = new Audio();
const audioSrc =
exerciseType === "sentence"
? `${import.meta.env.BASE_URL}media/exercise/mp3/sentence/${shuffledQuiz[currentQuestionIndex].audio.src}.mp3`
: `${import.meta.env.BASE_URL}media/word/mp3/${shuffledQuiz[currentQuestionIndex].audio.src}.mp3`;
audioRef.current = audio;
audio.src = audioSrc;
setIsLoading(true);
audio.oncanplaythrough = () => {
setIsLoading(false);
setIsPlaying(true);
audio.play();
startTimer();
};
audio.onended = () => {
setIsPlaying(false);
};
audio.onerror = () => {
setIsLoading(false);
console.error("Audio error occurred. Unable to play the audio.");
sonnerErrorToast(t("toast.audioPlayFailed"));
};
audio.onpause = () => {
setIsPlaying(false);
};
audioRef.current.load(); // Load the new audio source
};
const renderWords = () => {
const currentWords = shuffledQuiz[currentQuestionIndex]?.words || [];
// Check if the current word has both `value` and `textbox`
const hasValueAndTextbox =
currentWords.some((w) => w.value) && currentWords.some((w) => w.textbox);
return currentWords.map((word, index) => {
if (word.value) {
return (
{word.value}
);
}
if (word.textbox) {
const isCorrect = answer.trim().toLowerCase() === word.textbox.toLowerCase();
return (
{
setAnswer(e.target.value);
startTimer();
}}
autoComplete="off"
spellCheck="false"
disabled={isTextboxDisabled}
className={`grow text-center ${
hasValueAndTextbox ? "mx-2" : "mx-auto"
} text-black dark:text-slate-200`}
style={hasValueAndTextbox ? { width: "40%" } : {}}
/>
{showValidation && (
<>
{isCorrect ? (
) : (
)}
>
)}
);
}
return null;
});
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{isLoading ? (
) : isPlaying ? (
) : (
)}
{showValidation && validationVariant === "danger" && (
{t("exercise_page.result.correctAnswer")}
{validationMessage}
)}
{" "}
{t("exercise_page.buttons.checkBtn")}
{currentQuestionIndex < quiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
DictationQuiz.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
timer: PropTypes.number.isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default DictationQuiz;
================================================
FILE: src/components/exercise_page/ExerciseDetailPage.jsx
================================================
import _ from "lodash";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsChevronLeft } from "react-icons/bs";
import { PiArrowsCounterClockwise } from "react-icons/pi";
import LoadingOverlay from "../general/LoadingOverlay";
import PropTypes from "prop-types";
// Emoji SVGs import
import seedlingEmoji from "../../emojiSvg/emoji_u1f331.svg";
import partyPopperEmoji from "../../emojiSvg/emoji_u1f389.svg";
import thumbUpEmoji from "../../emojiSvg/emoji_u1f44d.svg";
import flexedBicepsEmoji from "../../emojiSvg/emoji_u1f4aa.svg";
import smilingFaceWithSmilingEyesEmoji from "../../emojiSvg/emoji_u1f60a.svg";
import rocketEmoji from "../../emojiSvg/emoji_u1f680.svg";
import railwayPathEmoji from "../../emojiSvg/emoji_u1f6e4.svg";
// Lazy load the quiz components
const DictationQuiz = lazy(() => import("./DictationQuiz"));
const MatchUp = lazy(() => import("./MatchUp"));
const Reordering = lazy(() => import("./Reordering"));
const SoundAndSpelling = lazy(() => import("./SoundAndSpelling"));
const SortingExercise = lazy(() => import("./SortingExercise"));
const OddOneOut = lazy(() => import("./OddOneOut"));
const Snap = lazy(() => import("./Snap"));
const MemoryMatch = lazy(() => import("./MemoryMatch"));
const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => {
const [instructions, setInstructions] = useState([]);
const [quiz, setQuiz] = useState([]);
const [split] = useState("");
const [quizCompleted, setQuizCompleted] = useState(false);
const [score, setScore] = useState(0);
const [totalAnswered, setTotalAnswered] = useState(0);
const [currentExerciseType, setCurrentExerciseType] = useState("");
const [timer, setTimer] = useState(null);
const [timeIsUp, setTimeIsUp] = useState(false);
const [onMatchFinished, setOnMatchFinished] = useState(false); // Track if all cards in Memory Match are matched
const [isloading, setIsLoading] = useState(true);
const { t } = useTranslation();
const instructionModal = useRef(null);
const getInstructionKey = (exerciseKey, exerciseId) => {
if (exerciseKey === "sound_n_spelling")
return `exercise_page.exerciseInstruction.sound_n_spelling.sound`;
return `exercise_page.exerciseInstruction.${exerciseKey}.${exerciseId}`;
};
const fetchInstructions = useCallback(
(exerciseKey, exerciseId, ipaSound) => {
const instructionKey = getInstructionKey(exerciseKey, exerciseId);
const instructions = t(instructionKey, {
ipaSound: ipaSound || "",
returnObjects: true,
});
return Array.isArray(instructions) ? instructions : []; // Ensure it's always an array
},
[t]
);
// Helper function to handle the exercise data logic (setting quiz, instructions, etc.)
const handleExerciseData = useCallback(
(exerciseDetails, data, exerciseKey) => {
const savedSettings = JSON.parse(localStorage.getItem("ispeaker"));
const fixedTimers = {
memory_match: 4,
snap: 2,
};
const timerValue =
fixedTimers[exerciseKey] ??
((savedSettings?.timerSettings?.enabled === true &&
savedSettings?.timerSettings?.[exerciseKey]) ||
0);
setTimer(timerValue);
let selectedAccentData;
let combinedQuizzes = [];
const ipaSound =
(exerciseKey === "sound_n_spelling" && exerciseDetails.exercise.trim()) || "";
const loadInstructions = fetchInstructions(exerciseKey, id, ipaSound);
if (id === "random") {
data[exerciseKey].forEach((exercise) => {
if (exercise.id !== "random") {
if (exercise.british_american) {
selectedAccentData = exercise.british_american[0];
} else {
selectedAccentData =
accent === "American English"
? exercise.american?.[0]
: exercise.british?.[0];
}
if (selectedAccentData) {
combinedQuizzes.push(
...selectedAccentData.quiz.map((quiz) => ({
...quiz,
split: exercise.split,
type: exercise.type,
}))
);
}
}
});
const uniqueShuffledCombinedQuizzes = _.shuffle(
Array.from(new Set(combinedQuizzes.map(JSON.stringify))).map(JSON.parse)
);
setQuiz(uniqueShuffledCombinedQuizzes);
if (exerciseDetails.british_american) {
selectedAccentData = exerciseDetails.british_american[0];
} else {
selectedAccentData =
accent === "American English"
? exerciseDetails.american?.[0]
: exerciseDetails.british?.[0];
}
setInstructions(loadInstructions || selectedAccentData?.instructions);
} else {
if (exerciseDetails.british_american) {
selectedAccentData = exerciseDetails.british_american[0];
} else {
selectedAccentData =
accent === "American English"
? exerciseDetails.american?.[0]
: exerciseDetails.british?.[0];
}
if (selectedAccentData) {
setInstructions(loadInstructions || selectedAccentData.instructions);
setQuiz(
selectedAccentData.quiz.map((quiz) => ({
...quiz,
split: exerciseDetails.split,
type: exerciseDetails.type,
}))
);
}
}
},
[accent, id, fetchInstructions]
);
const fetchExerciseData = useCallback(async () => {
try {
setIsLoading(true);
// If no cache or in Electron, fetch data from the network
const response = await fetch(`${import.meta.env.BASE_URL}json/${file}`);
if (!response.ok) {
throw new Error("Failed to fetch exercise data");
}
const data = await response.json();
const exerciseKey = file.replace("exercise_", "").replace(".json", "");
const exerciseDetails = data[exerciseKey]?.find((exercise) => exercise.id === id);
// Save fetched data to IndexedDB (excluding Electron)
setCurrentExerciseType(exerciseKey);
if (exerciseDetails) {
handleExerciseData(exerciseDetails, data, exerciseKey);
}
} catch (error) {
console.error("Error fetching exercise data:", error);
alert("Error loading exercise data. Please check your Internet connection.");
} finally {
setIsLoading(false);
}
}, [id, file, handleExerciseData]);
useEffect(() => {
fetchExerciseData();
}, [fetchExerciseData]);
const handleAnswer = (correctCountOrBoolean, quizType = "single", quizAnswerNum = 1) => {
if (quizType === "single") {
// For single answer quizzes like DictationQuiz
setTotalAnswered((prev) => prev + 1);
if (correctCountOrBoolean) {
setScore((prev) => prev + 1);
}
} else if (quizType === "multiple") {
// For multiple answer quizzes like MatchUp
setTotalAnswered((prev) => prev + quizAnswerNum);
setScore((prev) => prev + correctCountOrBoolean);
}
};
const handleQuizQuit = () => {
setQuizCompleted(true);
setTimeIsUp(false);
};
const handleQuizRestart = () => {
setScore(0);
setTotalAnswered(0);
setQuizCompleted(false);
setTimeIsUp(false);
setOnMatchFinished(false);
};
const handleMatchFinished = () => {
setOnMatchFinished(true); // Set match finished to true when all cards are revealed
};
const getEncouragementMessage = () => {
if (totalAnswered === 0)
return (
{t("exercise_page.encouragementMsg.level0")}
);
const percentage = (score / totalAnswered) * 100;
let level;
switch (true) {
case percentage === 100:
level = 6;
break;
case percentage >= 80:
level = 5;
break;
case percentage >= 60:
level = 4;
break;
case percentage >= 40:
level = 3;
break;
case percentage >= 20:
level = 2;
break;
default:
level = 1;
}
const emojis = {
6: partyPopperEmoji,
5: thumbUpEmoji,
4: smilingFaceWithSmilingEyesEmoji,
3: flexedBicepsEmoji,
2: seedlingEmoji,
1: railwayPathEmoji,
};
return (
{t(`exercise_page.encouragementMsg.level${level}`)}
);
};
const encouragementMessage =
quizCompleted && totalAnswered > 0 ? getEncouragementMessage() : null;
const renderQuizComponent = () => {
// Remove "exercise_" prefix and ".json" suffix
const exerciseType = file.replace("exercise_", "").replace(".json", "");
const componentsMap = {
dictation: DictationQuiz,
matchup: MatchUp,
reordering: Reordering,
sound_n_spelling: SoundAndSpelling,
sorting: SortingExercise,
odd_one_out: OddOneOut,
snap: Snap,
memory_match: MemoryMatch,
};
const QuizComponent = componentsMap[exerciseType];
return (
}>
{QuizComponent ? (
) : (
This quiz type is not yet implemented.
)}
);
};
return (
<>
{isloading ? (
) : (
<>
{t(heading)}
{title}
{t("accent.accentSettings")}: {" "}
{accent === "American English"
? t("accent.accentAmerican")
: t("accent.accentBritish")}
{t("exercise_page.buttons.instructionBtn")}
{instructions &&
Array.isArray(instructions) &&
instructions.length > 0 ? (
instructions.map((instruction, index) => (
{instruction}
))
) : (
[Instructions for this type of exercise is not yet
translated. Please update accordingly.]
)}
instructionModal.current?.showModal()}
>
{t("exercise_page.buttons.expandBtn")}
{t("exercise_page.buttons.expandBtn")}
{instructions &&
Array.isArray(instructions) &&
instructions.length > 0 ? (
instructions.map((instruction, index) => (
{instruction}
))
) : (
[Instructions for this type of exercise is not yet
translated. Please update accordingly.]
)}
{" "}
{t("exercise_page.buttons.backBtn")}
{timeIsUp || quizCompleted || onMatchFinished ? (
<>
{t("exercise_page.result.cardHeading")}
{onMatchFinished ? (
{t("exercise_page.result.matchUpFinished")}
) : (
""
)}
{timeIsUp && !onMatchFinished ? (
{t("exercise_page.result.timeUp")}
) : (
""
)}
{score === 0 &&
totalAnswered === 0 &&
currentExerciseType !== "memory_match" ? (
{t("exercise_page.result.notAnswered")}
) : currentExerciseType !== "memory_match" ? (
<>
{t("exercise_page.result.answerResult", {
score,
totalAnswered,
})}
{encouragementMessage}
{t("exercise_page.result.answerBottom")}
>
) : (
{t("exercise_page.result.answerBottom")}
)}
{" "}
{t("exercise_page.buttons.restartBtn")}
>
) : (
<>{renderQuizComponent()}>
)}
{timeIsUp || quizCompleted || currentExerciseType == "memory_match" ? (
""
) : (
{t("sound_page.reviewCard")}
{score === 0 && totalAnswered === 0 ? (
""
) : (
{t("exercise_page.result.answerResult", {
score,
totalAnswered,
})}
)}
{getEncouragementMessage()}
{score === 0 && totalAnswered === 0 ? (
""
) : (
{t("exercise_page.result.tryAgainBottom")}
)}
)}
>
)}
>
);
};
ExerciseDetailPage.propTypes = {
heading: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
title: PropTypes.string.isRequired,
accent: PropTypes.string.isRequired,
file: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
};
export default ExerciseDetailPage;
================================================
FILE: src/components/exercise_page/ExercisePage.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import Container from "../../ui/Container";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import isElectron from "../../utils/isElectron";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import AccentDropdown from "../general/AccentDropdown";
import LoadingOverlay from "../general/LoadingOverlay";
import TopNavBar from "../general/TopNavBar";
import ExerciseDetailPage from "./ExerciseDetailPage";
const ExercisePage = () => {
const { t } = useTranslation();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedAccent, setSelectedAccent] = AccentLocalStorage();
const [selectedExercise, setSelectedExercise] = useState(null);
const modalRef = useRef(null);
const [modalInfo, setModalInfo] = useState(null);
const handleShowModal = (info) => {
setModalInfo(info);
if (modalRef.current) {
modalRef.current.showModal();
}
};
const handleCloseModal = () => {
if (modalRef.current) {
modalRef.current.close();
}
setModalInfo(null);
};
const selectedAccentOptions = [
{ name: "American English", value: "american" },
{ name: "British English", value: "british" },
];
const getLocalizedTitle = (exercise) => {
if (exercise.titleKey) {
return t(exercise.titleKey); // Use localization for keys
}
return exercise.title;
};
const handleSelectExercise = (exercise, heading) => {
setSelectedExercise({
id: exercise.id,
title: getLocalizedTitle(exercise),
accent: selectedAccentOptions.find((item) => item.value === selectedAccent).name,
file: exercise.file,
heading: heading,
});
};
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.exercises")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
const getInfoText = (exercise, defaultInfoKey) => {
// If exercise has a specific infoKey, use it. Otherwise, use the general infoKey.
return exercise.infoKey ? t(exercise.infoKey) : t(defaultInfoKey);
};
const TooltipIcon = ({ info, onClick }) => {
return (
<>
{/* Tooltip for larger screens */}
{/* Modal trigger button for small screens */}
>
);
};
const ExerciseCard = ({ heading, titles, infoKey, file, onShowModal }) => (
{t(heading)}
{titles
.filter(({ american, british }) => {
if (selectedAccent === "american" && american === false) return false;
if (selectedAccent === "british" && british === false) return false;
return true;
})
.map((exercise, index) => {
const modalId = `exerciseInfoModal-${heading}-${index}`;
const info = getInfoText(exercise, infoKey);
return (
);
})}
);
TooltipIcon.propTypes = {
info: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
ExerciseCard.propTypes = {
heading: PropTypes.string.isRequired,
titles: PropTypes.arrayOf(
PropTypes.shape({
american: PropTypes.bool,
british: PropTypes.bool,
titleKey: PropTypes.string,
title: PropTypes.string,
infoKey: PropTypes.string,
id: PropTypes.any,
file: PropTypes.any,
})
).isRequired,
infoKey: PropTypes.string.isRequired,
file: PropTypes.any,
onShowModal: PropTypes.func.isRequired,
};
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// If not in IndexedDB or running in Electron, fetch from the network
const response = await fetch(`${import.meta.env.BASE_URL}json/exercise_list.json`);
const data = await response.json();
setData(data.exerciseList);
setLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
sonnerErrorToast(t("toast.dataLoadFailed"));
}
};
fetchData();
}, [t]);
useEffect(() => {
// Save the selected accent to localStorage
const savedSettings = JSON.parse(localStorage.getItem("ispeaker")) || {};
savedSettings.selectedAccent = selectedAccent;
localStorage.setItem("ispeaker", JSON.stringify(savedSettings));
}, [selectedAccent]);
const handleGoBack = () => {
setSelectedExercise(null);
};
return (
<>
{t("navigation.exercises")}
{selectedExercise ? (
) : (
<>
{t("exercise_page.exerciseSubheading")}
{loading ? (
) : (
{data.map((section, index) => (
))}
)}
{t("exercise_page.modalInfoHeader")}
{modalInfo}
>
)}
>
);
};
export default ExercisePage;
================================================
FILE: src/components/exercise_page/MatchUp.jsx
================================================
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import useCountdownTimer from "../../utils/useCountdownTimer";
import SortableWord from "./SortableWord";
const MatchUp = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [shuffledWords, setShuffledWords] = useState([]);
const [audioItems, setAudioItems] = useState([]);
const [isPlaying, setIsPlaying] = useState(null);
const [isLoading, setIsLoading] = useState([]);
const [isCorrectArray, setIsCorrectArray] = useState([]);
const [buttonsDisabled, setButtonsDisabled] = useState(false);
const [originalPairs, setOriginalPairs] = useState([]);
const [activeId, setActiveId] = useState(null);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const [exerciseType, setExerciseType] = useState("");
const audioRef = useRef(null);
const { t } = useTranslation();
const filterAndShuffleQuiz = useCallback((quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
return ShuffleArray(uniqueQuiz);
}, []);
const loadQuiz = useCallback((quizData) => {
setExerciseType(quizData.type);
// Store the original pairs for checking answers
const pairs = quizData.audio.map((audio, index) => ({
audio: audio.src.split("_")[0].toLowerCase(),
word: quizData.words[index].text.toLowerCase(),
}));
setOriginalPairs(pairs);
// Generate unique IDs for each word
const wordsWithIds = quizData.words.map((word, index) => ({
...word,
id: `${word.text}-${index}-${Math.random().toString(36).substring(2, 11)}`,
}));
// Shuffle words and audio independently
const shuffledWordsArray = ShuffleArray(wordsWithIds);
const shuffledAudioArray = ShuffleArray(quizData.audio);
setShuffledWords(shuffledWordsArray);
setAudioItems(shuffledAudioArray);
setIsCorrectArray(new Array(shuffledWordsArray.length).fill(null));
setButtonsDisabled(false);
}, []);
useEffect(() => {
if (quiz?.length > 0) {
setShuffledQuiz(filterAndShuffleQuiz(quiz));
setcurrentQuestionIndex(0);
}
}, [quiz, filterAndShuffleQuiz]);
useEffect(() => {
if (shuffledQuiz.length > 0 && currentQuestionIndex < shuffledQuiz.length) {
loadQuiz(shuffledQuiz[currentQuestionIndex]);
setIsLoading(new Array(shuffledQuiz[currentQuestionIndex].audio.length).fill(false));
}
}, [shuffledQuiz, currentQuestionIndex, loadQuiz]);
useEffect(() => {
// Initialize the audioRef
if (!audioRef.current) {
audioRef.current = new Audio();
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
// Prevent scrolling while dragging on touch devices
preventScrolling: true,
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleAudioPlay = (src, index) => {
// Pause the current audio if it's playing
if (isPlaying !== null) {
audioRef.current.pause();
setIsPlaying(null);
}
// Reset all loading states before setting the new one
setIsLoading((prev) => prev.map(() => false));
// Start the new audio if it's not already playing
if (isPlaying !== index) {
const audioSrc =
exerciseType === "comprehension" || exerciseType === "sentence"
? `${import.meta.env.BASE_URL}media/exercise/mp3/sentence/${src}.mp3`
: `${import.meta.env.BASE_URL}media/word/mp3/${src}.mp3`;
audioRef.current.src = audioSrc; // Set the new audio source
// Set loading state for this specific button
setIsLoading((prev) => {
const newLoadingState = [...prev];
newLoadingState[index] = true;
return newLoadingState;
});
audioRef.current.play().then(() => {
setIsPlaying(index);
startTimer();
// Disable loading state for this specific button
setIsLoading((prev) => {
const newLoadingState = [...prev];
newLoadingState[index] = false;
return newLoadingState;
});
});
audioRef.current.onended = () => setIsPlaying(null);
audioRef.current.onerror = () => {
setIsPlaying(null);
// Disable loading state in case of error
setIsLoading((prev) => {
const newLoadingState = [...prev];
newLoadingState[index] = false;
return newLoadingState;
});
console.error("Error loading the audio file:", audioSrc);
sonnerErrorToast(t("toast.audioPlayFailed"));
};
}
};
const stopAudio = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0; // Reset the audio to the start
audioRef.current.removeAttribute("src"); // Remove the audio source attribute
audioRef.current.load(); // Reload the audio element to reset the state
setIsPlaying(false); // Reset the playing state
setIsLoading(false);
}
};
const handleDragStart = (event) => {
const { active } = event;
setActiveId(active.id);
startTimer();
};
const handleDragEnd = ({ active, over }) => {
setActiveId(null);
if (active.id !== over.id) {
setShuffledWords((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleSubmit = () => {
let correctCount = 0;
const updatedCorrectArray = [...isCorrectArray];
shuffledWords.forEach((word, index) => {
const audioSrc = audioItems[index].src.split("_")[0].toLowerCase();
const wordText = word.text.toLowerCase();
// Compare the shuffled pair with the original pair using the word text
const isCorrect = originalPairs.some(
(pair) => pair.audio === audioSrc && pair.word === wordText
);
updatedCorrectArray[index] = isCorrect;
if (isCorrect) {
correctCount++;
}
});
setIsCorrectArray(updatedCorrectArray);
setButtonsDisabled(true);
onAnswer(correctCount, "multiple", shuffledWords.length);
};
const handleNextQuiz = () => {
if (!buttonsDisabled) {
const updatedCorrectArray = new Array(shuffledWords.length).fill(false);
setIsCorrectArray(updatedCorrectArray);
onAnswer(0, "multiple", shuffledWords.length);
}
// Reset audio state and element
stopAudio();
if (currentQuestionIndex < shuffledQuiz.length - 1) {
setcurrentQuestionIndex(currentQuestionIndex + 1);
} else {
onQuit();
stopAudio();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
stopAudio();
clearTimer();
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{/* Audio Buttons Column */}
{audioItems.map((audio, index) => (
handleAudioPlay(audio.src, index)}
disabled={isLoading[index]}
>
{isLoading[index] ? (
) : isPlaying === index ? (
) : (
)}
))}
{/* Sortable Words Column */}
word.id)}
strategy={verticalListSortingStrategy}
>
{shuffledWords.map((word, index) => (
))}
{activeId ? (
item.id === activeId
)?.text,
}}
isOverlay={true} // Pass a prop to indicate it's in the overlay
/>
) : null}
{" "}
{t("exercise_page.buttons.checkBtn")}
{currentQuestionIndex < shuffledQuiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
MatchUp.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
timer: PropTypes.number.isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default MatchUp;
================================================
FILE: src/components/exercise_page/MemoryMatch.jsx
================================================
import he from "he";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
import { LiaTimesCircle } from "react-icons/lia";
import "../../styles/memory-card.css";
import { ShuffleArray } from "../../utils/ShuffleArray";
import useCountdownTimer from "../../utils/useCountdownTimer";
const MemoryMatch = ({ quiz, timer, onQuit, setTimeIsUp, onMatchFinished }) => {
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [flippedCards, setFlippedCards] = useState([]);
const [matchedCards, setMatchedCards] = useState([]);
const [, setIsChecking] = useState(false);
const [, setFeedback] = useState(null);
const [cardFeedback, setCardFeedback] = useState({});
const [hasStarted, setHasStarted] = useState(false);
// Use the countdown timer
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
// Function to randomly pick 8 pairs from the entire quiz data
const prepareQuiz = useCallback((quizData) => {
// Step 1: Group pairs by their `value` within each object and assign a unique `id`
let idCounter = 0; // To assign a unique identifier to each pair
const groupedPairs = quizData.reduce((acc, quizItem) => {
// Iterate over each item in the `data` array of each `quizItem`
for (let i = 0; i < quizItem.data.length; i += 2) {
const value = quizItem.data[i].value;
const pair = quizItem.data.filter((item) => item.value === value).slice(0, 2); // Only take the first two
if (pair.length === 2) {
acc.push({ id: idCounter++, pair }); // Add each pair with a unique `id`
}
}
return acc;
}, []);
// Step 2: Shuffle the combined grouped pairs to randomize them
const shuffledPairs = ShuffleArray([...groupedPairs]);
// Step 3: Keep track of selected texts to avoid duplicates (but allow the same `value` across different pairs)
const selectedTexts = new Set();
const selectedPairs = [];
// Step 4: Loop through the shuffled pairs and select 8 pairs, while filtering out pairs with duplicate texts
for (const { id, pair } of shuffledPairs) {
const [first, second] = pair;
// Check if either text has already been used
if (!selectedTexts.has(first.text) && !selectedTexts.has(second.text)) {
// Add both texts to the selectedTexts set
selectedTexts.add(first.text);
selectedTexts.add(second.text);
// Add the pair (with its id) to the selectedPairs array
selectedPairs.push({ id, pair });
// Stop once we have 8 pairs
if (selectedPairs.length === 8) break;
}
}
// Step 5: Duplicate and shuffle the selected pairs to form the memory match cards
const quizCards = ShuffleArray(
selectedPairs.flatMap(({ id, pair }) => [
{ ...pair[0], id: id * 2 }, // Create a unique id for each card based on the pair's unique `id`
{ ...pair[1], id: id * 2 + 1 }, // Duplicate the matching pair with a different id
])
);
// Step 6: Set the state with the new set of shuffled cards
setShuffledQuiz(quizCards);
setFlippedCards([]);
setMatchedCards([]);
setFeedback(null);
}, []);
// Prepare quiz data when the component loads
useEffect(() => {
if (quiz && quiz.length > 0) {
prepareQuiz(quiz);
}
}, [quiz, prepareQuiz]);
// Handle card click logic
const handleCardClick = (card) => {
if (!hasStarted) {
setHasStarted(true);
startTimer(); // Start the timer immediately on the first card click
}
if (
flippedCards.length < 2 &&
!flippedCards.includes(card.id) &&
!matchedCards.includes(card.id)
) {
setFlippedCards((prev) => [...prev, card.id]);
}
};
// Logic to check matching cards
useEffect(() => {
if (flippedCards.length === 2) {
setIsChecking(true);
setTimeout(() => {
const [firstCardId, secondCardId] = flippedCards;
const firstCard = shuffledQuiz.find((card) => card.id === firstCardId);
const secondCard = shuffledQuiz.find((card) => card.id === secondCardId);
// Compare using the pair's `id` instead of the `value`
if (Math.floor(firstCard.id / 2) === Math.floor(secondCard.id / 2)) {
// Cards belong to the same pair, so it's a match
setMatchedCards((prev) => [...prev, firstCardId, secondCardId]);
setCardFeedback((prev) => ({
...prev,
[firstCardId]: "correctPair",
[secondCardId]: "correctPair",
}));
} else {
// Cards do not belong to the same pair
setCardFeedback((prev) => ({
...prev,
[firstCardId]: "incorrectPair",
[secondCardId]: "incorrectPair",
}));
}
setTimeout(() => {
setFlippedCards([]);
setIsChecking(false);
setCardFeedback((prev) => {
// Clear feedback only for incorrect pairs
const updatedFeedback = { ...prev };
delete updatedFeedback[firstCardId];
delete updatedFeedback[secondCardId];
return updatedFeedback;
});
}, 1000);
}, 900);
}
}, [flippedCards, shuffledQuiz]);
// Check if all matches are completed
useEffect(() => {
if (matchedCards.length === shuffledQuiz.length && shuffledQuiz.length > 0) {
clearTimer();
setTimeout(() => {
if (onMatchFinished) onMatchFinished();
}, 2000);
}
}, [matchedCards, shuffledQuiz, onMatchFinished, clearTimer]);
// Handle quit action
const handleQuit = () => {
clearTimer();
onQuit();
};
return (
<>
{t("exercise_page.memoryMatchHeading")}
{t("exercise_page.timer")} {formatTime()}
{shuffledQuiz.map((card) => (
handleCardClick(card)}
className={`memory-card ${
flippedCards.includes(card.id) ||
matchedCards.includes(card.id)
? "flipped"
: ""
} ${
cardFeedback[card.id] === "correctPair"
? "bg-success text-success-content z-50 rounded-lg"
: cardFeedback[card.id] === "incorrectPair"
? "bg-error text-error-content z-50 rounded-lg"
: ""
}`}
>
{he.decode(card.text)}
{cardFeedback[card.id] === "correctPair" && (
)}
{cardFeedback[card.id] === "incorrectPair" && (
)}
))}
{t("exercise_page.buttons.quitBtn")}
>
);
};
MemoryMatch.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
timer: PropTypes.number.isRequired,
onQuit: PropTypes.func.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
onMatchFinished: PropTypes.func.isRequired,
};
export default MemoryMatch;
================================================
FILE: src/components/exercise_page/OddOneOut.jsx
================================================
import he from "he";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import useCountdownTimer from "../../utils/useCountdownTimer";
const OddOneOut = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [shuffledOptions, setShuffledOptions] = useState([]);
const [selectedOption, setSelectedOption] = useState(null);
const [buttonsDisabled, setButtonsDisabled] = useState(false);
const [submitted, setSubmitted] = useState(false);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
useEffect(() => {
if (timer > 0) {
startTimer();
}
}, [timer, startTimer]);
const filterAndShuffleQuiz = useCallback((quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
return ShuffleArray(uniqueQuiz);
}, []);
const loadQuiz = useCallback((quizData) => {
const shuffledOptions = ShuffleArray([...quizData.data]);
setShuffledOptions(shuffledOptions);
setSelectedOption(null);
setButtonsDisabled(false);
setSubmitted(false);
}, []);
useEffect(() => {
if (quiz && quiz.length > 0) {
const shuffledQuizArray = filterAndShuffleQuiz([...quiz]);
setShuffledQuiz(shuffledQuizArray);
loadQuiz(shuffledQuizArray[currentQuestionIndex]);
}
}, [quiz, currentQuestionIndex, loadQuiz, filterAndShuffleQuiz]);
const handleOptionClick = (index) => {
setSelectedOption(index);
};
const handleSubmit = () => {
if (selectedOption === null) return; // Ensure an option is selected
const isCorrect = shuffledOptions[selectedOption].answer === "true";
setButtonsDisabled(true);
setSubmitted(true);
onAnswer(isCorrect ? 1 : 0, "single");
};
const handleNextQuiz = () => {
if (currentQuestionIndex < shuffledQuiz.length - 1) {
setcurrentQuestionIndex((prevIndex) => prevIndex + 1);
} else {
onQuit();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
clearTimer();
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{shuffledOptions.map((option, index) => (
handleOptionClick(index)}
>
{he.decode(option.value)}
{submitted ? (
submitted && option.answer === "true" ? (
) : (
)
) : (
""
)}
))}
{" "}
{t("exercise_page.buttons.checkBtn")}
{currentQuestionIndex < quiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
OddOneOut.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
timer: PropTypes.number.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default OddOneOut;
================================================
FILE: src/components/exercise_page/Reordering.jsx
================================================
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import he from "he";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline, IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import useCountdownTimer from "../../utils/useCountdownTimer";
import SortableWord from "./SortableWord";
const Reordering = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [shuffledItems, setShuffledItems] = useState([]);
const [activeId, setActiveId] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false); // Loading state for audio
const [buttonsDisabled, setButtonsDisabled] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [correctAnswer, setCorrectAnswer] = useState("");
const [currentSplitType, setCurrentSplitType] = useState("");
const [currentAudioSrc, setCurrentAudioSrc] = useState("");
const [shuffledQuizArray, setShuffledQuizArray] = useState([]);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
const filterAndShuffleQuiz = (quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
// Shuffle the unique items
return ShuffleArray(uniqueQuiz);
};
// Use a ref to manage the audio element
const audioRef = useRef(null);
const loadQuiz = useCallback((quizData) => {
const splitType = quizData.split;
setCurrentSplitType(splitType);
const pairedData = quizData.data.map((item, index) => ({
word: item.value,
audio: quizData.audio.src,
answer: quizData.answer[index],
}));
const shuffledPairs = ShuffleArray(pairedData);
const shuffledWords = shuffledPairs.map((pair) => pair.word);
const correctAnswer = shuffledPairs
.map((pair) => pair.answer)
.join(splitType === "sentence" ? " " : "");
let itemsToShuffle;
if (splitType === "sentence") {
itemsToShuffle = shuffledWords[0].split(" "); // Split into words for sentences
} else if (splitType === "word") {
itemsToShuffle = shuffledWords[0].split(""); // Split into letters for words
} else {
console.warn("Unexpected split type, defaulting to splitting by letters");
itemsToShuffle = shuffledWords[0].split("");
}
const uniqueItems = generateUniqueItems(itemsToShuffle);
const shuffledItemsArray = ShuffleArray(uniqueItems);
setShuffledItems(shuffledItemsArray);
setButtonsDisabled(false);
setCurrentAudioSrc(
splitType === "sentence"
? `${import.meta.env.BASE_URL}media/exercise/mp3/sentence/${shuffledPairs[0].audio}.mp3`
: `${import.meta.env.BASE_URL}media/word/mp3/${shuffledPairs[0].audio}.mp3`
);
setCorrectAnswer(correctAnswer);
}, []);
// Clean up the audio element
const stopAudio = () => {
if (audioRef.current) {
// Stop the audio and remove event listeners
audioRef.current.pause();
audioRef.current.currentTime = 0; // Reset the audio to the beginning
// Remove event listeners to prevent them from triggering after the source is cleared
audioRef.current.oncanplaythrough = null;
audioRef.current.onended = null;
audioRef.current.onerror = null;
setIsPlaying(false);
setIsLoading(false);
}
};
useEffect(() => {
if (quiz && quiz.length > 0) {
// Filter out unique items and shuffle the quiz array
const uniqueShuffledQuiz = filterAndShuffleQuiz(quiz);
setShuffledQuizArray(uniqueShuffledQuiz);
// Reset currentQuestionIndex to 0
setcurrentQuestionIndex(0);
}
}, [quiz]);
useEffect(() => {
if (shuffledQuizArray.length > 0 && currentQuestionIndex < shuffledQuizArray.length) {
loadQuiz(shuffledQuizArray[currentQuestionIndex]);
}
}, [shuffledQuizArray, currentQuestionIndex, loadQuiz]);
useEffect(() => {
return () => {
stopAudio();
};
}, []);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
preventScrolling: true,
})
);
const generateUniqueItems = (items) => {
return items.map((item, index) => ({
id: `${item}-${index}-${Math.random().toString(36).substr(2, 9)}`,
value: item,
}));
};
const handleAudioPlay = () => {
if (!audioRef.current) {
// Initialize the audio element if not already set
audioRef.current = new Audio();
}
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
setIsLoading(true); // Start loading
// Set the audio source and load the audio
audioRef.current.src = currentAudioSrc;
audioRef.current.load();
// Play the audio once it's ready
audioRef.current.oncanplaythrough = () => {
setIsLoading(false); // Stop loading spinner
audioRef.current.play();
setIsPlaying(true);
startTimer();
};
// Handle the audio ended event
audioRef.current.onended = () => {
setIsPlaying(false);
};
// Handle errors during loading
audioRef.current.onerror = () => {
setIsLoading(false); // Stop loading spinner
setIsPlaying(false); // Reset playing state
console.error("Error playing audio.");
sonnerErrorToast(t("toast.audioPlayFailed"));
};
}
};
const handleDragStart = (event) => {
const { active } = event;
setActiveId(active.id);
startTimer();
};
const handleDragEnd = ({ active, over }) => {
setActiveId(null);
if (over && active.id !== over.id) {
setShuffledItems((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleSubmit = () => {
const splitType = currentSplitType;
// Join the shuffled items into a single string
let joinedItems = shuffledItems
.map((item) => item.value)
.join(splitType === "sentence" ? " " : "");
// Normalize user answer
let normalizedUserAnswer = joinedItems
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "") // Remove punctuation
.replace(/\s{2,}/g, " ") // Replace multiple spaces with a single space
.trim()
.toLowerCase();
// Normalize correct answer
let normalizedCorrectAnswer = correctAnswer
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "") // Remove punctuation
.replace(/\s{2,}/g, " ") // Replace multiple spaces with a single space
.trim()
.toLowerCase();
// Determine if the user's answer is correct
const isCorrect = normalizedCorrectAnswer === normalizedUserAnswer;
setButtonsDisabled(true);
// Determine the final display text
const finalDisplay = isCorrect && splitType === "sentence" ? correctAnswer : joinedItems;
// Update the shuffledItems to display either the user input or the correct answer
setShuffledItems([{ value: finalDisplay, id: "united", isCorrect }]);
// Show the correct answer alert if the user's answer was incorrect
if (!isCorrect) {
setShowAlert(true);
}
onAnswer(isCorrect ? 1 : 0, "single");
};
const handleNextQuiz = () => {
if (!buttonsDisabled) {
onAnswer(0, "single");
}
stopAudio();
if (currentQuestionIndex < shuffledQuizArray.length - 1) {
setcurrentQuestionIndex(currentQuestionIndex + 1);
setShowAlert(false);
loadQuiz(shuffledQuizArray[currentQuestionIndex + 1]);
} else {
onQuit();
stopAudio();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
stopAudio();
clearTimer();
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{isLoading ? (
) : isPlaying ? (
) : (
)}
{shuffledItems.map((item) => (
))}
{activeId ? (
item.id === activeId)
?.value || ""
),
id: activeId,
}}
isOverlay={true}
/>
) : null}
{showAlert && (
{t("exercise_page.result.correctAnswer")}
{correctAnswer}
)}
{" "}
{t("exercise_page.buttons.checkBtn")}
{currentQuestionIndex < quiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
Reordering.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
timer: PropTypes.number.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default Reordering;
================================================
FILE: src/components/exercise_page/Snap.jsx
================================================
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import he from "he";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
import { LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import useCountdownTimer from "../../utils/useCountdownTimer";
const filterAndShuffleQuiz = (quiz) => {
// Remove duplicate questions
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
// Shuffle the quiz questions
const shuffledQuiz = ShuffleArray(uniqueQuiz);
// Shuffle the options (data) within each quiz question
return shuffledQuiz.map((quizItem) => {
return {
...quizItem,
data: ShuffleArray(quizItem.data), // Shuffle the answer options within each question
};
});
};
const Snap = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [currentQuiz, setCurrentQuiz] = useState({});
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [isDropped, setIsDropped] = useState(false);
const [result, setResult] = useState(null);
const [droppedOn, setDroppedOn] = useState(null); // Track where the item is dropped
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
useEffect(() => {
if (timer > 0) {
startTimer();
}
}, [timer, startTimer]);
const loadQuiz = useCallback((quizData) => {
const shuffledOptions = ShuffleArray([...quizData.data]); // Shuffle the options for this specific question
setCurrentQuiz({ ...quizData, data: shuffledOptions }); // Set the current quiz with shuffled options
setIsDropped(false);
setResult(null);
setDroppedOn(null);
}, []);
useEffect(() => {
if (quiz && quiz.length > 0) {
const uniqueShuffledQuiz = filterAndShuffleQuiz(quiz); // Filter and shuffle the quiz
setShuffledQuiz(uniqueShuffledQuiz); // Set the shuffled quiz
setcurrentQuestionIndex(0); // Reset the quiz index to the first one
loadQuiz(uniqueShuffledQuiz[0]); // Load the first question
}
}, [quiz, loadQuiz]);
const handleDragEnd = (event) => {
const { over } = event;
if (!over) return;
const feedbackValue = over.id.toLowerCase(); // The ID is either "yes" or "no", convert to lowercase
const correctAnswer = currentQuiz?.feedbacks
?.find((f) => f.correctAns)
?.correctAns.toLowerCase(); // Normalize correctAns to lowercase
// Check if the drop zone ("Yes" or "No") matches the correct answer
const isCorrect = feedbackValue === correctAnswer;
setResult(isCorrect ? "success" : "danger"); // Show success for correct, danger for incorrect
setIsDropped(true);
setDroppedOn(over.id); // Set the ID of the area where the item was dropped
onAnswer(isCorrect ? 1 : 0, "single");
};
const handleNextQuiz = () => {
if (currentQuestionIndex < shuffledQuiz.length - 1) {
const nextQuizIndex = currentQuestionIndex + 1;
setcurrentQuestionIndex(nextQuizIndex);
loadQuiz(shuffledQuiz[nextQuizIndex]);
} else {
onQuit();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
clearTimer();
};
// Draggable item component
const DraggableItem = ({ isDropped }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useDraggable({
id: "draggable-item",
});
const adjustedTransform = transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined;
const style = {
transform: adjustedTransform,
transition,
touchAction: "none",
cursor: isDropped ? "not-allowed" : isDragging ? "grabbing" : "grab",
WebkitUserDrag: "none",
WebkitTouchCallout: "none",
opacity: 1, // Always keep the item visible
userSelect: "none",
};
return (
{t("exercise_page.dragThisItem")}
);
};
// Droppable area component
const DroppableArea = ({ feedback, isDropped, result, droppedOn }) => {
const { isOver, setNodeRef } = useDroppable({
id: feedback?.value || "droppable-area",
});
const showColor = droppedOn === feedback?.value;
const bgColor = showColor
? result === "success"
? "bg-success"
: "bg-error"
: isOver
? ""
: "";
const buttonVariant = result === "success" ? "text-success-content" : "text-error-content";
const buttonText =
result === "success" ? (
<>
{t("exercise_page.snapCorrect")}
>
) : (
<>
{t("exercise_page.snapIncorrect")}
>
);
// Translate "Yes" and "No" strings
const translatedValue =
feedback?.value === "Yes"
? t("exercise_page.snapYes")
: feedback?.value === "No"
? t("exercise_page.snapNo")
: feedback?.value;
return (
{isDropped && droppedOn === feedback?.value ? (
buttonText
) : (
{translatedValue} // Show Yes/No only if item hasn't been dropped
)}
);
};
DraggableItem.propTypes = {
isDropped: PropTypes.bool.isRequired,
};
DroppableArea.propTypes = {
feedback: PropTypes.object.isRequired,
isDropped: PropTypes.bool.isRequired,
result: PropTypes.string,
droppedOn: PropTypes.string,
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{/* Present Word and Phonetic Transcription */}
{he.decode(currentQuiz?.data?.[0]?.value || "")}
{he.decode(currentQuiz?.data?.[1]?.value || "")}
{/* Yes drop zone */}
f.value === "Yes")}
isDropped={isDropped}
result={result}
droppedOn={droppedOn} // Track where the item is dropped
/>
{/* Draggable item */}
{!isDropped && }
{/* No drop zone */}
f.value === "No")}
isDropped={isDropped}
result={result}
droppedOn={droppedOn} // Track where the item is dropped
/>
{currentQuestionIndex < quiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
Snap.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
timer: PropTypes.number.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default Snap;
================================================
FILE: src/components/exercise_page/SortableWord.jsx
================================================
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import he from "he";
import PropTypes from "prop-types";
import React, { useEffect, useState } from "react";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
const SortableWord = ({ word, item, isCorrect, disabled, isOverlay }) => {
const [itemWidth, setItemWidth] = useState(null);
const ref = React.useRef(null);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: word?.id || item?.id, // Handle both word and item cases
});
useEffect(() => {
if (ref.current) {
setItemWidth(ref.current.offsetWidth);
}
}, []);
const style = {
transform: CSS.Transform.toString(transform),
transition,
touchAction: "none",
cursor: disabled ? "not-allowed" : isOverlay ? "grabbing" : "grab",
userSelect: "none",
WebkitUserDrag: "none",
WebkitTouchCallout: "none",
width: isDragging ? itemWidth + 1 : "inherit",
opacity: isDragging ? 0.5 : 1,
};
const btnVariant = isOverlay
? ""
: isCorrect === null
? "btn-outline"
: isCorrect
? "btn-success"
: "btn-error";
const renderTrueFalseIcon = () => {
if (isOverlay || isCorrect === null) return null;
return isCorrect ? (
) : (
);
};
return !disabled ? (
{
setNodeRef(node);
ref.current = node;
}}
style={style}
{...attributes}
{...listeners}
className={`btn btn-lg no-animation break-all transition-none ${btnVariant} text-lg ${item ? "min-w-full" : ""} ${
isDragging && !disabled ? "opacity-50" : ""
} ${disabled ? "pointer-events-none" : ""} ${isOverlay ? "z-2 shadow-lg" : ""}`}
>
{he.decode(word?.text || item?.value)}
{renderTrueFalseIcon()}
) : (
{he.decode(word?.text || item?.value)} {renderTrueFalseIcon()}
);
};
SortableWord.propTypes = {
word: PropTypes.object,
item: PropTypes.object,
isCorrect: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf([null])]),
disabled: PropTypes.bool,
isOverlay: PropTypes.bool,
};
export default SortableWord;
================================================
FILE: src/components/exercise_page/SortingExercise.jsx
================================================
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import he from "he";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import useCountdownTimer from "../../utils/useCountdownTimer";
import SortableWord from "./SortableWord";
const SortingExercise = ({
quiz,
onAnswer,
onQuit,
useHorizontalStrategy = false,
timer,
setTimeIsUp,
}) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [itemsLeft, setItemsLeft] = useState([]);
const [itemsRight, setItemsRight] = useState([]);
const [activeId, setActiveId] = useState(null);
const [buttonsDisabled, setButtonsDisabled] = useState(false);
const [currentTableHeading, setCurrentTableHeading] = useState([]);
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [hasSubmitted, setHasSubmitted] = useState(false);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
useEffect(() => {
if (timer > 0) {
startTimer();
}
}, [timer, startTimer]);
const sensors = useSensors(useSensor(PointerSensor));
const filterAndShuffleQuiz = useCallback((quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
return ShuffleArray(uniqueQuiz);
}, []);
const generateUniqueItems = (items) => {
return items.map((item, index) => ({
...item,
id: `${item.value}-${index}-${Math.random().toString(36).substring(2, 11)}`,
}));
};
const loadQuiz = useCallback((quizData) => {
const shuffledOptions = ShuffleArray([...quizData.rowOptions]);
const uniqueItems = generateUniqueItems(shuffledOptions);
const halfwayPoint = Math.ceil(uniqueItems.length / 2);
const itemsLeft = uniqueItems.slice(0, halfwayPoint);
const itemsRight = uniqueItems.slice(halfwayPoint);
setItemsLeft(itemsLeft);
setItemsRight(itemsRight);
setCurrentTableHeading(quizData.tableHeading);
setButtonsDisabled(false);
setHasSubmitted(false); // Reset submission status for each quiz
}, []);
useEffect(() => {
if (quiz && quiz.length > 0) {
const uniqueShuffledQuiz = filterAndShuffleQuiz(quiz); // Ensure uniqueness and shuffle
setShuffledQuiz(uniqueShuffledQuiz);
setcurrentQuestionIndex(0); // Reset currentQuestionIndex to 0
}
}, [quiz, filterAndShuffleQuiz]);
useEffect(() => {
if (shuffledQuiz.length > 0 && currentQuestionIndex < shuffledQuiz.length) {
loadQuiz(shuffledQuiz[currentQuestionIndex]);
}
}, [shuffledQuiz, currentQuestionIndex, loadQuiz]);
const handleDragStart = (event) => {
setActiveId(event.active.id);
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over) {
setActiveId(null);
return;
}
if (active.id !== over.id) {
// Find the active item
const activeItem =
itemsLeft.find((item) => item.id === active.id) ||
itemsRight.find((item) => item.id === active.id);
if (!activeItem) return;
// Determine if the item is moving between columns
if (itemsLeft.some((item) => item.id === active.id)) {
// Moving from left to right
if (itemsRight.some((item) => item.id === over.id) || itemsRight.length === 0) {
setItemsLeft((prev) => prev.filter((item) => item.id !== active.id));
setItemsRight((prev) => [...prev, activeItem]);
} else {
const oldIndex = itemsLeft.findIndex((item) => item.id === active.id);
const newIndex = itemsLeft.findIndex((item) => item.id === over.id);
setItemsLeft((prev) => arrayMove(prev, oldIndex, newIndex));
}
} else if (itemsRight.some((item) => item.id === active.id)) {
// Moving from right to left
if (itemsLeft.some((item) => item.id === over.id) || itemsLeft.length === 0) {
setItemsRight((prev) => prev.filter((item) => item.id !== active.id));
setItemsLeft((prev) => [...prev, activeItem]);
} else {
const oldIndex = itemsRight.findIndex((item) => item.id === active.id);
const newIndex = itemsRight.findIndex((item) => item.id === over.id);
setItemsRight((prev) => arrayMove(prev, oldIndex, newIndex));
}
}
}
setActiveId(null);
};
const handleSubmit = () => {
const allItems = [...itemsLeft, ...itemsRight];
let correctCount = 0;
allItems.forEach((item) => {
const expectedColumn = item.columnPos;
const actualColumn = itemsLeft.includes(item) ? 1 : 2;
if (expectedColumn === actualColumn) {
correctCount++;
}
});
setButtonsDisabled(true);
setHasSubmitted(true); // Mark the answers as submitted
onAnswer(correctCount, "multiple", allItems.length);
};
const handleNextQuiz = () => {
if (currentQuestionIndex < shuffledQuiz.length - 1) {
setcurrentQuestionIndex((prevIndex) => prevIndex + 1);
} else {
onQuit();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
clearTimer();
};
const sortableStrategy = useHorizontalStrategy
? horizontalListSortingStrategy
: verticalListSortingStrategy;
const SortableColumn = ({
items,
heading,
columnPos,
sortableStrategy,
hasSubmitted,
buttonsDisabled,
t,
}) => (
{heading && (
)}
{items.length > 0 ? (
items.map((item) => (
))
) : (
{t("exercise_page.dropLayer")}
)}
);
SortingExercise.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
useHorizontalStrategy: PropTypes.bool,
timer: PropTypes.number.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
SortableColumn.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
heading: PropTypes.object,
columnPos: PropTypes.number.isRequired,
sortableStrategy: PropTypes.func.isRequired,
hasSubmitted: PropTypes.bool.isRequired,
buttonsDisabled: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{[itemsLeft, itemsRight].map((items, index) => (
))}
{activeId ? (
item.id === activeId)}
isOverlay
/>
) : null}
{" "}
{t("exercise_page.buttons.checkBtn")}
{currentQuestionIndex < shuffledQuiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
export default SortingExercise;
================================================
FILE: src/components/exercise_page/SoundAndSpelling.jsx
================================================
import he from "he";
import _ from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5";
import { LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia";
import { ShuffleArray } from "../../utils/ShuffleArray";
import { sonnerErrorToast } from "../../utils/sonnerCustomToast";
import useCountdownTimer from "../../utils/useCountdownTimer";
const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => {
const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0);
const [shuffledQuiz, setShuffledQuiz] = useState([]);
const [shuffledOptions, setShuffledOptions] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
const [buttonsDisabled, setButtonsDisabled] = useState(false);
const [currentQuestionText, setCurrentQuestionText] = useState("");
const [currentAudioSrc, setCurrentAudioSrc] = useState("");
const [selectedOption, setSelectedOption] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () =>
setTimeIsUp(true)
);
const { t } = useTranslation();
// Use a ref to manage the audio element
const audioRef = useRef(null);
const filterAndShuffleQuiz = (quiz) => {
const uniqueQuiz = _.uniqWith(quiz, _.isEqual);
return ShuffleArray(uniqueQuiz);
};
const loadQuiz = useCallback((quizData) => {
// Shuffle the answer options
const shuffledOptions = ShuffleArray([...quizData.data]);
setShuffledOptions(shuffledOptions);
// Set the question text and audio source
setCurrentQuestionText(quizData.question[0].text);
setCurrentAudioSrc(`${import.meta.env.BASE_URL}media/word/mp3/${quizData.audio.src}.mp3`);
setButtonsDisabled(false);
setSelectedOption(null);
}, []);
useEffect(() => {
if (quiz && quiz.length > 0) {
// Filter out unique items and shuffle the quiz array
const uniqueShuffledQuiz = filterAndShuffleQuiz(quiz);
setShuffledQuiz(uniqueShuffledQuiz);
// Reset currentQuestionIndex to 0
setcurrentQuestionIndex(0);
}
}, [quiz]);
const stopAudio = () => {
if (audioRef.current) {
// Stop the audio and remove event listeners
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.oncanplaythrough = null;
audioRef.current.onended = null;
audioRef.current.onerror = null;
setIsPlaying(false);
setIsLoading(false);
}
};
useEffect(() => {
return () => {
stopAudio();
};
}, []);
useEffect(() => {
if (shuffledQuiz.length > 0 && currentQuestionIndex < shuffledQuiz.length) {
loadQuiz(shuffledQuiz[currentQuestionIndex]);
}
}, [shuffledQuiz, currentQuestionIndex, loadQuiz]);
const handleAudioPlay = () => {
if (isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
setIsPlaying(false);
} else {
setIsLoading(true);
const newAudio = new Audio(currentAudioSrc);
audioRef.current = newAudio;
newAudio.load();
// Handle when the audio can be played through
newAudio.oncanplaythrough = () => {
setIsLoading(false);
setIsPlaying(true);
newAudio.play();
startTimer();
};
// Handle when the audio ends
newAudio.onended = () => {
setIsPlaying(false);
};
// Handle any loading errors
newAudio.onerror = () => {
setIsLoading(false);
setIsPlaying(false);
console.error("Error playing audio.");
sonnerErrorToast(t("toast.audioPlayFailed"));
};
}
};
const handleOptionClick = (isCorrect, index) => {
startTimer();
setButtonsDisabled(true);
setSelectedOption({ index, isCorrect });
// Replace the underscore with the selected answer
const updatedQuestionText = currentQuestionText.replace(
"_____",
shuffledOptions[index].value
);
setCurrentQuestionText(updatedQuestionText);
onAnswer(isCorrect ? 1 : 0, "single");
};
const handleNextQuiz = () => {
stopAudio();
if (currentQuestionIndex < shuffledQuiz.length - 1) {
setcurrentQuestionIndex((prevIndex) => prevIndex + 1);
} else {
onQuit();
stopAudio();
clearTimer();
}
};
const handleQuit = () => {
onQuit();
stopAudio();
clearTimer();
};
return (
<>
{timer > 0 ? (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
{t("exercise_page.timer")} {formatTime()}
) : (
{t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
)}
{isLoading ? (
) : isPlaying ? (
) : (
)}
{he.decode(currentQuestionText)}
{selectedOption ? (
selectedOption.isCorrect ? (
) : (
)
) : (
""
)}
{shuffledOptions.map((option, index) => {
const isSelected = selectedOption?.index === index;
return (
handleOptionClick(option.answer === "true", index)
}
disabled={!!selectedOption && !isSelected}
>
{option.value}
);
})}
{currentQuestionIndex < shuffledQuiz.length - 1 && (
{" "}
{t("exercise_page.buttons.nextBtn")}
)}
{" "}
{t("exercise_page.buttons.quitBtn")}
>
);
};
SoundAndSpelling.propTypes = {
quiz: PropTypes.arrayOf(PropTypes.object).isRequired,
onAnswer: PropTypes.func.isRequired,
onQuit: PropTypes.func.isRequired,
timer: PropTypes.number.isRequired,
setTimeIsUp: PropTypes.func.isRequired,
};
export default SoundAndSpelling;
================================================
FILE: src/components/general/AccentDropdown.jsx
================================================
import PropTypes from "prop-types";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
// Emoji SVGs import
import UKFlagEmoji from "../../emojiSvg/emoji_u1f1ec_1f1e7.svg";
import USFlagEmoji from "../../emojiSvg/emoji_u1f1fa_1f1f8.svg";
const AccentDropdown = ({ onAccentChange }) => {
const [selectedAccent, setSelectedAccent] = AccentLocalStorage();
const { t } = useTranslation();
const selectedAccentOptions = [
{ name: `${t("accent.accentAmerican")}`, value: "american", emoji: USFlagEmoji },
{ name: `${t("accent.accentBritish")}`, value: "british", emoji: UKFlagEmoji },
];
useEffect(() => {
const currentSettings = JSON.parse(localStorage.getItem("ispeaker")) || {};
const updatedSettings = { ...currentSettings, selectedAccent: selectedAccent };
localStorage.setItem("ispeaker", JSON.stringify(updatedSettings));
}, [selectedAccent]);
const handleAccentChange = (value) => {
setSelectedAccent(value);
onAccentChange(value);
sonnerSuccessToast(t("settingPage.changeSaved"));
};
return (
<>
{t("accent.accentSettings")}:
item.value === selectedAccent)
.emoji
}
className="inline-block h-6 w-6"
/>
{selectedAccentOptions.find((item) => item.value === selectedAccent).name}
>
);
};
AccentDropdown.propTypes = {
onAccentChange: PropTypes.func.isRequired,
};
export default AccentDropdown;
================================================
FILE: src/components/general/Footer.jsx
================================================
import openExternal from "../../utils/openExternal";
import LogoLightOrDark from "./LogoLightOrDark";
const Footer = () => {
return (
Created by{" "}
openExternal("https://yell0wsuit.page")}
target="_blank"
>
yell0wsuit
Maintained by the community and contributors.{" "}
openExternal(
"https://github.com/learnercraft/ispeakerreact/graphs/contributors"
)
}
target="_blank"
>
See contributors
Licensed under the Apache License, Version 2.0
Video and audio materials © Oxford University Press
More English learning materials
openExternal("https://yell0wsuit.github.io/docugrammar/")}
target="_blank"
>
DocuGrammar
A collection of grammar references in web format, powered by Docusaurus.
);
};
export default Footer;
================================================
FILE: src/components/general/LoadingOverlay.jsx
================================================
import LogoLightOrDark from "./LogoLightOrDark";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { FiRefreshCw } from "react-icons/fi";
const LoadingOverlay = () => {
const { t } = useTranslation();
const [showSlow, setShowSlow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setShowSlow(true), 5000);
return () => clearTimeout(timer);
}, []);
return (
{t("loadingOverlay.loading")}
{showSlow && (
<>
{t("loadingOverlay.slowText")}
window.location.reload()}
>
{t("appCrash.refreshBtn")}
>
)}
);
};
export default LoadingOverlay;
================================================
FILE: src/components/general/LogoLightOrDark.jsx
================================================
import PropTypes from "prop-types";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
const LogoLightOrDark = ({ width, height }) => {
const { autoDetectedTheme } = useAutoDetectTheme();
const logoSrc =
autoDetectedTheme === "dark"
? `${import.meta.env.BASE_URL}images/logos/ispeakerreact-no-background-darkmode.svg`
: `${import.meta.env.BASE_URL}images/logos/ispeakerreact-no-background.svg`;
return ;
};
LogoLightOrDark.propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
};
export default LogoLightOrDark;
================================================
FILE: src/components/general/NotFound.jsx
================================================
import Container from "../../ui/Container";
import TopNavBar from "./TopNavBar";
const NotFound = () => {
return (
<>
404 - Page Not Found
We couldn’t find the page you were looking for. Please check to ensure the
URL is correct.
>
);
};
export default NotFound;
================================================
FILE: src/components/general/TopNavBar.jsx
================================================
import { BsAlphabet, BsCardChecklist, BsChatText } from "react-icons/bs";
import { CgMenuLeft } from "react-icons/cg";
import { FaGithub } from "react-icons/fa";
import { FiExternalLink } from "react-icons/fi";
import { IoHomeOutline, IoMicOutline } from "react-icons/io5";
import { LiaToolsSolid } from "react-icons/lia";
import { PiExam } from "react-icons/pi";
import { useTranslation } from "react-i18next";
import { NavLink, useLocation } from "react-router-dom";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
import openExternal from "../../utils/openExternal";
const TopNavBar = () => {
const { t } = useTranslation();
const { autoDetectedTheme } = useAutoDetectTheme();
const location = useLocation();
const logoSrc =
autoDetectedTheme === "dark"
? `${import.meta.env.BASE_URL}images/logos/ispeakerreact-no-background-darkmode.svg`
: `${import.meta.env.BASE_URL}images/logos/ispeakerreact-no-background.svg`;
const navbarClass = autoDetectedTheme === "dark" ? "bg-slate-600/50" : "bg-lime-300/75";
const menuItems = [
{
to: "/",
icon: ,
label: t("navigation.home"),
childMenu: null,
},
{
to: null,
icon: ,
label: t("navigation.practice"),
childMenu: [
{
to: "/sounds",
icon: ,
label: t("navigation.sounds"),
},
{
to: "/words",
icon: ,
label: t("navigation.words"),
},
],
},
{
to: "/exercises",
icon: ,
label: t("navigation.exercises"),
childMenu: null,
},
{
to: "/conversations",
icon: ,
label: t("navigation.conversations"),
childMenu: null,
},
{
to: "/exams",
icon: ,
label: t("navigation.exams"),
childMenu: null,
},
{
to: "/settings",
icon: ,
label: t("navigation.settings"),
childMenu: null,
},
];
return (
{/* Mobile Drawer */}
{menuItems.map((item) =>
item.childMenu ? (
item.childMenu.map((child) => (
isActive ? "menu-active" : ""
}
>
{child.icon} {child.label}
))
) : (
isActive ? "menu-active" : ""
}
>
{item.icon} {item.label}
)
)}
{/* Logo */}
iSpeakerReact
{/* Desktop Navigation */}
{menuItems.map((item) => {
const isActive =
item.childMenu?.some((child) => location.pathname === child.to) ||
location.pathname === item.to;
return item.childMenu ? (
{item.icon} {item.label}
{item.childMenu.map((child) => (
isActive ? "menu-active" : ""
}
>
{child.icon} {child.label}
))}
) : (
(isActive ? "menu-active" : "")}
>
{item.icon} {item.label}
);
})}
{/* GitHub Link */}
openExternal("https://github.com/learnercraft/ispeakerreact/")}
>
GitHub
);
};
export default TopNavBar;
================================================
FILE: src/components/general/VersionUpdateDialog.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
const RATE_LIMIT_KEY = "github_ratelimit_timestamp";
const RATE_LIMIT_THRESHOLD = 45;
const currentVersion = __APP_VERSION__;
const VersionUpdateDialog = ({ open, onRefresh }) => {
const { t } = useTranslation();
const dialogRef = useRef(null);
const [latestVersion, setLatestVersion] = useState(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const abortControllerRef = useRef(null);
useEffect(() => {
if (!open) return;
setChecking(true);
setError(null);
// Create new AbortController for this request
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const checkVersion = async () => {
try {
const now = Math.floor(Date.now() / 1000);
const savedResetTime = localStorage.getItem(RATE_LIMIT_KEY);
if (savedResetTime && now < parseInt(savedResetTime, 10)) {
const resetTime = new Date(parseInt(savedResetTime, 10) * 1000);
setError(
t("alert.rateLimitExceeded", { resetTime: resetTime.toLocaleString() })
);
setChecking(false);
return;
}
const response = await fetch(
"https://api.github.com/repos/learnercraft/ispeakerreact/contents/package.json",
{
headers: {
"User-Agent": "iSpeakerReact-learnercraft",
Accept: "application/vnd.github.v3+json",
},
signal, // Add abort signal
}
);
if (!response.ok) {
if (response.status === 403) {
const rateLimitReset = parseInt(
response.headers.get("x-ratelimit-reset") || "0",
10
);
const resetTime = new Date(rateLimitReset * 1000);
localStorage.setItem(
RATE_LIMIT_KEY,
(rateLimitReset + 5 * 3600).toString()
);
setError(
t("alert.rateLimitExceeded", { resetTime: resetTime.toLocaleString() })
);
} else {
throw new Error(`GitHub API error: ${response.status}`);
}
setChecking(false);
return;
}
const rateLimitRemaining = parseInt(
response.headers.get("x-ratelimit-remaining") || "0",
10
);
const rateLimitReset = parseInt(
response.headers.get("x-ratelimit-reset") || "0",
10
);
if (rateLimitRemaining < RATE_LIMIT_THRESHOLD && rateLimitReset) {
const resetTime = new Date(rateLimitReset * 1000);
localStorage.setItem(RATE_LIMIT_KEY, (rateLimitReset + 5 * 3600).toString());
setError(
t("alert.rateLimitExceeded", { resetTime: resetTime.toLocaleString() })
);
setChecking(false);
return;
}
const data = await response.json();
if (!data.content) throw new Error("No content in response");
const decodedContent = JSON.parse(atob(data.content));
const latest = decodedContent.version;
setLatestVersion(latest);
setChecking(false);
// Only show dialog if versions don't match and component is still mounted
if (latest !== currentVersion && dialogRef.current) {
dialogRef.current.showModal();
}
} catch (err) {
// Only set error if it's not an abort error
if (err.name !== "AbortError") {
setError(err.message);
}
setChecking(false);
}
};
checkVersion();
// Cleanup function
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [open, t]);
useEffect(() => {
if (!open && dialogRef.current) {
dialogRef.current.close();
}
}, [open]);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
// Clear all caches except permanent-cache
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => name !== "permanent-cache")
.map((name) => caches.delete(name))
);
// Wait for cache clearing to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
onRefresh();
} catch (err) {
setError(err.message);
setIsRefreshing(false);
}
};
return (
{error
? t("alert.appNewVersionErrorDialogTitle")
: t("alert.appNewVersionDialogTitle")}
{checking &&
}
{error ? (
<>
{t("alert.appUpdateError")}
{t("alert.appNewVersionErrorReason")}{" "}
{error}
>
) : (
<>
{isRefreshing ? (
<>
{t("alert.appNewVersionDialogChecking")}
>
) : (
!checking &&
latestVersion && (
<>
{latestVersion !== currentVersion ? (
{t("alert.appNewVersionDialogBody", {
version: latestVersion,
})}
) : (
<>>
)}
>
)
)}
>
)}
{error ? (
{
if (dialogRef.current) dialogRef.current.close();
}}
>
{t("sound_page.closeBtn")}
) : (
latestVersion &&
latestVersion !== currentVersion &&
!isRefreshing && (
{t("alert.appNewVersionDialogRefreshBtn")}
)
)}
);
};
VersionUpdateDialog.propTypes = {
open: PropTypes.bool.isRequired,
onRefresh: PropTypes.func.isRequired,
};
export default VersionUpdateDialog;
================================================
FILE: src/components/setting_page/Appearance.jsx
================================================
import { useTranslation } from "react-i18next";
import { useTheme } from "../../utils/ThemeContext/useTheme";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
import { MdOutlineLightMode, MdOutlineDarkMode } from "react-icons/md";
import { LuSunMoon } from "react-icons/lu";
const themeOptions = {
auto: {
labelKey: "settingPage.appearanceSettings.themeAuto",
icon: ,
},
light: {
labelKey: "settingPage.appearanceSettings.themeLight",
icon: ,
},
dark: {
labelKey: "settingPage.appearanceSettings.themeDark",
icon: ,
},
};
const AppearanceSettings = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const handleThemeSelect = (selectedTheme) => {
setTheme(selectedTheme);
sonnerSuccessToast(t("settingPage.changeSaved"));
};
return (
{t("settingPage.appearanceSettings.themeOption")}
{themeOptions[theme]?.icon} {t(themeOptions[theme]?.labelKey)}
);
};
export default AppearanceSettings;
================================================
FILE: src/components/setting_page/AppInfo.jsx
================================================
import { useState } from "react";
import { LuExternalLink } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { IoCloseOutline, IoInformationCircleOutline } from "react-icons/io5";
const AppInfo = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [alertVisible, setAlertVisible] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertVariant, setAlertVariant] = useState("success");
const [currentVersion] = useState(__APP_VERSION__);
const RATE_LIMIT_KEY = "github_ratelimit_timestamp";
const RATE_LIMIT_THRESHOLD = 50;
const checkForUpdates = async () => {
// Prevent running in either development mode or Electron version
if (process.env.NODE_ENV === "development") {
console.warn("Dev mode detected, skipping version check.");
setAlertMessage(t("Dev mode detected, skipping version check."));
setAlertVisible(true);
return;
}
setIsLoading(true);
if (!navigator.onLine) {
setAlertMessage(t("alert.alertAppNoInternet"));
setAlertVariant("alert-error");
setAlertVisible(true);
setIsLoading(false);
return;
}
try {
const now = Math.floor(Date.now() / 1000); // Current timestamp in seconds
const savedResetTime = localStorage.getItem(RATE_LIMIT_KEY);
const resetTime = new Date(parseInt(savedResetTime, 10) * 1000).toLocaleString();
// If a reset time is stored and it's in the future, skip API request
if (savedResetTime && now < parseInt(savedResetTime, 10)) {
console.warn("Skipping version check due to rate limiting.");
setAlertMessage(t("alert.rateLimited", { time: resetTime }));
setAlertVariant("alert-warning");
setAlertVisible(true);
return;
}
const response = await fetch(
"https://api.github.com/repos/learnercraft/ispeakerreact/contents/package.json",
{
headers: {
"Content-Type": "application/json",
"User-Agent": "iSpeakerReact-learnercraft",
Accept: "application/vnd.github.v3+json",
},
}
);
const data = await response.json();
// Check if html_url contains the expected URL
if (
!data.html_url ||
!data.html_url.startsWith("https://github.com/learnercraft/ispeakerreact/")
) {
window.electron.log(
"error",
"The html_url is invalid or does not match the expected repository."
);
throw new Error(
"The html_url is invalid or does not match the expected repository."
);
}
// Get rate limit headers
const rateLimitRemaining = parseInt(
response.headers.get("x-ratelimit-remaining") || "0",
10
);
const rateLimitReset = parseInt(response.headers.get("x-ratelimit-reset") || "0", 10);
// If we are near the rate limit, stop further checks
if (rateLimitRemaining < RATE_LIMIT_THRESHOLD && rateLimitReset) {
console.warn(
`Rate limit is low (${rateLimitRemaining} remaining). Skipping update check.`
);
const resetTimeFirst = new Date(
parseInt(rateLimitReset + 5 * 3600, 10) * 1000
).toLocaleString();
localStorage.setItem(RATE_LIMIT_KEY, (rateLimitReset + 5 * 3600).toString());
setAlertMessage(t("alert.rateLimited", { time: resetTimeFirst }));
setAlertVariant("alert-warning");
setAlertVisible(true);
return;
}
// GitHub returns the content in base64 encoding, so we need to decode it
const decodedContent = JSON.parse(atob(data.content));
const latestVersion = decodedContent.version;
if (latestVersion !== currentVersion) {
setAlertMessage(t("alert.appNewVersionGitHub"));
setAlertVariant("alert-success");
setAlertVisible(true);
} else {
setAlertMessage(t("alert.appVersionLatest"));
setAlertVariant("alert-info");
setAlertVisible(true);
}
} catch (error) {
console.error("Failed to fetch version:", error);
window.electron.log("error", `Failed to fetch version. ${error}`);
setAlertMessage(t("alert.appUpdateError"));
setAlertVariant("alert-error");
setAlertVisible(true);
} finally {
setIsLoading(false);
}
};
const openGithubPage = () => {
window.electron.openExternal(
"https://github.com/learnercraft/ispeakerreact/releases/latest"
);
};
const openMsStore = () => {
window.electron.openExternal("ms-windows-store://pdp/?productid=9NWK49GLXGFP");
};
return (
{alertVisible && (
{alertMessage.includes("GitHub") ? (
<>
{alertMessage}
{t("settingPage.openGitHubAlertLink")}
>
) : (
alertMessage
)}
setAlertVisible(false)}
>
)}
iSpeakerReact
Version {currentVersion}
{window.electron.isUwp() ? (
{t("settingPage.checkUpdateMSBtn")}{" "}
) : (
{isLoading &&
}
{t("settingPage.checkUpdateBtn")}
)}
);
};
export default AppInfo;
================================================
FILE: src/components/setting_page/ExerciseTimer.jsx
================================================
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const defaultTimerSettings = {
enabled: false,
dictation: 5,
matchup: 5,
reordering: 5,
sound_n_spelling: 5,
sorting: 5,
odd_one_out: 5,
};
const ExerciseTimer = () => {
const { t } = useTranslation();
const [timerSettings, setTimerSettings] = useState(() => {
const savedSettings = JSON.parse(localStorage.getItem("ispeaker"));
if (savedSettings && savedSettings.timerSettings) {
return savedSettings.timerSettings;
}
return defaultTimerSettings;
});
const [inputEnabled, setInputEnabled] = useState(timerSettings.enabled);
const [tempSettings, setTempSettings] = useState(timerSettings);
const [isValid, setIsValid] = useState(true);
const [isModified, setIsModified] = useState(false);
// Automatically save settings to localStorage whenever timerSettings change
useEffect(() => {
const savedSettings = JSON.parse(localStorage.getItem("ispeaker")) || {};
savedSettings.timerSettings = timerSettings;
localStorage.setItem("ispeaker", JSON.stringify(savedSettings));
}, [timerSettings]);
const handleTimerToggle = (enabled) => {
setTimerSettings((prev) => ({
...prev,
enabled,
}));
setInputEnabled(enabled);
sonnerSuccessToast(t("settingPage.changeSaved"));
};
// Validation function to check if the inputs are valid (0-10 numbers only)
const validateInputs = (settings) => {
return Object.values(settings).every(
(value) => value !== "" && !isNaN(value) && value >= 0 && value <= 10
);
};
const checkIfModified = (settings) => {
const savedSettings =
JSON.parse(localStorage.getItem("ispeaker"))?.timerSettings || defaultTimerSettings;
return JSON.stringify(settings) !== JSON.stringify(savedSettings);
};
const handleInputChange = (e, settingKey) => {
const { value } = e.target;
if (/^\d*$/.test(value) && value.length <= 2) {
const numValue = value === "" ? "" : parseInt(value, 10);
setTempSettings((prev) => ({
...prev,
[settingKey]: numValue,
}));
}
};
const handleApply = () => {
if (validateInputs(tempSettings)) {
setTimerSettings((prev) => ({
...prev,
...tempSettings, // Apply modified fields
enabled: prev.enabled, // Ensure the `enabled` flag is preserved
}));
setIsModified(false);
sonnerSuccessToast(t("settingPage.changeSaved"));
}
};
const handleCancel = () => {
setTempSettings(timerSettings); // revert to original settings
setIsModified(false); // Reset modified state
};
// Update validity and modified state when temporary settings change
useEffect(() => {
setIsValid(validateInputs(tempSettings));
setIsModified(checkIfModified(tempSettings)); // Check if values differ from localStorage or defaults
}, [tempSettings]);
const exerciseNames = {
dictation: t("exercise_page.dictationHeading"),
matchup: t("exercise_page.matchUpHeading"),
reordering: t("exercise_page.reorderingHeading"),
sound_n_spelling: t("exercise_page.soundSpellingHeading"),
sorting: t("exercise_page.sortingHeading"),
odd_one_out: t("exercise_page.oddOneOutHeading"),
};
return (
{Object.keys(exerciseNames).map((exercise) => (
))}
{t("settingPage.exerciseSettings.hint")}
{t("settingPage.exerciseSettings.applyBtn")}
{t("settingPage.exerciseSettings.cancelBtn")}
);
};
export default ExerciseTimer;
================================================
FILE: src/components/setting_page/LanguageSwitcher.jsx
================================================
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import openExternal from "../../utils/openExternal";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
// Supported languages
const supportedLanguages = [
{ code: "en", label: "English", emoji: "uk" },
{ code: "zh", label: "中文", emoji: "cn" },
];
{
/*
The label should be in the native language (e.g., Japanese as 日本語).
The language code must follow the ISO 639-1 standard (2 characters, e.g., "ja" for Japanese).
Emoji follows the ISO 3166-1 standard (2 characters, e.g. "jp" for Japan).
*/
}
const LanguageSwitcher = () => {
const { t, i18n } = useTranslation();
const handleLanguageChange = (lng) => {
i18n.changeLanguage(lng);
document.documentElement.setAttribute("lang", lng); // Update HTML lang attribute
const ispeakerSettings = JSON.parse(localStorage.getItem("ispeaker")) || {};
ispeakerSettings.language = lng;
localStorage.setItem("ispeaker", JSON.stringify(ispeakerSettings));
sonnerSuccessToast(t("settingPage.changeSaved"));
};
const currentLanguage =
supportedLanguages.find((lang) => lang.code === i18n.language) || supportedLanguages[0];
return (
{t("settingPage.languageSettings.languageOption")}
{currentLanguage.label}
);
};
export default LanguageSwitcher;
================================================
FILE: src/components/setting_page/LogSettings.jsx
================================================
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const LogSettings = () => {
const { t } = useTranslation();
const [, setFolderPath] = useState(null);
const maxLogOptions = useMemo(
() => [
{
value: "5",
label: `5 ${t("settingPage.logSettings.numOfLogsNumLog")}`,
numOfLogs: 5,
},
{
value: "10",
label: `10 ${t("settingPage.logSettings.numOfLogsNumLog")}`,
numOfLogs: 10,
},
{
value: "25",
label: `25 ${t("settingPage.logSettings.numOfLogsNumLog")}`,
numOfLogs: 25,
},
{
value: "unlimited",
label: t("settingPage.logSettings.numOfLogsUnlimited"),
numOfLogs: 0,
},
],
[t]
);
const deleteLogsOptions = useMemo(
() => [
{
value: "1",
label: `1 ${t("settingPage.logSettings.deleteOldLogNumDay")}`,
keepForDays: 1,
},
{
value: "7",
label: `7 ${t("settingPage.logSettings.deleteOldLogNumDay")}`,
keepForDays: 7,
},
{
value: "14",
label: `14 ${t("settingPage.logSettings.deleteOldLogNumDay")}`,
keepForDays: 14,
},
{
value: "30",
label: `30 ${t("settingPage.logSettings.deleteOldLogNumDay")}`,
keepForDays: 30,
},
{
value: "never",
label: t("settingPage.logSettings.deleteOldLogNever"),
keepForDays: 0,
},
],
[t]
);
// Fetch initial settings from Electron main process
const [maxLogWritten, setMaxLogWritten] = useState("unlimited");
const [deleteLogsOlderThan, setDeleteLogsOlderThan] = useState("never");
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
async function fetchLogSettings() {
try {
const settings = await window.electron.ipcRenderer.invoke("get-log-settings");
if (!isMounted) return;
// Find the corresponding options based on stored values
const initialMaxLog =
maxLogOptions.find((option) => option.numOfLogs === settings.numOfLogs)
?.value || "unlimited";
const initialDeleteLog =
deleteLogsOptions.find((option) => option.keepForDays === settings.keepForDays)
?.value || "never";
setMaxLogWritten(initialMaxLog);
setDeleteLogsOlderThan(initialDeleteLog);
} catch {
// fallback to defaults
setMaxLogWritten("unlimited");
setDeleteLogsOlderThan("never");
} finally {
setLoading(false);
}
}
fetchLogSettings();
return () => {
isMounted = false;
};
}, [maxLogOptions, deleteLogsOptions]);
// Memoize the function so that it doesn't change on every render
const handleApplySettings = useCallback(
(maxLogWrittenValue, deleteLogsOlderThanValue) => {
const selectedMaxLogOption = maxLogOptions.find(
(option) => option.value === maxLogWrittenValue
);
const selectedDeleteLogOption = deleteLogsOptions.find(
(option) => option.value === deleteLogsOlderThanValue
);
const electronSettings = {
numOfLogs: selectedMaxLogOption.numOfLogs,
keepForDays: selectedDeleteLogOption.keepForDays,
};
// Send the settings to Electron main process
window.electron.ipcRenderer.send("update-log-settings", electronSettings);
sonnerSuccessToast(t("settingPage.changeSaved"));
},
[maxLogOptions, deleteLogsOptions, t]
);
// Helper function to get the label based on the current value
const getLabel = (options, value) => {
const selectedOption = options.find((option) => option.value === value);
return selectedOption ? selectedOption.label : value;
};
const handleOpenLogFolder = async () => {
// Send an IPC message to open the folder and get the folder path
const logFolder = await window.electron.ipcRenderer.invoke("open-log-folder");
setFolderPath(logFolder); // Save the folder path in state
};
if (loading) {
return (
);
}
return (
<>
{t("settingPage.logSettings.logSettingsHeading")}
{t("settingPage.logSettings.logSettingsDescription")}
{t("settingPage.logSettings.numOfLogsOption")}
{getLabel(maxLogOptions, maxLogWritten)}
{t("settingPage.logSettings.deleteOldLogsOption")}
{getLabel(deleteLogsOptions, deleteLogsOlderThan)}
{t("settingPage.logSettings.openLogBtn")}
>
);
};
export default LogSettings;
================================================
FILE: src/components/setting_page/modelOptions.js
================================================
const modelOptions = [
{
value: "vitouphy/wav2vec2-xls-r-300m-timit-phoneme",
description: "settingPage.pronunciationSettings.modelDescriptionHighAccuracy",
size: "1.26GB",
},
{
value: "facebook/wav2vec2-lv-60-espeak-cv-ft",
description: "settingPage.pronunciationSettings.modelDescriptionMediumAccuracy",
size: "1.26GB",
},
{
value: "facebook/wav2vec2-xlsr-53-espeak-cv-ft",
description: "settingPage.pronunciationSettings.modelDescriptionMediumAccuracy",
size: "1.26GB",
},
{
value: "bookbot/wav2vec2-ljspeech-gruut",
description: "settingPage.pronunciationSettings.modelDescriptionLowAccuracy",
size: "378MB",
},
];
export default modelOptions;
================================================
FILE: src/components/setting_page/PronunciationCheckerDialogContent.jsx
================================================
import PropTypes from "prop-types";
import { Trans } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import openExternal from "../../utils/openExternal";
import modelOptions from "./modelOptions";
const PronunciationCheckerDialogContent = ({
t,
checking,
closeConfirmDialog,
handleProceed,
installState,
modelValue,
onModelChange,
}) => {
return (
{t("settingPage.pronunciationSettings.pronunciationModalHeading")}
{/* Model selection dropdown */}
{t("settingPage.pronunciationSettings.modelSelectLabel")}
{t(
"settingPage.pronunciationSettings.modelExplanationTitle"
)}
{t(
"settingPage.pronunciationSettings.modelExplanationBody"
)}
onModelChange(e.target.value)}
disabled={checking}
>
{modelOptions.map((opt) => (
{opt.value} ({opt.size})
))}
{/* Show description for selected model */}
{modelOptions.find((opt) => opt.value === modelValue) &&
t(modelOptions.find((opt) => opt.value === modelValue).description)}
{installState === "failed" && (
{t("settingPage.pronunciationSettings.previousInstallFailedMsg")}
openExternal(
"https://learnercraft.github.io/blog/2025-05-20-how-to-install-python-for-beginners/"
)
}
/>,
]}
/>
)}
{installState === "complete" && (
{t("settingPage.pronunciationSettings.previousInstallMsg")}
)}
{installState === "not_installed" && (
openExternal("https://www.python.org/downloads/")
}
/>,
]}
/>
openExternal(
"https://learnercraft.github.io/blog/2025-05-20-how-to-install-python-for-beginners/"
)
}
/>,
]}
/>
)}
{t("settingPage.pronunciationSettings.pronunciationModalBody")}
{t("settingPage.exerciseSettings.cancelBtn")}
{!checking && (
{t("settingPage.pronunciationSettings.pronunciationModalBtn")}
)}
);
};
PronunciationCheckerDialogContent.propTypes = {
t: PropTypes.func.isRequired,
checking: PropTypes.bool.isRequired,
closeConfirmDialog: PropTypes.func.isRequired,
handleProceed: PropTypes.func.isRequired,
installState: PropTypes.string.isRequired,
modelValue: PropTypes.string.isRequired,
onModelChange: PropTypes.func.isRequired,
};
export default PronunciationCheckerDialogContent;
================================================
FILE: src/components/setting_page/PronunciationCheckerInfo.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { IoCheckmark, IoCloseOutline } from "react-icons/io5";
import { getPronunciationStepStatuses } from "./pronunciationStepUtils";
const PronunciationCheckerInfo = ({ t, checking, error, pythonCheckResult, modelSize }) => {
// Helper to get status icon
const getStatusIcon = (status) => {
if (status === "pending")
return ;
if (status === "success")
return ;
if (status === "error")
return ;
return null;
};
const { step1Status, step2Status, step3Status, deps } = getPronunciationStepStatuses(
pythonCheckResult,
checking,
error
);
const steps = [
{
key: "step1",
label: t("settingPage.pronunciationSettings.installationProcessStep1"),
status: step1Status,
},
{
key: "step2",
label: t("settingPage.pronunciationSettings.installationProcessStep2"),
status: step2Status,
deps,
},
{
key: "step3",
label: t("settingPage.pronunciationSettings.installationProcessStep3", {
size: modelSize,
}),
status: step3Status,
},
];
// Ref for the log container
const logRef = useRef(null);
// Get the log output
const logOutput = [
pythonCheckResult?.pythonLog
? `--- Python Check ---\n${pythonCheckResult.pythonLog}`
: null,
pythonCheckResult?.dependencyLog
? `--- Dependency Installation ---\n${pythonCheckResult.dependencyLog}`
: null,
pythonCheckResult?.modelLog
? `--- Model Download ---\n${pythonCheckResult.modelLog}`
: null,
]
.filter(Boolean)
.join("\n\n");
// Auto-scroll to bottom when log changes
useEffect(() => {
if (logRef.current && logOutput) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [logOutput]);
// Add state for copy confirmation
const [copied, setCopied] = useState(false);
return (
{steps.map((step) => (
{getStatusIcon(step.status)}
{step.label}
{step.status === "success" && (
{t("settingPage.pronunciationSettings.stepDone")}
)}
{step.status === "error" && (
{t("settingPage.pronunciationSettings.stepFailed")}
)}
))}
{t("settingPage.pronunciationSettings.showDetailsCollapse")}
{checking && (
)}
{error &&
{error}
}
{pythonCheckResult && (
{logOutput ? (
<>
{logOutput}
{
await navigator.clipboard.writeText(
`\u0060\u0060\u0060\n${logOutput}\n\u0060\u0060\u0060`
);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied
? t(
"settingPage.pronunciationSettings.pronunciationCopyLogCopied"
)
: t(
"settingPage.pronunciationSettings.pronunciationCopyLogBtn"
)}
>
) : pythonCheckResult.stderr ? (
Stderr: {" "}
{pythonCheckResult.stderr}
) : null}
)}
);
};
PronunciationCheckerInfo.propTypes = {
t: PropTypes.func.isRequired,
checking: PropTypes.bool.isRequired,
error: PropTypes.oneOfType([PropTypes.string, PropTypes.oneOf([null])]),
pythonCheckResult: PropTypes.oneOfType([PropTypes.object, PropTypes.oneOf([null])]),
modelSize: PropTypes.string.isRequired,
};
export default PronunciationCheckerInfo;
================================================
FILE: src/components/setting_page/PronunciationSettings.jsx
================================================
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
IoCheckmarkCircleOutline,
IoCloseCircleOutline,
IoInformationCircleOutline,
IoWarningOutline,
} from "react-icons/io5";
import PronunciationCheckerDialogContent from "./PronunciationCheckerDialogContent";
import PronunciationCheckerInfo from "./PronunciationCheckerInfo";
import {
checkPythonInstalled,
downloadModelStepIPC,
installDependenciesIPC,
} from "./PronunciationUtils";
import {
getPronunciationStepStatuses,
getPronunciationInstallState,
} from "./pronunciationStepUtils";
import modelOptions from "./modelOptions";
const PronunciationSettings = () => {
const { t } = useTranslation();
const confirmDialogRef = useRef(null);
const checkerDialogRef = useRef(null);
const [pythonCheckResult, setPythonCheckResult] = useState(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [isCancelling, setIsCancelling] = useState(false);
const [installState, setInstallState] = useState("not_installed");
// Model selection state
const [modelValue, setModelValue] = useState("vitouphy/wav2vec2-xls-r-300m-timit-phoneme");
const onModelChange = (value) => setModelValue(value);
const openConfirmDialog = () => {
confirmDialogRef.current?.showModal();
};
const closeConfirmDialog = () => {
confirmDialogRef.current?.close();
};
const openCheckerDialog = () => {
checkerDialogRef.current?.showModal();
};
const closeCheckerDialog = () => {
checkerDialogRef.current?.close();
// Update installState after closing if all steps are done
const { step1Status, step2Status, step3Status } = getPronunciationStepStatuses(
pythonCheckResult,
checking,
error
);
const allStepsDone = [step1Status, step2Status, step3Status].every(
(status) => status === "success" || status === "error"
);
if (allStepsDone) {
// Transform pythonCheckResult to expected structure for getPronunciationInstallState
const installStatusObj = {
python: { found: pythonCheckResult?.found },
dependencies: pythonCheckResult?.deps,
model: { status: pythonCheckResult?.modelStatus },
};
setInstallState(getPronunciationInstallState(installStatusObj));
}
};
// Helper to save install status to electron-conf
const saveInstallStatus = async (result) => {
if (window.electron?.ipcRenderer) {
await window.electron.ipcRenderer.invoke("set-pronunciation-install-status", result);
}
};
// On mount, load cached install status
useEffect(() => {
const fetchInstallStatus = async () => {
if (window.electron?.ipcRenderer) {
const cachedStatus = await window.electron.ipcRenderer.invoke(
"get-pronunciation-install-status"
);
const state = getPronunciationInstallState(cachedStatus);
setInstallState(state);
console.log("cachedStatus", cachedStatus);
if (state === "complete") {
setPythonCheckResult(cachedStatus);
setError(null);
} else if (state === "failed") {
setPythonCheckResult(cachedStatus);
setError(
cachedStatus?.log ||
cachedStatus?.modelMessage ||
cachedStatus?.dependencyLog ||
"Installation failed."
);
window.electron.log("error", `Pronunciation install failed. ${cachedStatus}`);
} else {
setPythonCheckResult(null);
setError(null);
}
}
};
fetchInstallStatus();
}, []);
// Listen for dependency installation progress
useEffect(() => {
const handleDepProgress = (_event, depStatus) => {
setPythonCheckResult((prev) => {
let deps = Array.isArray(prev?.deps) ? [...prev.deps] : [];
const idx = deps.findIndex((d) => d.name === depStatus.name);
if (idx !== -1) deps[idx] = depStatus;
else deps.push(depStatus);
const updated = {
...prev,
deps,
dependencyLog: depStatus.log || prev?.dependencyLog || "",
};
return updated;
});
};
const handleCancelled = () => {
setIsCancelling(false);
setChecking(false);
setPythonCheckResult(null);
setError(null);
checkerDialogRef.current?.close();
};
if (window.electron?.ipcRenderer) {
window.electron.ipcRenderer.on("pronunciation-dep-progress", handleDepProgress);
window.electron.ipcRenderer.on("pronunciation-cancelled", handleCancelled);
}
return () => {
if (window.electron?.ipcRenderer) {
window.electron.ipcRenderer.removeListener(
"pronunciation-dep-progress",
handleDepProgress
);
window.electron.ipcRenderer.removeListener(
"pronunciation-cancelled",
handleCancelled
);
}
};
}, []);
// Listen for model download progress (live console output)
useEffect(() => {
const handleModelProgress = (_event, msg) => {
setPythonCheckResult((prev) => {
const updated = {
...prev,
log: undefined, // Remove old log field if present
modelLog: (prev?.modelLog ? prev.modelLog + "\n" : "") + (msg.message || ""),
modelStatus: msg.status,
modelMessage: msg.message,
};
return updated;
});
};
if (window.electron?.ipcRenderer) {
window.electron.ipcRenderer.on("pronunciation-model-progress", handleModelProgress);
}
return () => {
if (window.electron?.ipcRenderer) {
window.electron.ipcRenderer.removeListener(
"pronunciation-model-progress",
handleModelProgress
);
}
};
}, []);
// Decoupled logic for checking Python installation
const checkPython = async () => {
setChecking(true);
setError(null);
setPythonCheckResult(null);
try {
const result = await checkPythonInstalled();
setPythonCheckResult((prev) => ({
...prev,
pythonLog: result.log || "",
...result,
}));
if (result.found) {
installDependencies();
}
} catch (err) {
console.error("[Pronunciation] Python check error:", err);
setError(err.message || String(err));
} finally {
setChecking(false);
}
};
// Function to trigger dependency installation
const installDependencies = async () => {
if (window.electron?.ipcRenderer) {
setChecking(true);
try {
const result = await installDependenciesIPC();
setPythonCheckResult((prev) => {
const updated = { ...prev, ...result };
return updated;
});
if (result.deps && result.deps.every((dep) => dep.status === "success")) {
downloadModelStep();
}
} catch (err) {
console.error("[Pronunciation] Dependency install error:", err);
setError(err.message || String(err));
window.electron.log(
"error",
`Dependency install error: ${err.message || String(err)}`
);
} finally {
setChecking(false);
}
}
};
const downloadModelStep = async () => {
if (window.electron?.ipcRenderer) {
setChecking(true);
setPythonCheckResult((prev) => {
const updated = {
...prev,
modelStatus: "downloading",
log: (prev?.log ? prev.log + "\n" : "") + "Starting model download...\n",
};
return updated;
});
try {
// Pass modelValue to IPC
const result = await downloadModelStepIPC(modelValue);
setPythonCheckResult((prev) => {
const updated = { ...prev, ...result };
return updated;
});
} catch (err) {
console.error("[Pronunciation] Model download error:", err);
setPythonCheckResult((prev) => {
const updated = {
...prev,
modelStatus: "error",
log: (prev?.log ? prev.log + "\n" : "") + (err.message || String(err)),
};
return updated;
});
window.electron.log("error", `Model download error: ${err.message || String(err)}`);
} finally {
setChecking(false);
}
}
};
// Save install status when installation is complete (all steps done)
useEffect(() => {
const { step1Status, step2Status, step3Status } = getPronunciationStepStatuses(
pythonCheckResult,
checking,
error
);
const allStepsDone = [step1Status, step2Status, step3Status].every(
(status) => status === "success" || status === "error"
);
if (pythonCheckResult && allStepsDone) {
saveInstallStatus(pythonCheckResult);
}
}, [pythonCheckResult, checking, error]);
const handleProceed = async () => {
closeConfirmDialog();
openCheckerDialog();
if (window.electron?.ipcRenderer) {
await window.electron.ipcRenderer.invoke("pronunciation-reset-cancel-flag");
}
checkPython();
};
// Use shared utility for step statuses
const { step1Status, step2Status, step3Status } = getPronunciationStepStatuses(
pythonCheckResult,
checking,
error
);
// Consider all steps done if none are pending
const allStepsDone = [step1Status, step2Status, step3Status].every(
(status) => status === "success" || status === "error"
);
// Consider installation failed if all steps are done and at least one is error
const installationFailed =
allStepsDone &&
[step1Status, step2Status, step3Status].some((status) => status === "error");
// Find the selected model's size
const selectedModel = modelOptions.find((opt) => opt.value === modelValue);
const selectedModelSize = selectedModel ? selectedModel.size : "";
return (
<>
{t("settingPage.pronunciationSettings.pronunciationHeading")}
{installState === "complete" ? (
{t("settingPage.pronunciationSettings.reinstallBtn")}
) : (
{t("settingPage.pronunciationSettings.pronunciationBtn")}
)}
{/* Confirmation dialog */}
{/* Checker dialog */}
{t("settingPage.pronunciationSettings.installationProcess")}
{!allStepsDone ? (
{t(
"settingPage.pronunciationSettings.installationProcessWarning"
)}
) : installationFailed ? (
{t(
"settingPage.pronunciationSettings.installationProcessFailed"
)}
) : (
{t(
"settingPage.pronunciationSettings.installationProcessSuccess"
)}
)}
{isCancelling && (
{t(
"settingPage.pronunciationSettings.installationProcessCancelling"
)}
)}
{
if (window.electron?.ipcRenderer) {
setIsCancelling(true);
window.electron.ipcRenderer.invoke(
"pronunciation-cancel-process"
);
}
}
: closeCheckerDialog
}
disabled={isCancelling}
>
{!allStepsDone ? (
isCancelling ? (
<>
{t("settingPage.exerciseSettings.cancelBtn")}
>
) : (
t("settingPage.exerciseSettings.cancelBtn")
)
) : (
t("sound_page.closeBtn", "Close")
)}
>
);
};
export default PronunciationSettings;
================================================
FILE: src/components/setting_page/pronunciationStepUtils.js
================================================
// Utility to calculate step statuses for pronunciation checker
const getPronunciationStepStatuses = (pythonCheckResult, checking, error) => {
// Step 1: Checking Python installation
let step1Status = checking
? "pending"
: error
? "error"
: pythonCheckResult && pythonCheckResult.found
? "success"
: pythonCheckResult && pythonCheckResult.found === false
? "error"
: "pending";
// Step 2: Installing dependencies
let step2Status = "pending";
let deps = pythonCheckResult && pythonCheckResult.deps;
if (step1Status === "error") {
step2Status = "error";
} else if (deps && Array.isArray(deps)) {
if (deps.some((dep) => dep.status === "error")) step2Status = "error";
else if (deps.every((dep) => dep.status === "success")) step2Status = "success";
else if (deps.some((dep) => dep.status === "pending")) step2Status = "pending";
} else if (step1Status === "success") {
step2Status = checking ? "pending" : "success";
}
// Step 3: Downloading phoneme model
let step3Status = "pending";
if (step1Status === "error" || step2Status === "error") {
step3Status = "error";
} else if (pythonCheckResult && pythonCheckResult.modelStatus) {
if (
pythonCheckResult.modelStatus === "found" ||
pythonCheckResult.modelStatus === "success"
) {
step3Status = "success";
} else if (pythonCheckResult.modelStatus === "downloading") {
step3Status = "pending";
} else if (pythonCheckResult.modelStatus === "error") {
step3Status = "error";
}
} else if (checking) {
step3Status = "pending";
}
return { step1Status, step2Status, step3Status, deps };
};
// Utility to determine overall install state: 'not_installed', 'failed', or 'complete'
const getPronunciationInstallState = (statusObj) => {
if (!statusObj) return "not_installed";
// Recursively check for any error/failed status
const hasErrorStatus = (obj) => {
if (!obj || typeof obj !== "object") return false;
if (Array.isArray(obj)) {
return obj.some(hasErrorStatus);
}
for (const key in obj) {
if (
(key === "status" &&
(obj[key] === "error" || obj[key] === "failed" || obj[key] === "cancelled")) ||
(key === "found" && obj[key] === false)
) {
return true;
}
if (typeof obj[key] === "object" && hasErrorStatus(obj[key])) {
return true;
}
}
return false;
};
if (hasErrorStatus(statusObj)) return "failed";
// If all steps are success/found, consider complete
if (
statusObj.python?.found === true &&
(!statusObj.dependencies ||
(Array.isArray(statusObj.dependencies) &&
statusObj.dependencies.every((dep) => dep.status === "success"))) &&
(statusObj.model?.status === "found" || statusObj.model?.status === "success")
) {
return "complete";
}
return "not_installed";
};
export { getPronunciationInstallState, getPronunciationStepStatuses };
================================================
FILE: src/components/setting_page/PronunciationUtils.js
================================================
const checkPythonInstalled = async () => {
if (window.electron?.ipcRenderer) {
return await window.electron.ipcRenderer.invoke("check-python-installed");
} else {
throw new Error("Not running in Electron environment.");
}
};
const installDependenciesIPC = async () => {
if (window.electron?.ipcRenderer) {
return await window.electron.ipcRenderer.invoke("pronunciation-install-deps");
} else {
throw new Error("Not running in Electron environment.");
}
};
const downloadModelStepIPC = async (modelName) => {
if (window.electron?.ipcRenderer) {
return await window.electron.ipcRenderer.invoke("pronunciation-download-model", modelName);
} else {
throw new Error("Not running in Electron environment.");
}
};
export { checkPythonInstalled, downloadModelStepIPC, installDependenciesIPC };
================================================
FILE: src/components/setting_page/ResetSettings.jsx
================================================
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import isElectron from "../../utils/isElectron";
import { sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const ResetSettings = () => {
const { t } = useTranslation();
const [isResettingLocalStorage, setIsResettingLocalStorage] = useState(false);
const [isResettingIndexedDb, setIsResettingIndexedDb] = useState(false);
const localStorageModal = useRef(null);
const indexedDbModal = useRef(null);
const checkAndCloseDatabase = async (dbName) => {
// Check if the database exists
const databases = await window.indexedDB.databases();
const exists = databases.some((db) => db.name === dbName);
if (!exists) {
return false; // Database does not exist
}
// If it exists, open and close the database
return new Promise((resolve) => {
const dbRequest = window.indexedDB.open(dbName);
dbRequest.onsuccess = (event) => {
const db = event.target.result;
db.close();
resolve(true); // Database exists and is closed
};
dbRequest.onerror = () => {
resolve(false); // Database exists but couldn't be opened
};
});
};
const resetLocalStorage = () => {
setIsResettingLocalStorage(true);
checkAndCloseDatabase("CacheDatabase").then(() => {
const deleteRequest = window.indexedDB.deleteDatabase("CacheDatabase");
deleteRequest.onsuccess = () => {
Object.keys(localStorage).forEach((key) => {
if (key !== "github_ratelimit_timestamp") {
localStorage.removeItem(key);
}
});
setIsResettingLocalStorage(false);
localStorageModal.current?.close();
window.location.reload();
sonnerSuccessToast(t("settingPage.changeSaved"));
};
deleteRequest.onerror = () => setIsResettingLocalStorage(false);
deleteRequest.onblocked = () => setIsResettingLocalStorage(false);
});
};
const resetIndexedDb = async () => {
setIsResettingIndexedDb(true);
// Check if the database exists
const exists = await checkAndCloseDatabase("iSpeaker_data");
if (!exists) {
console.log("Database does not exist");
setIsResettingIndexedDb(false);
indexedDbModal.current?.close();
sonnerSuccessToast(t("settingPage.noDataToDelete"));
return; // Exit early
}
console.log("Database exists, proceeding to delete...");
// Proceed to delete the database
const deleteRequest = window.indexedDB.deleteDatabase("iSpeaker_data");
deleteRequest.onsuccess = async () => {
console.log("Database deleted successfully");
setIsResettingIndexedDb(false);
indexedDbModal.current?.close();
};
deleteRequest.onerror = () => {
console.error("Error deleting database");
setIsResettingIndexedDb(false);
indexedDbModal.current?.close();
};
deleteRequest.onblocked = () => {
console.warn("Database deletion is blocked");
setIsResettingIndexedDb(false);
indexedDbModal.current?.close();
window.location.reload(); // Reload the page to close the database
};
};
return (
<>
{t("settingPage.resetSettings.resetHeading")}
<>
localStorageModal.current?.showModal()}
disabled={isResettingLocalStorage}
>
{isResettingLocalStorage && (
)}
{t("settingPage.resetSettings.resetSettingsData")}
{!isElectron() && (
indexedDbModal.current?.showModal()}
disabled={isResettingIndexedDb}
>
{isResettingIndexedDb && (
)}
{t("settingPage.resetSettings.deleteRecordingData")}
)}
>
{/* LocalStorage Modal */}
{/* IndexedDB Modal */}
{!isElectron() && (
)}
>
);
};
export default ResetSettings;
================================================
FILE: src/components/setting_page/SavedRecordingLocationMenu.jsx
================================================
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
const SavedRecordingLocationMenu = () => {
const { t } = useTranslation();
const [, setFolderPath] = useState("");
const handleOpenRecordingFolder = async () => {
// Send an IPC message to open the folder and get the folder path
const recordingFolder = await window.electron.ipcRenderer.invoke("open-recording-folder");
setFolderPath(recordingFolder); // Save the folder path in state
};
return (
{t(
"settingPage.savedRecordingLocationSettings.savedRecordingLocationHeading"
)}
{t("settingPage.savedRecordingLocationSettings.savedRecordingLocationBtn")}
);
};
export default SavedRecordingLocationMenu;
================================================
FILE: src/components/setting_page/SaveFolderSettings.jsx
================================================
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoWarningOutline } from "react-icons/io5";
import isElectron from "../../utils/isElectron";
import { sonnerErrorToast, sonnerSuccessToast } from "../../utils/sonnerCustomToast";
const SaveFolderSettings = () => {
const { t } = useTranslation();
const [currentFolder, setCurrentFolder] = useState("");
const [customFolder, setCustomFolder] = useState(null);
const [loading, setLoading] = useState(false);
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
const [moveProgress, setMoveProgress] = useState(null);
const [venvDeleteStatus, setVenvDeleteStatus] = useState(null);
const moveDialogRef = useRef(null);
const confirmRef = useRef(null);
const [confirmAction, setConfirmAction] = useState(null);
const [processStarting, setProcessStarting] = useState(false);
useEffect(() => {
if (!isElectron()) return;
// Get the resolved save folder
window.electron.ipcRenderer.invoke("get-save-folder").then(setCurrentFolder);
// Get the custom folder (if any)
window.electron.ipcRenderer.invoke("get-custom-save-folder").then(setCustomFolder);
}, []);
useEffect(() => {
if (!isElectron()) return;
const handler = (_event, data) => {
setProcessStarting(false);
setMoveProgress(data);
setMoveDialogOpen(true);
if (data.phase === "delete-done") {
setMoveDialogOpen(false);
setMoveProgress(null);
setVenvDeleteStatus(null);
sonnerSuccessToast(t("toast.folderChanged"));
}
};
window.electron.ipcRenderer.on("move-folder-progress", handler);
return () => window.electron.ipcRenderer.removeAllListeners("move-folder-progress");
}, [t]);
useEffect(() => {
if (!isElectron()) return;
const handler = (_event, data) => {
setProcessStarting(false);
setVenvDeleteStatus(data);
};
window.electron.ipcRenderer.on("venv-delete-status", handler);
return () => window.electron.ipcRenderer.removeAllListeners("venv-delete-status");
}, []);
const handleChooseFolder = async () => {
setLoading(true);
setMoveDialogOpen(false);
setMoveProgress(null);
setVenvDeleteStatus(null);
setProcessStarting(true);
try {
// Open folder dialog via Electron
const folderPaths = await window.electron.ipcRenderer.invoke("show-open-dialog", {
properties: ["openDirectory"],
title: t("settingPage.saveFolderSettings.saveFolderChooseBtn"),
});
if (folderPaths && folderPaths.length > 0) {
setMoveDialogOpen(true);
setMoveProgress({ moved: 0, total: 1, phase: "copy", name: "" });
const selected = folderPaths[0];
const result = await window.electron.ipcRenderer.invoke(
"set-custom-save-folder",
selected
);
setMoveDialogOpen(false);
setMoveProgress(null);
if (result.success) {
setCustomFolder(selected);
setCurrentFolder(result.newPath || selected);
} else {
let msg = t(`toast.${result.error}`);
if (result.reason) {
msg += ` ${t(result.reason)}`;
}
sonnerErrorToast(msg);
}
}
} finally {
setLoading(false);
}
};
const handleResetDefault = async () => {
setLoading(true);
setMoveDialogOpen(false);
setMoveProgress(null);
setVenvDeleteStatus(null);
setProcessStarting(true);
try {
setMoveDialogOpen(true);
setMoveProgress({ moved: 0, total: 1, phase: "copy", name: "" });
const result = await window.electron.ipcRenderer.invoke("set-custom-save-folder", null);
setMoveDialogOpen(false);
setMoveProgress(null);
setCustomFolder(null);
setCurrentFolder(result.newPath || "");
} finally {
setLoading(false);
}
};
const openChooseDialog = () => {
setConfirmAction("choose");
confirmRef.current.showModal();
};
const openResetDialog = () => {
setConfirmAction("reset");
confirmRef.current.showModal();
};
const handleConfirm = () => {
if (confirmAction === "choose") {
handleChooseFolder();
} else if (confirmAction === "reset") {
handleResetDefault();
}
confirmRef.current.close();
setConfirmAction(null);
};
const handleCancel = () => {
confirmRef.current.close();
setConfirmAction(null);
};
if (!isElectron()) return null;
return (
<>
{t("settingPage.saveFolderSettings.saveFolderHeading")}
{t("settingPage.saveFolderSettings.saveFolderDescription")}
{t("settingPage.saveFolderSettings.saveFolderCurrentFolder")}{" "}
{currentFolder}
{loading ? (
) : (
t("settingPage.saveFolderSettings.saveFolderChooseBtn")
)}
{customFolder && (
{t("settingPage.saveFolderSettings.saveFolderResetBtn")}
)}
{t("settingPage.saveFolderSettings.saveFolderConfirmTitle")}
{t("settingPage.saveFolderSettings.saveFolderConfirmDescription", {
returnObjects: true,
}).map((desc, index) => (
{desc}
))}
{t("settingPage.saveFolderSettings.saveFolderConfirmBtn")}
{t("settingPage.saveFolderSettings.saveFolderConfirmCancelBtn")}
{moveDialogOpen && (
{t("settingPage.saveFolderSettings.saveFolderMovingTitle")}
{processStarting ? (
) : venvDeleteStatus && venvDeleteStatus.status === "deleting" ? (
<>
{t("settingPage.saveFolderSettings.saveFolderDeleteVenv")}
>
) : moveProgress ? (
<>
{t(
`settingPage.saveFolderSettings.${
moveProgress.phase === "copy"
? "saveFolderCopyPhase"
: moveProgress.phase === "delete"
? "saveFolderDeletePhase"
: "saveFolderDeleteEmptyDirPhase"
}`
)}{" "}
{moveProgress.name}
{moveProgress.moved} / {moveProgress.total}{" "}
{t("settingPage.saveFolderSettings.saveFolderMovingFiles")}
>
) : null}
{t("settingPage.saveFolderSettings.saveFolderMovingWarning")}
)}
>
);
};
export default SaveFolderSettings;
================================================
FILE: src/components/setting_page/Settings.jsx
================================================
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Container from "../../ui/Container";
import isElectron from "../../utils/isElectron";
import TopNavBar from "../general/TopNavBar";
import AppearanceSettings from "./Appearance";
import AppInfo from "./AppInfo";
import ExerciseTimer from "./ExerciseTimer";
import LanguageSwitcher from "./LanguageSwitcher";
import LogSettings from "./LogSettings";
import PronunciationSettings from "./PronunciationSettings";
import ResetSettings from "./ResetSettings";
import SavedRecordingLocationMenu from "./SavedRecordingLocationMenu";
import SaveFolderSettings from "./SaveFolderSettings";
import VideoDownloadMenu from "./VideoDownloadMenu";
import VideoDownloadSubPage from "./VideoDownloadSubPage";
const SettingsPage = () => {
const { t } = useTranslation();
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.settings")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
const [currentPage, setCurrentPage] = useState("settings");
const handleVideoDownloadMenuPage = () => {
setCurrentPage("video-download");
};
const handleGoBackToSettings = () => {
setCurrentPage("settings");
};
useEffect(() => {
window.scrollTo(0, 0);
}, []);
return (
<>
{currentPage === "settings" && (
<>
{t("settingPage.heading")}
{isElectron() && (
<>
>
)}
{isElectron() && (
<>
>
)}
{isElectron() && (
<>
>
)}
{isElectron() && (
<>
>
)}
{isElectron() && (
<>
>
)}
{isElectron() && (
<>
>
)}
>
)}
{currentPage === "video-download" && (
)}
>
);
};
export default SettingsPage;
================================================
FILE: src/components/setting_page/VideoDownloadMenu.jsx
================================================
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const VideoDownloadMenu = ({ onClick }) => {
const { t } = useTranslation();
return (
{t("settingPage.videoDownloadSettings.videoDownloadHeading")}
{t("settingPage.videoDownloadSettings.videoDownloadOption")}
);
};
VideoDownloadMenu.propTypes = {
onClick: PropTypes.func.isRequired,
};
export default VideoDownloadMenu;
================================================
FILE: src/components/setting_page/VideoDownloadSubPage.jsx
================================================
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { BsArrowLeft } from "react-icons/bs";
import { IoWarningOutline } from "react-icons/io5";
import { LuExternalLink } from "react-icons/lu";
import isElectron from "../../utils/isElectron";
import VideoDownloadTable from "./VideoDownloadTable";
const VideoDownloadSubPage = ({ onGoBack }) => {
const { t } = useTranslation();
const [, setFolderPath] = useState(null);
const [zipFileData, setZipFileData] = useState([]);
const [isDownloaded, setIsDownloaded] = useState([]);
const [tableLoading, setTableLoading] = useState(true);
const handleOpenFolder = async () => {
// Send an IPC message to open the folder and get the folder path
const videoFolder = await window.electron.ipcRenderer.invoke("get-video-save-folder");
setFolderPath(videoFolder); // Save the folder path in state
};
// Fetch JSON data from Electron's main process via IPC when component mounts
useEffect(() => {
const fetchData = async () => {
try {
const data = await window.electron.ipcRenderer.invoke("get-video-file-data");
setZipFileData(data); // Set the JSON data into the state
} catch (error) {
console.error("Error reading JSON file:", error); // Handle any error
isElectron() && window.electron.log("error", `Error reading JSON file: ${error}`);
}
};
fetchData(); // Call fetchData when component mounts
}, []); // Empty dependency array means this effect runs once when the component mounts
const checkDownloadedFiles = useCallback(async () => {
try {
const downloadedFiles = await window.electron.ipcRenderer.invoke("check-downloads");
console.log("Downloaded files in folder:", downloadedFiles);
isElectron() &&
window.electron.log("log", `Downloaded files in folder: ${downloadedFiles}`);
// Initialize fileStatus as an array to hold individual statuses
const newFileStatus = [];
for (const item of zipFileData) {
let extractedFolderExists;
try {
extractedFolderExists = await window.electron.ipcRenderer.invoke(
"check-extracted-folder",
item.zipFile.replace(".7z", ""),
item.zipContents
);
} catch (error) {
console.error(`Error checking extracted folder for ${item.zipFile}:`, error);
isElectron() &&
window.electron.log(
"error",
`Error checking extracted folder for ${item.zipFile}: ${error}`
);
extractedFolderExists = false; // Default to false if there's an error
}
const isDownloadedFile = downloadedFiles.includes(item.zipFile);
newFileStatus.push({
zipFile: item.zipFile,
isDownloaded: isDownloadedFile,
hasExtractedFolder: extractedFolderExists,
});
}
// Update the state with an array of statuses instead of a single object
setIsDownloaded(newFileStatus);
console.log(newFileStatus);
} catch (error) {
console.error("Error checking downloaded or extracted files:", error);
isElectron() &&
window.electron.log(
"error",
`Error checking downloaded or extracted files: ${error}`
);
}
}, [zipFileData]);
useEffect(() => {
if (zipFileData.length > 0) {
setTableLoading(true);
checkDownloadedFiles().finally(() => setTableLoading(false));
}
}, [zipFileData, checkDownloadedFiles]);
const localizedInstructionStep = t("settingPage.videoDownloadSettings.steps", {
returnObjects: true,
});
const stepCount = localizedInstructionStep.length;
const stepKeys = Array.from(
{ length: stepCount },
(_, i) => `settingPage.videoDownloadSettings.steps.${i}`
);
return (
{t("settingPage.videoDownloadSettings.videoPageHeading")}
{t("settingPage.videoDownloadSettings.instructionCardHeading")}
{stepKeys.map((key, index) => (
}}
/>
))}
{t("settingPage.videoDownloadSettings.warningHeading")}
{t("settingPage.videoDownloadSettings.warningBody")}
{t("settingPage.videoDownloadSettings.openDownloadBtn")}
{t("settingPage.videoDownloadSettings.refreshDownloadStateBtn")}
{tableLoading ? (
) : (
)}
);
};
VideoDownloadSubPage.propTypes = {
onGoBack: PropTypes.func.isRequired,
};
export default VideoDownloadSubPage;
================================================
FILE: src/components/setting_page/VideoDownloadTable.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { Trans } from "react-i18next";
import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs";
import { LuExternalLink } from "react-icons/lu";
const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => {
const [, setShowVerifyModal] = useState(false);
const [showProgressModal, setShowProgressModal] = useState(false);
const [selectedZip, setSelectedZip] = useState(null);
const [verifyFiles, setVerifyFiles] = useState([]);
const [progress, setProgress] = useState(0);
const [modalMessage, setModalMessage] = useState("");
const [isSuccess, setIsSuccess] = useState(null);
const [progressText, setProgressText] = useState("");
const [isPercentage, setIsPercentage] = useState(false);
const [progressError, setProgressError] = useState(false);
const [verificationErrors, setVerificationErrors] = useState([]);
const verifyModal = useRef(null);
const progressModal = useRef(null);
useEffect(() => {
window.scrollTo(0, 0);
const handleProgressUpdate = (event, percentage) => {
setProgress(percentage);
setIsPercentage(true); // Use percentage-based progress
};
const handleProgressText = (event, text) => {
setProgressText(t(text)); // Update progress text
setIsPercentage(false); // Not percentage-based, show full progress bar
};
const handleVerificationSuccess = (event, data) => {
setModalMessage(
<>
{t(data.messageKey)}{" "}
{data.param}
>
);
setIsSuccess(true);
setShowProgressModal(false); // Hide modal after success
setVerificationErrors([]);
if (onStatusChange) onStatusChange(); // Trigger parent to refresh status
};
const handleVerificationError = (event, data) => {
setModalMessage(
{data.param}
{data.errorMessage ? `Error message: ${data.errorMessage}` : ""}
);
setIsSuccess(false);
setShowProgressModal(false); // Hide modal on error
setVerificationErrors([]);
if (onStatusChange) onStatusChange(); // Trigger parent to refresh status
};
const handleVerificationErrors = (event, errors) => {
setVerificationErrors(errors);
setIsSuccess(false);
setShowProgressModal(false);
setModalMessage("");
};
window.electron.ipcRenderer.on("progress-update", handleProgressUpdate);
window.electron.ipcRenderer.on("progress-text", handleProgressText);
window.electron.ipcRenderer.on("verification-success", handleVerificationSuccess);
window.electron.ipcRenderer.on("verification-error", handleVerificationError);
window.electron.ipcRenderer.on("verification-errors", handleVerificationErrors);
return () => {
window.electron.ipcRenderer.removeAllListeners("progress-update");
window.electron.ipcRenderer.removeAllListeners("progress-text");
window.electron.ipcRenderer.removeAllListeners("verification-success");
window.electron.ipcRenderer.removeAllListeners("verification-error");
window.electron.ipcRenderer.removeAllListeners("verification-errors");
};
}, [t, data.messageKey, data.param, onStatusChange]);
const handleVerify = async (zip) => {
if (onStatusChange) {
// Await refresh if onStatusChange returns a promise
await onStatusChange();
}
// Find the latest file status after refresh
const fileStatus = isDownloaded.find((status) => status.zipFile === zip.zipFile);
// Allow verify if extracted folder exists, even if not downloaded
const fileToVerify =
fileStatus && zip.name && (fileStatus.isDownloaded || fileStatus.hasExtractedFolder)
? [{ name: zip.name }]
: fileStatus && fileStatus.hasExtractedFolder && zip.name
? [{ name: zip.name }]
: [];
setSelectedZip(zip);
setVerifyFiles(fileToVerify);
setShowVerifyModal(true);
verifyModal.current?.showModal();
};
const handleNextModal = async () => {
if (onStatusChange) {
await onStatusChange();
}
const fileStatus = isDownloaded.find((status) => status.zipFile === selectedZip?.zipFile);
// Allow verify if extracted folder exists, even if not downloaded
const fileToVerify =
fileStatus &&
selectedZip?.name &&
(fileStatus.isDownloaded || fileStatus.hasExtractedFolder)
? [{ name: selectedZip.name }]
: fileStatus && fileStatus.hasExtractedFolder && selectedZip?.name
? [{ name: selectedZip.name }]
: [];
if (fileToVerify.length === 0) {
setShowVerifyModal(false);
setShowProgressModal(false);
setIsSuccess(false);
setProgressError(true);
setProgressText("");
setModalMessage(t("settingPage.videoDownloadSettings.verifyFailedMessage"));
progressModal.current?.showModal();
verifyModal.current?.close();
return;
}
setShowVerifyModal(false);
setShowProgressModal(true);
setProgressError(false);
setProgressText(t("settingPage.videoDownloadSettings.verifyinProgressMsg"));
window.electron.ipcRenderer.send("verify-and-extract", selectedZip);
progressModal.current?.showModal();
verifyModal.current?.close();
};
const handleCloseVerifyModal = () => {
setShowVerifyModal(false);
setVerificationErrors([]);
setModalMessage("");
verifyModal.current?.close();
};
const handleCloseProgressModal = () => {
setModalMessage("");
setVerificationErrors([]);
progressModal.current?.close();
};
return (
<>
{t("settingPage.videoDownloadSettings.downloadTable.nameHeading")}
{t(
"settingPage.videoDownloadSettings.downloadTable.fileSizeTotalHeading"
)}
{t("settingPage.videoDownloadSettings.downloadTable.linkHeading")}
{t(
"settingPage.videoDownloadSettings.downloadTable.downloadStatusHeading"
)}
{t("settingPage.videoDownloadSettings.downloadTable.actionHeading")}
{data.map((item) => {
// Find the corresponding download status for the current item
const fileStatus = isDownloaded.find(
(status) => status.zipFile === item.zipFile
);
return (
{t(item.name)}
{(item.fileSize / (1024 * 1024)).toFixed(2)} MB
window.electron.openExternal(item.link)}
>
{t(
"settingPage.videoDownloadSettings.downloadTable.downloadLink"
)}
{fileStatus ? ( // Check if fileStatus is found
fileStatus.isDownloaded ||
fileStatus.hasExtractedFolder ? (
) : (
)
) : (
// Handle the case where fileStatus is not found
// This can happen if `isDownloaded` array does not include all items in `data`
Loading...
)}
await handleVerify(item)}
disabled={
!fileStatus ||
(!fileStatus.isDownloaded &&
!fileStatus.hasExtractedFolder)
} // Disable if fileStatus not found or not downloaded
>
{(() => {
if (!fileStatus)
return t(
"settingPage.videoDownloadSettings.downloadTable.extractBtn"
);
if (fileStatus.hasExtractedFolder)
return t(
"settingPage.videoDownloadSettings.downloadTable.verifyBtn"
);
if (fileStatus.isDownloaded)
return t(
"settingPage.videoDownloadSettings.downloadTable.extractBtn"
);
return t(
"settingPage.videoDownloadSettings.downloadTable.extractBtn"
);
})()}
);
})}
{t("settingPage.videoDownloadSettings.verifyModalHeading")}
{verifyFiles.length > 0 ? (
<>
{t("settingPage.videoDownloadSettings.verifyTitle")}
{verifyFiles.map((file, idx) => (
{t(file.name)}
))}
{t("settingPage.videoDownloadSettings.verifyFooter")}
>
) : (
{t("settingPage.videoDownloadSettings.verifyFailedMessage")}
)}
{t("settingPage.exerciseSettings.cancelBtn")}
await handleNextModal()}
disabled={verifyFiles.length === 0}
>
{t("settingPage.videoDownloadSettings.nextBtn")}
{showProgressModal === true
? t("settingPage.videoDownloadSettings.inProgressModalHeading")
: isSuccess
? t("settingPage.videoDownloadSettings.verifySuccess")
: t("settingPage.videoDownloadSettings.verifyFailed")}
{showProgressModal === true && !progressError ? (
<>
{progressText}
{isPercentage ? `${progress}%` : null}
>
) : verificationErrors.length > 0 ? (
<>
{verificationErrors.some((err) => err.type === "missing")
? t("settingPage.videoDownloadSettings.fileMissing")
: t("settingPage.videoDownloadSettings.hashMismatch")}
{verificationErrors.map((err, idx) => (
{err.name}
))}
>
) : (
{modalMessage}
)}
{t("sound_page.closeBtn")}
>
);
};
VideoDownloadTable.propTypes = {
t: PropTypes.func.isRequired,
data: PropTypes.array.isRequired,
isDownloaded: PropTypes.array.isRequired,
onStatusChange: PropTypes.func,
};
export default VideoDownloadTable;
================================================
FILE: src/components/sound_page/ReviewCard.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import {
BsEmojiFrown,
BsEmojiFrownFill,
BsEmojiNeutral,
BsEmojiNeutralFill,
BsEmojiSmile,
BsEmojiSmileFill,
} from "react-icons/bs";
import { sonnerSuccessToast, sonnerWarningToast } from "../../utils/sonnerCustomToast";
import { Trans } from "react-i18next";
import he from "he";
import { checkRecordingExists } from "../../utils/databaseOperations";
const ReviewCard = ({ sound, accent, t, onReviewUpdate }) => {
const [review, setReview] = useState(null);
const [hasRecording, setHasRecording] = useState(false);
// Load review from localStorage on mount
useEffect(() => {
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
const soundReview = storedData.soundReview?.[accent]?.[`${sound.type}${sound.id}`] || null;
setReview(soundReview);
}, [accent, sound]);
// Check if recording exists
useEffect(() => {
const checkRecording = async () => {
const recordingKey = `${sound.type === "consonants" ? "constant" : sound.type === "vowels" ? "vowel" : "dipthong"}-${accent}-${sound.id}-0`;
const exists = await checkRecordingExists(recordingKey);
setHasRecording(exists);
};
checkRecording();
}, [accent, sound]);
const handleReviewClick = (type) => {
if (!hasRecording) {
sonnerWarningToast(t("toast.noRecording"));
return;
}
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
storedData.soundReview = storedData.soundReview || {};
storedData.soundReview[accent] = storedData.soundReview[accent] || {};
storedData.soundReview[accent][`${sound.type}${sound.id}`] = type;
localStorage.setItem("ispeaker", JSON.stringify(storedData));
setReview(type);
sonnerSuccessToast(t("toast.reviewUpdated"));
if (onReviewUpdate) {
onReviewUpdate();
}
};
const emojiStyle = (reviewType) => {
const styles = {
good: "text-success",
neutral: "text-warning",
bad: "text-error",
};
return review === reviewType ? styles[reviewType] : "";
};
return (
{he.decode(sound.phoneme)} ?
);
};
ReviewCard.propTypes = {
sound: PropTypes.shape({
phoneme: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
type: PropTypes.oneOf(["consonants", "vowels", "diphthongs"]).isRequired,
}).isRequired,
accent: PropTypes.oneOf(["british", "american"]).isRequired,
t: PropTypes.func.isRequired,
onReviewUpdate: PropTypes.func,
};
export default ReviewCard;
================================================
FILE: src/components/sound_page/SoundList.jsx
================================================
import he from "he";
import PropTypes from "prop-types";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Container from "../../ui/Container";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import isElectron from "../../utils/isElectron";
import { useScrollTo } from "../../utils/useScrollTo";
import AccentDropdown from "../general/AccentDropdown";
import LoadingOverlay from "../general/LoadingOverlay";
import TopNavBar from "../general/TopNavBar";
const SoundMain = lazy(() => import("./SoundMain"));
const BADGE_COLORS = {
good: "badge-success",
neutral: "badge-warning",
bad: "badge-error",
};
const TabNavigation = ({ activeTab, onTabChange, scrollTo, t }) => {
const tabs = ["consonants", "vowels", "diphthongs"];
return (
);
};
TabNavigation.propTypes = {
activeTab: PropTypes.string.isRequired,
onTabChange: PropTypes.func.isRequired,
scrollTo: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
const useReviews = (selectedAccent) => {
const [reviews, setReviews] = useState({});
const [reviewsUpdateTrigger, setReviewsUpdateTrigger] = useState(0);
useEffect(() => {
const fetchReviews = () => {
const ispeakerData = JSON.parse(localStorage.getItem("ispeaker") || "{}");
const accentReviews = ispeakerData.soundReview?.[selectedAccent] || {};
setReviews(accentReviews);
};
fetchReviews();
}, [selectedAccent, reviewsUpdateTrigger]);
const triggerReviewsUpdate = () => setReviewsUpdateTrigger((prev) => prev + 1);
return { reviews, triggerReviewsUpdate };
};
const SoundCard = ({
sound,
index,
selectedAccent,
handlePracticeClick,
getBadgeColor,
getReviewText,
getReviewKey,
reviews,
t,
}) => {
const badgeColor = getBadgeColor(sound, index);
const reviewKey = getReviewKey(sound, index);
const reviewText = badgeColor ? getReviewText(reviews[reviewKey]) : null;
return (
{badgeColor && (
{reviewText}
)}
{he.decode(sound.phoneme)}
handlePracticeClick(sound, selectedAccent, index)}
aria-label={t("sound_page.practiceBtn", {
sound: he.decode(sound.phoneme),
})}
>
{t("sound_page.practiceBtn")}
);
};
SoundCard.propTypes = {
sound: PropTypes.shape({
phoneme: PropTypes.string.isRequired,
word: PropTypes.string.isRequired,
british: PropTypes.bool.isRequired,
american: PropTypes.bool.isRequired,
id: PropTypes.number.isRequired,
}).isRequired,
index: PropTypes.number.isRequired,
selectedAccent: PropTypes.string.isRequired,
handlePracticeClick: PropTypes.func.isRequired,
getBadgeColor: PropTypes.func.isRequired,
getReviewText: PropTypes.func.isRequired,
getReviewKey: PropTypes.func.isRequired,
reviews: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
const SoundList = () => {
const { t } = useTranslation();
const { ref: scrollRef, scrollTo } = useScrollTo();
const [selectedSound, setSelectedSound] = useState(null);
const [loading, setLoading] = useState(true);
const [selectedAccent, setSelectedAccent] = AccentLocalStorage();
const [activeTab, setActiveTab] = useState("consonants");
const [phonemesData, setPhonemesData] = useState({
consonants: [],
vowels: [],
diphthongs: [],
});
const { reviews, triggerReviewsUpdate } = useReviews(selectedAccent);
const handlePracticeClick = (sound, accent, index) => {
setSelectedSound({
sound: { ...sound, type: activeTab },
accent,
index,
});
};
const handleGoBack = () => {
setSelectedSound(null);
triggerReviewsUpdate();
};
const getReviewKey = (sound, index) => `${activeTab}${index + 1}`;
const getBadgeColor = (sound, index) => {
const reviewKey = getReviewKey(sound, index);
return BADGE_COLORS[reviews[reviewKey]] || null;
};
const getReviewText = (review) =>
t(`sound_page.review${review?.charAt(0).toUpperCase() + review?.slice(1)}`);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(`${import.meta.env.BASE_URL}json/sounds_menu.json`);
const data = await response.json();
setPhonemesData(data.phonemes);
} catch (error) {
console.error("Error fetching menu data:", error);
alert(t("sound_page.loadError"));
} finally {
setLoading(false);
}
};
fetchData();
}, [t]);
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.sounds")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
const filteredSounds = useMemo(() => {
const currentTabData = phonemesData[activeTab] || [];
return currentTabData.filter((sound) => sound[selectedAccent]);
}, [activeTab, selectedAccent, phonemesData]);
return (
<>
{t("navigation.sounds")}
{selectedSound ? (
}>
) : (
<>
{loading ? (
) : (
<>
{filteredSounds.map((sound, index) => (
))}
>
)}
>
)}
>
);
};
export default SoundList;
================================================
FILE: src/components/sound_page/SoundMain.jsx
================================================
import he from "he";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoChevronBackOutline } from "react-icons/io5";
import { MdChecklist, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md";
import { useScrollTo } from "../../utils/useScrollTo";
import LoadingOverlay from "../general/LoadingOverlay";
import { SoundVideoDialogProvider } from "./hooks/useSoundVideoDialog";
import ReviewCard from "./ReviewCard";
import SoundPracticeCard from "./SoundPracticeCard";
import TongueTwister from "./TongueTwister";
import WatchVideoCard from "./WatchVideoCard";
const PracticeSound = ({ sound, accent, onBack }) => {
const { t } = useTranslation();
const [soundsData, setSoundsData] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("watchTab");
const { ref: scrollRef, scrollTo } = useScrollTo();
// Fetch sounds data
useEffect(() => {
const fetchSoundsData = async () => {
try {
const response = await fetch(`${import.meta.env.BASE_URL}json/sounds_data.json`);
const data = await response.json();
setSoundsData(data);
} catch (error) {
console.error("Error fetching sounds data:", error);
} finally {
setLoading(false);
}
};
fetchSoundsData();
}, []);
// Find the sound data using the type and phoneme from sounds_menu.json
const soundData = soundsData?.[sound.type]?.find((item) => item.id === sound.id);
const accentData = soundData?.[accent]?.[0];
const handleReviewUpdate = () => {
// This function is intentionally empty as the review update is handled by the ReviewCard component
// We just need to pass a function to trigger the parent component's re-render
};
// Show loading while fetching data
if (loading) {
return ;
}
return (
{t("sound_page.soundTop")} {he.decode(sound.phoneme)}
{t("accent.accentSettings")}:{" "}
{accent === "american" ? t("accent.accentAmerican") : t("accent.accentBritish")}
{accentData && (
<>
{t("sound_page.exampleWords")}
{["initial", "medial", "final"].map((position) => (
))}
>
)}
{t("sound_page.backBtn")}
{activeTab === "watchTab" && (
)}
{activeTab === "practieTab" && (
{accentData && (
<>
{["main", "initial", "medial", "final"].map(
(position, index) => {
const isMain = position === "main";
const videoUrl = isMain
? accentData.mainOnlineVideo
: accentData.practiceOnlineVideos[index];
const offlineVideo = isMain
? accentData.mainOfflineVideo
: accentData.practiceOfflineVideos[index];
const textContent = isMain
? sound.phoneme
: accentData[position];
const cardIndex = isMain ? 0 : index;
const type =
sound.type === "consonants"
? "constant"
: sound.type === "vowels"
? "vowel"
: "dipthong";
return (
(isMain || accentData[position]) && (
)
);
}
)}
>
)}
)}
{activeTab === "reviewTab" && (
)}
);
};
PracticeSound.propTypes = {
sound: PropTypes.shape({
phoneme: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
type: PropTypes.oneOf(["consonants", "vowels", "diphthongs"]).isRequired,
key: PropTypes.string.isRequired,
}).isRequired,
accent: PropTypes.oneOf(["british", "american"]).isRequired,
onBack: PropTypes.func.isRequired,
};
export default PracticeSound;
================================================
FILE: src/components/sound_page/SoundPracticeCard.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { MdMic, MdOutlineOndemandVideo, MdPlayArrow, MdStop } from "react-icons/md";
import { checkRecordingExists, playRecording, saveRecording } from "../../utils/databaseOperations";
import isElectron from "../../utils/isElectron";
import {
sonnerErrorToast,
sonnerSuccessToast,
sonnerWarningToast,
} from "../../utils/sonnerCustomToast";
import { useSoundVideoDialog } from "./hooks/useSoundVideoDialogContext";
const MAX_RECORDING_DURATION_MS = 2 * 60 * 1000; // 2 minutes
const SoundPracticeCard = ({
textContent,
videoUrl,
offlineVideo,
accent,
t,
phoneme,
phonemeId,
index,
type,
shouldShowPhoneme = true,
}) => {
const [localVideoUrl, setLocalVideoUrl] = useState(null);
const [useOnlineVideo, setUseOnlineVideo] = useState(false);
const [iframeLoadingStates, setIframeLoadingStates] = useState({
modalIframe: true,
});
const [isRecording, setIsRecording] = useState(false);
const [hasRecording, setHasRecording] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const recordingStartTimeRef = useRef(null);
const [currentAudioSource, setCurrentAudioSource] = useState(null);
const [currentAudioElement, setCurrentAudioElement] = useState(null);
const { showDialog, isAnyCardActive, setCardActive } = useSoundVideoDialog();
const recordingKey = `${type}-${accent}-${phonemeId}-${index}`;
const cardId = `${type}-${accent}-${phonemeId}-${index}`;
useEffect(() => {
// Check if recording exists when component mounts
const checkExistingRecording = async () => {
const exists = await checkRecordingExists(recordingKey);
setHasRecording(exists);
};
checkExistingRecording();
}, [recordingKey]);
const startRecording = async () => {
try {
setCardActive(cardId, true);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Get supported MIME types
const mimeTypes = ["audio/webm", "audio/mp4", "audio/ogg", "audio/wav"];
// Find the first supported MIME type
const supportedMimeType =
mimeTypes.find((type) => MediaRecorder.isTypeSupported(type)) || "audio/webm"; // Fallback to webm if none supported
const recordOptions = {
audioBitsPerSecond: 128000,
mimeType: supportedMimeType,
};
const mediaRecorder = new MediaRecorder(stream, recordOptions);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
recordingStartTimeRef.current = Date.now();
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: supportedMimeType });
await saveRecording(audioBlob, recordingKey, supportedMimeType);
setHasRecording(true);
stream.getTracks().forEach((track) => track.stop());
sonnerSuccessToast(t("toast.recordingSuccess"));
};
// Start recording
mediaRecorder.start();
setIsRecording(true);
// Automatically stop recording after time limit
setTimeout(() => {
if (mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
sonnerWarningToast(t("toast.recordingExceeded"));
setIsRecording(false);
}
}, MAX_RECORDING_DURATION_MS);
} catch (error) {
console.error("Error starting recording:", error);
if (isElectron()) {
window.electron.log("error", `Error accessing the microphone: ${error}`);
}
sonnerErrorToast(`${t("toast.recordingFailed")} ${error.message}`);
setIsRecording(false);
setCardActive(cardId, false);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
const recordingDuration = Date.now() - recordingStartTimeRef.current;
if (recordingDuration > MAX_RECORDING_DURATION_MS) {
sonnerWarningToast(t("toast.recordingExceeded"));
}
mediaRecorderRef.current.stop();
setIsRecording(false);
setCardActive(cardId, false);
}
};
const handlePlayRecording = async () => {
if (isPlaying) {
// Stop playback
if (currentAudioSource) {
currentAudioSource.stop();
setCurrentAudioSource(null);
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
setCurrentAudioElement(null);
}
setIsPlaying(false);
setCardActive(cardId, false);
return;
}
setIsPlaying(true);
setCardActive(cardId, true);
await playRecording(
recordingKey,
(audio, audioSource) => {
if (audioSource) {
setCurrentAudioSource(audioSource);
} else {
setCurrentAudioElement(audio);
}
},
(error) => {
console.error("Error playing recording:", error);
sonnerErrorToast(t("toast.playbackFailed"));
setIsPlaying(false);
setCardActive(cardId, false);
},
() => {
setIsPlaying(false);
setCurrentAudioSource(null);
setCurrentAudioElement(null);
setCardActive(cardId, false);
}
);
};
const imgPhonemeThumbSrc =
accent === "american"
? `${import.meta.env.BASE_URL}images/ispeaker/sound_images/sounds_american.webp`
: `${import.meta.env.BASE_URL}images/ispeaker/sound_images/sounds_british.webp`;
const handleIframeLoad = (iframeKey) => {
setIframeLoadingStates((prevStates) => ({
...prevStates,
[iframeKey]: false,
}));
};
const handleShow = () => {
showDialog({
videoUrl: isElectron() && !useOnlineVideo ? localVideoUrl : videoUrl,
title: textContent.split(" - ")[0],
phoneme,
isLocalVideo: localVideoUrl && !useOnlineVideo,
onIframeLoad: () => handleIframeLoad("modalIframe"),
iframeLoading: iframeLoadingStates.modalIframe,
showOnlineVideoAlert: isElectron() && useOnlineVideo,
t,
});
};
useEffect(() => {
const checkLocalVideo = async () => {
if (isElectron() && offlineVideo) {
try {
const currentPort = await window.electron.ipcRenderer.invoke("get-port");
const folderName = `iSpeakerReact_SoundVideos_${accent === "british" ? "GB" : "US"}`;
const localUrl = `http://localhost:${currentPort}/video/${folderName}/${offlineVideo}`;
// Check if the video exists
const response = await fetch(localUrl, { method: "HEAD" });
if (response.ok) {
setLocalVideoUrl(localUrl);
setUseOnlineVideo(false);
} else {
setUseOnlineVideo(true);
}
} catch (error) {
console.error("Error checking local video:", error);
setUseOnlineVideo(true);
}
} else {
setUseOnlineVideo(true);
}
};
checkLocalVideo();
}, [offlineVideo, accent]);
// Add cleanup effect
useEffect(() => {
return () => {
// Cleanup recording resources
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
// Cleanup audio playback resources
if (currentAudioSource) {
currentAudioSource.stop();
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
}
// Cleanup any active media streams
if (mediaRecorderRef.current?.stream) {
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
}
};
}, [currentAudioSource, currentAudioElement]);
if (!shouldShowPhoneme) {
return null;
}
return (
{isRecording ? (
) : (
)}
{isPlaying ? (
) : (
)}
);
};
SoundPracticeCard.propTypes = {
textContent: PropTypes.string.isRequired,
videoUrl: PropTypes.string.isRequired,
offlineVideo: PropTypes.string.isRequired,
accent: PropTypes.oneOf(["british", "american"]).isRequired,
t: PropTypes.func.isRequired,
phoneme: PropTypes.string.isRequired,
phonemeId: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
type: PropTypes.oneOf(["constant", "vowel", "dipthong"]).isRequired,
shouldShowPhoneme: PropTypes.bool,
};
export default SoundPracticeCard;
================================================
FILE: src/components/sound_page/TongueTwister.jsx
================================================
import he from "he";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { MdMic, MdPlayArrow, MdStop } from "react-icons/md";
import { checkRecordingExists, playRecording, saveRecording } from "../../utils/databaseOperations";
import isElectron from "../../utils/isElectron";
import {
sonnerErrorToast,
sonnerSuccessToast,
sonnerWarningToast,
} from "../../utils/sonnerCustomToast";
const MAX_RECORDING_DURATION_MS = 2 * 60 * 1000; // 2 minutes
const TongueTwister = ({ tongueTwisters, t, sound, accent }) => {
const [isRecording, setIsRecording] = useState(false);
const [hasRecording, setHasRecording] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
const [currentPlayingIndex, setCurrentPlayingIndex] = useState(null);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const recordingStartTimeRef = useRef(null);
const [currentAudioSource, setCurrentAudioSource] = useState(null);
const [currentAudioElement, setCurrentAudioElement] = useState(null);
// Check if recordings exist for each tongue twister
useEffect(() => {
const checkRecordings = async () => {
const existsArray = await Promise.all(
tongueTwisters.map((_, index) => {
const recordingKey = `${sound.type === "consonants" ? "constant" : sound.type === "vowels" ? "vowel" : "dipthong"}-${accent}-${sound.id}-tt-${index}`;
return checkRecordingExists(recordingKey);
})
);
setHasRecording(existsArray);
};
if (tongueTwisters) {
checkRecordings();
}
}, [tongueTwisters, sound, accent]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Cleanup recording resources
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
// Cleanup audio playback resources
if (currentAudioSource) {
currentAudioSource.stop();
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
}
// Cleanup any active media streams
if (mediaRecorderRef.current?.stream) {
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
}
};
}, [currentAudioSource, currentAudioElement]);
const startRecording = async (index) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeTypes = ["audio/webm", "audio/mp4", "audio/ogg", "audio/wav"];
const supportedMimeType =
mimeTypes.find((type) => MediaRecorder.isTypeSupported(type)) || "audio/webm";
const recordOptions = {
audioBitsPerSecond: 128000,
mimeType: supportedMimeType,
};
const mediaRecorder = new MediaRecorder(stream, recordOptions);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
recordingStartTimeRef.current = Date.now();
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: supportedMimeType });
const recordingKey = `${sound.type === "consonants" ? "constant" : sound.type === "vowels" ? "vowel" : "dipthong"}-${accent}-${sound.id}-tt-${index}`;
await saveRecording(audioBlob, recordingKey, supportedMimeType);
setHasRecording((prev) => {
const updated = [...prev];
updated[index] = true;
return updated;
});
stream.getTracks().forEach((track) => track.stop());
sonnerSuccessToast(t("toast.recordingSuccess"));
};
mediaRecorder.start();
setIsRecording(true);
setCurrentPlayingIndex(index);
setTimeout(() => {
if (mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
sonnerWarningToast(t("toast.recordingExceeded"));
setIsRecording(false);
setCurrentPlayingIndex(null);
}
}, MAX_RECORDING_DURATION_MS);
} catch (error) {
console.error("Error starting recording:", error);
if (isElectron()) {
window.electron.log("error", `Error accessing the microphone: ${error}`);
}
sonnerErrorToast(`${t("toast.recordingFailed")} ${error.message}`);
setIsRecording(false);
setCurrentPlayingIndex(null);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
const recordingDuration = Date.now() - recordingStartTimeRef.current;
if (recordingDuration > MAX_RECORDING_DURATION_MS) {
sonnerWarningToast(t("toast.recordingExceeded"));
}
mediaRecorderRef.current.stop();
setIsRecording(false);
setCurrentPlayingIndex(null);
}
};
const handlePlayRecording = async (index) => {
if (isPlaying && currentPlayingIndex === index) {
if (currentAudioSource) {
currentAudioSource.stop();
setCurrentAudioSource(null);
}
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
setCurrentAudioElement(null);
}
setIsPlaying(false);
setCurrentPlayingIndex(null);
return;
}
setIsPlaying(true);
setCurrentPlayingIndex(index);
const recordingKey = `${sound.type === "consonants" ? "constant" : sound.type === "vowels" ? "vowel" : "dipthong"}-${accent}-${sound.id}-tt-${index}`;
await playRecording(
recordingKey,
(audio, audioSource) => {
if (audioSource) {
setCurrentAudioSource(audioSource);
} else {
setCurrentAudioElement(audio);
}
},
(error) => {
console.error("Error playing recording:", error);
sonnerErrorToast(t("toast.playbackFailed"));
setIsPlaying(false);
setCurrentPlayingIndex(null);
},
() => {
setIsPlaying(false);
setCurrentAudioSource(null);
setCurrentAudioElement(null);
setCurrentPlayingIndex(null);
}
);
};
if (!tongueTwisters || tongueTwisters.length === 0) {
return null;
}
return (
{t("sound_page.tongueTwister")}
{t("sound_page.tongueTwisterInstructions")}
{tongueTwisters.map((twister, index) => (
{twister.title && (
{twister.title}
)}
{twister.lines ? (
{twister.lines.map((line, lineIndex) => (
{he.decode(line)}
))}
) : (
{he.decode(twister.text)}
)}
startRecording(index)
}
disabled={
isPlaying ||
(isRecording && currentPlayingIndex !== index)
}
>
{isRecording && currentPlayingIndex === index ? (
) : (
)}
handlePlayRecording(index)}
disabled={
!hasRecording[index] ||
isRecording ||
(isPlaying && currentPlayingIndex !== index)
}
>
{isPlaying && currentPlayingIndex === index ? (
) : (
)}
))}
);
};
TongueTwister.propTypes = {
tongueTwisters: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.string,
title: PropTypes.string,
lines: PropTypes.arrayOf(PropTypes.string),
})
),
t: PropTypes.func.isRequired,
sound: PropTypes.shape({
type: PropTypes.oneOf(["consonants", "vowels", "diphthongs"]).isRequired,
id: PropTypes.number.isRequired,
}).isRequired,
accent: PropTypes.oneOf(["british", "american"]).isRequired,
};
export default TongueTwister;
================================================
FILE: src/components/sound_page/WatchVideoCard.jsx
================================================
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { IoInformationCircleOutline } from "react-icons/io5";
import isElectron from "../../utils/isElectron";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
const WatchVideoCard = ({ videoData, accent, t, phoneme }) => {
const [iframeLoading, setIframeLoading] = useState(true);
const [localVideoUrl, setLocalVideoUrl] = useState(null);
const [useOnlineVideo, setUseOnlineVideo] = useState(false);
const { autoDetectedTheme } = useAutoDetectTheme();
useEffect(() => {
const checkLocalVideo = async () => {
if (isElectron() && videoData?.mainOfflineVideo) {
try {
const port = await window.electron?.ipcRenderer?.invoke("get-port");
const folderName = `iSpeakerReact_SoundVideos_${accent === "british" ? "GB" : "US"}`;
const localUrl = `http://localhost:${port}/video/${folderName}/${videoData.mainOfflineVideo}`;
// Check if the local video exists
const response = await fetch(localUrl, { method: "HEAD" });
if (response.ok) {
setLocalVideoUrl(localUrl);
setUseOnlineVideo(false);
} else {
console.warn("Local video not found, falling back to online version");
setUseOnlineVideo(true);
}
} catch (error) {
console.warn(
"Error checking local video, falling back to online version:",
error
);
setUseOnlineVideo(true);
}
} else {
setUseOnlineVideo(true);
}
};
checkLocalVideo();
}, [videoData, accent]);
const handleIframeLoad = () => {
setIframeLoading(false);
};
const videoUrl = isElectron() && !useOnlineVideo ? localVideoUrl : videoData?.mainOnlineVideo;
// Get the pronunciation instructions based on the phoneme type
const getPronunciationInstructions = () => {
if (!phoneme) return null;
const phonemeType = phoneme.type; // 'consonant', 'vowel', or 'diphthong'
const phonemeKey = phoneme.key; // e.g., 'pPen', 'eeSee', 'aySay'
const instructions = t(`sound_page.soundInstructions.${phonemeType}.${phonemeKey}`, {
returnObjects: true,
});
return instructions;
};
const pronunciationInstructions = getPronunciationInstructions();
return (
<>
{t("tabConversationExam.watchCard")}
{localVideoUrl && !useOnlineVideo ? (
) : (
<>
{iframeLoading && (
)}
>
)}
{isElectron() && useOnlineVideo ? (
{t("alert.alertOnlineVideo")}
) : (
""
)}
{pronunciationInstructions && (
{t("sound_page.soundInstructions.howToPronounce")}
{pronunciationInstructions.map((instruction, index) => (
{instruction}
))}
)}
>
);
};
WatchVideoCard.propTypes = {
videoData: PropTypes.shape({
mainOfflineVideo: PropTypes.string,
mainOnlineVideo: PropTypes.string,
}),
accent: PropTypes.oneOf(["british", "american"]).isRequired,
t: PropTypes.func.isRequired,
phoneme: PropTypes.shape({
type: PropTypes.oneOf(["consonant", "vowel", "diphthong"]).isRequired,
key: PropTypes.string.isRequired,
}),
};
export default WatchVideoCard;
================================================
FILE: src/components/sound_page/hooks/useSoundVideoDialog.jsx
================================================
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default";
import PropTypes from "prop-types";
import { useRef, useState } from "react";
import { IoInformationCircleOutline } from "react-icons/io5";
import useAutoDetectTheme from "../../../utils/ThemeContext/useAutoDetectTheme";
import { SoundVideoDialogContext } from "./useSoundVideoDialogContext";
export const SoundVideoDialogProvider = ({ children, t }) => {
const [dialogState, setDialogState] = useState({
isOpen: false,
videoUrl: null,
title: "",
phoneme: "",
isLocalVideo: false,
onIframeLoad: null,
iframeLoading: true,
showOnlineVideoAlert: false,
t: null,
});
const [activeCard, setActiveCard] = useState(null);
const [isAnyCardActive, setIsAnyCardActive] = useState(false);
const dialogRef = useRef(null);
const mediaPlayerRef = useRef(null);
const { autoDetectedTheme } = useAutoDetectTheme();
const showDialog = (state) => {
setDialogState({ ...state, isOpen: true, iframeLoading: true });
dialogRef.current?.showModal();
};
const closeDialog = () => {
setDialogState((prev) => ({ ...prev, isOpen: false }));
dialogRef.current?.close();
};
const handleIframeLoad = () => {
setDialogState((prev) => ({ ...prev, iframeLoading: false }));
};
const setCardActive = (cardId, isActive) => {
setActiveCard(isActive ? cardId : null);
setIsAnyCardActive(isActive);
};
return (
{children}
{t("sound_page.clipModalTitle")} :{" "}
{dialogState.isOpen && dialogState.isLocalVideo ? (
) : dialogState.isOpen && dialogState.videoUrl ? (
<>
{dialogState.iframeLoading && (
)}
>
) : null}
{dialogState.showOnlineVideoAlert && (
{t("alert.alertOnlineVideo")}
)}
);
};
SoundVideoDialogProvider.propTypes = {
children: PropTypes.node.isRequired,
t: PropTypes.func.isRequired,
};
================================================
FILE: src/components/sound_page/hooks/useSoundVideoDialogContext.jsx
================================================
import { createContext, useContext } from "react";
export const SoundVideoDialogContext = createContext(null);
export const useSoundVideoDialog = () => {
const context = useContext(SoundVideoDialogContext);
if (!context) {
throw new Error("useSoundVideoDialog must be used within a SoundVideoDialogProvider");
}
return context;
};
================================================
FILE: src/components/word_page/ipaUtils.js
================================================
// IPA normalization and fuzzy matching utilities
// Map of IPA variants to canonical forms
const IPA_NORMALIZATION_MAP = {
ɑː: "ɑ",
ɡ: "g",
r: "ɹ",
ɾ: "ɹ",
ɻ: "ɹ",
ɽ: "ɹ",
ɺ: "ɹ",
əʊ: "oʊ",
ou: "oʊ",
ei: "eɪ",
ai: "aɪ",
au: "aʊ",
oi: "ɔɪ",
ʧ: "t͡ʃ",
ʤ: "d͡ʒ",
ɚ: "ə",
ʃ: "ʃ",
ʒ: "ʒ",
ŋ: "ŋ",
er: "ɚ",
ər: "ɚ",
ɜr: "ɚ",
// Add more as needed
};
// Common learner substitutions that should be treated as very close matches
const LEARNER_SUBSTITUTIONS = [
["ʊ", "u"],
["i", "ɪ"],
["ɑ", "a"],
["ɔ", "o"],
];
// Fuzzy phoneme groups (each array contains close phonemes)
const FUZZY_PHONEME_GROUPS = [
["ɑ", "ɑː"],
["ə", "ɚ"],
["oʊ", "ou", "əʊ"],
["eɪ", "ei"],
["aɪ", "ai"],
["aʊ", "au"],
["ɔɪ", "oi"],
["t͡ʃ", "ʧ"],
["d͡ʒ", "ʤ"],
["g", "ɡ"],
["ɹ", "r", "ɾ", "ɻ", "ɽ", "ɺ"],
["ŋ", "ŋ"],
["ɛ", "e"],
// Add more as needed
];
// Normalize a single IPA token
const normalizeIPAToken = (token) => IPA_NORMALIZATION_MAP[token] || token;
// Normalize a full IPA string (tokenized by space)
const normalizeIPAString = (str) => {
if (!str) return "";
return str
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map(normalizeIPAToken)
.join(" ");
};
// Check if two IPA tokens are fuzzy matches (close enough)
const arePhonemesClose = (a, b) => {
// Ignore the long mark ː in comparison
if (a === "ː" || b === "ː") return true;
a = normalizeIPAToken(a.replace(/ː/gu, ""));
b = normalizeIPAToken(b.replace(/ː/gu, ""));
if (a === b) return true;
for (const group of FUZZY_PHONEME_GROUPS) {
if (group.includes(a) && group.includes(b)) return true;
}
return false;
};
// Character-based Levenshtein with fuzzy matching
const charLevenshtein = (a, b) => {
const dp = Array(a.length + 1)
.fill(null)
.map(() => Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
if (arePhonemesClose(a[i - 1], b[j - 1])) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + 1 // substitution
);
}
}
}
return dp[a.length][b.length];
};
// Format model output to match official phoneme's spacing
const formatToOfficialSpacing = (modelStr, officialStr) => {
// Remove spaces from both
const model = modelStr.replace(/ /g, "");
const official = officialStr.trim().split(/\s+/);
let idx = 0;
const groups = official.map((syll) => {
const group = model.slice(idx, idx + syll.length);
idx += syll.length;
return group;
});
// Add any extra phonemes from the model output
if (idx < model.length) {
groups.push(model.slice(idx));
}
return groups.join(" ");
};
// Check if two phonemes are common learner substitutions
const isLearnerSubstitution = (a, b) => {
return LEARNER_SUBSTITUTIONS.some(
([p1, p2]) => (a === p1 && b === p2) || (a === p2 && b === p1)
);
};
export {
arePhonemesClose,
charLevenshtein,
formatToOfficialSpacing,
isLearnerSubstitution,
normalizeIPAString,
normalizeIPAToken,
};
================================================
FILE: src/components/word_page/Pagination.jsx
================================================
import PropTypes from "prop-types";
const Pagination = ({ currentPage, totalPages, onPageChange, t, scrollTo }) => {
const goToPage = (page) => {
if (page >= 1 && page <= totalPages) {
onPageChange(page);
}
};
return (
{
goToPage(1);
scrollTo();
}}
disabled={currentPage === 1}
>
« {t("wordPage.firstPageBtn")}
{
goToPage(currentPage - 1);
scrollTo();
}}
disabled={currentPage === 1}
>
‹ {t("wordPage.prevPageBtn")}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(
(page) => page >= currentPage - 1 && page <= currentPage + 1 // Show nearby pages
)
.map((page) => (
{
goToPage(page);
scrollTo();
}}
>
{page}
))}
{
goToPage(currentPage + 1);
scrollTo();
}}
disabled={currentPage === totalPages}
>
{t("wordPage.nextPageBtn")} ›
{
goToPage(totalPages);
scrollTo();
}}
disabled={currentPage === totalPages}
>
{t("wordPage.lastPageBtn")} »
);
};
Pagination.propTypes = {
currentPage: PropTypes.number.isRequired,
totalPages: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
scrollTo: PropTypes.func,
};
export default Pagination;
================================================
FILE: src/components/word_page/PronunciationChecker.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import convertToWav from "../../utils/ffmpegWavConverter";
import isElectron from "../../utils/isElectron";
import openExternal from "../../utils/openExternal";
import { getPronunciationInstallState } from "../setting_page/pronunciationStepUtils";
import {
arePhonemesClose,
charLevenshtein,
formatToOfficialSpacing,
normalizeIPAString,
isLearnerSubstitution,
} from "./ipaUtils";
import { parseIPA } from "./syllableParser";
// Add CSS animation for radial progress
// https://github.com/saadeghi/daisyui/discussions/3206
const radialProgressStyle = `
@property --_value {
syntax: "";
inherits: true;
initial-value: 0;
}
.animate-value {
animation: grow 1s ease-in-out 0s forwards;
}
@keyframes grow {
from {
--_value: 0;
}
to {
--_value: var(--target-value);
}
}
`;
const PronunciationChecker = ({
icon,
disabled,
wordKey,
displayPronunciation,
modelName,
onLoadingChange,
}) => {
const { t } = useTranslation();
const [result, setResult] = useState(null);
const [showResult, setShowResult] = useState(false);
const notInstalledDialogRef = useRef();
const webDialogRef = useRef();
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState("");
const [errorDetails, setErrorDetails] = useState("");
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [showFailedInstall, setShowFailedInstall] = useState(false);
let accuracyScore = null;
useEffect(() => {
if (!isElectron()) return;
const handler = (_event, msg) => {
if (msg && msg.status === "progress") {
setProgress(msg.message);
}
};
window.electron?.ipcRenderer?.on("pronunciation-model-progress", handler);
return () => {
window.electron?.ipcRenderer?.removeListener("pronunciation-model-progress", handler);
};
}, []);
useEffect(() => {
if (showResult) setProgress("");
}, [showResult]);
useEffect(() => {
if (onLoadingChange) onLoadingChange(loading);
}, [loading, onLoadingChange]);
const checkPronunciation = async () => {
if (!isElectron()) {
// Show dialog for web
if (webDialogRef.current) {
webDialogRef.current.showModal();
}
return;
}
// Fetch install status on demand
let installStatus = null;
if (window.electron?.ipcRenderer) {
installStatus = await window.electron.ipcRenderer.invoke(
"get-pronunciation-install-status"
);
}
// 1. No install status at all
const installState = getPronunciationInstallState(installStatus);
if (installState === "not_installed") {
setShowFailedInstall(false);
if (notInstalledDialogRef.current) notInstalledDialogRef.current.showModal();
return;
}
if (installState === "failed") {
setShowFailedInstall(true);
if (notInstalledDialogRef.current) notInstalledDialogRef.current.showModal();
return;
} else {
setShowFailedInstall(false);
}
setLoading(true);
setProgress("");
setErrorDetails("");
try {
// 1. Get the original recording blob from Electron
const originalBlobArrayBuffer = await window.electron.getRecordingBlob(wordKey);
const originalBlob = new Blob([originalBlobArrayBuffer]);
// 2. Convert to real WAV
const realWavBlob = await convertToWav(originalBlob);
// 3. Save the new WAV to disk with -realwav suffix
const realWavKey = `${wordKey}-realwav`;
await window.electron.saveRecording(realWavKey, await realWavBlob.arrayBuffer());
// 4. Get the file path for the new WAV
const audioPath = await window.electron.ipcRenderer.invoke(
"get-recording-path",
realWavKey
);
// 5. Run the Python process with the new WAV and selected model
const response = await window.electron.ipcRenderer.invoke(
"pronunciation-check",
audioPath,
modelName
);
if (response && response.status === "success") {
const phonemes = response.phonemes;
const readablePhonemes = phonemes ? JSON.parse(`"${phonemes}"`) : null;
setResult(readablePhonemes);
} else {
setResult(`Error: ${response?.message || "Unknown error"}`);
setErrorDetails(response?.traceback || response?.message || "Unknown error");
window.electron.log(
"error",
`Pronunciation check error. Traceback: ${response?.traceback || "Unknown error"}`
);
}
} catch (err) {
setResult(`Error: ${err.message || err}`);
setErrorDetails(err.stack || err.message || JSON.stringify(err));
window.electron.log(
"error",
`Pronunciation check error. Stack trace: ${err.stack || err.message || JSON.stringify(err)}`
);
} finally {
setShowResult(true);
setLoading(false);
}
};
const parsedPhonemes = parseIPA(displayPronunciation);
const phoneme = parsedPhonemes.map((syl) => syl.text).join(" ");
// Remove all spaces for comparison
const normalizedResultNoSpaces = normalizeIPAString(result).replace(/ /g, "");
const normalizedPhonemeNoSpaces = normalizeIPAString(phoneme).replace(/ /g, "");
// Character-based Levenshtein with fuzzy matching
const phonemeLevenshtein =
result && phoneme
? charLevenshtein(normalizedResultNoSpaces, normalizedPhonemeNoSpaces)
: null;
const isClose = phonemeLevenshtein !== null && phonemeLevenshtein <= 1;
// Display logic: format and highlight aligned model output
let alignedResult = result;
let diff = null;
let rendered = null;
const resultTooFarOff = phonemeLevenshtein !== null && phonemeLevenshtein > 5;
if (resultTooFarOff) {
rendered = {t("wordPage.pronunciationChecker.cannotHear")}
;
} else if (result && phoneme) {
// Format model output to match official phoneme's spacing
alignedResult = formatToOfficialSpacing(normalizedResultNoSpaces, phoneme);
// Build a diff array for rendering (character by character)
diff = [];
const modelArr = alignedResult.replace(/ /g, "").split("");
const officialArr = normalizedPhonemeNoSpaces.split("");
let i = 0,
j = 0;
while (i < modelArr.length && j < officialArr.length) {
if (arePhonemesClose(modelArr[i], officialArr[j])) {
diff.push({ type: "same", value: modelArr[i] });
i++;
j++;
} else if (isLearnerSubstitution(modelArr[i], officialArr[j])) {
diff.push({ type: "substitution", value: modelArr[i] });
i++;
j++;
} else {
diff.push({ type: "replace", value: modelArr[i] });
i++;
j++;
}
}
while (i < modelArr.length) {
diff.push({ type: "delete", value: modelArr[i] });
i++;
}
while (j < officialArr.length) {
diff.push({ type: "insert", value: officialArr[j] });
j++;
}
// --- Score calculation ---
let matchCount = 0;
let extraAtEnd = 0;
let mispronounced = 0;
let totalErrors = 0;
// Analyze errors
diff.forEach((d, idx) => {
if (d.type === "same") {
matchCount++;
} else {
// Check if it's an extra sound at the end
if (d.type === "delete" && idx >= officialArr.length) {
extraAtEnd++;
} else {
totalErrors++;
if (d.type === "replace") {
mispronounced++;
}
}
}
});
const totalPhonemes = officialArr.length;
// Calculate score
if (matchCount === 0 || matchCount / totalPhonemes < 0.3) {
// Completely different word
accuracyScore = 0;
} else {
// Start with base score
let score = 100;
// Count different types of errors present
const hasExtra = extraAtEnd > 0;
const hasMispronounced = mispronounced > 0;
const hasSubstitution = diff.some((d) => d.type === "substitution");
const otherErrors = totalErrors - mispronounced - extraAtEnd;
const hasOtherErrors = otherErrors > 0;
// Calculate error type count
const errorTypes = [hasExtra, hasMispronounced, hasSubstitution, hasOtherErrors].filter(
Boolean
).length;
if (errorTypes === 1) {
// Single error type - be lenient
if (hasExtra) score -= 10;
if (hasMispronounced) score -= 10;
if (hasSubstitution) score -= 5;
if (hasOtherErrors) score -= 25;
} else if (errorTypes > 1) {
// Multiple error types - progressive penalties
if (hasExtra) score -= 10;
if (hasMispronounced) score -= 10;
if (hasSubstitution) score -= 10;
if (hasOtherErrors) score -= 30;
}
accuracyScore = Math.round(Math.max(0, score));
}
// --- End score calculation ---
// Render alignedResult with spaces, highlighting differences
// Insert spaces to match official phoneme's spacing
let spaceIdx = 0;
let charCount = 0;
const officialSyllables = phoneme.trim().split(/\s+/);
const syllableBoundaries = officialSyllables.map((syll) => syll.length);
let currentBoundary = syllableBoundaries[spaceIdx] || 0;
rendered = [];
let afterLastSyllable = false;
for (let idx = 0; idx < diff.length; idx++) {
const d = diff[idx];
// Insert a space after the last official syllable if there are extra phonemes
if (!afterLastSyllable && charCount === currentBoundary && idx !== diff.length - 1) {
rendered.push( );
spaceIdx++;
if (spaceIdx >= syllableBoundaries.length) {
afterLastSyllable = true;
}
currentBoundary += syllableBoundaries[spaceIdx] || 0;
}
// If we've just passed the last syllable boundary, insert a separator before extra phonemes
if (
afterLastSyllable &&
charCount === currentBoundary - (syllableBoundaries[spaceIdx - 1] || 0)
) {
rendered.push(
|{/* separator for extra phonemes */}
);
// Only insert once
afterLastSyllable = false;
}
if (d.type === "same") rendered.push({d.value} );
if (d.type === "replace")
rendered.push(
{d.value}
);
if (d.type === "substitution")
rendered.push(
{d.value}
);
if (d.type === "insert")
rendered.push(
{d.value}
);
if (d.type === "delete")
rendered.push(
{d.value}
);
charCount++;
}
}
return (
<>
{icon} {t("wordPage.pronunciationChecker.checkPronunciationBtn")}
{(showResult || loading) && (
{t("wordPage.pronunciationChecker.pronunciationResult")}
{loading ? (
{progress || t("wordPage.pronunciationChecker.inProgress")}
) : (
<>
{result && result.startsWith && result.startsWith("Error:") ? (
<>
{t("wordPage.pronunciationChecker.errorOccurred")}
{result.replace(/^Error:\s*/, "")}
{errorDetails && (
setShowErrorDialog(true)}
>
{t(
"wordPage.pronunciationChecker.errorDetails"
)}
)}
>
) : result === null || resultTooFarOff ? (
{t("wordPage.pronunciationChecker.cannotHear")}
) : (
<>
{accuracyScore !== null && (
= 70
? "text-primary"
: accuracyScore >= 40
? "text-warning"
: "text-error"
}`}
style={{
"--value": "var(--_value)",
"--size": "5rem",
"--target-value": accuracyScore,
}}
aria-valuenow={accuracyScore}
role="progressbar"
>
{accuracyScore}
{t(
"wordPage.pronunciationChecker.approximateScore"
)}
)}
{t(
"wordPage.pronunciationChecker.receivedResult"
)}
{" "}
{rendered}
{t(
"wordPage.pronunciationChecker.correctResult"
)}
{" "}
{phoneme}
{t("wordPage.pronunciationChecker.colorLegend")}
{t(
"wordPage.pronunciationChecker.colorLegendMispronounce"
)}
{t(
"wordPage.pronunciationChecker.colorLegendExtra"
)}
{t(
"wordPage.pronunciationChecker.colorLegendMissing"
)}
{t(
"wordPage.pronunciationChecker.colorLegendSubstitution"
)}
{phonemeLevenshtein === 0 && (
{t(
"wordPage.pronunciationChecker.perfectResult"
)}
)}
{isClose && phonemeLevenshtein !== 0 && (
{t("wordPage.pronunciationChecker.closeResult")}
)}
{!isClose && phonemeLevenshtein !== 0 && (
{t(
"wordPage.pronunciationChecker.notSoCloseResult"
)}
)}
{t("wordPage.pronunciationChecker.disclaimerText")}
>
)}
>
)}
)}
{showFailedInstall
? t("wordPage.pronunciationChecker.installationProcessFailed")
: t("wordPage.pronunciationChecker.pronunciationCheckerNotInstalled")}
{showFailedInstall
? t("wordPage.pronunciationChecker.installationProcessFailedMsg")
: t(
"wordPage.pronunciationChecker.pronunciationCheckerNotInstalledMsg"
)}
notInstalledDialogRef.current.close()}
>
{t("wordPage.pronunciationChecker.okBtn")}
{t("wordPage.pronunciationChecker.featureNotAvailable")}
openExternal(
"https://learnercraft.github.io/ispeakerreact/download"
)
}
/>,
]}
/>
webDialogRef.current.close()}
>
{t("wordPage.pronunciationChecker.okBtn")}
{/* Error Details Dialog */}
setShowErrorDialog(false)}
>
{t("wordPage.pronunciationChecker.errorDetailsTitle")}
setShowErrorDialog(false)}
>
{t("wordPage.pronunciationChecker.okBtn")}
>
);
};
PronunciationChecker.propTypes = {
icon: PropTypes.node,
disabled: PropTypes.bool,
wordKey: PropTypes.string.isRequired,
displayPronunciation: PropTypes.string,
modelName: PropTypes.string.isRequired,
onLoadingChange: PropTypes.func,
};
export default PronunciationChecker;
================================================
FILE: src/components/word_page/RecordingWaveform.jsx
================================================
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BsPauseCircle,
BsPlayCircle,
BsRecordCircle,
BsStopCircle,
BsClipboard2Check,
} from "react-icons/bs";
import WaveSurfer from "wavesurfer.js";
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
import {
checkRecordingExists,
openDatabase,
playRecording,
saveRecording,
} from "../../utils/databaseOperations";
import isElectron from "../../utils/isElectron";
import { sonnerErrorToast, sonnerSuccessToast } from "../../utils/sonnerCustomToast";
import useWaveformTheme from "./useWaveformTheme";
import PronunciationChecker from "./PronunciationChecker";
const getSupportedMimeType = () => {
const mimeTypes = ["audio/webm", "audio/ogg", "audio/wav", "audio/mpeg", "audio/mp4"];
for (const type of mimeTypes) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
console.warn("No supported MIME type found. Defaulting to 'audio/wav'.");
return "audio/wav"; // Fallback
};
const RecordingWaveform = ({
wordKey,
maxDuration,
disableControls = false,
onActivityChange = null,
onRecordingSaved = null,
isAudioLoading = false,
displayPronunciation,
t,
}) => {
const waveformRef = useRef(null);
const [recording, setRecording] = useState(false);
const [recordedUrl, setRecordedUrl] = useState(null);
const [wavesurfer, setWaveSurfer] = useState(null);
const [recordPlugin, setRecordPlugin] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [, setRecordingTime] = useState(0);
const recordingInterval = useRef(null);
const [pronunciationLoading, setPronunciationLoading] = useState(false);
const waveformLight = "hsl(224.3 76.3% 48%)"; // Light mode waveform color
const waveformDark = "hsl(213.1 93.9% 67.8%)"; // Dark mode waveform color
const progressLight = "hsl(83.7 80.5% 44.3%)"; // Light mode progress color
const progressDark = "hsl(82 84.5% 67.1%)"; // Dark mode progress color
const cursorLight = "hsl(83.7 80.5% 44.3%)"; // Dark mode progress color
const cursorDark = "hsl(82 84.5% 67.1%)"; // Dark mode progress color
const { waveformColor, progressColor, cursorColor } = useWaveformTheme(
waveformLight,
waveformDark,
progressLight,
progressDark,
cursorLight,
cursorDark
);
const notifyActivityChange = useCallback(
(isActive) => {
if (onActivityChange) {
onActivityChange(isActive);
}
},
[onActivityChange]
);
useEffect(() => {
const wavesurferInstance = WaveSurfer.create({
container: waveformRef.current,
waveColor: waveformColor,
progressColor: progressColor,
cursorColor: cursorColor,
height: 80,
cursorWidth: 2,
autoScroll: true,
hideScrollbar: true,
});
setWaveSurfer(wavesurferInstance);
const getMimeType = getSupportedMimeType();
// console.log("Using MIME type:", mimeType);
const recordPluginInstance = RecordPlugin.create({
renderRecordedAudio: true,
continuousWaveform: true,
continuousWaveformDuration: maxDuration,
mimeType: getMimeType,
});
setRecordPlugin(recordPluginInstance);
wavesurferInstance.registerPlugin(recordPluginInstance);
recordPluginInstance.on("record-end", async (blob) => {
const recordedUrl = URL.createObjectURL(blob);
setRecordedUrl(recordedUrl);
setTimeout(() => {
wavesurferInstance.empty(); // Clear previous waveform
wavesurferInstance.load(recordedUrl); // Load recorded audio
}, 100); // Slight delay to ensure blob readiness
try {
await saveRecording(blob, wordKey, getMimeType);
console.log("Recording saved successfully");
if (onRecordingSaved) {
onRecordingSaved(); // Notify the parent
}
notifyActivityChange(false);
sonnerSuccessToast(t("toast.recordingSuccess"));
} catch (error) {
console.error("Error saving recording:", error);
sonnerErrorToast(`${t("toast.recordingFailed")} ${error.message}`);
}
});
const loadExistingRecording = async () => {
const exists = await checkRecordingExists(wordKey);
if (exists) {
if (isElectron()) {
await playRecording(
wordKey,
async (audio) => {
if (audio) {
const url = audio.src;
setRecordedUrl(url);
wavesurferInstance.load(url);
audio.pause(); // Pause the audio immediately after loading
audio.currentTime = 0;
}
},
(error) => {
console.error("Playback error:", error);
sonnerErrorToast(`${t("toast.playbackError")} ${error.message}`);
},
() => {
// console.log("Playback finished");
//notifyActivityChange(false);
}
);
} else {
console.log(`Recording found for key: ${wordKey}`);
const db = await openDatabase();
const transaction = db.transaction(["recording_data"], "readonly");
const store = transaction.objectStore("recording_data");
const request = store.get(wordKey);
request.onsuccess = () => {
if (request.result) {
const { recording, mimeType } = request.result;
const blob = new Blob([recording], { type: mimeType });
const url = URL.createObjectURL(blob);
setRecordedUrl(url);
wavesurferInstance.load(url);
} else {
console.log(`No data found for key: ${wordKey}`);
}
};
}
}
};
loadExistingRecording();
wavesurferInstance.on("play", () => {
setIsPlaying(true);
});
wavesurferInstance.on("pause", () => {
setIsPlaying(false);
});
wavesurferInstance.on("finish", () => {
setIsPlaying(false);
notifyActivityChange(false); // Notify parent when playback ends
wavesurferInstance.seekTo(0);
});
return () => {
if (recordingInterval) clearInterval(recordingInterval.current);
if (wavesurferInstance) {
wavesurferInstance.unAll();
wavesurferInstance.destroy();
}
};
}, [
notifyActivityChange,
onRecordingSaved,
wordKey,
maxDuration,
cursorColor,
progressColor,
waveformColor,
t,
]);
const handleRecordClick = () => {
if (recordPlugin) {
if (recording) {
recordPlugin.stopRecording(); // Stop recording
setRecording(false);
clearInterval(recordingInterval.current); // Clear the interval
} else {
if (wavesurfer) {
wavesurfer.empty(); // Clear waveform for live input
}
setRecordedUrl(null); // Clear previously recorded URL
setRecordingTime(0); // Reset the recording time
recordPlugin.startRecording(); // Start recording
setRecording(true);
recordingInterval.current = setInterval(() => {
setRecordingTime((prevTime) => {
if (prevTime >= maxDuration) {
console.log("Max recording duration reached. Stopping...");
recordPlugin.stopRecording();
setRecording(false);
clearInterval(recordingInterval.current);
setRecordingTime(0);
return prevTime;
}
return prevTime + 1;
});
}, 1000);
}
}
};
const handlePlayPause = () => {
if (wavesurfer) {
try {
wavesurfer.playPause();
} catch (error) {
console.error("Playback error:", error);
sonnerErrorToast(`${t("toast.playbackError")} ${error.message}`);
}
}
};
return (
{recording ? (
<>
{" "}
{t("buttonConversationExam.stopRecordBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.recordBtn")}
>
)}
{wavesurfer?.isPlaying() ? (
<>
{t("wordPage.pauseRecordingBtn")}
>
) : (
<>
{" "}
{t("buttonConversationExam.playBtn")}
>
)}
}
wordKey={wordKey}
displayPronunciation={displayPronunciation}
onLoadingChange={setPronunciationLoading}
/>
{/*recordedUrl && (
Download recording
)*/}
);
};
RecordingWaveform.propTypes = {
wordKey: PropTypes.string.isRequired,
maxDuration: PropTypes.number.isRequired,
disableControls: PropTypes.bool,
onActivityChange: PropTypes.func,
t: PropTypes.func.isRequired,
onRecordingSaved: PropTypes.func,
isAudioLoading: PropTypes.bool,
displayPronunciation: PropTypes.string,
};
export default RecordingWaveform;
================================================
FILE: src/components/word_page/ReviewRecording.jsx
================================================
import PropTypes from "prop-types";
import {
BsEmojiFrown,
BsEmojiFrownFill,
BsEmojiNeutral,
BsEmojiNeutralFill,
BsEmojiSmile,
BsEmojiSmileFill,
} from "react-icons/bs";
import { sonnerSuccessToast, sonnerWarningToast } from "../../utils/sonnerCustomToast";
import { useState, useEffect } from "react";
const ReviewRecording = ({ wordName, accent, isRecordingExists, t, onReviewUpdate }) => {
const [review, setReview] = useState(null);
// Load review from localStorage on mount
useEffect(() => {
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
const wordReview = storedData.wordReview?.[accent]?.[wordName] || null;
setReview(wordReview);
}, [accent, wordName]);
const handleReviewClick = (type) => {
if (!isRecordingExists) {
sonnerWarningToast(t("toast.noRecording"));
return;
}
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
storedData.wordReview = storedData.wordReview || {};
storedData.wordReview[accent] = storedData.wordReview[accent] || {};
storedData.wordReview[accent][wordName] = type;
localStorage.setItem("ispeaker", JSON.stringify(storedData));
setReview(type);
sonnerSuccessToast(t("toast.reviewUpdated"));
onReviewUpdate();
};
const emojiStyle = (reviewType) => {
const styles = {
good: "text-success",
neutral: "text-warning",
bad: "text-error",
};
return review === reviewType ? styles[reviewType] : "";
};
return (
{t("wordPage.ratePronunciation")}
handleReviewClick("good")}>
{review === "good" ? (
) : (
)}
handleReviewClick("neutral")}>
{review === "neutral" ? (
) : (
)}
handleReviewClick("bad")}>
{review === "bad" ? (
) : (
)}
);
};
ReviewRecording.propTypes = {
wordName: PropTypes.string.isRequired,
accent: PropTypes.string.isRequired,
isRecordingExists: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
onReviewUpdate: PropTypes.func,
};
export default ReviewRecording;
================================================
FILE: src/components/word_page/SpeechRecognitionTest.jsx
================================================
import { useState } from "react";
const SpeechRecognitionTest = () => {
const [transcript, setTranscript] = useState("");
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState(null);
// const [confidence, setConfidence] = useState(null);
// const [feedback, setFeedback] = useState("");
const startSpeechRecognition = () => {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
setError("Your browser does not support the Web Speech API.");
return;
}
// Use either webkitSpeechRecognition or SpeechRecognition
const Recognition = window.webkitSpeechRecognition || window.SpeechRecognition;
const recognition = new Recognition();
recognition.lang = "en"; // Set the language
recognition.interimResults = false; // Only capture final results
recognition.maxAlternatives = 1; // Limit to one transcription
recognition.onstart = () => {
setIsListening(true);
setError(null);
console.log("Speech recognition started");
};
recognition.onerror = (e) => {
setError(`Error: ${e.error}`);
console.error("Speech recognition error:", e);
};
recognition.onend = () => {
setIsListening(false);
console.log("Speech recognition stopped");
};
recognition.onresult = (e) => {
const recognizedText = e.results[0][0].transcript;
const confidenceScore = e.results[0][0].confidence;
console.log(confidenceScore);
setTranscript(recognizedText);
// setConfidence(confidenceScore);
// Confidence score is only available on Google Chorme and Safari.
// Microsoft Edge always returns `0`, and Firefox returns `1`, therefore, its relevant code
// is commented out until major browsers implement this feature properly.
// Provide feedback based on confidence
/* if (confidenceScore >= 0.9) {
setFeedback("Excellent! Your pronunciation is spot on.");
} else if (confidenceScore >= 0.7) {
setFeedback(
"Good job! Your pronunciation is clear but could use slight improvement."
);
} else {
setFeedback("Keep practicing! Your pronunciation was unclear.");
} */
};
recognition.start();
};
return (
Web Speech API Test
{isListening ? "Listening..." : "Start Speech Recognition"}
{error && (
{error}
)}
Recognized text:
{transcript || "No speech recognized yet."}
);
};
export default SpeechRecognitionTest;
================================================
FILE: src/components/word_page/syllableParser.jsx
================================================
export const parseIPA = (ipa) => {
const syllables = [];
let current = "";
let primary = false;
let secondary = false;
for (const char of ipa) {
if (char === "ˈ" || char === "ˌ" || char === "." || char === " ") {
if (current) {
syllables.push({ text: current, primary, secondary });
current = "";
primary = false;
secondary = false;
}
if (char === "ˈ") primary = true;
if (char === "ˌ") secondary = true;
} else {
current += char;
}
}
if (current) {
syllables.push({ text: current, primary, secondary });
}
return syllables;
};
================================================
FILE: src/components/word_page/useWaveformTheme.jsx
================================================
import { useEffect, useState } from "react";
import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme";
const useWaveformTheme = (
waveformLight,
waveformDark,
progressLight,
progressDark,
cursorLight,
cursorDark
) => {
const { autoDetectedTheme } = useAutoDetectTheme();
const [colors, setColors] = useState({
waveformColor: waveformLight,
progressColor: progressLight,
cursorColor: cursorLight,
});
useEffect(() => {
setColors({
waveformColor: autoDetectedTheme === "dark" ? waveformDark : waveformLight,
progressColor: autoDetectedTheme === "dark" ? progressDark : progressLight,
cursorColor: autoDetectedTheme === "dark" ? cursorDark : cursorLight,
});
}, [
autoDetectedTheme,
waveformLight,
waveformDark,
progressLight,
progressDark,
cursorLight,
cursorDark,
]);
return colors;
};
export default useWaveformTheme;
================================================
FILE: src/components/word_page/WordDetails.jsx
================================================
import WavesurferPlayer from "@wavesurfer/react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { BsPauseFill, BsPlayFill } from "react-icons/bs";
import { IoChevronBackOutline, IoInformationCircleOutline } from "react-icons/io5";
import { VscFeedback } from "react-icons/vsc";
import { checkRecordingExists } from "../../utils/databaseOperations";
import openExternal from "../../utils/openExternal";
import RecordingWaveform from "./RecordingWaveform";
import ReviewRecording from "./ReviewRecording";
import { parseIPA } from "./syllableParser";
import useWaveformTheme from "./useWaveformTheme";
const WordDetails = ({ word, handleBack, t, accent, onReviewUpdate, scrollRef }) => {
const [activeSyllable, setActiveSyllable] = useState(-1);
const [isPlaying, setIsPlaying] = useState(false);
const [isAudioLoading, setIsAudioLoading] = useState(true); // State to track loading
const [, setAudioError] = useState(false);
const [peaks, setPeaks] = useState([]);
const [isSlowMode, setIsSlowMode] = useState(false);
const [isRecordingWaveformActive, setIsRecordingWaveformActive] = useState(false);
const [isRecordingExists, setIsRecordingExists] = useState(false);
const waveformLight = "hsl(24.6 95% 53.1%)"; // Light mode waveform color
const waveformDark = "hsl(27 96% 61%)"; // Dark mode waveform color
const progressLight = "hsl(262.1 83.3% 57.8%)"; // Light mode progress color
const progressDark = "hsl(258.3 89.5% 66.3%)"; // Dark mode progress color
const cursorLight = "hsl(262.1 83.3% 57.8%)"; // Dark mode progress color
const cursorDark = "hsl(258.3 89.5% 66.3%)"; // Dark mode progress color
const { waveformColor, progressColor, cursorColor } = useWaveformTheme(
waveformLight,
waveformDark,
progressLight,
progressDark,
cursorLight,
cursorDark
);
const wordKey = `wordPronunciation-${word.name}-${accent}`;
useEffect(() => {
const checkRecording = async () => {
const exists = await checkRecordingExists(wordKey);
setIsRecordingExists(exists);
};
checkRecording();
}, [wordKey]);
const baseURL = import.meta.env.BASE_URL;
const audioFile =
accent === "american" && word.fileNameUS
? `${baseURL}media/word/mp3/${word.fileNameUS}.mp3`
: `${baseURL}media/word/mp3/${word.fileName}.mp3`;
const displayName = accent === "american" && word.nameUS ? word.nameUS : word.name;
const displayPronunciation =
accent === "american" && word.pronunciationUS ? word.pronunciationUS : word.pronunciation;
const syllables = parseIPA(displayPronunciation);
const wordPronunInfoBody = t("wordPage.wordPronunInfoBody", { returnObjects: true });
const [wavesurfer, setWavesurfer] = useState(null);
const wordPronunInfoModalRef = useRef(null);
const onReady = (ws) => {
setWavesurfer(ws);
setIsPlaying(false);
setAudioError(false);
setIsAudioLoading(false); // Stop showing loading spinner
// Generate peaks using exportPeaks
const peaks = ws.exportPeaks({
maxLength: 1000, // Higher values for better resolution
precision: 2, // Precision of peak data
});
setPeaks(peaks[0] || []); // Use the first channel's peaks
ws.on("finish", () => {
setActiveSyllable(-1); // Reset the highlighting
});
ws.setPlaybackRate(isSlowMode ? 0.5 : 1);
};
const onPlayPause = () => {
if (isRecordingWaveformActive || isAudioLoading) return;
if (wavesurfer) {
setIsPlaying((prev) => !prev);
wavesurfer.playPause();
} else {
console.error("WaveSurfer instance is not ready.");
}
};
const onAudioprocess = (currentTime) => {
if (peaks.length > 0 && wavesurfer) {
const duration = wavesurfer.getDuration();
const totalPeaks = peaks.length;
// Calculate current peak index based on playback time
const currentPeakIndex = Math.floor((currentTime / duration) * totalPeaks);
// Map the current peak index to syllables
const syllableIndex = Math.floor(
(currentPeakIndex / totalPeaks) * (syllables.length + 2)
);
if (syllableIndex !== activeSyllable && syllableIndex < syllables.length) {
setActiveSyllable(syllableIndex);
}
}
};
const toggleSlowMode = () => {
if (wavesurfer) {
const newRate = isSlowMode ? 1 : 0.5; // Normal speed vs Slow speed
wavesurfer.setPlaybackRate(newRate);
setIsSlowMode(!isSlowMode);
}
};
const handleWaveformActivityChange = (isActive) => {
setIsRecordingWaveformActive(isActive);
};
return (
<>
{t("wordPage.backBtn")}
{displayName}{" "}
{word.pos.join(", ")}
{word.level.map((wordLevel, id) => (
{wordLevel.toUpperCase()}
))}
{syllables.map((syllable, index) => (
{syllable.primary && (
{t("wordPage.primaryBadge")}
)}
{syllable.secondary && (
{t("wordPage.secondaryBadge")}
)}
{syllable.text}
))}
wordPronunInfoModalRef.current &&
wordPronunInfoModalRef.current.showModal()
}
/>
{/* Waveform */}
{isAudioLoading ? (
) : isPlaying ? (
) : (
)}
setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onError={() => setAudioError(true)}
onAudioprocess={(ws, currentTime) => onAudioprocess(currentTime)}
/>
{/* Recording Controls */}
setIsRecordingExists(true)}
isAudioLoading={isAudioLoading}
displayPronunciation={displayPronunciation}
/>
openExternal(
"https://github.com/learnercraft/ispeakerreact/discussions/34"
)
}
>
{t("wordPage.feedbackBtn")}
{t("wordPage.wordPronunInfoHeader")}
{wordPronunInfoBody.map((text, index) => (
{text}
))}
>
);
};
WordDetails.propTypes = {
word: PropTypes.shape({
fileName: PropTypes.string.isRequired,
fileNameUS: PropTypes.string,
level: PropTypes.arrayOf(PropTypes.string).isRequired,
name: PropTypes.string.isRequired,
nameUS: PropTypes.string,
pos: PropTypes.arrayOf(PropTypes.string).isRequired,
pronunciation: PropTypes.string.isRequired,
pronunciationUS: PropTypes.string,
wordId: PropTypes.number.isRequired,
}).isRequired,
handleBack: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
accent: PropTypes.string.isRequired,
onAccentChange: PropTypes.func.isRequired,
onReviewUpdate: PropTypes.func,
scrollRef: PropTypes.object,
};
export default WordDetails;
================================================
FILE: src/components/word_page/WordList.jsx
================================================
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IoInformationCircleOutline } from "react-icons/io5";
import Container from "../../ui/Container";
import AccentLocalStorage from "../../utils/AccentLocalStorage";
import isElectron from "../../utils/isElectron";
import { useScrollTo } from "../../utils/useScrollTo";
import AccentDropdown from "../general/AccentDropdown";
import TopNavBar from "../general/TopNavBar";
import Pagination from "./Pagination";
import WordDetails from "./WordDetails";
const PronunciationPractice = () => {
const { t } = useTranslation();
const { ref: scrollRef, scrollTo } = useScrollTo();
const [activeTab, setActiveTab] = useState("oxford3000");
const [words, setWords] = useState([]);
const [reviewData, setReviewData] = useState({});
const [accent, setAccent] = AccentLocalStorage();
const [loading, setLoading] = useState(false);
// Search and filter state
const [searchQuery, setSearchQuery] = useState("");
const [selectedLevel, setSelectedLevel] = useState("");
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 18;
// View state
const [viewState, setViewState] = useState("list");
const [selectedWord, setSelectedWord] = useState(null);
// Derived data
const filteredWords = words.filter(
(word) =>
(word.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
word.nameUS?.toLowerCase().includes(searchQuery.toLowerCase()) ||
!searchQuery) &&
(selectedLevel ? word.level.includes(selectedLevel) : true)
);
// Derived pagination values
const totalPages = Math.ceil(filteredWords.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentWords = filteredWords.slice(startIndex, endIndex);
useEffect(() => {
const fetchWords = async () => {
setLoading(true);
try {
const response = await fetch(
activeTab === "oxford3000"
? `${import.meta.env.BASE_URL}json/oxford-3000.json`
: `${import.meta.env.BASE_URL}json/oxford-5000.json`
);
const data = await response.json();
setWords(data);
} catch (error) {
console.error("Error fetching word data:", error);
} finally {
setLoading(false);
setCurrentPage(1);
}
};
fetchWords();
}, [activeTab]);
useEffect(() => {
if (isElectron()) {
document.title = `iSpeakerReact v${__APP_VERSION__}`;
} else {
document.title = `${t("navigation.words")} | iSpeakerReact v${__APP_VERSION__}`;
}
}, [t]);
// Load review data from localStorage
useEffect(() => {
updateReviewData();
}, []);
// Reset to first page when search query or filter changes
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, selectedLevel]);
const handlePractice = (word) => {
setSelectedWord(word);
setViewState("details");
};
// Handle back navigation
const handleBack = () => {
setSelectedWord(null);
setViewState("list");
};
const handleAccentChange = (newAccent) => {
setAccent(newAccent); // Update the accent
};
const getBadgeClass = (review) => {
switch (review) {
case "good":
return "badge badge-success";
case "neutral":
return "badge badge-warning";
case "bad":
return "badge badge-error";
default:
return "";
}
};
const getReviewText = (review) => {
switch (review) {
case "good":
return t("sound_page.reviewGood");
case "neutral":
return t("sound_page.reviewNeutral");
case "bad":
return t("sound_page.reviewBad");
default:
return "";
}
};
const updateReviewData = () => {
const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {};
setReviewData(storedData.wordReview || {});
};
return (
<>
{t("navigation.words")}
{viewState === "list" && (
<>
{/* Tabs for switching data */}
{filteredWords.length > 0 && !loading && (
)}
{/* Content Area */}
{loading ? (
{t("wordPage.loadingText")}
) : currentWords.length > 0 ? (
{currentWords.map((word) => {
const wordReview = reviewData[accent]?.[word.name] || null;
const wordReviewText = getReviewText(wordReview);
const wordAccent =
accent === "american" && word.nameUS
? word.nameUS
: word.name;
return (
{wordReview && (
{wordReviewText}
)}
{wordAccent}
{word.pos.join(", ")}
{word.level.map((wordLevel, id) => (
{wordLevel.toUpperCase()}
))}
{
handlePractice(word);
scrollTo();
}}
>
{t("sound_page.practiceBtn")}
);
})}
) : (
{t("wordPage.noResultText")}
)}
{filteredWords.length > 0 && !loading && (
)}
>
)}
{viewState === "details" && selectedWord && (
)}
>
);
};
export default PronunciationPractice;
================================================
FILE: src/styles/index.css
================================================
:root {
--font-sans:
"Inter", system-ui, -apple-system, Roboto, "Segoe UI", "Helvetica Neue", "Noto Sans",
Oxygen, Ubuntu, Cantarell, "Open Sans", Arial, sans-serif;
font-family: var(--font-sans);
--video-font-family: var(--font-sans, --font-serif, --font-mono);
}
/* Force Sonner toast to use Inter first */
[data-sonner-toast],
.sonner-toast,
:where([data-sonner-toast]) :where([data-title]) :where([data-description]) {
font-family: var(--font-sans) !important;
}
@import "tailwindcss";
@plugin "daisyui" {
themes: dim --prefersdark;
}
@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(99.19% 0.0105 106.59);
--color-base-200: oklch(95.1% 0.0203 118.94);
--color-base-300: oklch(92.2% 0.0305 123.31);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(47.31% 0.1359 134.26);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(83.66% 0.1165 66.29);
--color-secondary-content: oklch(0% 0 0);
--color-accent: oklch(46.23% 0.1623 305.49);
--color-accent-content: oklch(100% 0 0);
--color-neutral: rgb(56, 102, 100);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(50.97% 0.1024 253.22);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(52.73% 0.1371 150.07);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(83.69% 0.1644 84.43);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(50.6% 0.1927 27.7);
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 1;
}
@plugin "daisyui/theme" {
name: "dim";
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
}
@custom-variant dark (&:where([data-theme=dim], [data-theme=dim] *));
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
:where([data-sonner-toast]) :where([data-title]) {
@apply text-base; /* For success toasts */
}
================================================
FILE: src/styles/memory-card.css
================================================
@import "tailwindcss";
.memory-card {
@apply h-32 w-28 cursor-pointer perspective-distant;
}
.card-inner {
@apply relative h-full w-full transition-transform duration-500 ease-in-out transform-3d;
}
/* Flip the card */
.memory-card.flipped .card-inner {
@apply rotate-y-180;
}
.card-front,
.card-back {
@apply absolute flex h-full w-full items-center justify-center rounded-md border border-gray-300 text-base font-bold backface-hidden select-none;
}
/* Style for the front side */
.card-front {
@apply z-10 bg-[#f8f5f0] bg-contain bg-center bg-no-repeat text-gray-300;
background-image: url("/images/logos/ispeakerreact-logo-color.svg");
}
/* Style for the back side */
.card-back {
@apply z-0 transform text-center text-sm break-all md:text-base;
}
================================================
FILE: src/ui/Container.jsx
================================================
import PropTypes from "prop-types";
const Container = ({ children, className = "", ...props }) => {
return (
{children}
);
};
Container.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
};
export default Container;
================================================
FILE: src/utils/AccentLocalStorage.jsx
================================================
import { useState } from "react";
const AccentLocalStorage = () => {
const [selectedAccent, setSelectedAccent] = useState(() => {
const savedSettings = JSON.parse(localStorage.getItem("ispeaker"));
return savedSettings?.selectedAccent || "american";
});
return [selectedAccent, setSelectedAccent];
};
export default AccentLocalStorage;
================================================
FILE: src/utils/databaseOperations.jsx
================================================
import { fixWebmDuration } from "@fix-webm-duration/fix";
import isElectron from "./isElectron";
let db;
// Helper function to convert Blob to ArrayBuffer
const blobToArrayBuffer = (blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(blob);
});
};
// Open IndexedDB database
const openDatabase = () => {
return new Promise((resolve, reject) => {
if (db) {
resolve(db);
return;
}
const request = window.indexedDB.open("iSpeaker_data", 1);
request.onerror = (event) => {
console.error("Database error: ", event.target.error);
reject(event.target.error);
};
request.onsuccess = (event) => {
db = event.target.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create required object stores if they don't exist
const storeNames = ["recording_data", "conversation_data", "exam_data"];
storeNames.forEach((storeName) => {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: "id" });
}
});
};
});
};
// Save recording to either Electron or IndexedDB
const saveRecording = async (blob, key, mimeType, duration) => {
// If duration is not provided, calculate it from the blob
if (!duration) {
const audioContext = new AudioContext();
const arrayBuffer = await blobToArrayBuffer(blob);
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
duration = audioBuffer.duration * 1000; // Convert to milliseconds
}
const fixedBlob = await fixWebmDuration(blob, duration);
const arrayBuffer = await blobToArrayBuffer(fixedBlob);
if (isElectron()) {
// Electron environment
try {
await window.electron.saveRecording(key, arrayBuffer);
console.log("Recording saved successfully via Electron API");
} catch (error) {
console.error("Error saving recording in Electron:", error);
}
} else {
// Browser environment with IndexedDB
try {
const db = await openDatabase();
if (!db) return;
const transaction = db.transaction(["recording_data"], "readwrite");
const store = transaction.objectStore("recording_data");
const request = store.put({ id: key, recording: arrayBuffer, mimeType: mimeType });
return new Promise((resolve, reject) => {
request.onsuccess = () => {
console.log("Recording saved successfully to IndexedDB");
resolve();
};
request.onerror = (error) => {
console.error("Error saving recording:", error);
reject(error);
};
});
} catch (error) {
console.error("Error saving recording to IndexedDB:", error);
}
}
};
// Check if recording exists
const checkRecordingExists = async (key) => {
if (isElectron()) {
return window.electron.checkRecordingExists(key);
} else {
try {
const db = await openDatabase();
if (!db) return false;
const transaction = db.transaction(["recording_data"]);
const store = transaction.objectStore("recording_data");
const request = store.get(key);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
if (request.result) resolve(true);
else resolve(false);
};
request.onerror = (error) => reject(error);
});
} catch (error) {
console.error("Error checking recording existence in IndexedDB:", error);
return false;
}
}
};
// Play recording from either Electron or IndexedDB
const playRecording = async (key, onSuccess, onError, onEnded) => {
if (isElectron()) {
try {
// Get the audio data as an ArrayBuffer from the main process
const arrayBuffer = await window.electron.playRecording(key);
// Create a Blob from the ArrayBuffer
const audioBlob = new Blob([arrayBuffer], { type: "audio/wav" });
// Create a Blob URL
const blobUrl = URL.createObjectURL(audioBlob);
console.log("Blob URL:", blobUrl);
// Use the Blob URL for audio playback
const audio = new Audio(blobUrl);
audio.onended = () => {
// Revoke the Blob URL after playback to free up memory
URL.revokeObjectURL(blobUrl);
if (onEnded) onEnded();
};
audio.onerror = (error) => {
// Revoke the Blob URL in case of an error
URL.revokeObjectURL(blobUrl);
if (onError) onError(error);
};
// Play the audio
await audio.play();
// Call success callback
if (onSuccess) onSuccess(audio, null);
} catch (error) {
console.error("Error playing audio file in Electron:", error);
// Call error callback
if (onError) onError(error);
}
} else {
// Browser environment with IndexedDB
try {
const db = await openDatabase();
if (!db) return;
const transaction = db.transaction(["recording_data"]);
const store = transaction.objectStore("recording_data");
const request = store.get(key);
request.onsuccess = async () => {
const { recording, mimeType } = request.result;
try {
// Use AudioContext for playback
const audioContext = new AudioContext();
const buffer = await audioContext.decodeAudioData(recording);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.onended = onEnded;
source.start();
if (onSuccess) onSuccess(null, source);
} catch (decodeError) {
console.error("Error decoding audio data:", decodeError);
// Fallback to Blob URL
const audioBlob = new Blob([recording], { type: mimeType });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.onended = onEnded;
audio.onerror = onError;
audio
.play()
.then(() => {
if (onSuccess) onSuccess(audio, null);
})
.catch((playError) => {
console.error("Error playing audio via Blob URL:", playError);
if (onError) onError(playError);
});
}
};
request.onerror = (error) => {
console.error("Error retrieving recording from IndexedDB:", error);
if (onError) onError(error);
};
} catch (error) {
console.error("Error playing recording from IndexedDB:", error);
if (onError) onError(error);
}
}
};
export { checkRecordingExists, openDatabase, playRecording, saveRecording };
================================================
FILE: src/utils/ffmpegWavConverter.js
================================================
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import isElectron from "./isElectron";
let ffmpeg = null;
let ffmpegLoading = null;
const getFFmpeg = async () => {
if (!ffmpeg) {
ffmpeg = new FFmpeg();
ffmpegLoading = (async () => {
let coreURL, wasmURL;
if (isElectron()) {
const coreBaseURL = await window.electron.getFfmpegWasmPath();
const jsPath = `${coreBaseURL}/ffmpeg-core.js`;
const wasmPath = `${coreBaseURL}/ffmpeg-core.wasm`;
coreURL = await window.electron.getFileAsBlobUrl(jsPath, "text/javascript");
wasmURL = await window.electron.getFileAsBlobUrl(wasmPath, "application/wasm");
}
await ffmpeg.load({ coreURL, wasmURL });
})();
}
if (ffmpegLoading) {
await ffmpegLoading;
ffmpegLoading = null;
}
return ffmpeg;
};
const convertToWav = async (inputBlob) => {
const ffmpeg = await getFFmpeg();
await ffmpeg.writeFile("input", await fetchFile(inputBlob));
await ffmpeg.exec([
"-i",
"input",
"-ar",
"32000",
"-ac",
"1",
"-sample_fmt",
"s16",
"-b:a",
"96k",
"output.wav",
]);
const data = await ffmpeg.readFile("output.wav");
const wavBlob = new Blob([data.buffer], { type: "audio/wav" });
await ffmpeg.deleteFile("input");
await ffmpeg.deleteFile("output.wav");
return wavBlob;
};
export default convertToWav;
================================================
FILE: src/utils/isElectron.jsx
================================================
const isElectron = () => {
// Renderer process
if (
typeof window !== "undefined" &&
typeof window.process === "object" &&
window.process.type === "renderer"
) {
return true;
}
// Main process
if (
typeof process !== "undefined" &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to false
if (
typeof navigator === "object" &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
) {
return true;
}
return false;
};
export default isElectron;
================================================
FILE: src/utils/isTouchDevice.jsx
================================================
export const isTouchDevice = () => {
return "ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
};
================================================
FILE: src/utils/levenshtein.js
================================================
const levenshtein = (a, b) => {
const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
if (a[i - 1] === b[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + 1 // substitution
);
}
}
}
return matrix[a.length][b.length];
};
const getCharacterDiff = (a, b) => {
// Simple character diff using dynamic programming
const m = a.length,
n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
// Backtrack to get the diff
let i = m,
j = n,
res = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
res.unshift({ char: a[i - 1], type: "same" });
i--;
j--;
} else if (i > 0 && j > 0 && dp[i][j] === dp[i - 1][j - 1] + 1) {
res.unshift({ char: b[j - 1], type: "replace" });
i--;
j--;
} else if (j > 0 && dp[i][j] === dp[i][j - 1] + 1) {
res.unshift({ char: b[j - 1], type: "insert" });
j--;
} else {
res.unshift({ char: a[i - 1], type: "delete" });
i--;
}
}
return res;
};
const alignPhonemes = (modelPhoneme, officialPhoneme) => {
// Tokenize the official phoneme string
const officialTokens = officialPhoneme.trim().split(/\s+/);
// Remove all spaces from model output for easier matching
let modelStr = modelPhoneme.replace(/\s+/g, "");
let idx = 0;
const aligned = [];
for (const token of officialTokens) {
// Try to match the next segment of modelStr to the current official token
if (modelStr.substr(idx, token.length) === token) {
aligned.push(token);
idx += token.length;
} else {
// If not matching, try to find the best match (fallback: take the next N chars)
aligned.push(modelStr.substr(idx, token.length));
idx += token.length;
}
}
// If there are leftovers in modelStr, add them as extra tokens
if (idx < modelStr.length) {
aligned.push(modelStr.substr(idx));
}
return aligned.join(" ");
};
export { alignPhonemes, getCharacterDiff, levenshtein };
================================================
FILE: src/utils/openExternal.jsx
================================================
import isElectron from "./isElectron";
const openExternal = (url) => {
if (isElectron()) {
window.electron.openExternal(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
};
export default openExternal;
================================================
FILE: src/utils/phonemeUtils.jsx
================================================
export const findPhonemeDetails = (phoneme, soundsData) => {
let phonemeIndex = soundsData.consonants.findIndex((p) => p.phoneme === phoneme);
if (phonemeIndex !== -1) return { index: phonemeIndex, type: "consonant" };
phonemeIndex = soundsData.vowels_n_diphthongs.findIndex((p) => p.phoneme === phoneme);
if (phonemeIndex !== -1) return { index: phonemeIndex, type: "vowel" };
return { index: -1, type: null }; // Not found
};
================================================
FILE: src/utils/ShuffleArray.jsx
================================================
export const ShuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
================================================
FILE: src/utils/sonnerCustomToast.jsx
================================================
import { IoAlertCircleOutline, IoCheckmarkCircleOutline, IoWarningOutline } from "react-icons/io5";
import { toast } from "sonner";
export const sonnerSuccessToast = (message) => {
toast.custom(() => (
{message}
));
};
export const sonnerWarningToast = (message) => {
toast.custom(() => (
{message}
));
};
export const sonnerErrorToast = (message) => {
toast.custom(() => (
{message}
));
};
================================================
FILE: src/utils/useCountdownTimer.jsx
================================================
import { useCallback, useEffect, useRef, useState } from "react";
const useCountdownTimer = (initialTime, onTimerEnd) => {
const [remainingTime, setRemainingTime] = useState(initialTime * 60); // Track remaining time in state (seconds)
const intervalIdRef = useRef(null); // Ref to store the interval ID
const [isActive, setIsActive] = useState(false); // Control timer activation
// Clear the timer when needed
const clearTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
setIsActive(false); // Deactivate the timer
}, []);
// Start the timer and keep it running every second
const startTimer = useCallback(() => {
if (initialTime === 0) {
// If the timer is set to 0, disable the timer
clearTimer(); // Ensure any existing interval is cleared
setRemainingTime(0); // Ensure the timer stays at 0
return;
}
if (!isActive && intervalIdRef.current === null) {
setIsActive(true); // Activate the timer
intervalIdRef.current = setInterval(() => {
setRemainingTime((prevTime) => {
if (prevTime <= 1) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
return 0;
}
return prevTime - 1;
});
}, 1000);
}
}, [initialTime, isActive, clearTimer]);
// Effect to trigger `onTimerEnd` when the time is up
useEffect(() => {
if (remainingTime === 0 && initialTime !== 0) {
onTimerEnd(); // Call the parent function when time is up
}
}, [initialTime, remainingTime, onTimerEnd]);
// Format time into minutes and seconds
const formatTime = useCallback(() => {
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
}, [remainingTime]);
// Cleanup interval when the component unmounts
useEffect(() => {
return () => clearTimer();
}, [clearTimer]);
return { remainingTime, formatTime, clearTimer, startTimer, isActive };
};
export default useCountdownTimer;
================================================
FILE: src/utils/useScrollTo.jsx
================================================
import { useRef } from "react";
export const useScrollTo = () => {
const ref = useRef(null);
const padding = 300; // extra padding
const scrollTo = (options = { behavior: "smooth" }) => {
if (ref.current) {
const element = ref.current;
const top = element.getBoundingClientRect().top + window.scrollY - padding;
window.scrollTo({ top, behavior: options.behavior });
}
};
return { ref, scrollTo };
};
================================================
FILE: src/utils/ThemeContext/ThemeProvider.jsx
================================================
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import isElectron from "../isElectron";
import ThemeProviderContext from "./ThemeProviderContext";
const ThemeProvider = ({
children,
defaultTheme = "auto",
storageKey = "ispeakerreact-ui-theme",
}) => {
const [theme, setTheme] = useState(defaultTheme);
const [loaded, setLoaded] = useState(false);
// Load theme from storage on mount
useEffect(() => {
if (isElectron()) {
window.electron.ipcRenderer.invoke("get-theme", storageKey).then((storedTheme) => {
setTheme(storedTheme || defaultTheme);
setLoaded(true);
});
} else {
const storedTheme = localStorage.getItem(storageKey);
setTheme(storedTheme || defaultTheme);
setLoaded(true);
}
}, [defaultTheme, storageKey]);
useEffect(() => {
if (!loaded) return;
const root = window.document.documentElement;
// Function to update the theme
const updateTheme = () => {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
switch (theme) {
case "auto":
root.setAttribute("data-theme", systemTheme === "dark" ? "dim" : systemTheme);
break;
case "dark":
root.setAttribute("data-theme", "dim");
break;
default:
root.setAttribute("data-theme", theme);
break;
}
};
// Initial theme setup
updateTheme();
// Add event listener for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme === "auto") {
updateTheme();
}
};
mediaQuery.addEventListener("change", handleChange);
// Cleanup event listener
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [theme, loaded]);
const value = {
theme,
setTheme: async (newTheme) => {
if (isElectron()) {
await window.electron.ipcRenderer.invoke("set-theme", newTheme);
} else {
localStorage.setItem(storageKey, newTheme);
}
setTheme(newTheme);
},
};
// Don't render children until theme is loaded
if (!loaded) return null;
return {children} ;
};
ThemeProvider.propTypes = {
children: PropTypes.node,
defaultTheme: PropTypes.string,
storageKey: PropTypes.string,
};
export default ThemeProvider;
================================================
FILE: src/utils/ThemeContext/ThemeProviderContext.jsx
================================================
import { createContext } from "react";
const ThemeProviderContext = createContext({
theme: "auto",
setTheme: () => null,
});
export default ThemeProviderContext;
================================================
FILE: src/utils/ThemeContext/useAutoDetectTheme.jsx
================================================
import { useEffect, useState } from "react";
import { useTheme } from "./useTheme";
const useAutoDetectTheme = () => {
const { theme } = useTheme();
const [isDarkMode, setIsDarkMode] = useState(false);
const [autoDetectedTheme, setAutoDetectedTheme] = useState(theme);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateTheme = () => {
if (theme === "auto") {
const systemPrefersDark = mediaQuery.matches;
setAutoDetectedTheme(systemPrefersDark ? "dark" : "light");
setIsDarkMode(systemPrefersDark);
} else {
setAutoDetectedTheme(theme);
setIsDarkMode(theme === "dark");
}
};
updateTheme();
if (theme === "auto") {
mediaQuery.addEventListener("change", updateTheme);
}
return () => {
mediaQuery.removeEventListener("change", updateTheme);
};
}, [theme]);
return { isDarkMode, autoDetectedTheme };
};
export default useAutoDetectTheme;
================================================
FILE: src/utils/ThemeContext/useTheme.jsx
================================================
import { useContext } from "react";
import ThemeProviderContext from "./ThemeProviderContext";
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: Bug report
description: Report a bug or incorrect behavior in iSpeakerReact.
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Duplicated issue check
description: Please make sure that this issue has not been reported before.
options:
- label: I confirm that I have searched the existing issue(s)
required: true
- type: input
id: version
attributes:
label: iSpeakerReact version
description: The version of iSpeakerReact you are using. It is found in the homepage or title bar.
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the bug.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction steps
description: Steps to reproduce the behavior.
placeholder: |
1. Open this...
1. Do this...
1. See error...
validations:
required: true
- type: checkboxes
attributes:
label: Does the app show the "App crashed" dialog?
description: Select the checkbox if it shows, otherwise ignore this field. If you tick it, please copy the log it shows and paste it in the "Additional info" field.
options:
- label: The app shows the "App crashed" dialog
required: false
- type: dropdown
id: os
attributes:
label: Operating system
description: Which operating system are you using?
options:
- Windows
- Linux
- macOS
- Android
- iOS
- Other (specify in "Additional info")
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Browser
description: Which browser are you using?
options:
- Chromium (Google Chrome, Microsoft Edge, Brave, Opera, etc.)
- Gecko (Firefox, Librewolf, Waterfox, etc.)
- WebKit (Safari on macOS, every browser on iOS)
- Other (please specify in additional info)
validations:
required: true
- type: textarea
attributes:
label: Additional info
description: Any extra info, logs, or stack traces go here.
placeholder: |
Paste any error logs or useful notes here...
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: Questions & discussions
url: https://github.com/learnercraft/ispeakerreact/discussions
about: For general questions or discussions, please visit our Discussions page.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: Feature request
description: Suggest an idea for iSpeakerReact
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: Is your feature request related to a problem? Please describe it clearly.
placeholder: |
I’m frustrated when...
I find it difficult to...
- type: textarea
id: description
attributes:
label: Proposed idea / solution
description: A clear and concise description of what you’d like to see added or changed.
placeholder: |
I would like the app to...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe any alternative solutions or features you’ve considered.
placeholder: |
I’ve tried...
I’ve also thought about...
- type: textarea
attributes:
label: Additional info
description: Add any other context, mockups, or screenshots here.
placeholder: |
Screenshots, error messages, or links...
validations:
required: false
================================================
FILE: .github/workflows/electron.yaml
================================================
name: Build portable Electron versions
on:
push:
branches:
- main
paths:
- "package.json"
permissions:
contents: write
pages: write
id-token: write
pull-requests: write
issues: write
actions: read
checks: write
jobs:
vite-build:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@main
- name: Check if version bumped
id: version_check
run: |
git fetch origin ${{ github.ref }} --depth=2
PREV_VERSION=$(git show HEAD^:package.json | jq -r .version)
CURR_VERSION=$(jq -r .version package.json)
echo "Previous version: $PREV_VERSION"
echo "Current version: $CURR_VERSION"
if [ "$PREV_VERSION" = "$CURR_VERSION" ]; then
echo "No version bump detected. Skipping workflow."
exit 78
fi
- name: Set up Node.js
uses: actions/setup-node@main
with:
node-version: 22
cache: "npm"
- name: Install Dependencies
run: npm ci
- name: Build Vite App
run: npm run vitebuildcli
- name: Upload Vite Build Artifact
uses: actions/upload-artifact@main
with:
name: vite-dist
path: ./dist
build:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: macos-latest
arch: [x64, arm64]
- os: windows-latest
arch: [x64, arm64]
runs-on: ${{ matrix.os }}
needs: vite-build
outputs:
mac_x64_artifact_id: ${{ steps.upload-mac-x64.outputs.artifact-id }}
mac_arm64_artifact_id: ${{ steps.upload-mac-arm64.outputs.artifact-id }}
win_x64_artifact_id: ${{ steps.upload-win-x64.outputs.artifact-id }}
win_arm64_artifact_id: ${{ steps.upload-win-arm64.outputs.artifact-id }}
linux_x64_artifact_id: ${{ steps.upload-linux-x64.outputs.artifact-id }}
steps:
- name: Checkout code
uses: actions/checkout@main
- name: Download Vite Build Artifact
uses: actions/download-artifact@main
with:
name: vite-dist
path: ./dist
- name: Get version from package.json
id: get_version
run: |
echo "PACKAGE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV
- name: Set up Node.js
uses: actions/setup-node@main
with:
node-version: 22
cache: "npm"
- name: Set up Python (macOS only)
if: matrix.os == 'macos-latest'
uses: actions/setup-python@main
with:
python-version: "3.13"
- name: Install Python virtual environment (macOS only)
if: matrix.os == 'macos-latest'
run: |
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip setuptools
- name: Ensure package-lock.json is up to date
run: npm install
env:
CI: true
- name: Install dependencies (Ubuntu and macOS)
if: matrix.os != 'windows-latest'
run: |
if [ "${{ matrix.os }}" = "macos-latest" ]; then
source venv/bin/activate
npm install appdmg --save-dev
fi
npm ci
- name: Install dependencies (Windows)
if: matrix.os == 'windows-latest'
run: npm ci
- name: Build Electron app (macOS)
if: matrix.os == 'macos-latest'
run: |
source venv/bin/activate
for arch in ${{ join(matrix.arch, ' ') }}; do
npx electron-forge make --arch=$arch
mkdir -p out/make-mac-$arch
mv out/make/* out/make-mac-$arch/
done
- name: Build Electron app (Windows)
if: matrix.os == 'windows-latest'
run: |
$architectures = @("x64", "arm64")
foreach ($arch in $architectures) {
npx electron-forge make --arch=$arch
New-Item -ItemType Directory -Force -Path "out/make-win-$arch"
Move-Item -Path "out/make/*" -Destination "out/make-win-$arch/"
}
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: Build Electron app (Linux)
if: matrix.os == 'ubuntu-latest'
run: |
npx electron-forge make
mkdir -p out/make-linux
mv out/make/* out/make-linux/
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: Upload Artifacts (macOS x64)
if: matrix.os == 'macos-latest' && contains(matrix.arch, 'x64')
id: upload-mac-x64
uses: actions/upload-artifact@main
with:
name: iSpeakerReact-macos-x64
path: out/make-mac-x64
- name: Upload Artifacts (macOS arm64)
if: matrix.os == 'macos-latest' && contains(matrix.arch, 'arm64')
id: upload-mac-arm64
uses: actions/upload-artifact@main
with:
name: iSpeakerReact-macos-arm64
path: out/make-mac-arm64
- name: Upload Artifacts (Windows x64)
if: matrix.os == 'windows-latest' && contains(matrix.arch, 'x64')
id: upload-win-x64
uses: actions/upload-artifact@main
with:
name: iSpeakerReact-windows-x64
path: out/make-win-x64
- name: Upload Artifacts (Windows arm64)
if: matrix.os == 'windows-latest' && contains(matrix.arch, 'arm64')
id: upload-win-arm64
uses: actions/upload-artifact@main
with:
name: iSpeakerReact-windows-arm64
path: out/make-win-arm64
- name: Upload Artifacts (Linux x64)
if: matrix.os == 'ubuntu-latest'
id: upload-linux-x64
uses: actions/upload-artifact@main
with:
name: iSpeakerReact-linux-x64
path: out/make-linux
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@main
with:
fetch-depth: 0
- name: Download all build artifacts
uses: actions/download-artifact@main
with:
path: ./release
- name: Get version from package.json
id: get_version
run: |
echo "PACKAGE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_ENV
- name: Get latest tag
id: get_tag
run: |
latest_tag=$(git tag | sort -V | tail -n 1)
if [ -z "$latest_tag" ]; then
new_tag="v${{ env.PACKAGE_VERSION }}"
else
random_str=$(openssl rand -hex 4)
version_prefix=${{ env.PACKAGE_VERSION }}
new_tag="${version_prefix}-${random_str}"
fi
echo "LATEST_TAG=$new_tag" >> $GITHUB_ENV
- name: Create tag
run: |
git tag ${{ env.LATEST_TAG }}
git push origin ${{ env.LATEST_TAG }}
- name: Create GitHub release
id: create_release
uses: softprops/action-gh-release@c43d7637b9b9ce3e953168c325d27253a5d48d8e
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.LATEST_TAG }}
name: v${{ env.PACKAGE_VERSION }}
body: |
Release version ${{ env.PACKAGE_VERSION }} of the project.
- name: Display the release directory structure
run: ls -R ./release
- name: Upload Release Assets
run: |
for artifact in ./release/iSpeakerReact-*; do
if [ -d "$artifact" ]; then
find "$artifact" -type f -print0 | while IFS= read -r -d '' file; do
if [[ "$file" == *"RELEASES"* || "$file" == *.nupkg ]]; then
echo "Skipping $file"
continue
fi
echo "Attempting to upload: $file"
if gh release upload "${{ env.LATEST_TAG }}" "$file" --clobber; then
echo "Successfully uploaded: $file"
else
echo "Failed to upload: $file"
fi
done
else
echo "No artifact found in $artifact"
fi
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/static.yml
================================================
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
paths:
- "package.json"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@main
- name: Check if version bumped
id: version_check
run: |
git fetch origin ${{ github.ref }} --depth=2
PREV_VERSION=$(git show HEAD^:package.json | jq -r .version)
CURR_VERSION=$(jq -r .version package.json)
echo "Previous version: $PREV_VERSION"
echo "Current version: $CURR_VERSION"
if [ "$PREV_VERSION" = "$CURR_VERSION" ]; then
echo "No version bump detected. Skipping workflow."
exit 78
fi
- name: Set up Node
uses: actions/setup-node@main
with:
node-version: 22
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build && cp ./dist/index.html ./dist/404.html
- name: Setup Pages
uses: actions/configure-pages@main
- name: Upload artifact
uses: actions/upload-pages-artifact@main
with:
# Upload dist folder
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@main