Directory structure:
└── learnercraft-ispeakerreact/
    ├── README.md
    ├── CONTRIBUTING.md
    ├── eslint.config.js
    ├── forge.config.cjs
    ├── i18n.js
    ├── index.html
    ├── LICENSE
    ├── main.js
    ├── netlify.toml
    ├── package.json
    ├── postcss.config.js
    ├── preload.cjs
    ├── SECURITY.md
    ├── vite.config.js
    ├── .prettierrc
    ├── data/
    │   └── splash.html
    ├── electron-main/
    │   ├── createWindow.js
    │   ├── customFolderLocationOperation.js
    │   ├── expressServer.js
    │   ├── filePath.js
    │   ├── getFileAndFolder.js
    │   ├── isDeniedSystemFolder.js
    │   ├── logOperations.js
    │   ├── pronunciationCheckerIPC.js
    │   ├── pronunciationOperations.js
    │   ├── videoFileOperations.js
    │   └── zipOperation.js
    ├── public/
    │   ├── _redirects
    │   ├── google5c7ff3958ee35135.html
    │   ├── images/
    │   │   ├── homepage_screenshot.webp
    │   │   ├── ispeaker/
    │   │   │   ├── exam_images/
    │   │   │   │   └── thumb/
    │   │   │   │       ├── Discussing-opinions_main-task_1.webp
    │   │   │   │       ├── Discussing-opinions_practice-task_1_1.webp
    │   │   │   │       ├── Discussing-opinions_practice-task_1_2.webp
    │   │   │   │       ├── Job-interview_main-task_1.webp
    │   │   │   │       ├── Negotiating_main-task_4.webp
    │   │   │   │       ├── Negotiating_main-task_5.webp
    │   │   │   │       ├── Negotiating_practice-task_1_1.webp
    │   │   │   │       ├── Negotiating_practice-task_1_2.webp
    │   │   │   │       ├── Negotiating_practice-task_1_3.webp
    │   │   │   │       ├── Negotiating_practice-task_1_4.webp
    │   │   │   │       └── Negotiating_practice-task_2_4.webp
    │   │   │   └── sound_images/
    │   │   │       ├── sounds_american.webp
    │   │   │       └── sounds_british.webp
    │   │   └── screenshots/
    │   │       ├── screenshot-00.webp
    │   │       ├── screenshot-01.webp
    │   │       ├── screenshot-02.webp
    │   │       ├── screenshot-03.webp
    │   │       ├── screenshot-04.webp
    │   │       └── screenshot-08.webp
    │   ├── json/
    │   │   ├── conversation_list.json
    │   │   ├── ex_data.json
    │   │   ├── examspeaking_list.json
    │   │   ├── exercise_list.json
    │   │   ├── exercise_memory_match.json
    │   │   ├── json_file_hashes.json
    │   │   └── sounds_menu.json
    │   ├── media/
    │   │   ├── conversation/
    │   │   │   └── subtitles/
    │   │   │       ├── gb/
    │   │   │       │   ├── OALD9_GB_dialogues_1.srt
    │   │   │       │   ├── OALD9_GB_dialogues_10.srt
    │   │   │       │   ├── OALD9_GB_dialogues_11.srt
    │   │   │       │   ├── OALD9_GB_dialogues_12.srt
    │   │   │       │   ├── OALD9_GB_dialogues_13.srt
    │   │   │       │   ├── OALD9_GB_dialogues_14.srt
    │   │   │       │   ├── OALD9_GB_dialogues_15.srt
    │   │   │       │   ├── OALD9_GB_dialogues_16.srt
    │   │   │       │   ├── OALD9_GB_dialogues_17.srt
    │   │   │       │   ├── OALD9_GB_dialogues_18.srt
    │   │   │       │   ├── OALD9_GB_dialogues_19.srt
    │   │   │       │   ├── OALD9_GB_dialogues_2.srt
    │   │   │       │   ├── OALD9_GB_dialogues_20.srt
    │   │   │       │   ├── OALD9_GB_dialogues_21.srt
    │   │   │       │   ├── OALD9_GB_dialogues_22.srt
    │   │   │       │   ├── OALD9_GB_dialogues_23.srt
    │   │   │       │   ├── OALD9_GB_dialogues_24.srt
    │   │   │       │   ├── OALD9_GB_dialogues_25.srt
    │   │   │       │   ├── OALD9_GB_dialogues_26.srt
    │   │   │       │   ├── OALD9_GB_dialogues_27.srt
    │   │   │       │   ├── OALD9_GB_dialogues_28.srt
    │   │   │       │   ├── OALD9_GB_dialogues_29.srt
    │   │   │       │   ├── OALD9_GB_dialogues_3.srt
    │   │   │       │   ├── OALD9_GB_dialogues_30.srt
    │   │   │       │   ├── OALD9_GB_dialogues_31.srt
    │   │   │       │   ├── OALD9_GB_dialogues_32.srt
    │   │   │       │   ├── OALD9_GB_dialogues_33.srt
    │   │   │       │   ├── OALD9_GB_dialogues_34.srt
    │   │   │       │   ├── OALD9_GB_dialogues_35.srt
    │   │   │       │   ├── OALD9_GB_dialogues_36.srt
    │   │   │       │   ├── OALD9_GB_dialogues_37.srt
    │   │   │       │   ├── OALD9_GB_dialogues_38.srt
    │   │   │       │   ├── OALD9_GB_dialogues_39.srt
    │   │   │       │   ├── OALD9_GB_dialogues_4.srt
    │   │   │       │   ├── OALD9_GB_dialogues_5.srt
    │   │   │       │   ├── OALD9_GB_dialogues_6.srt
    │   │   │       │   ├── OALD9_GB_dialogues_7.srt
    │   │   │       │   ├── OALD9_GB_dialogues_8.srt
    │   │   │       │   └── OALD9_GB_dialogues_9.srt
    │   │   │       └── us/
    │   │   │           ├── OALD9_US_dialogues_1.srt
    │   │   │           ├── OALD9_US_dialogues_10.srt
    │   │   │           ├── OALD9_US_dialogues_11.srt
    │   │   │           ├── OALD9_US_dialogues_12.srt
    │   │   │           ├── OALD9_US_dialogues_13.srt
    │   │   │           ├── OALD9_US_dialogues_14.srt
    │   │   │           ├── OALD9_US_dialogues_15.srt
    │   │   │           ├── OALD9_US_dialogues_16.srt
    │   │   │           ├── OALD9_US_dialogues_17.srt
    │   │   │           ├── OALD9_US_dialogues_18.srt
    │   │   │           ├── OALD9_US_dialogues_19.srt
    │   │   │           ├── OALD9_US_dialogues_2.srt
    │   │   │           ├── OALD9_US_dialogues_20.srt
    │   │   │           ├── OALD9_US_dialogues_21.srt
    │   │   │           ├── OALD9_US_dialogues_22.srt
    │   │   │           ├── OALD9_US_dialogues_23.srt
    │   │   │           ├── OALD9_US_dialogues_24.srt
    │   │   │           ├── OALD9_US_dialogues_25.srt
    │   │   │           ├── OALD9_US_dialogues_26.srt
    │   │   │           ├── OALD9_US_dialogues_27.srt
    │   │   │           ├── OALD9_US_dialogues_28.srt
    │   │   │           ├── OALD9_US_dialogues_29.srt
    │   │   │           ├── OALD9_US_dialogues_3.srt
    │   │   │           ├── OALD9_US_dialogues_30.srt
    │   │   │           ├── OALD9_US_dialogues_31.srt
    │   │   │           ├── OALD9_US_dialogues_32.srt
    │   │   │           ├── OALD9_US_dialogues_33.srt
    │   │   │           ├── OALD9_US_dialogues_34.srt
    │   │   │           ├── OALD9_US_dialogues_35.srt
    │   │   │           ├── OALD9_US_dialogues_36.srt
    │   │   │           ├── OALD9_US_dialogues_37.srt
    │   │   │           ├── OALD9_US_dialogues_38.srt
    │   │   │           ├── OALD9_US_dialogues_39.srt
    │   │   │           ├── OALD9_US_dialogues_4.srt
    │   │   │           ├── OALD9_US_dialogues_5.srt
    │   │   │           ├── OALD9_US_dialogues_6.srt
    │   │   │           ├── OALD9_US_dialogues_7.srt
    │   │   │           ├── OALD9_US_dialogues_8.srt
    │   │   │           └── OALD9_US_dialogues_9.srt
    │   │   └── exam/
    │   │       └── subtitles/
    │   │           ├── 1_talking_about_a_topic.srt
    │   │           ├── 2_discussing_opinions.srt
    │   │           ├── 3_negotiating.srt
    │   │           ├── 4_describing_a_picture.srt
    │   │           ├── 5_giving_personal_information.srt
    │   │           ├── 6_job_interview.srt
    │   │           └── 7_giving_a_presentation.srt
    │   └── styles/
    │       └── style.css
    ├── scripts/
    │   ├── gh_releases_body.py
    │   └── minify-dist-jsons.js
    ├── src/
    │   ├── App.jsx
    │   ├── ErrorBoundary.jsx
    │   ├── ErrorBoundaryInner.jsx
    │   ├── index.jsx
    │   ├── components/
    │   │   ├── Homepage.jsx
    │   │   ├── conversation_page/
    │   │   │   ├── ConversationDetailPage.jsx
    │   │   │   ├── ConversationMenu.jsx
    │   │   │   ├── ListeningTab.jsx
    │   │   │   ├── PracticeTab.jsx
    │   │   │   ├── ReviewTab.jsx
    │   │   │   └── WatchAndStudyTab.jsx
    │   │   ├── download_page/
    │   │   │   └── DownloadPage.jsx
    │   │   ├── exam_page/
    │   │   │   ├── ExamDetailPage.jsx
    │   │   │   ├── ExamPage.jsx
    │   │   │   ├── ListeningTab.jsx
    │   │   │   ├── PracticeTab.jsx
    │   │   │   ├── ReviewTab.jsx
    │   │   │   └── WatchAndStudyTab.jsx
    │   │   ├── exercise_page/
    │   │   │   ├── DictationQuiz.jsx
    │   │   │   ├── ExerciseDetailPage.jsx
    │   │   │   ├── ExercisePage.jsx
    │   │   │   ├── MatchUp.jsx
    │   │   │   ├── MemoryMatch.jsx
    │   │   │   ├── OddOneOut.jsx
    │   │   │   ├── Reordering.jsx
    │   │   │   ├── Snap.jsx
    │   │   │   ├── SortableWord.jsx
    │   │   │   ├── SortingExercise.jsx
    │   │   │   └── SoundAndSpelling.jsx
    │   │   ├── general/
    │   │   │   ├── AccentDropdown.jsx
    │   │   │   ├── Footer.jsx
    │   │   │   ├── LoadingOverlay.jsx
    │   │   │   ├── LogoLightOrDark.jsx
    │   │   │   ├── NotFound.jsx
    │   │   │   ├── TopNavBar.jsx
    │   │   │   └── VersionUpdateDialog.jsx
    │   │   ├── setting_page/
    │   │   │   ├── Appearance.jsx
    │   │   │   ├── AppInfo.jsx
    │   │   │   ├── ExerciseTimer.jsx
    │   │   │   ├── LanguageSwitcher.jsx
    │   │   │   ├── LogSettings.jsx
    │   │   │   ├── modelOptions.js
    │   │   │   ├── PronunciationCheckerDialogContent.jsx
    │   │   │   ├── PronunciationCheckerInfo.jsx
    │   │   │   ├── PronunciationSettings.jsx
    │   │   │   ├── pronunciationStepUtils.js
    │   │   │   ├── PronunciationUtils.js
    │   │   │   ├── ResetSettings.jsx
    │   │   │   ├── SavedRecordingLocationMenu.jsx
    │   │   │   ├── SaveFolderSettings.jsx
    │   │   │   ├── Settings.jsx
    │   │   │   ├── VideoDownloadMenu.jsx
    │   │   │   ├── VideoDownloadSubPage.jsx
    │   │   │   └── VideoDownloadTable.jsx
    │   │   ├── sound_page/
    │   │   │   ├── ReviewCard.jsx
    │   │   │   ├── SoundList.jsx
    │   │   │   ├── SoundMain.jsx
    │   │   │   ├── SoundPracticeCard.jsx
    │   │   │   ├── TongueTwister.jsx
    │   │   │   ├── WatchVideoCard.jsx
    │   │   │   └── hooks/
    │   │   │       ├── useSoundVideoDialog.jsx
    │   │   │       └── useSoundVideoDialogContext.jsx
    │   │   └── word_page/
    │   │       ├── ipaUtils.js
    │   │       ├── Pagination.jsx
    │   │       ├── PronunciationChecker.jsx
    │   │       ├── RecordingWaveform.jsx
    │   │       ├── ReviewRecording.jsx
    │   │       ├── SpeechRecognitionTest.jsx
    │   │       ├── syllableParser.jsx
    │   │       ├── useWaveformTheme.jsx
    │   │       ├── WordDetails.jsx
    │   │       └── WordList.jsx
    │   ├── styles/
    │   │   ├── index.css
    │   │   └── memory-card.css
    │   ├── ui/
    │   │   └── Container.jsx
    │   └── utils/
    │       ├── AccentLocalStorage.jsx
    │       ├── databaseOperations.jsx
    │       ├── ffmpegWavConverter.js
    │       ├── isElectron.jsx
    │       ├── isTouchDevice.jsx
    │       ├── levenshtein.js
    │       ├── openExternal.jsx
    │       ├── phonemeUtils.jsx
    │       ├── ShuffleArray.jsx
    │       ├── sonnerCustomToast.jsx
    │       ├── useCountdownTimer.jsx
    │       ├── useScrollTo.jsx
    │       └── ThemeContext/
    │           ├── ThemeProvider.jsx
    │           ├── ThemeProviderContext.jsx
    │           ├── useAutoDetectTheme.jsx
    │           └── useTheme.jsx
    └── .github/
        ├── ISSUE_TEMPLATE/
        │   ├── bug_report.yml
        │   ├── config.yml
        │   └── feature_request.yml
        └── workflows/
            ├── electron.yaml
            └── static.yml

================================================
FILE: README.md
================================================
<p align="center">
  <img src="./public/images/homepage_screenshot.webp" alt="iSpeakerReact homepage screenshot" />
</p>

<p align="center">
  <a href="https://hosted.weblate.org/engage/ispeakerreact/">
    <img src="https://hosted.weblate.org/widget/ispeakerreact/ispeakerreact-component/multi-blue.svg" width="600px" alt="Translation status" />
  </a>
</p>

Currently, the app is available in English and Simplified Chinese. You can [help us translate into more languages](https://github.com/learnercraft/ispeakerreact/issues/18).

# About iSpeakerReact

**Introducing iSpeakerReact**: A modern reimagining, open-source alternative to Oxford iSpeaker

Rewritten from the ground up with React + Vite, **iSpeakerReact** is a self-study interactive tool designed to help learners improve their speaking and listening skills. It is based on the original Oxford iSpeaker developed by Oxford University.

While the official, modern, Angular-based Oxford iSpeaker has introduced significant design updates, it also streamlined or removed certain features that users found valuable (like the Exercises section). Therefore, we’ve decided to rebuild the app based on the older jQuery version, while taking inspiration from the newer Angular version to create a richer, more complete learning experience.

> [!NOTE]
> 
> 📢 The iSpeakerReact project has moved!
>
>Users should update their bookmarks to point to the new URL:
> 
> 🔗 New URL: 👉 https://learnercraft.github.io/ispeakerreact/
> 
> Old URL (no longer maintained): https://yllst-testing-labs.github.io/ispeakerreact/

# Use the app

Visit the webiste to use the tool online: <https://learnercraft.github.io/ispeakerreact/>

Or download the offline portable version: <https://github.com/learnercraft/ispeakerreact/releases/latest>

For Windows, you can also download from the Microsoft Store if you want automatic updates:

<p align="center">
    <a href="https://apps.microsoft.com/store/detail/9NWK49GLXGFP?launch=true&mode=mini">
        <img width="300px" src="https://get.microsoft.com/images/en-us%20dark.svg" alt="Microsoft Store badge"/>
    </a>
</p>

The offline portable version (or "Slim edition" on the Microsoft Store) includes audio files for offline playback. You can manually download the video files in the Settings page for offline use.

**Also check out other English-learning interactive tools:**

- [iWriter](http://github.com/yell0wsuit/iwriter): help learners write effectively in English.
- [Practical English Usage: Diagnostic Test](http://github.com/yell0wsuit/oxford-peu-diagnostics): help learners improve grammar and vocabulary skills.

# Features

- **Sounds**

  Learning to read phonetic transcriptions in the dictionary is a great way to ensure correct pronunciation. The Sounds section helps you practice pronouncing the International Phonetic Alphabet (IPA) used in English dictionaries. Begin with a video that demonstrates how each sound is pronounced, then use shorter clips to practice. You can also record* your pronunciation for later review and assessment.

- **Words**

  Practice pronunciation with common words from the Oxford 3000™ and Oxford 5000™ lists. Each word is broken down into syllables with highlighted primary and secondary stress. Features include playback, recording, and reviewing your pronunciation with smiley ratings to track progress.

- **Exercises**

  Interactive exercises that test how well you can listen and pronounce words. Choose from eight different exercise types:

  - Dictation: Test both your listening and spelling skills.
  - Match-up: Match sounds with the correct written words or phonetic transcriptions.
  - Reordering: Reorder words or sentences to match what you hear.
  - Sounds and Spelling: Listen and choose the correct vowel or diphthong spelling.
  - Sorting: Group words with the same sound, stress, or number of syllables.
  - Odd one out: Identify the word with a different stress pattern, rhythm, or number of syllables.
  - Snap!: Answer "Yes" or "No" to whether the phonetics match the written word or sound the same.
  - Memory match: A timed game where you find pairs of rhyming words or homophones.

- **Conversations**

  Covering over 30 real-life, everyday topics, this section helps you learn how to use appropriate language for various situations. Watch videos to understand different contexts, highlight key phrases, explore additional expressions, and practice by writing and recording* your own conversations.

- **Exams**

  Preparing for an English-speaking exam or a longer speaking task? The Exam section offers a wide range of tasks commonly encountered in evaluations like the Cambridge First, IELTS, TOEFL, and more. Watch real students complete these tasks, highlight key phrases from their responses, and expand your own repertoire of useful expressions. Boost your speaking skills by recording* yourself performing similar tasks, helping you enhance both your language proficiency and exam strategies.

*In order to use the recording functionality, please ensure that you have a microphone device, and you have allowed the app to access it.

# Browser compatibility

Work on the latest version of the following browsers:

- Chromium browsers (Microsoft Edge, Chrome, Opera, Brave, ...)
- Mozilla Firefox
- Safari (macOS, iOS, iPadOS)

# Contributions

Please see [CONTRIBUTING.MD](./CONTRIBUTING.md) for more information.

# License

This project is licensed under the Apache License 2.0. See the [LICENSE](./LICENSE) file for details.

# Acknowledgements

- [Oxford University Press](https://www.oxfordlearnersdictionaries.com/) for the original Oxford iSpeaker project.
- [Weblate](https://weblate.org/) for providing the translation platform.
- [i18next](https://www.i18next.com/) for the internationalization framework.
- [React](https://react.dev/) and [Vite](https://vitejs.dev/) for the frontend framework and build tool.
- [JS7z](https://github.com/GMH-Code/JS7z) for the 7-zip library used in the offline version.
- [Tailwind CSS](https://tailwindcss.com/) & [daisyUI](https://daisyui.com/) for the UI design.
- [React Icons](https://react-icons.github.io/react-icons/) for the icons.
- [SVG Repo](https://www.svgrepo.com/) for the homepage icons.
- [Logo.com](https://logo.com/) for the app logo.
- [Electron](https://www.electronjs.org/) for the desktop app.
- [dnd kit](https://dndkit.com/) for the drag-and-drop functionality.
- [Sonner](https://sonner.emilkowal.ski/) for the toast notifications.
- [Vidstack Player](https://vidstack.io/) for the custom video player used in the offline version.
- [Wavesurfer.js](https://wavesurfer.xyz/) for the audio visualization.

Special thanks to:

- [Bootstrap](https://getbootstrap.com/) for the updated design in [version 2.0](https://github.com/learnercraft/ispeakerreact/pull/6).



================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to iSpeakerReact

Thank you for your interest in contributing! Please take a moment to review this guide before submitting issues, feature requests, translation contributions, or pull requests.

> 📝 **Note:** This guide is not exhaustive. Project practices may evolve, and new situations may arise. When in doubt, feel free to ask questions or open an issue for clarification.

## 📬 Submitting issues, feature requests, or translations

- To report bugs or request features, please [open an issue](https://github.com/learnercraft/ispeakerreact/issues/new/choose) and choose the appropriate category.

- For translation contributions, please [refer to this issue](https://github.com/learnercraft/ispeakerreact/issues/18) for more information.

## 🔀 Submitting pull requests

### 📌 Project note

- This project **does not use TypeScript** or any kind of static type checking yet.
  - In the meantime, **use** `PropTypes` to validate component props for basic type safety.

### ✅ What you should do

- **Use a code editor** (e.g., Visual Studio Code) to write and format code efficiently.

- **Format your code** before committing and pushing. You must use **Prettier with our configuration** to ensure consistent formatting.

- **Test your code thoroughly** before pushing. Resolve any ESLint errors if possible.
  - An exception is allowed for variables defined in `vite.config.js` and available only at build time. If ESLint complains about these (e.g., the `__APP_VERSION__` variable), you can safely ignore the warning.

- **Use clear, concise variable names** written in `camelCase`. Names should be self-explanatory and reflect their purpose.

- **Use a clear, concise pull request title**. We recommend following [semantic commit message conventions](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716). Examples:
  - `fix: handle audio timeout error on older devices`
  - `feat: add pitch detection to feedback view`

  If the title doesn’t fully describe your changes, please provide a detailed description in the PR body.

- Use **multiple small commits** with clear messages when possible. This improves readability and makes it easier to review specific changes.

- Before submitting a **large pull request** or major change, open an issue first and select the appropriate category. After a review by our team, you can start your work.

### 🧪 Review process

- All PRs are reviewed before merging. Please be responsive to feedback.  
  When resolving comments, **make a new commit with**:  
  `address feedback by @<username>`

### 🤔 What you should NOT do

- Submit pull requests that only include **cosmetic changes** like whitespace tweaks or code reformatting without any functional impact.  
  These changes clutter diffs and make code reviews harder. [See this comment by the Rails team](https://github.com/rails/rails/pull/13771#issuecomment-32746700).

- Submit a pull request with **one or several giant commit(s)**. This makes it difficult to review.

- Use unclear, vague, or default commit messages like `Update file`, `fix`, or `misc changes`.

- Modify configuration files (e.g., `.prettierrc`, `eslint.config.js`, etc.), or any files in the `.github` folder without prior discussion.

### 🚫 Prohibited actions

- Add code or commits that:
  - Are **obscure** or **unclear** in intent  
  - Are **malicious** or **unsafe**  
  - **Executes scripts from external sources** associated with malicious, unsafe, or illegal behavior  
  - Attempts to introduce **backdoors** or hidden functionality

  If we find any code that violates these rules, you will be blocked from further contributions and reported to GitHub for Terms of Use violations.

- Use expletives or offensive language. This project is intended for everyone, and we strive to maintain a respectful environment for all contributors and users.

---

Thank you again for helping us improve the project!



================================================
FILE: eslint.config.js
================================================
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'

export default [
  { ignores: ['dist'] },
  {
    files: ['**/*.{js,jsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        ecmaVersion: 'latest',
        ecmaFeatures: { jsx: true },
        sourceType: 'module',
      },
    },
    settings: { react: { version: '18.3' } },
    plugins: {
      react,
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...js.configs.recommended.rules,
      ...react.configs.recommended.rules,
      ...react.configs['jsx-runtime'].rules,
      ...reactHooks.configs.recommended.rules,
      'react/jsx-no-target-blank': 'off',
      'react-refresh/only-export-components': [
        'warn',
        { allowConstantExport: true },
      ],
    },
  },
]



================================================
FILE: forge.config.cjs
================================================
const { FusesPlugin } = require("@electron-forge/plugin-fuses");
const { FuseV1Options, FuseVersion } = require("@electron/fuses");
const path = require("path");

module.exports = {
    packagerConfig: {
        asar: true,
        name: "iSpeakerReact",
        executableName: "ispeakerreact",
        appCopyright:
            "Licensed under the Apache License, Version 2.0. Video and audio materials © Oxford University Press. All rights reserved.",
        appBundleId: "page.learnercraft.ispeakerreact",
        appCategoryType: "public.app-category.education",
        win32metadata: {
            CompanyName: "LearnerCraft Labs",
            ProductName: "iSpeakerReact",
        },
        prune: true,
        icon: path.join(__dirname, "dist", "appicon"),
        ignore: [
            "^/\\.github$", // Ignore the .github directory
            "^/venv$", // Ignore the venv directory
            //"^/node_modules$", // Ignore the node_modules directory
            "^/\\.vscode$", // Ignore .vscode if exists
            "^/tests$", // Ignore tests directory if exists
            "^/scripts$", // Ignore scripts directory if exists
            "^/\\..*$", // Ignore any dotfiles (e.g., .gitignore, .eslintrc, etc.)
            "^/(README|SECURITY|CONTRIBUTING).md$", // Ignore README.md file
            "^/package-lock.json$", // Ignore package-lock.json file
            "^/public$",
            "^/src$",
            "^/netlify.toml$",
        ],
    },
    rebuildConfig: {},
    makers: [
        {
            name: "@electron-forge/maker-zip",
            platforms: ["linux", "win32", "darwin"],
        },
        /*{
            name: "@electron-forge/maker-dmg",
            config: {
                options: {
                    icon: path.join(__dirname, "dist", "appicon.icns"),
                },
            },
        },*/
        {
            name: "@electron-forge/maker-deb",
            config: {
                options: {
                    icon: path.join(__dirname, "dist", "appicon.png"),
                },
            },
        },
        {
            name: "@electron-forge/maker-rpm",
            options: {
                icon: path.join(__dirname, "dist", "appicon.png"),
            },
        },
        /*{
            name: "@electron-forge/maker-squirrel",
            config: (arch) => ({
                setupIcon: path.join(__dirname, "dist", "appicon.ico"),
                iconUrl: path.join(__dirname, "dist", "appicon.ico"),
                setupExe: `iSpeakerReact-win32-${arch}-Setup.exe`,
            }),
        },*/
    ],
    plugins: [
        {
            name: "@electron-forge/plugin-auto-unpack-natives",
            config: {},
        },
        // Fuses are used to enable/disable various Electron functionality
        // at package time, before code signing the application
        new FusesPlugin({
            version: FuseVersion.V1,
            [FuseV1Options.RunAsNode]: false,
            [FuseV1Options.EnableCookieEncryption]: true,
            [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
            [FuseV1Options.EnableNodeCliInspectArguments]: false,
            [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
            [FuseV1Options.OnlyLoadAppFromAsar]: true,
        }),
    ],
};



================================================
FILE: i18n.js
================================================
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpApi from "i18next-http-backend";

i18n.use(HttpApi) // Load translations via HTTP (use with i18next-http-backend)
    .use(LanguageDetector) // Detect user language
    .use(initReactI18next) // Pass the i18n instance to react-i18next.
    .init({
        lng: localStorage.getItem("ispeaker")
            ? JSON.parse(localStorage.getItem("ispeaker")).language
            : "en",
        fallbackLng: {
            "en-US": ["en"],
            "en-GB": ["en"],
            "zh-CN": ["zh"],
            default: ["en"],
        },
        load: "languageOnly",
        debug: true, // Enable debug messages
        backend: {
            loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}.json`, // Translation files path
        },
        interpolation: {
            escapeValue: false, // React already escapes values
            format: (value, format) => {
                if (format === "capitalize") {
                    if (typeof value !== "string") return value;
                    return value.charAt(0).toUpperCase() + value.slice(1);
                }
                return value;
            },
        },
    })
    .then(() => {
        // Ensure the `lang` attribute on `html` is updated immediately
        const currentLang = i18n.language || "en";
        document.documentElement.setAttribute("lang", currentLang);
    });

export default i18n;



================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
        <link rel="icon" href="/favicon.ico" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="theme-color" content="#2a303c" />
        <meta name="iSpeakerReact" />
        <link rel="apple-touch-icon" href="/images/icons/ios/192.png" />
        <title>iSpeakerReact</title>
        <link href="/styles/style.css" rel="stylesheet" />
        <meta
            name="description"
            content="An English-learning interactive tool written in React, designed to help learners practice speaking and listening."
        />
        <meta
            name="keywords"
            content="ispeaker, iSpeakerReact, oxford, react, vite, JavaScript, English-learning, english, interactive tool, speaking, listening, online exercise"
        />
        <!-- Canonical URL -->
        <link rel="canonical" href="https://learnercraft.github.io/ispeakerreact/" />
        <!-- Author and Copyright -->
        <meta name="author" content="LearnerCraft Labs" />
        <meta name="copyright" content="LearnerCraft Labs" />
        <!-- Robots -->
        <meta name="robots" content="index, follow" />
        <!-- Open Graph Metadata -->
        <meta property="og:title" content="iSpeakerReact" />
        <meta
            property="og:description"
            content="An English-learning interactive tool written in React, designed to help learners practice speaking and listening."
        />
        <meta property="og:type" content="website" />
        <meta property="og:url" content="https://learnercraft.github.io/ispeakerreact/" />
        <meta
            property="og:image"
            content="https://learnercraft.github.io/ispeakerreact/socialimg.jpg"
        />
        <meta property="og:locale" content="en_US" />
        <!-- Twitter Card Metadata -->
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content="iSpeakerReact" />
        <meta
            name="twitter:description"
            content="An English-learning interactive tool written in React, designed to help learners practice speaking and listening."
        />
        <meta
            name="twitter:image"
            content="https://learnercraft.github.io/ispeakerreact/socialimg.jpg"
        />
        <!-- Language -->
        <meta http-equiv="Content-Language" content="en" />
    </head>
    <body>
        <noscript>You need to enable JavaScript to run this app.</noscript>
        <div id="root"></div>
        <script type="module" src="/src/index.jsx"></script>
    </body>
</html>



================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.



================================================
FILE: main.js
================================================
/* global setImmediate */ // for eslint because setImmediate is node global
import cors from "cors";
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import applog from "electron-log";
import { Buffer } from "node:buffer";
import fs from "node:fs";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

import { Conf } from "electron-conf/main";
import { createSplashWindow, createWindow } from "./electron-main/createWindow.js";
import { setCustomSaveFolderIPC } from "./electron-main/customFolderLocationOperation.js";
import { expressApp } from "./electron-main/expressServer.js";
import { getLogFolder, getSaveFolder, readUserSettings } from "./electron-main/filePath.js";
import {
    getCustomSaveFolderIPC,
    getFfmpegWasmPathIPC,
    getSaveFolderIPC,
    getVideoFileDataIPC,
    getVideoSaveFolderIPC,
} from "./electron-main/getFileAndFolder.js";
import {
    getCurrentLogSettings,
    manageLogFiles,
    setCurrentLogSettings,
} from "./electron-main/logOperations.js";
import {
    cancelProcess,
    checkPythonInstalled,
    downloadModel,
    installDependencies,
    killCurrentPythonProcess,
    resetGlobalCancel,
    setupPronunciationInstallStatusIPC,
} from "./electron-main/pronunciationOperations.js";
import { checkDownloads, checkExtractedFolder } from "./electron-main/videoFileOperations.js";
import { verifyAndExtractIPC } from "./electron-main/zipOperation.js";
import {
    setupPronunciationCheckerIPC,
    setupGetRecordingBlobIPC,
} from "./electron-main/pronunciationCheckerIPC.js";

const DEFAULT_PORT = 8998;

let server; // Declare server at the top so it's in scope for all uses

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

let electronSquirrelStartup = false;
try {
    electronSquirrelStartup = (await import("electron-squirrel-startup")).default;
} catch (e) {
    console.log("Error importing electron-squirrel-startup:", e);
    applog.error("Error importing electron-squirrel-startup:", e);
}
if (electronSquirrelStartup) app.quit();

// Log operations
manageLogFiles();

const conf = new Conf();
conf.registerRendererListener();

let mainWindow;

// Allow requests from localhost:5173 (Vite's default development server)
expressApp.use(cors({ origin: "http://localhost:5173" }));

// Set up rate limiter: maximum of 2000 requests per 15 minutes
/*const limiter = RateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 2000,
});*/

// Set up the express server to serve video files
expressApp.get("/video/:folderName/:fileName", async (req, res) => {
    const { folderName, fileName } = req.params;
    const documentsPath = await getSaveFolder(readUserSettings);
    const videoFolder = path.resolve(documentsPath, "video_files", folderName);
    const videoFilePath = path.resolve(videoFolder, fileName);

    if (!videoFilePath.startsWith(videoFolder)) {
        res.status(403).send("Access denied.");
        return;
    }

    try {
        await fsPromises.access(videoFilePath);
        const stat = await fsPromises.stat(videoFilePath);
        const fileSize = stat.size;
        const range = req.headers.range;

        if (range) {
            const parts = range.replace(/bytes=/, "").split("-");
            const start = parseInt(parts[0], 10);
            const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
            const chunkSize = end - start + 1;

            const file = fs.createReadStream(videoFilePath, { start, end });
            const head = {
                "Content-Range": `bytes ${start}-${end}/${fileSize}`,
                "Accept-Ranges": "bytes",
                "Content-Length": chunkSize,
                "Content-Type": "video/mp4",
            };

            res.writeHead(206, head);
            file.pipe(res);
        } else {
            const head = {
                "Content-Length": fileSize,
                "Content-Type": "video/mp4",
            };
            res.writeHead(200, head);
            fs.createReadStream(videoFilePath).pipe(res);
        }
    } catch {
        res.status(404).send("Video file not found.");
        return;
    }
});

// IPC event from the renderer
ipcMain.handle("open-external-link", async (event, url) => {
    await shell.openExternal(url); // Open the external link
});

// Handle saving a recording
ipcMain.handle("save-recording", async (event, key, arrayBuffer) => {
    const saveFolder = await getSaveFolder(readUserSettings);
    const recordingFolder = path.join(saveFolder, "saved_recordings");
    const filePath = path.join(recordingFolder, `${key}.wav`);

    // Ensure the directory exists
    try {
        await fsPromises.access(recordingFolder);
    } catch {
        await fsPromises.mkdir(recordingFolder, { recursive: true });
    }

    try {
        const buffer = Buffer.from(arrayBuffer);
        await fsPromises.writeFile(filePath, buffer);
        console.log("Recording saved to:", filePath);
        applog.log("Recording saved to:", filePath);
        return "Success";
    } catch (error) {
        console.error("Error saving the recording to disk:", error);
        throw error;
    }
});

// Handle checking if a recording exists
ipcMain.handle("check-recording-exists", async (event, key) => {
    const saveFolder = await getSaveFolder(readUserSettings);
    const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`);

    try {
        await fsPromises.access(filePath);
        return true;
    } catch {
        return false;
    }
});

// Handle playing a recording (this can be improved for streaming)
ipcMain.handle("play-recording", async (event, key) => {
    const filePath = path.join(
        await getSaveFolder(readUserSettings),
        "saved_recordings",
        `${key}.wav`
    );

    // Check if the file exists
    try {
        const data = await fsPromises.readFile(filePath);
        return data.buffer; // Return the ArrayBuffer to the renderer process
    } catch {
        console.error("File not found:", filePath);
        throw new Error("Recording file not found");
    }
});

/* Video file operations */

// Get video file data
getVideoFileDataIPC(__dirname);

// IPC event to get and open the video folder
getVideoSaveFolderIPC();

// Check video file downloads
checkDownloads();

// Check video file extracted folder
checkExtractedFolder();

/* End video file operations */

// IPC event to get the current server port
ipcMain.handle("get-port", () => {
    return server?.address()?.port || DEFAULT_PORT;
});

ipcMain.handle("open-log-folder", async () => {
    // Open the folder in the file manager
    const logFolder = await getLogFolder(readUserSettings);
    await shell.openPath(logFolder); // Open the folder
    return logFolder; // Send the path back to the renderer
});

ipcMain.handle("open-recording-folder", async () => {
    // Open the folder in the file manager
    const recordingFolder = await getSaveFolder(readUserSettings);
    const recordingFolderPath = path.join(recordingFolder, "saved_recordings");
    try {
        await fsPromises.access(recordingFolderPath);
    } catch {
        await fsPromises.mkdir(recordingFolderPath, { recursive: true });
    }
    await shell.openPath(recordingFolderPath); // Open the folder
    return recordingFolderPath; // Send the path back to the renderer
});

// IPC event to verify and extract a zip file
verifyAndExtractIPC();

// Listen for logging messages from the renderer process
ipcMain.on("renderer-log", (event, logMessage) => {
    const { level, message } = logMessage;
    if (applog[level]) {
        applog[level](`Renderer log: ${message}`);
    }
});

// Handle uncaught exceptions globally and quit the app
process.on("uncaughtException", (error) => {
    console.error("An uncaught error occurred:", error);
    applog.error("An uncaught error occurred:", error);
    app.quit(); // Quit the app on an uncaught exception
});

// Handle unhandled promise rejections globally and quit the app
process.on("unhandledRejection", (reason, promise) => {
    console.error("Unhandled promise rejection at:", promise, "reason:", reason);
    applog.error("Unhandled promise rejection at:", promise, "reason:", reason);
    app.quit(); // Quit the app on an unhandled promise rejection
});

app.on("renderer-process-crashed", (event, webContents, killed) => {
    applog.error("Renderer process crashed", { event, killed });
    app.quit();
});

// Quit when all windows are closed, except on macOS.
app.on("window-all-closed", () => {
    if (process.platform !== "darwin") {
        app.quit();
    }
});

// Recreate the window on macOS when the dock icon is clicked.
app.on("activate", () => {
    if (mainWindow === null) {
        createWindow(__dirname, (srv) => {
            server = srv;
        });
    }
});

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
    app.quit();
    process.exit(0);
} else {
    app.whenReady()
        .then(() => {
            // 1. Show splash window immediately
            createSplashWindow(__dirname, ipcMain, conf);

            // 2. Start heavy work in parallel after splash is shown
            setImmediate(() => {
                // Create main window (can be shown after splash)
                createWindow(__dirname, (srv) => {
                    server = srv;
                });

                // Wait for log settings and manage logs in background
                ipcMain.once("update-log-settings", (event, settings) => {
                    setCurrentLogSettings(settings);
                    applog.info("Log settings received from renderer:", settings);
                    manageLogFiles().then(() => {
                        applog.info("Log files managed successfully.");
                    });
                });
            });
        })
        .catch((error) => {
            // Catch any errors thrown in the app.whenReady() promise itself
            applog.error("Error in app.whenReady():", error);
        });
}

getFfmpegWasmPathIPC(__dirname);

/* Custom save folder operations */

// IPC: Get current save folder (resolved)
getSaveFolderIPC();

// IPC: Get current custom save folder (raw, may be undefined)
getCustomSaveFolderIPC();

// IPC: Set custom save folder
setCustomSaveFolderIPC();

/* End custom save folder operations */

// IPC: Show open dialog for folder selection
ipcMain.handle("show-open-dialog", async (event, options) => {
    const win = BrowserWindow.getFocusedWindow();
    const result = await dialog.showOpenDialog(win, options);
    return result.filePaths;
});

ipcMain.handle("get-log-settings", async () => {
    return getCurrentLogSettings();
});

// DEBUG: Trace undefined logs
const origConsoleLog = console.log;
console.log = (...args) => {
    if (args.length === 1 && args[0] === undefined) {
        origConsoleLog.call(console, "console.log(undefined) called! Stack trace:");
        origConsoleLog.call(console, new Error().stack);
    }
    origConsoleLog.apply(console, args);
};

/* Pronunciation checker operations */

ipcMain.handle("check-python-installed", async () => {
    try {
        const result = await checkPythonInstalled();
        if (result.found) {
            applog.info("Python found:", result.version);
        } else {
            applog.error("Python not found. Stderr:", result.stderr);
        }
        return result;
    } catch (err) {
        applog.error("Error checking Python installation:", err);
        return { found: false, version: null, stderr: String(err) };
    }
});

installDependencies();

downloadModel();

cancelProcess();

// Setup pronunciation install status IPC
setupPronunciationInstallStatusIPC();

// Before starting a new workflow, reset the global cancel flag
ipcMain.handle("pronunciation-reset-cancel-flag", async () => {
    resetGlobalCancel();
});

/* End pronunciation checker operations */

ipcMain.handle("get-recording-path", async (_event, wordKey) => {
    const saveFolder = await getSaveFolder(readUserSettings);
    return path.join(saveFolder, "saved_recordings", `${wordKey}.wav`);
});

setupPronunciationCheckerIPC();
setupGetRecordingBlobIPC();

app.on("before-quit", async () => {
    await killCurrentPythonProcess();
});



================================================
FILE: netlify.toml
================================================
[build]
  publish = "dist"
  command = "npm run build"

[context.deploy-preview]
  # For PR previews on Netlify
  [context.deploy-preview.environment]
    VITE_BASE = "/"



================================================
FILE: package.json
================================================
{
    "name": "ispeakerreact",
    "author": "LearnerCraft Labs",
    "description": "An English-learning interactive tool written in React, designed to help learners practice speaking and listening",
    "private": true,
    "version": "3.5.0",
    "type": "module",
    "main": "main.js",
    "license": "Apache-2.0",
    "scripts": {
        "dev": "vite",
        "build": "vite build && node scripts/minify-dist-jsons.js",
        "lint": "eslint .",
        "preview": "vite preview",
        "start": "concurrently \"cross-env NODE_ENV=development vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\"",
        "package": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package",
        "make": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge make",
        "vitebuildcli": "vite build --mode electron && node scripts/minify-dist-jsons.js",
        "makecli": "electron-forge make",
        "appx": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-x64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-x64  --assets ../public/images/icons/windows11 -m ./appxmanifest.xml",
        "appxarm": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package --arch=arm64 && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-arm64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-arm64  --assets ../public/images/icons/windows11 -m ./appxmanifestARM.xml"
    },
    "dependencies": {
        "@ffmpeg/ffmpeg": "^0.12.15",
        "@ffmpeg/util": "^0.12.2",
        "@fix-webm-duration/fix": "^1.0.1",
        "cors": "^2.8.5",
        "electron-conf": "^1.3.0",
        "electron-log": "^5.4.0",
        "electron-squirrel-startup": "^1.0.1",
        "express": "^5.2.1",
        "express-rate-limit": "^7.5.0",
        "fkill": "^9.0.0",
        "js7z-tools": "^2.4.1",
        "masonry-layout": "^4.2.2",
        "mime": "^4.0.7"
    },
    "devDependencies": {
        "@dnd-kit/core": "^6.3.1",
        "@dnd-kit/sortable": "^10.0.0",
        "@electron-forge/cli": "^7.8.1",
        "@electron-forge/maker-deb": "^7.8.1",
        "@electron-forge/maker-dmg": "^7.8.1",
        "@electron-forge/maker-rpm": "^7.8.1",
        "@electron-forge/maker-squirrel": "^7.8.1",
        "@electron-forge/maker-zip": "^7.8.1",
        "@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
        "@electron-forge/plugin-fuses": "^7.8.1",
        "@electron/fuses": "^1.8.0",
        "@eslint/js": "^9.27.0",
        "@tailwindcss/postcss": "^4.1.7",
        "@tailwindcss/typography": "^0.5.16",
        "@tailwindcss/vite": "^4.1.7",
        "@types/react": "^19.1.5",
        "@types/react-dom": "^19.1.5",
        "@vidstack/react": "^1.12.13",
        "@vitejs/plugin-react": "^4.5.0",
        "@vitejs/plugin-react-swc": "^3.10.0",
        "@wavesurfer/react": "^1.0.11",
        "autoprefixer": "^10.4.21",
        "concurrently": "^9.1.2",
        "cross-env": "^7.0.3",
        "daisyui": "^5.0.37",
        "dexie": "^4.0.11",
        "electron": "^36.9.5",
        "eslint": "^9.27.0",
        "eslint-plugin-react": "^7.37.5",
        "eslint-plugin-react-hooks": "^6.0.0-rc.1",
        "eslint-plugin-react-refresh": "^0.4.20",
        "globals": "^16.1.0",
        "he": "^1.2.0",
        "i18next": "^25.2.0",
        "i18next-browser-languagedetector": "^8.1.0",
        "i18next-http-backend": "^3.0.2",
        "lodash": "^4.17.21",
        "postcss": "^8.5.3",
        "prettier": "^3.5.3",
        "prettier-plugin-tailwindcss": "^0.6.11",
        "react": "^19.1.0",
        "react-dom": "^19.1.0",
        "react-flip-toolkit": "^7.2.4",
        "react-i18next": "^15.5.2",
        "react-icons": "^5.5.0",
        "react-loading-skeleton": "^3.5.0",
        "react-router-dom": "^7.6.0",
        "rollup-plugin-visualizer": "^6.0.0",
        "sonner": "^2.0.3",
        "tailwindcss": "^4.1.7",
        "vidstack": "^1.12.12",
        "vite": "^6.4.1",
        "vite-plugin-pwa": "^1.0.0",
        "wait-on": "^8.0.3",
        "wavesurfer.js": "^7.9.5"
    },
    "optionalDependencies": {
        "appdmg": "latest"
    }
}



================================================
FILE: postcss.config.js
================================================
export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}



================================================
FILE: preload.cjs
================================================
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electron", {
    openExternal: (url) => ipcRenderer.invoke("open-external-link", url),
    saveRecording: (key, arrayBuffer) => ipcRenderer.invoke("save-recording", key, arrayBuffer),
    checkRecordingExists: (key) => ipcRenderer.invoke("check-recording-exists", key),
    playRecording: (key) => ipcRenderer.invoke("play-recording", key),
    ipcRenderer: {
        invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
        send: (channel, ...args) => ipcRenderer.send(channel, ...args),
        on: (channel, func) => ipcRenderer.on(channel, func),
        removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
        removeListener: (channel, func) => ipcRenderer.removeListener(channel, func),
    },
    getDirName: () => __dirname,
    isUwp: () => process.windowsStore,
    send: (channel, data) => {
        ipcRenderer.send(channel, data);
    },
    log: (level, message) => {
        // Send log message to the main process
        ipcRenderer.send("renderer-log", { level, message });
    },
    getRecordingBlob: async (key) => {
        // Use IPC to ask the main process for the blob
        return await ipcRenderer.invoke("get-recording-blob", key);
    },
    getFfmpegWasmPath: async () => {
        return await ipcRenderer.invoke("get-ffmpeg-wasm-path");
    },
    getFileAsBlobUrl: async (filePath, mimeType) => {
        const arrayBuffer = await ipcRenderer.invoke("read-file-buffer", filePath);
        const blob = new Blob([arrayBuffer], { type: mimeType });
        return URL.createObjectURL(blob);
    },
});



================================================
FILE: SECURITY.md
================================================
# Security Policy

## Supported Versions

We are committed to supporting only the latest version of the app. All users are required to be on the latest version of *iSpeakerReact* to ensure they receive critical security patches, bug fixes, and feature improvements. Older versions are not supported and may contain unpatched vulnerabilities.

The latest version of the software adheres to either the web version or the most recent release available on GitHub, with the web version being the preferred choice. Additionally, we will make effort to update the Microsoft Store and other app stores with the published version on GitHub.

Users still using *iSpeaker: Pronunciation Tool* from the Microsoft Store should uninstall it from their system and switch to iSpeakerReact.

## Reporting a Vulnerability

> [!WARNING]
> **Do not open a public issue to report security vulnerabilities.**

If you discover a security vulnerability, please report it privately by using GitHub's private disclosure feature:

🔒 [Report via GitHub Security Advisories](https://github.com/learnercraft/ispeakerreact/security/advisories)

We will respond as soon as possible, typically within 48 hours.



================================================
FILE: vite.config.js
================================================
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import packageJson from "./package.json";

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
    const isElectron = mode === "electron";
    const isDev = mode === "development";
    const base = process.env.VITE_BASE || isElectron ? "./" : isDev ? "/" : "/ispeakerreact/";

    return {
        base: base,
        build: {
            rollupOptions: {
                output: {
                    ...(isElectron
                        ? { inlineDynamicImports: true } // disables chunking for Electron
                        : {
                              manualChunks(id) {
                                  if (id.includes("node_modules")) {
                                      return id
                                          .toString()
                                          .split("node_modules/")[1]
                                          .split("/")[0]
                                          .toString();
                                  }
                              },
                          }),
                },
            },
            manifest: true,
        },
        plugins: [
            react(),
            visualizer(),
            tailwindcss(),
            !isElectron &&
                VitePWA({
                    registerType: "autoUpdate",
                    manifest: {
                        name: "iSpeakerReact",
                        short_name: "iSpeakerReact",
                        theme_color: "#2a303c",
                        background_color: "#2a303c",
                        description:
                            "An English-learning interactive tool written in React, designed to help learners practice speaking and listening.",
                        lang: "en",
                        icons: [
                            {
                                src: `${base}images/icons/ios/192.png`,
                                sizes: "192x192",
                                type: "image/png",
                            },
                            {
                                src: `${base}images/icons/ios/512.png`,
                                sizes: "512x512",
                                type: "image/png",
                            },
                        ],
                        screenshots: [
                            {
                                src: `${base}images/screenshots/screenshot-00.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-01.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-02.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-03.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-04.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-05.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-06.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                            {
                                src: `${base}images/screenshots/screenshot-07.webp`,
                                sizes: "1920x1080",
                                type: "image/webp",
                                form_factor: "wide",
                            },
                        ],
                        id: "io.yllsttestinglabs.ispeakerreact",
                        dir: "auto",
                        orientation: "any",
                        categories: ["education"],
                        prefer_related_applications: false,
                    },
                    workbox: {
                        runtimeCaching: [
                            {
                                // Files that need caching permanently
                                urlPattern: /\.(?:woff2|ttf|jpg|jpeg|webp|svg|ico|png)$/,
                                handler: "CacheFirst",
                                options: {
                                    cacheName: "permanent-cache",
                                    expiration: {
                                        maxEntries: 50,
                                        maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
                                    },
                                },
                            },
                            {
                                // Cache other assets dynamically with versioning
                                urlPattern: /\.(?:js|css|json|html)$/,
                                handler: "CacheFirst",
                                options: {
                                    cacheName: `dynamic-cache-v${packageJson.version}`,
                                    expiration: {
                                        maxEntries: 150,
                                        maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week
                                    },
                                },
                            },
                        ],
                    },
                }),
        ],
        define: {
            __APP_VERSION__: JSON.stringify(packageJson.version), // Inject version
        },
        optimizeDeps: {
            exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
        },
        server: {
            fs: {
                // Allow serving files from one level up to the project root
                allow: [".."],
            },
        },
    };
});



================================================
FILE: .prettierrc
================================================
{
    "trailingComma": "es5",
    "tabWidth": 4,
    "semi": true,
    "singleQuote": false,
    "useTabs": false,
    "printWidth": 100,
    "plugins": ["prettier-plugin-tailwindcss"]
}



================================================
FILE: data/splash.html
================================================
<!doctype html>
<html lang="en" data-theme="light">
    <head>
        <meta charset="UTF-8" />
        <title>iSpeakerReact</title>
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <style>
            .img-fluid {
                max-width: 100%;
                height: auto;
            }
            body {
                overflow: hidden;
                margin: 0;
            }
        </style>
    </head>
    <body>
        <div>
            <img
                class="img-fluid"
                src="../dist/images/icons/windows11/Wide310x150Logo.scale-400.png"
                id="splash"
                alt="Splash Image"
            />
        </div>
        <script>
            const htmlEl = document.documentElement;
            const bodyElement = document.body;
            const splashImage = document.getElementById("splash");

            const splashImageDark = "../dist/images/icons/logo-splash-dark.png";
            const splashImageLight = "../dist/images/icons/windows11/Wide310x150Logo.scale-400.png";
            const bodyElementDark = "#222";
            const bodyElementLight = "#f2f2f2";

            const darkElement = () => {
                htmlEl.setAttribute("data-theme", "dim");
                splashImage.src = splashImageDark;
                bodyElement.style.backgroundColor = bodyElementDark;
            };
            const lightElement = () => {
                htmlEl.setAttribute("data-theme", "light");
                splashImage.src = splashImageLight;
                bodyElement.style.backgroundColor = bodyElementLight;
            };

            document.addEventListener("DOMContentLoaded", async () => {
                let storedTheme;
                storedTheme = await window.electron.ipcRenderer.invoke(
                    "get-theme",
                    "ispeakerreact-ui-theme"
                );
                if (storedTheme === "dark") storedTheme = "dim";

                const applyTheme = (theme) => {
                    switch (theme) {
                        case "dim":
                        case "dark":
                            darkElement();
                            break;
                        case "light":
                            lightElement();
                            break;
                        default:
                            // auto or not set: follow system preference
                            switch (window.matchMedia("(prefers-color-scheme: dark)").matches) {
                                case true:
                                    darkElement();
                                    break;
                                case false:
                                    lightElement();
                                    break;
                            }
                            break;
                    }
                };

                applyTheme(storedTheme);

                // Listen for system theme changes only if theme is not explicitly set
                if (!storedTheme || storedTheme === "auto") {
                    window
                        .matchMedia("(prefers-color-scheme: dark)")
                        .addEventListener("change", () => {
                            applyTheme(storedTheme);
                        });
                }
            });
        </script>
    </body>
</html>



================================================
FILE: electron-main/createWindow.js
================================================
import { app, BrowserWindow, Menu, shell } from "electron";
import applog from "electron-log";
import path from "node:path";
import process from "node:process";
import { startExpressServer } from "./expressServer.js";

const isDev = process.env.NODE_ENV === "development";

let mainWindow;
let splashWindow;

const createSplashWindow = (rootDir, ipcMain, conf) => {
    splashWindow = new BrowserWindow({
        width: 854,
        height: 413,
        frame: false, // Remove window controls
        transparent: true, // Make the window background transparent
        alwaysOnTop: true,
        icon: path.join(rootDir, "dist", "appicon.png"),
        webPreferences: {
            preload: path.join(rootDir, "preload.cjs"),
            nodeIntegration: false,
            contextIsolation: true,
            enableRemoteModule: false,
            devTools: isDev ? true : false,
        },
    });

    // For splash screen
    ipcMain.handle("get-conf", (event, key) => {
        return conf.get(key);
    });

    // Load the splash screen HTML
    splashWindow.loadFile(path.join(rootDir, "data", "splash.html"));

    splashWindow.setTitle("Starting up...");

    // Splash window should close when the main window is ready
    splashWindow.on("closed", () => {
        splashWindow = null;
    });
};

const createWindow = (rootDir, onServerReady) => {
    mainWindow = new BrowserWindow({
        width: 1280,
        height: 720,
        show: false,
        webPreferences: {
            preload: path.join(rootDir, "preload.cjs"),
            nodeIntegration: false,
            contextIsolation: true,
            enableRemoteModule: false,
            devTools: isDev ? true : false,
        },
        icon: path.join(rootDir, "dist", "appicon.png"),
    });

    if (isDev) {
        mainWindow.loadURL("http://localhost:5173"); // Point to Vite dev server
    } else {
        mainWindow.loadFile(path.join(rootDir, "./dist/index.html")); // Load the built HTML file
    }

    // Show the main window only when it's ready
    mainWindow.once("ready-to-show", () => {
        setTimeout(() => {
            splashWindow.close();
            mainWindow.maximize();
            mainWindow.show();

            // Start Express server in the background after main window is shown
            startExpressServer().then((srv) => {
                if (onServerReady) onServerReady(srv);
            });
        }, 500);
    });

    mainWindow.on("closed", () => {
        mainWindow = null;
    });

    mainWindow.on("enter-full-screen", () => {
        mainWindow.setMenuBarVisibility(false);
    });

    mainWindow.on("leave-full-screen", () => {
        mainWindow.setMenuBarVisibility(true);
    });

    const menu = Menu.buildFromTemplate([
        {
            label: "File",
            submenu: [{ role: "quit" }],
        },
        {
            label: "Edit",
            submenu: [
                { role: "undo" },
                { role: "redo" },
                { type: "separator" },
                { role: "cut" },
                { role: "copy" },
                { role: "paste" },
            ],
        },
        {
            label: "View",
            submenu: [
                { role: "reload" },
                { role: "forceReload" },
                { type: "separator" },
                { role: "resetZoom" },
                { role: "zoomIn" },
                { role: "zoomOut" },
                { type: "separator" },
                { role: "togglefullscreen" },
                isDev ? { role: "toggleDevTools" } : null,
            ].filter(Boolean),
        },
        {
            label: "Window",
            submenu: [{ role: "minimize" }, { role: "zoom" }],
        },
        {
            label: "About",
            submenu: [
                {
                    label: "Project's GitHub page",
                    click: () => {
                        shell.openExternal("https://github.com/learnercraft/ispeakerreact");
                    },
                },
            ],
        },
    ]);

    Menu.setApplicationMenu(menu);

    app.on("second-instance", () => {
        if (mainWindow) {
            if (mainWindow.isMinimized()) {
                mainWindow.restore();
            }
            mainWindow.focus();
        }
    });

    applog.info(`App started. Version ${app.getVersion()}`);
};

export { createSplashWindow, createWindow };



================================================
FILE: electron-main/customFolderLocationOperation.js
================================================
import { ipcMain } from "electron";
import applog from "electron-log";
import fs from "node:fs";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
    getSaveFolder,
    settingsConf,
    getDataSubfolder,
    deleteEmptyDataSubfolder,
} from "./filePath.js";
import isDeniedSystemFolder from "./isDeniedSystemFolder.js";
import { generateLogFileName } from "./logOperations.js";

// Helper: Recursively collect all files in a directory
const getAllFiles = async (dir, base = dir) => {
    let files = [];
    const entries = await fsPromises.readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            files = files.concat(await getAllFiles(fullPath, base));
        } else {
            files.push({
                abs: fullPath,
                rel: path.relative(base, fullPath),
            });
        }
    }
    return files;
};

// Helper: Determine if folder contents should be moved
const shouldMoveContents = (src, dest) => {
    return (
        src &&
        dest &&
        src !== dest &&
        fs.existsSync(src) &&
        fs.existsSync(dest) &&
        !src.startsWith(dest) &&
        !dest.startsWith(src)
    );
};

// Helper: Delete pronunciation-venv in oldSaveFolder before moving
const deletePronunciationVenv = async (oldSaveFolder, event) => {
    const oldVenvPath = path.join(oldSaveFolder, "pronunciation-venv");
    if (fs.existsSync(oldVenvPath)) {
        event.sender.send("venv-delete-status", { status: "deleting", path: oldVenvPath });
        try {
            await fsPromises.rm(oldVenvPath, { recursive: true, force: true });
            console.log("Deleted old pronunciation-venv at:", oldVenvPath);
            applog.info("Deleted old pronunciation-venv at:", oldVenvPath);
            event.sender.send("venv-delete-status", { status: "deleted", path: oldVenvPath });
        } catch (venvErr) {
            // Log but do not block move if venv doesn't exist or can't be deleted
            console.log("Could not delete old pronunciation-venv:", venvErr.message);
            applog.warn("Could not delete old pronunciation-venv:", venvErr.message);
            event.sender.send("venv-delete-status", {
                status: "error",
                path: oldVenvPath,
                error: venvErr.message,
            });
        }
    }
};

// Helper: Move all contents from one folder to another (copy then delete, robust for cross-device)
const moveFolderContents = async (src, dest, event) => {
    // Recursively collect all files for accurate progress
    const files = await getAllFiles(src);
    const total = files.length;
    let moved = 0;
    // 1. Copy all files
    for (const file of files) {
        const srcPath = file.abs;
        const destPath = path.join(dest, file.rel);
        await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
        await fsPromises.copyFile(srcPath, destPath);
        moved++;
        if (event)
            event.sender.send("move-folder-progress", {
                moved,
                total,
                phase: "copy",
                name: file.rel,
            });
    }
    // 2. Delete all originals (files only)
    moved = 0;
    for (const file of files) {
        const srcPath = file.abs;
        await fsPromises.rm(srcPath, { force: true });
        moved++;
        if (event)
            event.sender.send("move-folder-progress", {
                moved,
                total,
                phase: "delete",
                name: file.rel,
            });
    }
    // 3. Remove empty directories in src (track progress for dirs)
    // We'll collect all directories and send progress for each
    const collectDirs = async (dir) => {
        let dirs = [dir];
        const entries = await fsPromises.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
            if (entry.isDirectory()) {
                const fullPath = path.join(dir, entry.name);
                dirs = dirs.concat(await collectDirs(fullPath));
            }
        }
        return dirs;
    };
    const allDirs = await collectDirs(src);
    let dirDeleted = 0;
    for (const dirPath of allDirs.reverse()) {
        // delete from deepest
        try {
            await fsPromises.rmdir(dirPath);
            dirDeleted++;
            if (event) {
                event.sender.send("move-folder-progress", {
                    moved: dirDeleted,
                    total: allDirs.length,
                    phase: "delete-dir",
                    name: path.relative(src, dirPath) || ".",
                });
            }
        } catch {
            // Intentionally ignore errors if directory is not empty or already removed
        }
    }
    if (event)
        event.sender.send("move-folder-progress", {
            moved: total,
            total,
            phase: "delete-done",
            name: null,
        });
};

// IPC: Set custom save folder with validation and move contents
const setCustomSaveFolderIPC = () => {
    ipcMain.handle("set-custom-save-folder", async (event, folderPath) => {
        const oldSaveFolder = await getSaveFolder();
        let newSaveFolder;
        let prevCustomFolder = null;
        if (!folderPath) {
            // Reset to default
            const userSettings = settingsConf.store || {};
            if (userSettings.customSaveFolder) {
                prevCustomFolder = userSettings.customSaveFolder;
            }
            settingsConf.delete("customSaveFolder");
            // Use getSaveFolder to get the default save folder
            newSaveFolder = await getSaveFolder();
            applog.info("Reset to default save folder:", newSaveFolder);
            // Move contents back from previous custom folder's data subfolder if it exists
            let prevDataSubfolder = null;
            if (prevCustomFolder) {
                prevDataSubfolder = getDataSubfolder(prevCustomFolder);
            }
            // Ensure the destination exists before checking shouldMoveContents
            try {
                await fsPromises.mkdir(newSaveFolder, { recursive: true });
            } catch (e) {
                console.log("Failed to create default save folder:", e);
                applog.error("Failed to create default save folder:", e);
                return {
                    success: false,
                    error: "folderChangeError",
                    reason: e.message || "Unknown error",
                };
            }
            applog.info("[DEBUG] prevDataSubfolder:", prevDataSubfolder);
            applog.info("[DEBUG] newSaveFolder:", newSaveFolder);
            const shouldMove = shouldMoveContents(prevDataSubfolder, newSaveFolder);
            applog.info("[DEBUG] shouldMoveContents:", shouldMove);
            if (shouldMove) {
                applog.info(
                    "[DEBUG] Calling moveFolderContents with:",
                    prevDataSubfolder,
                    newSaveFolder
                );
                try {
                    await deletePronunciationVenv(prevDataSubfolder, event);
                    await moveFolderContents(prevDataSubfolder, newSaveFolder, event);
                } catch (moveBackErr) {
                    console.log("Failed to move contents back to default folder:", moveBackErr);
                    applog.error("Failed to move contents back to default folder:", moveBackErr);
                    return {
                        success: false,
                        error: "folderMoveError",
                        reason: moveBackErr.message || "Unknown move error",
                    };
                }
            }
        } else {
            try {
                await fsPromises.access(folderPath);
                const stat = await fsPromises.stat(folderPath);
                if (!stat.isDirectory()) {
                    console.log("Folder is not a directory:", folderPath);
                    applog.error("Folder is not a directory:", folderPath);
                    return {
                        success: false,
                        error: "folderChangeError",
                        reason: "toast.folderNotDir",
                    };
                }

                if (isDeniedSystemFolder(folderPath)) {
                    console.log("Folder is restricted:", folderPath);
                    applog.error("Folder is restricted:", folderPath);
                    return {
                        success: false,
                        error: "folderChangeError",
                        reason: "toast.folderRestricted",
                    };
                }

                const testFile = path.join(
                    folderPath,
                    `.__ispeakerreact_test_${process.pid}_${Date.now()}`
                );

                try {
                    await fsPromises.writeFile(testFile, "test");
                    await fsPromises.unlink(testFile);
                } catch {
                    console.log("Folder is not writable:", folderPath);
                    applog.error("Folder is not writable:", folderPath);
                    return {
                        success: false,
                        error: "folderChangeError",
                        reason: "toast.folderNoWrite",
                    };
                }
                // All good, save
                settingsConf.set("customSaveFolder", folderPath);
                // Use the data subfolder for all operations
                newSaveFolder = getDataSubfolder(folderPath);
                // Ensure the data subfolder exists
                try {
                    await fsPromises.mkdir(newSaveFolder, { recursive: true });
                } catch (e) {
                    console.log("Failed to create data subfolder:", e);
                    applog.error("Failed to create data subfolder:", e);
                    return {
                        success: false,
                        error: "folderChangeError",
                        reason: e.message || "Unknown error",
                    };
                }
                console.log("New save folder:", newSaveFolder);
                applog.info("New save folder:", newSaveFolder);
            } catch (err) {
                console.log("Error setting custom save folder:", err);
                applog.error("Error setting custom save folder:", err);
                return {
                    success: false,
                    error: "folderChangeError",
                    reason: err.message || "Unknown error",
                };
            }
        }
        // Move contents if needed
        try {
            if (shouldMoveContents(oldSaveFolder, newSaveFolder)) {
                await deletePronunciationVenv(oldSaveFolder, event);
                await moveFolderContents(oldSaveFolder, newSaveFolder, event);
            }
            // Update log directory and file name to new save folder
            const currentLogFolder = path.join(newSaveFolder, "logs");
            try {
                await fsPromises.mkdir(currentLogFolder, { recursive: true });
            } catch (e) {
                console.log("Failed to create new log directory:", e);
                applog.warn("Failed to create new log directory:", e);
            }
            applog.transports.file.fileName = generateLogFileName();
            applog.transports.file.resolvePathFn = () =>
                path.join(currentLogFolder, applog.transports.file.fileName);
            applog.info("New log directory:", currentLogFolder);
            console.log("New log directory:", currentLogFolder);
            // Only delete the empty ispeakerreact_data subfolder, never the parent custom folder
            if (prevCustomFolder) {
                await deleteEmptyDataSubfolder(prevCustomFolder);
            }
            return { success: true, newPath: newSaveFolder };
        } catch (moveErr) {
            console.log("Failed to move folder contents:", moveErr);
            applog.error("Failed to move folder contents:", moveErr);
            return {
                success: false,
                error: "folderMoveError",
                reason: moveErr.message || "Unknown move error",
            };
        }
    });
};

export { setCustomSaveFolderIPC };



================================================
FILE: electron-main/expressServer.js
================================================
import applog from "electron-log";
import express from "express";
import net from "net";

const DEFAULT_PORT = 8998;
const MIN_PORT = 1024; // Minimum valid port number
const MAX_PORT = 65535; // Maximum valid port number

// Create Express server
const expressApp = express();
// Function to generate a random port number within the range
const getRandomPort = () => {
    return Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
};

// Function to check if a port is available
const checkPortAvailability = (port) => {
    return new Promise((resolve, reject) => {
        const server = net.createServer();

        server.once("error", (err) => {
            if (err.code === "EADDRINUSE" || err.code === "ECONNREFUSED") {
                resolve(false); // Port is in use
                applog.log("Port is in use. Error:", err.code);
            } else {
                reject(err); // Some other error occurred
                applog.log("Another error related to the Express server. Error:", err.code);
            }
        });

        server.once("listening", () => {
            server.close(() => {
                resolve(true); // Port is available
            });
        });

        server.listen(port);
    });
};

// Function to start the Express server with the default port first, then randomize if necessary
const startExpressServer = async () => {
    let port = DEFAULT_PORT;
    let isPortAvailable = await checkPortAvailability(port);

    if (!isPortAvailable) {
        applog.warn(`Default port ${DEFAULT_PORT} is in use. Trying a random port...`);
        do {
            port = getRandomPort();
            isPortAvailable = await checkPortAvailability(port);
        } while (!isPortAvailable);
    }

    return expressApp.listen(port, () => {
        applog.info(`Express server is running on http://localhost:${port}`);
    });
};

export { expressApp, startExpressServer };



================================================
FILE: electron-main/filePath.js
================================================
import { app, ipcMain } from "electron";
import { Conf } from "electron-conf/main";
import * as fsPromises from "node:fs/promises";
import path from "node:path";

// Singleton instance for settings
const settingsConf = new Conf({ name: "ispeakerreact_config" });

const readUserSettings = async () => {
    return settingsConf.store || {};
};

const getSaveFolder = async () => {
    // Try to get custom folder from user settings
    const userSettings = await readUserSettings();
    let saveFolder;
    if (userSettings.customSaveFolder) {
        // For custom folder, use a subfolder 'ispeakerreact_data'
        const baseFolder = userSettings.customSaveFolder;
        // Ensure the base directory exists
        try {
            await fsPromises.access(baseFolder);
        } catch {
            await fsPromises.mkdir(baseFolder, { recursive: true });
        }
        saveFolder = path.join(baseFolder, "ispeakerreact_data");
        // Ensure the 'ispeakerreact_data' subfolder exists
        try {
            await fsPromises.access(saveFolder);
        } catch {
            await fsPromises.mkdir(saveFolder, { recursive: true });
        }
    } else {
        // For default, just use Documents/iSpeakerReact
        const documentsPath = app.getPath("documents");
        saveFolder = path.join(documentsPath, "iSpeakerReact");
        // Ensure the directory exists
        try {
            await fsPromises.access(saveFolder);
        } catch {
            await fsPromises.mkdir(saveFolder, { recursive: true });
        }
    }
    return saveFolder;
};

const getLogFolder = async () => {
    const saveFolder = path.join(await getSaveFolder(), "logs");
    // Ensure the directory exists
    try {
        await fsPromises.access(saveFolder);
    } catch {
        await fsPromises.mkdir(saveFolder, { recursive: true });
    }
    return saveFolder;
};

// Helper to get the data subfolder path
const getDataSubfolder = (baseFolder) => {
    return path.join(baseFolder, "ispeakerreact_data");
};

// Synchronous version to get the log folder path
const getLogFolderSync = () => {
    let saveFolder;
    const userSettings = settingsConf.store || {};
    if (userSettings.customSaveFolder) {
        // For custom folder, use the data subfolder
        saveFolder = path.join(userSettings.customSaveFolder, "ispeakerreact_data");
    } else {
        const documentsPath = app.getPath("documents");
        saveFolder = path.join(documentsPath, "iSpeakerReact");
    }
    return path.join(saveFolder, "logs");
};

// Helper to delete the empty ispeakerreact_data subfolder
const deleteEmptyDataSubfolder = async (baseFolder) => {
    const dataFolder = path.join(baseFolder, "ispeakerreact_data");
    try {
        const files = await fsPromises.readdir(dataFolder);
        if (files.length === 0) {
            await fsPromises.rmdir(dataFolder);
            return true;
        }
    } catch {
        // Folder does not exist or other error, ignore
    }
    return false;
};

// Add IPC handlers for theme
ipcMain.handle("get-theme", async () => {
    return settingsConf.get("ispeakerreact-ui-theme") || "auto";
});
ipcMain.handle("set-theme", async (event, newTheme) => {
    settingsConf.set("ispeakerreact-ui-theme", newTheme);
    return true;
});

export {
    getDataSubfolder,
    getLogFolder,
    getLogFolderSync,
    getSaveFolder,
    readUserSettings,
    settingsConf,
    deleteEmptyDataSubfolder,
};



================================================
FILE: electron-main/getFileAndFolder.js
================================================
import { ipcMain, shell } from "electron";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { getSaveFolder, readUserSettings } from "./filePath.js";
import fs from "node:fs";

const getVideoFileDataIPC = (rootDir) => {
    ipcMain.handle("get-video-file-data", async () => {
        const jsonPath = path.join(rootDir, "dist", "json", "videoFilesInfo.json");
        try {
            const jsonData = await fsPromises.readFile(jsonPath, "utf-8"); // Asynchronously read the JSON file
            return JSON.parse(jsonData); // Parse the JSON string and return it
        } catch (error) {
            console.error("Error reading JSON file:", error);
            throw error; // Ensure that any error is propagated back to the renderer process
        }
    });
};

const getVideoSaveFolderIPC = () => {
    ipcMain.handle("get-video-save-folder", async () => {
        const saveFolder = await getSaveFolder(readUserSettings);
        const videoFolder = path.join(saveFolder, "video_files");

        // Ensure the directory exists
        try {
            await fsPromises.access(videoFolder);
        } catch {
            await fsPromises.mkdir(videoFolder, { recursive: true });
        }

        // Open the folder in the file manager
        shell.openPath(videoFolder); // Open the folder

        return videoFolder; // Send the path back to the renderer
    });
};

// IPC: Get current save folder (resolved)
const getSaveFolderIPC = () => {
    ipcMain.handle("get-save-folder", async () => {
        return await getSaveFolder(readUserSettings);
    });
};

// IPC: Get current custom save folder (raw, may be undefined)
const getCustomSaveFolderIPC = () => {
    ipcMain.handle("get-custom-save-folder", async () => {
        const userSettings = await readUserSettings();
        return userSettings.customSaveFolder || null;
    });
};

// IPC: Get ffmpeg wasm absolute path
const getFfmpegWasmPathIPC = (rootDir) => {
    ipcMain.handle("get-ffmpeg-wasm-path", async () => {
        // Adjust the path as needed if you move the file elsewhere
        return path.resolve(rootDir, "data", "ffmpeg");
    });
};

// IPC: Read file as buffer for ffmpeg
ipcMain.handle("read-file-buffer", async (event, filePath) => {
    return fs.readFileSync(filePath); // returns Buffer, serialized as Uint8Array
});

export {
    getCustomSaveFolderIPC,
    getFfmpegWasmPathIPC,
    getSaveFolderIPC,
    getVideoFileDataIPC,
    getVideoSaveFolderIPC,
};



================================================
FILE: electron-main/isDeniedSystemFolder.js
================================================
import { app } from "electron";
import applog from "electron-log";
import path from "node:path";
import process from "node:process";
import fs from "node:fs";

const isDeniedSystemFolder = (folderPath) => {
    // Normalize and resolve the path to prevent path traversal attacks
    // This converts to absolute path and resolves ".." and "." segments
    let absoluteInput;
    try {
        // Resolve symlinks for robust security
        absoluteInput = fs.realpathSync.native(path.resolve(folderPath));
    } catch (err) {
        console.warn("Error getting realpath:", err.message);
        applog.error("Error getting realpath:", err.message);
    }
    const platform = process.platform;
    const isCaseSensitive = platform !== "win32";
    // Handle potential trailing slashes inconsistencies
    if (!absoluteInput.endsWith(path.sep) && absoluteInput !== path.sep) {
        absoluteInput += path.sep;
    }

    console.log("Checking path:", absoluteInput);
    applog.info("Checking path:", absoluteInput);

    // Define protected system locations based on platform
    let denyList = [];

    if (platform === "win32") {
        // Windows
        const systemDrive = process.env.SystemDrive || "C:";
        const systemRoot = process.env.SystemRoot || path.join(systemDrive, "Windows");

        denyList = [
            // System directories
            systemDrive + path.sep,
            systemRoot,
            path.join(systemDrive, "Program Files"),
            path.join(systemDrive, "Program Files (x86)"),
            path.join(systemDrive, "Users"),
            path.join(systemRoot, "System32"),
            path.join(systemRoot, "SysWOW64"),

            // Additional sensitive locations
            path.join(systemDrive, "ProgramData"),
            path.join(systemDrive, "Recovery"),
            path.join(systemDrive, "Boot"),
        ];
    } else if (platform === "darwin") {
        // macOS - using path.sep for consistency
        denyList = [
            `${path.sep}System`,
            `${path.sep}Library`,
            `${path.sep}bin`,
            `${path.sep}sbin`,
            `${path.sep}usr`,
            `${path.sep}private`,
            `${path.sep}etc`,
            `${path.sep}var`,
            `${path.sep}Applications`,
            `${path.sep}Users${path.sep}Shared`,
            `${path.sep}Network`,
            `${path.sep}Volumes`,
            `${path.sep}cores`,
        ];
        // Explicitly deny root directory
        denyList.push(path.sep);
        // Only deny other users' home directories, not the current user's
        const userHome = app.getPath("home");
        denyList.push(path.join(path.sep, "Users")); // For subfolders
        denyList = denyList.filter((p) => p !== path.join(path.sep, "Users")); // Remove blanket /Users
        // Deny all /Users/* except current user
        const usersDir = path.join(path.sep, "Users");
        try {
            const entries = fs.readdirSync(usersDir, { withFileTypes: true });
            for (const entry of entries) {
                if (entry.isDirectory()) {
                    const userDir = path.join(usersDir, entry.name);
                    if (userDir !== userHome) {
                        denyList.push(userDir);
                    }
                }
            }
        } catch (err) {
            console.warn("Error getting users directory:", err.message);
            applog.error("Error getting users directory:", err.message);
        }
    } else {
        // Linux and other Unix-like - using path.sep for consistency
        // We'll manually mark virtual filesystem paths that shouldn't be resolved
        const virtualPaths = [`${path.sep}proc`, `${path.sep}sys`, `${path.sep}dev`];

        const standardPaths = [
            `${path.sep}bin`,
            `${path.sep}boot`,
            `${path.sep}etc`,
            `${path.sep}home`,
            `${path.sep}lib`,
            `${path.sep}lib64`,
            `${path.sep}media`,
            `${path.sep}mnt`,
            `${path.sep}opt`,
            `${path.sep}root`,
            `${path.sep}run`,
            `${path.sep}sbin`,
            `${path.sep}srv`,
            `${path.sep}tmp`,
            `${path.sep}usr`,
            `${path.sep}var`,
        ];

        denyList = [...virtualPaths, ...standardPaths];
        // Explicitly deny root directory
        denyList.push(path.sep);
        // Only deny other users' home directories, not the current user's
        const userHome = app.getPath("home");
        denyList.push(path.join(path.sep, "home")); // For subfolders
        denyList = denyList.filter((p) => p !== path.join(path.sep, "home")); // Remove blanket /home
        // Deny all /home/* except current user
        const homeDir = path.join(path.sep, "home");
        try {
            const entries = fs.readdirSync(homeDir, { withFileTypes: true });
            for (const entry of entries) {
                if (entry.isDirectory()) {
                    const userDir = path.join(homeDir, entry.name);
                    if (userDir !== userHome) {
                        denyList.push(userDir);
                    }
                }
            }
        } catch (err) {
            console.warn("Error getting home directory:", err.message);
            applog.error("Error getting home directory:", err.message);
        }
    }

    // Add app-specific directories for all platforms
    try {
        const appPaths = [
            app.getPath("userData"),
            app.getPath("exe"),
            app.getPath("appData"),
            app.getPath("temp"),
            app.getPath("logs"),
            app.getPath("crashDumps"),
        ];
        denyList = [...denyList, ...appPaths];
    } catch (err) {
        console.warn("Error getting app paths:", err.message);
        applog.error("Error getting app paths:", err.message);
    }

    // De-duplicate and filter out any undefined or empty values
    denyList = [...new Set(denyList)].filter(Boolean);

    console.log("Raw deny list:", denyList);
    applog.info("Raw deny list:", denyList);

    // Format all deny paths consistently for comparison
    const formattedDenyList = denyList.map((p) => {
        // Either use the path as-is (for virtual paths) or resolve it
        let formatted;
        try {
            formatted = fs.realpathSync.native(path.resolve(p));
        } catch {
            formatted = path.resolve(p);
        }
        if (!isCaseSensitive) formatted = formatted.toLowerCase();
        if (!formatted.endsWith(path.sep) && formatted !== path.sep) {
            formatted += path.sep;
        }
        return formatted;
    });

    console.log("Formatted deny list:", formattedDenyList);
    applog.info("Formatted deny list:", formattedDenyList);

    if (!isCaseSensitive) absoluteInput = absoluteInput.toLowerCase();

    // Always allow the current user's home directory (with or without trailing slash)
    const userHome = app.getPath("home");
    let userHomeFormatted = userHome;
    if (!userHomeFormatted.endsWith(path.sep) && userHomeFormatted !== path.sep) {
        userHomeFormatted += path.sep;
    }
    let absoluteInputWithSep = absoluteInput;
    if (!absoluteInputWithSep.endsWith(path.sep) && absoluteInputWithSep !== path.sep) {
        absoluteInputWithSep += path.sep;
    }
    if (absoluteInput === userHome || absoluteInputWithSep === userHomeFormatted) {
        return false;
    }

    // Check if the path is a protected system folder or a subfolder of one
    return formattedDenyList.some((protectedPath) => {
        // Special case: only block root if it's exactly root
        if (protectedPath === path.sep) {
            return absoluteInput === protectedPath;
        }
        const isMatch = absoluteInput === protectedPath || absoluteInput.startsWith(protectedPath);
        if (isMatch) {
            console.log(`Match found: ${absoluteInput} matches ${protectedPath}`);
            applog.info(`Match found: ${absoluteInput} matches ${protectedPath}`);
        }
        return isMatch;
    });
};

export default isDeniedSystemFolder;



================================================
FILE: electron-main/logOperations.js
================================================
import { ipcMain } from "electron";
import applog from "electron-log";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { getLogFolder, getLogFolderSync, readUserSettings, settingsConf } from "./filePath.js";

const defaultLogSettings = {
    numOfLogs: 10,
    keepForDays: 0,
    logLevel: "info",
    logFormat: "{h}:{i}:{s} {text}",
    maxLogSize: 5 * 1024 * 1024,
};

let currentLogSettings = { ...defaultLogSettings };

const userLogSettings = settingsConf.get("logSettings");
if (userLogSettings) {
    currentLogSettings = { ...currentLogSettings, ...userLogSettings };
}

const getCurrentLogSettings = () => {
    return currentLogSettings;
};

const setCurrentLogSettings = (newSettings) => {
    currentLogSettings = { ...currentLogSettings, ...newSettings };
};

// Function to generate the log file name with date-time appended
const generateLogFileName = () => {
    const date = new Date();
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, "0");
    const day = String(date.getDate()).padStart(2, "0");
    const hours = String(date.getHours()).padStart(2, "0");
    const minutes = String(date.getMinutes()).padStart(2, "0");
    const seconds = String(date.getSeconds()).padStart(2, "0");
    return `ispeakerreact-log_${year}-${month}-${day}_${hours}-${minutes}-${seconds}.log`;
};

// Configure electron-log to use the log directory
applog.transports.file.fileName = generateLogFileName();
applog.transports.file.resolvePathFn = () => {
    const logFolder = getLogFolderSync();
    return path.join(logFolder, applog.transports.file.fileName);
};
applog.transports.file.maxSize = currentLogSettings.maxLogSize;
applog.transports.console.level = currentLogSettings.logLevel;

// Handle updated log settings from the renderer
ipcMain.on("update-log-settings", async (event, newSettings) => {
    setCurrentLogSettings(newSettings);
    applog.info("Log settings updated:", currentLogSettings);

    // Save to user settings file
    settingsConf.set("logSettings", currentLogSettings);

    manageLogFiles();
});

// Function to check and manage log files based on the currentLogSettings
const manageLogFiles = async () => {
    try {
        const { numOfLogs, keepForDays } = currentLogSettings;

        applog.info("Log settings:", currentLogSettings);

        // Get the current log folder dynamically
        const logFolder = await getLogFolder(readUserSettings);

        // Get all log files
        const logFiles = await fsPromises.readdir(logFolder);
        const logFilesResolved = [];
        for (const file of logFiles) {
            const filePath = path.join(logFolder, file);
            try {
                const stats = await fsPromises.stat(filePath);
                logFilesResolved.push({
                    path: filePath,
                    birthtime: stats.birthtime,
                });
            } catch (err) {
                if (err.code !== "ENOENT") {
                    applog.error(`Error stating log file: ${filePath}`, err);
                }
                // If ENOENT, just skip this file
            }
        }

        // Sort log files by creation time (oldest first)
        logFilesResolved.sort((a, b) => a.birthtime - b.birthtime);

        // Remove logs if they exceed the specified limit (excluding 0 for unlimited)
        if (numOfLogs > 0 && logFilesResolved.length > numOfLogs) {
            const filesToDelete = logFilesResolved.slice(0, logFilesResolved.length - numOfLogs);
            for (const file of filesToDelete) {
                try {
                    await fsPromises.unlink(file.path);
                    applog.info(`Deleted log file: ${file.path}`);
                } catch (err) {
                    if (err.code !== "ENOENT") {
                        applog.error(`Error deleting log file: ${file.path}`, err);
                    }
                }
            }
        }

        // Remove logs older than the specified days (excluding 0 for never)
        if (keepForDays > 0) {
            const now = new Date();
            for (const file of logFilesResolved) {
                const ageInDays = (now - new Date(file.birthtime)) / (1000 * 60 * 60 * 24);
                if (ageInDays > keepForDays) {
                    try {
                        await fsPromises.unlink(file.path);
                        applog.info(`Deleted old log file: ${file.path}`);
                    } catch (err) {
                        if (err.code !== "ENOENT") {
                            applog.error(`Error deleting old log file: ${file.path}`, err);
                        }
                    }
                }
            }
        }
    } catch (error) {
        applog.error("Error managing log files:", error);
    }
};

export { generateLogFileName, getCurrentLogSettings, manageLogFiles, setCurrentLogSettings };



================================================
FILE: electron-main/pronunciationCheckerIPC.js
================================================
import { spawn } from "child_process";
import { ipcMain } from "electron";
import applog from "electron-log";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { getSaveFolder, readUserSettings } from "./filePath.js";
import { getCurrentLogSettings } from "./logOperations.js";
import { getVenvPythonPath, ensureVenvExists } from "./pronunciationOperations.js";

const startProcess = (cmd, args) => {
    const proc = spawn(cmd, args, { shell: true });
    return proc;
};

// IPC handler to check pronunciation
const setupPronunciationCheckerIPC = () => {
    ipcMain.handle("pronunciation-check", async (event, audioPath, modelName) => {
        const saveFolder = await getSaveFolder(readUserSettings);
        // If modelName is not provided, read from user settings
        if (!modelName) {
            const userSettings = await readUserSettings();
            modelName = userSettings.modelName;
        }

        let modelDir;
        if (modelName) {
            const safeModelFolder = modelName.replace(/\//g, "_");
            modelDir = path.join(saveFolder, "phoneme-model", safeModelFolder);
            console.log(`[PronunciationChecker] modelDir: ${safeModelFolder}`);
        } else {
            modelDir = path.join(saveFolder, "phoneme-model");
        }
        const logSettings = getCurrentLogSettings();

        // Configure electron-log with current settings
        applog.transports.file.maxSize = logSettings.maxLogSize;
        applog.transports.console.level = logSettings.logLevel;

        applog.info(`[PronunciationChecker] audioPath: ${audioPath}`);
        applog.info(`[PronunciationChecker] modelDir: ${modelDir}`);

        // Robust Python script for model inference
        const pyCode = `
import sys
import json
import torch
import librosa
from transformers.models.wav2vec2 import (
    Wav2Vec2Processor,
    Wav2Vec2ForCTC,
    Wav2Vec2FeatureExtractor,
    Wav2Vec2CTCTokenizer,
)

# Set console encoding to UTF-8
if sys.platform == "win32":
    import codecs

    sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict")
    sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict")

MODEL_DIR = r"""${modelDir}"""
AUDIO_PATH = r"""${audioPath}"""


def log_json(obj):
    # Send a single JSON string to stdout
    print(json.dumps(obj, ensure_ascii=False))


def main():
    try:
        log_json({"status": "progress", "message": "Loading processor..."})
        feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_DIR)
        tokenizer = Wav2Vec2CTCTokenizer.from_pretrained(MODEL_DIR)
        processor = Wav2Vec2Processor(
            feature_extractor=feature_extractor, tokenizer=tokenizer
        )
        model = Wav2Vec2ForCTC.from_pretrained(MODEL_DIR)
        log_json({"status": "progress", "message": "Model loaded."})

        log_json({"status": "progress", "message": f"Loading audio: {AUDIO_PATH}"})
        speech_array, sampling_rate = librosa.load(
            AUDIO_PATH, sr=16000
        )  # Ensure 16kHz for wav2vec2
        log_json(
            {
                "status": "progress",
                "message": f"Audio loaded. Sample rate: {sampling_rate}",
            }
        )

        log_json({"status": "progress", "message": "Preparing inputs for model..."})
        inputs = processor(
            speech_array, sampling_rate=16000, return_tensors="pt", padding=True
        )
        log_json(
            {
                "status": "progress",
                "message": f"Input tensor shape: {inputs['input_values'].shape}, dtype: {inputs['input_values'].dtype}",
            }
        )

        log_json({"status": "progress", "message": "Running model..."})
        with torch.no_grad():
            logits = model(**inputs).logits
        log_json({"status": "progress", "message": "Model run complete."})

        predicted_ids = torch.argmax(logits, dim=-1)
        transcription = processor.batch_decode(predicted_ids)
        if transcription == "":
            log_json(
                {
                    "status": "success",
                    "phonemes": None,
                    "message": "No phonemes detected.",
                }
            )
        else:
            log_json({"status": "success", "phonemes": transcription[0].strip()})

    except Exception as e:
        import traceback

        tb = traceback.format_exc()
        log_json({"status": "error", "message": str(e), "traceback": tb})
        sys.exit(1)


if __name__ == "__main__":
    main()
`;
        const tempPyPath = path.join(saveFolder, "pronunciation_checker_temp.py");
        await fsPromises.writeFile(tempPyPath, pyCode, "utf-8");
        applog.info(`[PronunciationChecker] tempPyPath: ${tempPyPath}`);
        let venvPython;
        try {
            await ensureVenvExists();
            venvPython = await getVenvPythonPath();
        } catch (err) {
            applog.error(`[PronunciationChecker] Failed to create or find venv: ${err.message}`);
            return { status: "error", message: `Failed to create or find venv: ${err.message}` };
        }
        applog.info(`[PronunciationChecker] About to run: ${venvPython} -u ${tempPyPath}`);
        return new Promise((resolve) => {
            const py = startProcess(venvPython, ["-u", tempPyPath], (err) => {
                fsPromises.unlink(tempPyPath).catch(() => {
                    applog.warn(
                        "[PronunciationChecker] Failed to delete temp pronunciation checker file"
                    );
                });
                if (err) {
                    applog.error(
                        `[PronunciationChecker] Python process exited with code: ${err.code}`
                    );
                }
            });
            let lastJson = null;
            py.stdout.on("data", (data) => {
                const lines = data.toString().split(/\r?\n/);
                for (const line of lines) {
                    if (line.trim().startsWith("{") && line.trim().endsWith("}")) {
                        try {
                            const jsonData = JSON.parse(line.trim());
                            lastJson = jsonData;

                            // Log with appropriate level based on status
                            const logMessage = {
                                component: "PronunciationChecker",
                                ...jsonData,
                            };

                            switch (jsonData.status) {
                                case "progress":
                                    applog.info(logMessage);
                                    break;
                                case "success":
                                    applog.info(logMessage);
                                    break;
                                case "error":
                                    applog.error(logMessage);
                                    break;
                                default:
                                    applog.info(logMessage);
                            }
                        } catch {
                            applog.error(
                                `[PronunciationChecker] Failed to parse JSON: ${line.trim()}`
                            );
                        }
                    }
                }
            });
            py.stderr.on("data", (data) => {
                const lines = data.toString().split(/\r?\n/);
                for (const line of lines) {
                    if (line.trim()) {
                        applog.error({
                            component: "PronunciationChecker",
                            status: "error",
                            message: line.trim(),
                        });
                    }
                }
            });
            py.on("close", (code) => {
                if (lastJson && lastJson.status === "error") {
                    // Return the detailed error from Python
                    resolve(lastJson);
                } else if (code === 0 && lastJson) {
                    resolve(lastJson);
                } else {
                    resolve({
                        status: "error",
                        message: `Process exited with code ${code}`,
                    });
                }
            });
        });
    });
};

// IPC handler to get the recording blob for a given key
const setupGetRecordingBlobIPC = () => {
    ipcMain.handle("get-recording-blob", async (_event, key) => {
        const saveFolder = await getSaveFolder(readUserSettings);
        const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`);
        try {
            const data = await fsPromises.readFile(filePath);
            return data.buffer; // ArrayBuffer for renderer
        } catch (err) {
            throw new Error(`Failed to read recording: ${err.message}`);
        }
    });
};

export { setupGetRecordingBlobIPC, setupPronunciationCheckerIPC };



================================================
FILE: electron-main/pronunciationOperations.js
================================================
import { ipcMain } from "electron";
import { Conf } from "electron-conf/main";
import fkill from "fkill";
import { spawn } from "node:child_process";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { getSaveFolder, readUserSettings, settingsConf } from "./filePath.js";

let currentPythonProcess = null;
let pendingCancel = false;
let isGloballyCancelled = false;

// Singleton instance for pronunciation install status
const installConf = new Conf({ name: "pronunciation-install" });

const checkPythonInstalled = async () => {
    let log = "";
    return new Promise((resolve) => {
        // Try python3 first
        const tryPython = (cmd, cb) => {
            const proc = spawn(cmd, ["--version"], { shell: true });
            let stdout = "";
            let stderr = "";
            proc.stdout.on("data", (data) => {
                stdout += data.toString();
            });
            proc.stderr.on("data", (data) => {
                stderr += data.toString();
            });
            proc.on("close", (code) => {
                cb(code, stdout, stderr);
            });
        };
        tryPython("python3", (err, stdout, stderr) => {
            log += "> python3 --version\n" + stdout + stderr + "\n";
            if (!err && stdout) {
                resolve({
                    found: true,
                    version: stdout.trim(),
                    stderr: stderr.trim(),
                    log: log.trim(),
                });
            } else {
                tryPython("python", (err2, stdout2, stderr2) => {
                    log += "> python --version\n" + stdout2 + stderr2 + "\n";
                    if (!err2 && stdout2) {
                        resolve({
                            found: true,
                            version: stdout2.trim(),
                            stderr: stderr2.trim(),
                            log: log.trim(),
                        });
                    } else {
                        resolve({
                            found: false,
                            version: null,
                            stderr: (stderr2 || stderr || "").trim(),
                            log: log.trim(),
                        });
                    }
                });
            }
        });
    });
};

const startProcess = (cmd, args, onExit) => {
    const proc = spawn(cmd, args, { shell: true });
    currentPythonProcess = proc;
    if (pendingCancel) {
        // Wait 0.5s, then kill the process
        setTimeout(() => {
            fkill(proc.pid, { force: true, tree: true })
                .then(() => {
                    console.log("[Cancel] Process killed after short delay due to pending cancel.");
                })
                .catch((err) => {
                    if (err.message && err.message.includes("Process doesn't exist")) {
                        // Already dead, ignore
                    } else {
                        console.error("[Cancel] Error killing process after delay:", err);
                    }
                });
            proc._wasCancelledImmediately = true; // Mark for downstream logic
        }, 500); // 0.5 second delay
        pendingCancel = false;
    }
    proc.on("close", (code) => {
        onExit && onExit(code !== 0 ? { code } : null);
    });
    return proc;
};

const dependencies = [
    "numpy",
    "torch",
    "transformers",
    "huggingface_hub[hf_xet]",
    "librosa",
    "protobuf", // for facebook models
    "phonemizer", // for facebook models
];

const resetGlobalCancel = () => {
    isGloballyCancelled = false;
};

// --- VENV HELPERS ---
const getVenvDir = async () => {
    const saveFolder = await getSaveFolder(readUserSettings);
    return path.join(saveFolder, "pronunciation-venv");
};

const getVenvPythonPath = async () => {
    const venvDir = await getVenvDir();
    if (process.platform === "win32") {
        return path.join(venvDir, "Scripts", "python.exe");
    } else {
        return path.join(venvDir, "bin", "python");
    }
};

const getVenvPipPath = async () => {
    const venvDir = await getVenvDir();
    if (process.platform === "win32") {
        return path.join(venvDir, "Scripts", "pip.exe");
    } else {
        return path.join(venvDir, "bin", "pip");
    }
};

const upgradeVenvPip = async () => {
    const venvPython = await getVenvPythonPath();
    return new Promise((resolve, reject) => {
        const proc = spawn(venvPython, ["-m", "pip", "install", "--upgrade", "pip"], {
            shell: true,
        });
        let output = "";
        let error = "";
        proc.stdout.on("data", (data) => {
            output += data.toString();
        });
        proc.stderr.on("data", (data) => {
            error += data.toString();
        });
        proc.on("close", (code) => {
            if (code === 0) {
                resolve(output);
            } else {
                reject(new Error(error || `pip upgrade failed with code ${code}`));
            }
        });
    });
};

const ensureVenvExists = async () => {
    const venvDir = await getVenvDir();
    let venvPython = await getVenvPythonPath();
    try {
        await fsPromises.access(venvPython);
        // Already exists
        return venvPython;
    } catch {
        // Create venv
        // Use system python to create venv
        let systemPython = "python";
        // Try to use python3 if available
        try {
            await new Promise((resolve, reject) => {
                const proc = spawn("python3", ["--version"], { shell: true });
                proc.on("close", (code) => {
                    if (code === 0) resolve();
                    else reject();
                });
            });
            systemPython = "python3";
        } catch {
            // fallback to python
        }
        await new Promise((resolve, reject) => {
            const proc = spawn(systemPython, ["-m", "venv", venvDir], { shell: true });
            proc.on("close", (code) => {
                if (code === 0) resolve();
                else reject(new Error("Failed to create venv"));
            });
        });
        // Store venv path in settings
        settingsConf.set("pronunciationVenvPath", venvDir);
        return venvPython;
    }
};

const installDependencies = () => {
    ipcMain.handle("pronunciation-install-deps", async (event) => {
        if (isGloballyCancelled) {
            return { deps: [{ name: "all", status: "cancelled" }], log: "" };
        }
        let log = "";
        event.sender.send("pronunciation-dep-progress", { name: "all", status: "pending" });
        // Ensure venv exists
        let venvPip;
        try {
            await ensureVenvExists();
            venvPip = await getVenvPipPath();
            try {
                await upgradeVenvPip();
                log += "Upgraded pip to latest version.\n";
            } catch (err) {
                log += `Failed to upgrade pip: ${err.message}\n`;
            }
        } catch (err) {
            log += `Failed to create virtual environment: ${err.message}\n`;
            event.sender.send("pronunciation-dep-progress", {
                name: "all",
                status: "error",
                log: log.trim(),
            });
            return { deps: [{ name: "all", status: "error" }], log };
        }
        return new Promise((resolve) => {
            const pipArgs = ["install", ...dependencies, "-U"];
            const pipProcess = startProcess(venvPip, pipArgs, (err) => {
                const status = err ? "error" : "success";
                event.sender.send("pronunciation-dep-progress", {
                    name: "all",
                    status,
                    log: log.trim(),
                });
                resolve({ deps: [{ name: "all", status }], log });
            });
            pipProcess.stdout.on("data", (data) => {
                log += data.toString();
                event.sender.send("pronunciation-dep-progress", {
                    name: "all",
                    status: "pending",
                    log: log.trim(),
                });
            });
            pipProcess.stderr.on("data", (data) => {
                log += data.toString();
                event.sender.send("pronunciation-dep-progress", {
                    name: "all",
                    status: "pending",
                    log: log.trim(),
                });
            });
        });
    });
};

// Extracted model download logic
const downloadModelToDir = async (modelDir, modelName, onProgress) => {
    if (isGloballyCancelled) {
        if (onProgress) onProgress({ status: "cancelled", message: "Cancelled before start" });
        return { status: "cancelled", message: "Cancelled before start" };
    }
    // Ensure modelDir exists
    try {
        await fsPromises.access(modelDir);
    } catch {
        await fsPromises.mkdir(modelDir, { recursive: true });
    }
    // Ensure venv exists and get venv Python path
    let venvPython;
    try {
        await ensureVenvExists();
        venvPython = await getVenvPythonPath();
    } catch (err) {
        if (onProgress)
            onProgress({
                status: "error",
                message: `Failed to create virtual environment: ${err.message}`,
            });
        return { status: "error", message: `Failed to create virtual environment: ${err.message}` };
    }
    // Write Python code to temp file in save folder
    const pyCode = `
import os, sys, json

try:
    from huggingface_hub import snapshot_download, list_repo_files
except ImportError:
    print(json.dumps({"status": "error", "message": "huggingface_hub not installed"}))
    sys.exit(1)

model_dir = r"""${modelDir}"""
repo_id = "${modelName}"

try:
    files = list_repo_files(repo_id)
except Exception as e:
    print(
        json.dumps(
            {"status": "error", "message": f"Failed to list repo files: {str(e)}"}
        )
    )
    sys.exit(1)

has_safetensors = any(f.endswith(".safetensors") for f in files)
has_bin = any(f.endswith(".bin") for f in files)

if has_safetensors and has_bin:
    print(
        json.dumps(
            {
                "status": "downloading",
                "message": "Both .safetensors and .bin found. Downloading all except .bin...",
            }
        )
    )
    try:
        snapshot_download(
            repo_id=repo_id, local_dir=model_dir, ignore_patterns=["*.bin"]
        )
        print(
            json.dumps(
                {"status": "success", "message": "Downloaded all files except .bin"}
            )
        )
    except Exception as e:
        print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"}))
        sys.exit(1)
elif has_safetensors:
    print(
        json.dumps(
            {
                "status": "downloading",
                "message": "Only .safetensors found. Downloading all files...",
            }
        )
    )
    try:
        snapshot_download(repo_id=repo_id, local_dir=model_dir)
        print(
            json.dumps(
                {
                    "status": "success",
                    "message": "Downloaded all files (including .safetensors)",
                }
            )
        )
    except Exception as e:
        print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"}))
        sys.exit(1)
elif has_bin:
    print(
        json.dumps(
            {
                "status": "downloading",
                "message": "Only .bin found. Downloading all files...",
            }
        )
    )
    try:
        snapshot_download(repo_id=repo_id, local_dir=model_dir)
        print(
            json.dumps(
                {
                    "status": "success",
                    "message": "Downloaded all files (including .bin)",
                }
            )
        )
    except Exception as e:
        print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"}))
        sys.exit(1)
else:
    print(
        json.dumps(
            {
                "status": "error",
                "message": "No model file (.safetensors or .bin) found in repo.",
            }
        )
    )
    sys.exit(1)
`;
    const saveFolder = await getSaveFolder(readUserSettings);
    const tempPyPath = path.join(saveFolder, "download_model_temp.py");
    await fsPromises.writeFile(tempPyPath, pyCode, "utf-8");
    // Emit 'downloading' status before launching Python process
    if (onProgress)
        onProgress({ status: "downloading", message: "Preparing to download model..." });
    return new Promise((resolve) => {
        const py = startProcess(venvPython, ["-u", tempPyPath], (err) => {
            currentPythonProcess = null;
            fsPromises.unlink(tempPyPath).catch(() => {
                console.log("Failed to delete temp file");
            });
            if (err) {
                if (onProgress)
                    onProgress({
                        status: "error",
                        message: `Model download process exited with code ${err.code}`,
                    });
                resolve({
                    status: "error",
                    message: `Model download process exited with code ${err.code}`,
                });
            } else {
                if (onProgress)
                    onProgress({ status: "success", message: "Model download complete." });
                resolve({ status: "success", message: "Model download complete." });
            }
        });
        // If process was cancelled immediately, resolve and do not proceed
        if (py._wasCancelledImmediately) {
            if (onProgress)
                onProgress({ status: "cancelled", message: "Process cancelled before start" });
            resolve({ status: "cancelled", message: "Process cancelled before start" });
            return;
        }
        let lastStatus = null;
        let hadError = false;
        py.stdout.on("data", (data) => {
            const str = data.toString();
            // Always forward raw output to renderer for logging
            if (onProgress) onProgress({ status: "log", message: str });
            // Try to parse JSON lines as before
            str.split(/\r?\n/).forEach((line) => {
                if (line.trim()) {
                    try {
                        const msg = JSON.parse(line);
                        lastStatus = msg;
                        if (msg.status === "error") {
                            hadError = true;
                            if (onProgress) onProgress(msg);
                        } else {
                            if (onProgress) onProgress(msg);
                        }
                    } catch {
                        // Ignore parse errors for non-JSON lines
                    }
                }
            });
        });
        py.stderr.on("data", (data) => {
            // Only log stderr, do not send error status unless process exits with error
            if (onProgress) onProgress({ status: "log", message: data.toString() });
        });
        py.on("exit", (code) => {
            currentPythonProcess = null;
            fsPromises.unlink(tempPyPath).catch(() => {
                console.log("Failed to delete temp file");
            });
            // If process exited with error and no JSON error was sent, send a generic error
            if (code !== 0 && !hadError) {
                if (onProgress)
                    onProgress({
                        status: "error",
                        message: `Model download process exited with code ${code}`,
                    });
                resolve({
                    status: "error",
                    message: `Model download process exited with code ${code}`,
                });
            } else {
                resolve(lastStatus);
            }
        });
    });
};

const downloadModel = () => {
    ipcMain.handle("pronunciation-download-model", async (event, modelName) => {
        const saveFolder = await getSaveFolder(readUserSettings);
        // Replace / with _ for folder name
        const safeModelFolder = modelName.replace(/\//g, "_");
        const modelDir = path.join(saveFolder, "phoneme-model", safeModelFolder);
        // Forward progress to renderer
        const finalStatus = await downloadModelToDir(modelDir, modelName, (msg) => {
            event.sender.send("pronunciation-model-progress", msg);
        });
        // After successful download, update user settings with modelName
        console.log(
            `[PronunciationOperations] finalStatus: ${JSON.stringify(finalStatus, null, 2)}`
        );
        if (finalStatus && (finalStatus.status === "success" || finalStatus.status === "found")) {
            settingsConf.set("modelName", modelName);
            console.log(`[PronunciationOperations] modelName updated to ${modelName}`);
            console.log(`[PronunciationOperations] settingsConf: ${settingsConf.get("modelName")}`);
        }
        // Return summary to renderer
        return finalStatus;
    });
};

const cancelProcess = () => {
    ipcMain.handle("pronunciation-cancel-process", async (event) => {
        isGloballyCancelled = true;
        console.log(
            "[Cancel] Cancellation requested. currentPythonProcess:",
            currentPythonProcess ? "exists" : "null"
        );
        if (currentPythonProcess) {
            try {
                await fkill(currentPythonProcess.pid, { force: true, tree: true });
                console.log("[Cancel] Python process tree killed with fkill.");
            } catch (err) {
                console.error("[Cancel] Error killing Python process tree:", err);
            }
            currentPythonProcess = null;
        } else {
            console.log("[Cancel] No Python process to kill. Setting pendingCancel flag.");
            pendingCancel = true;
        }
        // Update installConf with cancelled state
        const prevStatus = installConf.get("status", {}) || {};
        installConf.set("status", { ...prevStatus, cancelled: true, timestamp: Date.now() });
        event.sender.send("pronunciation-cancelled");
        console.log("[Cancel] Pronunciation process cancelled (event sent to renderer)");
    });
};

// Add a function to kill the current Python process (for app quit cleanup)
const killCurrentPythonProcess = async () => {
    if (currentPythonProcess) {
        try {
            await fkill(currentPythonProcess.pid, { force: true, tree: true });
            console.log("[Cleanup] Python process tree killed on app quit.");
        } catch (err) {
            console.error("[Cleanup] Error killing Python process tree on app quit:", err);
        }
        currentPythonProcess = null;
    }
};

// The install status is now structured as:
// {
//   python: { found, version },
//   dependencies: [ { name, status, log }, ... ],
//   model: { status, message, log },
//   stderr: <string>,
//   log: <string>,
//   timestamp: <number>
// }
const setupPronunciationInstallStatusIPC = () => {
    ipcMain.handle("get-pronunciation-install-status", async () => {
        return installConf.get("status", null);
    });
    ipcMain.handle("set-pronunciation-install-status", async (_event, status) => {
        // If the status is already in the new structure, save as is
        if (
            status &&
            status.python &&
            status.dependencies &&
            status.model &&
            "stderr" in status &&
            "log" in status
        ) {
            installConf.set("status", { ...status, timestamp: Date.now() });
        } else {
            // If the status is in the old format, attempt to migrate
            const migrated = migrateOldStatusToStructured(status);
            installConf.set("status", migrated);
        }
        return true;
    });
};

// Helper to migrate old flat status to new structured format
const migrateOldStatusToStructured = (status) => {
    if (!status) return null;
    // Try to extract python info
    const python = {
        found: status.found,
        version: status.version,
    };
    // Dependencies: if array, use as is; if single dep, wrap in array
    let dependencies = status.deps;
    if (!Array.isArray(dependencies)) {
        dependencies = dependencies ? [dependencies] : [];
    }
    // Model info
    const model = {
        status: status.modelStatus || status.status,
        message: status.modelMessage || status.message,
        log: status.modelLog || "",
    };
    return {
        python,
        dependencies,
        model,
        stderr: status.stderr || "",
        log: status.log || "",
        timestamp: Date.now(),
    };
};

export {
    cancelProcess,
    checkPythonInstalled,
    downloadModel,
    installDependencies,
    killCurrentPythonProcess,
    resetGlobalCancel,
    setupPronunciationInstallStatusIPC,
    ensureVenvExists,
    getVenvPythonPath,
};



================================================
FILE: electron-main/videoFileOperations.js
================================================
import { ipcMain } from "electron";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { getSaveFolder, readUserSettings } from "./filePath.js";

const checkDownloads = () => {
    ipcMain.handle("check-downloads", async () => {
        const saveFolder = await getSaveFolder(readUserSettings);
        const videoFolder = path.join(saveFolder, "video_files");
        // Ensure the directory exists
        try {
            await fsPromises.access(videoFolder);
        } catch {
            await fsPromises.mkdir(videoFolder, { recursive: true });
        }

        const files = await fsPromises.readdir(videoFolder);
        // Return the list of zip files in the download folder
        const zipFiles = files.filter((file) => file.endsWith(".7z"));
        return zipFiles.length === 0 ? "no zip files downloaded" : zipFiles;
    });
};

// Check video extracted folder
const checkExtractedFolder = () => {
    ipcMain.handle("check-extracted-folder", async (event, folderName, zipContents) => {
        const saveFolder = await getSaveFolder(readUserSettings);
        const extractedFolder = path.join(saveFolder, "video_files", folderName);

        // Check if extracted folder exists
        try {
            await fsPromises.access(extractedFolder);
            const extractedFiles = await fsPromises.readdir(extractedFolder);

            // Check if all expected files are present in the extracted folder
            const allFilesExtracted = zipContents[0].extractedFiles.every((file) => {
                return extractedFiles.includes(file.name);
            });

            event.sender.send("progress-update", 0);

            return allFilesExtracted; // Return true if all files are extracted, else false
        } catch {
            return false; // Return false if the folder doesn't exist
        }
    });
};

export { checkDownloads, checkExtractedFolder };



================================================
FILE: electron-main/zipOperation.js
================================================
import { ipcMain } from "electron";
import applog from "electron-log";
import JS7z from "js7z-tools";
import crypto from "node:crypto";
import fs from "node:fs";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { getSaveFolder, readUserSettings } from "./filePath.js";

// Function to calculate the SHA-256 hash of a file
const calculateFileHash = (filePath) => {
    return new Promise((resolve, reject) => {
        const hash = crypto.createHash("sha256");
        const stream = fs.createReadStream(filePath);
        stream.on("data", (data) => hash.update(data));
        stream.on("end", () => resolve(hash.digest("hex")));
        stream.on("error", (err) => reject(err));
    });
};

const fileVerification = async (event, zipContents, extractedFolder) => {
    // Verify existing extracted files
    const totalFiles = zipContents[0].extractedFiles.length;
    let filesProcessed = 0;
    let fileErrors = [];

    for (const file of zipContents[0].extractedFiles) {
        const extractedFilePath = path.join(extractedFolder, file.name);
        try {
            await fsPromises.access(extractedFilePath);
        } catch {
            console.log(`File missing: ${file.name}`);
            applog.log(`File missing: ${file.name}`);
            fileErrors.push({
                type: "missing",
                name: file.name,
                message: `File missing: ${file.name}. Make sure you do not rename or accidentally delete it.`,
            });
            continue;
        }

        const extractedFileHash = await calculateFileHash(extractedFilePath);
        if (extractedFileHash !== file.hash) {
            console.log(`Hash mismatch for file: ${file.name}`);
            applog.log(`Hash mismatch for file: ${file.name}`);
            fileErrors.push({
                type: "hash-mismatch",
                name: file.name,
                message: `Hash mismatch for file: ${file.name}. It seems like the file was either corrupted or tampered.`,
            });
            continue;
        }

        filesProcessed++;
        const progressPercentage = Math.floor((filesProcessed / totalFiles) * 100);
        event.sender.send("progress-update", progressPercentage); // Send progress update
        applog.log("Verifying extracted file:", file.name);
    }

    if (fileErrors.length === 0) {
        // If no missing or corrupted files, finish the verification
        event.sender.send("verification-success", {
            messageKey:
                "settingPage.videoDownloadSettings.electronVerifyMessage.extractedSuccessMsg",
            param: extractedFolder,
        });
        applog.log(`All extracted files are verified for "${extractedFolder}"`);
        return;
    } else {
        // Send all errors as an array
        event.sender.send("verification-errors", fileErrors);
        applog.log(
            `Verification found errors in extracted files for "${extractedFolder}"`,
            fileErrors
        );
        return;
    }
};

// Handle verify and extract process using JS7z
const verifyAndExtractIPC = () => {
    ipcMain.on("verify-and-extract", async (event, zipFileData) => {
        const { zipFile, zipHash, zipContents } = zipFileData;
        const saveFolder = await getSaveFolder(readUserSettings);
        const videoFolder = path.join(saveFolder, "video_files");
        const zipFilePath = path.join(videoFolder, zipFile);
        const extractedFolder = path.join(videoFolder, zipFile.replace(".7z", ""));

        // Step 0: Check if ZIP file exists first
        let zipExists = false;
        try {
            await fsPromises.access(zipFilePath);
            zipExists = true;
        } catch {
            zipExists = false;
        }

        if (zipExists) {
            // ZIP exists: proceed with hash verification and extraction
            console.log(`Starting verification for ${zipFile}`);
            applog.log(`Starting verification for ${zipFile}`);
            try {
                const js7z = await JS7z({
                    print: (text) => {
                        console.log(`7-Zip output: ${text}`);
                        applog.log(`7-Zip output: ${text}`);
                        if (text.includes("%")) {
                            const match = text.match(/\s+(\d+)%/); // Extract percentage from output
                            if (match) {
                                const percentage = parseInt(match[1], 10);
                                event.sender.send("progress-update", percentage);
                            }
                        }
                    },
                    printErr: (errText) => {
                        console.error(`7-Zip error: ${errText}`);
                        applog.error(`7-Zip error: ${errText}`);
                        event.sender.send("verification-error", `7-Zip error: ${errText}`);
                    },
                    onAbort: (reason) => {
                        console.error(`7-Zip aborted: ${reason}`);
                        applog.error(`7-Zip aborted: ${reason}`);
                        event.sender.send("verification-error", `7-Zip aborted: ${reason}`);
                    },
                    onExit: (exitCode) => {
                        if (exitCode === 0) {
                            console.log(`7-Zip exited successfully with code ${exitCode}`);
                            applog.log(`7-Zip exited successfully with code ${exitCode}`);
                        } else {
                            console.error(`7-Zip exited with error code: ${exitCode}`);
                            applog.error(`7-Zip exited with error code: ${exitCode}`);
                            event.sender.send(
                                "verification-error",
                                `7-Zip exited with error code: ${exitCode}`
                            );
                        }
                    },
                });

                // Mount the local file system to the JS7z instance using NODEFS
                js7z.FS.mkdir("/mnt");
                js7z.FS.mount(js7z.NODEFS, { root: videoFolder }, "/mnt");

                const emZipFilePath = `/mnt/${zipFile}`;
                const emExtractedFolder = `/mnt/${zipFile.replace(".7z", "")}`;

                // Step 1: Verifying ZIP file hash
                event.sender.send(
                    "progress-text",
                    "settingPage.videoDownloadSettings.electronVerifyMessage.zipFileVerifying"
                );
                const fileHash = await calculateFileHash(zipFilePath);
                if (fileHash !== zipHash) {
                    applog.error(
                        `Hash mismatch for ${zipFile}. It seems like the zip file was either corrupted or tampered.`
                    );
                    event.sender.send("verification-error", {
                        messageKey:
                            "settingPage.videoDownloadSettings.electronVerifyMessage.zipHashMismatchMsg",
                        param: zipFile,
                    });
                    return;
                }
                event.sender.send("progress-text", "ZIP file verified");

                // Step 2: Extracting ZIP file
                event.sender.send(
                    "progress-text",
                    "settingPage.videoDownloadSettings.electronVerifyMessage.zipExtractingMsg"
                );
                js7z.callMain(["x", emZipFilePath, `-o${emExtractedFolder}`]);

                js7z.onExit = async (exitCode) => {
                    if (exitCode !== 0) {
                        applog.error(`Error extracting ${zipFile}`);
                        event.sender.send("verification-error", {
                            messageKey:
                                "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg",
                            param: zipFile,
                        });
                        return;
                    }

                    console.log(`Extraction successful for ${zipFile}`);
                    applog.log(`Extraction successful for ${zipFile}`);

                    // Step 3: Verifying extracted files
                    event.sender.send(
                        "progress-text",
                        "settingPage.videoDownloadSettings.verifyinProgressMsg"
                    );
                    await fileVerification(event, zipContents, extractedFolder);

                    // Clean up the ZIP file after successful extraction and verification
                    try {
                        await fsPromises.unlink(zipFilePath);
                        console.log(`Deleted ZIP file: ${zipFilePath}`);
                        applog.log(`Extraction successful for ${zipFile}`);
                    } catch (err) {
                        console.error(`Failed to delete ZIP file: ${err.message}`);
                        applog.error(`Failed to delete ZIP file: ${err.message}`);
                    }

                    event.sender.send("verification-success", {
                        messageKey:
                            "settingPage.videoDownloadSettings.electronVerifyMessage.zipSuccessMsg",
                        param: zipFile,
                    });
                    applog.log(`Successfully verified and extracted ${zipFile}`);
                };
            } catch (err) {
                console.error(`Error processing ${zipFile}: ${err.message}`);
                event.sender.send("verification-error", {
                    messageKey:
                        "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg",
                    param: zipFile,
                    errorMessage: err.message,
                });
            }
        } else {
            // ZIP does not exist: check for extracted folder and verify its contents
            console.log(
                `ZIP file does not exist: ${zipFilePath}. It could be extracted before. Proceeding with extracted folder verification...`
            );
            applog.log(
                `ZIP file does not exist: ${zipFilePath}. It could be extracted before. Proceeding with extracted folder verification...`
            );
            try {
                await fsPromises.access(extractedFolder);
                console.log(`Extracted folder already exists: ${extractedFolder}`);
                applog.log(`Extracted folder already exists: ${extractedFolder}`);
                event.sender.send(
                    "progress-text",
                    "settingPage.videoDownloadSettings.verifyinProgressMsg"
                );
                await fileVerification(event, zipContents, extractedFolder);
            } catch {
                console.log(`Extracted folder does not exist: ${extractedFolder}`);
                applog.log(`Extracted folder does not exist: ${extractedFolder}`);
                event.sender.send("verification-error", {
                    messageKey: "settingPage.videoDownloadSettings.verifyFailedMessage",
                    param: extractedFolder,
                });
            }
        }
    });
};

export { fileVerification, verifyAndExtractIPC };



================================================
FILE: public/_redirects
================================================
/*  /index.html  200



================================================
FILE: public/google5c7ff3958ee35135.html
================================================
google-site-verification: google5c7ff3958ee35135.html


================================================
FILE: public/images/homepage_screenshot.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_main-task_1.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_practice-task_1_1.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_practice-task_1_2.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Job-interview_main-task_1.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_main-task_4.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_main-task_5.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_1.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_2.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_3.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_4.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_2_4.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/sound_images/sounds_american.webp
================================================
[Binary file]


================================================
FILE: public/images/ispeaker/sound_images/sounds_british.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-00.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-01.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-02.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-03.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-04.webp
================================================
[Binary file]


================================================
FILE: public/images/screenshots/screenshot-08.webp
================================================
[Binary file]


================================================
FILE: public/json/conversation_list.json
================================================
{
    "conversationList": [
        {
            "heading": "conversationPage.discussionAndOpinion.heading",
            "titles": [
                {
                    "title": "conversationPage.discussionAndOpinion.agreeingTitle",
                    "info": "conversationPage.discussionAndOpinion.agreeingInfo",
                    "id": "agreeing"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.askingForOpinionsTitle",
                    "info": "conversationPage.discussionAndOpinion.askingForOpinionsInfo",
                    "id": "opinions"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.concedingAPointTitle",
                    "info": "conversationPage.discussionAndOpinion.concedingAPointInfo",
                    "id": "conceding"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.dealingWithQuestionsTitle",
                    "info": "conversationPage.discussionAndOpinion.dealingWithQuestionsInfo",
                    "id": "deal-questions"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.disagreeingTitle",
                    "info": "conversationPage.discussionAndOpinion.disagreeingInfo",
                    "id": "disagreeing"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.expressingPreferencesTitle",
                    "info": "conversationPage.discussionAndOpinion.expressingPreferencesInfo",
                    "id": "preferences"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.givingReasonsTitle",
                    "info": "conversationPage.discussionAndOpinion.givingReasonsInfo",
                    "id": "reasons"
                },
                {
                    "title": "conversationPage.discussionAndOpinion.interruptingSomebodyTitle",
                    "info": "conversationPage.discussionAndOpinion.interruptingSomebodyInfo",
                    "id": "interrupting"
                }
            ]
        },
        {
            "heading": "conversationPage.doubtGuessingAndCertainty.heading",
            "titles": [
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.askingForClarificationTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.askingForClarificationInfo",
                    "id": "clarification"
                },
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.correctingYourselfTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.correctingYourselfInfo",
                    "id": "correcting-yourself"
                },
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.expressingCertaintyTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.expressingCertaintyInfo",
                    "id": "certainty"
                },
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.expressingIgnoranceTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.expressingIgnoranceInfo",
                    "id": "ignorance"
                },
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.expressingLikelihoodTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.expressingLikelihoodInfo",
                    "id": "likelihood"
                },
                {
                    "title": "conversationPage.doubtGuessingAndCertainty.speculatingTitle",
                    "info": "conversationPage.doubtGuessingAndCertainty.speculatingInfo",
                    "id": "speculating"
                }
            ]
        },
        {
            "heading": "conversationPage.feelingsAndEmotions.heading",
            "titles": [
                {
                    "title": "conversationPage.feelingsAndEmotions.apologizingTitle",
                    "info": "conversationPage.feelingsAndEmotions.apologizingInfo",
                    "id": "apologizing"
                },
                {
                    "title": "conversationPage.feelingsAndEmotions.congratulatingSomebodyTitle",
                    "info": "conversationPage.feelingsAndEmotions.congratulatingSomebodyInfo",
                    "id": "congratulating"
                },
                {
                    "title": "conversationPage.feelingsAndEmotions.expressingSympathyTitle",
                    "info": "conversationPage.feelingsAndEmotions.expressingSympathyInfo",
                    "id": "sympathy"
                },
                {
                    "title": "conversationPage.feelingsAndEmotions.makingComplaintsTitle",
                    "info": "conversationPage.feelingsAndEmotions.makingComplaintsInfo",
                    "id": "complaints"
                },
                {
                    "title": "conversationPage.feelingsAndEmotions.thankingSomebodyTitle",
                    "info": "conversationPage.feelingsAndEmotions.thankingSomebodyInfo",
                    "id": "thanking"
                },
                {
                    "title": "conversationPage.feelingsAndEmotions.wishingSomebodyLuckTitle",
                    "info": "conversationPage.feelingsAndEmotions.wishingSomebodyLuckInfo",
                    "id": "wishing-luck"
                }
            ]
        },
        {
            "heading": "conversationPage.openingsAndEndings.heading",
            "titles": [
                {
                    "title": "conversationPage.openingsAndEndings.endingConversationsTitle",
                    "info": "conversationPage.openingsAndEndings.endingConversationsInfo",
                    "id": "ending"
                },
                {
                    "title": "conversationPage.openingsAndEndings.introducingSomebodyTitle",
                    "info": "conversationPage.openingsAndEndings.introducingSomebodyInfo",
                    "id": "introducing"
                },
                {
                    "title": "conversationPage.openingsAndEndings.invitingSomebodyToSomethingTitle",
                    "info": "conversationPage.openingsAndEndings.invitingSomebodyToSomethingInfo",
                    "id": "inviting"
                },
                {
                    "title": "conversationPage.openingsAndEndings.leavingPhoneMessagesTitle",
                    "info": "conversationPage.openingsAndEndings.leavingPhoneMessagesInfo",
                    "id": "phonemsg"
                },
                {
                    "title": "conversationPage.openingsAndEndings.openingConversationsTitle",
                    "info": "conversationPage.openingsAndEndings.openingConversationsInfo",
                    "id": "opening"
                },
                {
                    "title": "conversationPage.openingsAndEndings.wrappingUpDiscussionsTitle",
                    "info": "conversationPage.openingsAndEndings.wrappingUpDiscussionsInfo",
                    "id": "wrapping-up"
                }
            ]
        },
        {
            "heading": "conversationPage.permissionAndObligation.heading",
            "titles": [
                {
                    "title": "conversationPage.permissionAndObligation.askingAboutObligationTitle",
                    "info": "conversationPage.permissionAndObligation.askingAboutObligationInfo",
                    "id": "obligation"
                },
                {
                    "title": "conversationPage.permissionAndObligation.askingForPermissionTitle",
                    "info": "conversationPage.permissionAndObligation.askingForPermissionInfo",
                    "id": "permission"
                },
                {
                    "title": "conversationPage.permissionAndObligation.forbiddingSomethingTitle",
                    "info": "conversationPage.permissionAndObligation.forbiddingSomethingInfo",
                    "id": "forbidding"
                },
                {
                    "title": "conversationPage.permissionAndObligation.givingOrdersTitle",
                    "info": "conversationPage.permissionAndObligation.givingOrdersInfo",
                    "id": "orders"
                },
                {
                    "title": "conversationPage.permissionAndObligation.makingRequestsTitle",
                    "info": "conversationPage.permissionAndObligation.makingRequestsInfo",
                    "id": "requests"
                }
            ]
        },
        {
            "heading": "conversationPage.suggestionsAndAdvice.heading",
            "titles": [
                {
                    "title": "conversationPage.suggestionsAndAdvice.askingForHelpTitle",
                    "info": "conversationPage.suggestionsAndAdvice.askingForHelpInfo",
                    "id": "asking-help"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.askingForInformationTitle",
                    "info": "conversationPage.suggestionsAndAdvice.askingForInformationInfo",
                    "id": "information"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.givingSomebodyAdviceTitle",
                    "info": "conversationPage.suggestionsAndAdvice.givingSomebodyAdviceInfo",
                    "id": "advice"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.makingRecommendationsTitle",
                    "info": "conversationPage.suggestionsAndAdvice.makingRecommendationsInfo",
                    "id": "recommendations"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.makingSuggestionsTitle",
                    "info": "conversationPage.suggestionsAndAdvice.makingSuggestionsInfo",
                    "id": "suggestions"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.offeringHelpTitle",
                    "info": "conversationPage.suggestionsAndAdvice.offeringHelpInfo",
                    "id": "offer-help"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.offeringSomebodySomethingTitle",
                    "info": "conversationPage.suggestionsAndAdvice.offeringSomebodySomethingInfo",
                    "id": "offer-somthing"
                },
                {
                    "title": "conversationPage.suggestionsAndAdvice.warningSomebodyOfDangerTitle",
                    "info": "conversationPage.suggestionsAndAdvice.warningSomebodyOfDangerInfo",
                    "id": "warning"
                }
            ]
        }
    ]
}



================================================
FILE: public/json/ex_data.json
================================================
{
    "sounds_n_spelling": [
        {
            "exercise": " / i&#720; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / i&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / i&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#601;&#650; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#601;&#650; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#601;&#650; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / o&#650;  / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / o&#650; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / o&#650; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#601; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#601; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#601; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#596;&#720; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#596;&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#596;&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#596;&#720;(r) / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#596;&#720;(r) /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#596;&#720;(r) /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#604;&#720; /  ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#604;&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#604;&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#604;&#720;r / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#604;&#720;r /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#604;&#720;r /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#618;&#601;(r)/  ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#618;&#601;(r) /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#618;&#601;(r) /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / &#618;r / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#618;r /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / &#618;r /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / u&#720; /   ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / u&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / u&#720; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / e&#618; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / e&#618; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / e&#618; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        },
        {
            "exercise": " / a&#618; / ",
            "info": "Listen and choose the correct spelling",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / a&#618; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen to the word and choose the spelling that represents the sound / a&#618; /.",
                        "You can listen to the word as many times as you like.",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": []
                }
            ]
        }
    ],
    "snap": [
        {
            "exercise": "Homophones",
            "info": "Do the two words sound the same or different?",
            "b_s": "yes",
            "a_s": "yes",
            "b": [
                {
                    "h4": "British English",
                    "left_p": [
                        "Listen and write the word you hear. You can listen to the word as many times as you like.",
                        "You must spell the word correctly!",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": [
                        {
                            "data": [
                                { "value": "creative", "index": "0" },
                                { "value": "assistance", "index": "1" }
                            ],
                            "feedbacks": [
                                { "value": "Yes", "index": "0", "answer": "false" },
                                { "value": "No", "index": "1", "answer": "true" },
                                { "correctAns": "No" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "creative", "index": "0" },
                                { "value": "assistance", "index": "1" }
                            ],
                            "feedbacks": [
                                { "value": "Yes", "index": "0", "answer": "false" },
                                { "value": "No", "index": "1", "answer": "true" },
                                { "correctAns": "No" }
                            ]
                        }
                    ]
                }
            ],
            "a": [
                {
                    "h4": "American English",
                    "left_p": [
                        "Listen and write the word you hear. You can listen to the word as many times as you like.",
                        "You must spell the word correctly!",
                        "When you want to stop, select \"Quit\" to see your score in this session."
                    ],
                    "act": [
                        {
                            "data": [
                                { "value": "creative", "index": "0" },
                                { "value": "assistance", "index": "1" }
                            ],
                            "feedbacks": [
                                { "value": "Yes", "index": "0", "answer": "false" },
                                { "value": "No", "index": "1", "answer": "true" },
                                { "correctAns": "No" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "creative", "index": "0" },
                                { "value": "assistance", "index": "1" }
                            ],
                            "feedbacks": [
                                { "value": "Yes", "index": "0", "answer": "false" },
                                { "value": "No", "index": "1", "answer": "true" },
                                { "correctAns": "No" }
                            ]
                        }
                    ]
                }
            ]
        },
        {
            "exercise": "Reading phonetics",
            "info": "Does the phonetic transcription match the word?"
        },
        { "exercise": "Random", "info": "A mixture of homophones and phonetics questions" }
    ]
}



================================================
FILE: public/json/examspeaking_list.json
================================================
{
    "examList": [
        {
            "heading": "examPage.groupTask.heading",
            "titles": [
                {
                    "title": "examPage.groupTask.discussingOpinions.title",
                    "id": "discussing",
                    "exam_popup": "examPage.groupTask.discussingOpinions.tooltip"
                },
                {
                    "title": "examPage.groupTask.negotiating.title",
                    "id": "negotiating",
                    "exam_popup": "examPage.groupTask.negotiating.tooltip"
                }
            ]
        },
        {
            "heading": "examPage.individualTask.heading",
            "titles": [
                {
                    "title": "examPage.individualTask.describingPicture.title",
                    "id": "describing",
                    "exam_popup": "examPage.individualTask.describingPicture.tooltip"
                },
                {
                    "title": "examPage.individualTask.personalInformation.title",
                    "id": "personal-info",
                    "exam_popup": "examPage.individualTask.personalInformation.tooltip"
                },
                {
                    "title": "examPage.individualTask.topicTalking.title",
                    "id": "talking-topic",
                    "exam_popup": "examPage.individualTask.topicTalking.tooltip"
                }
            ]
        },
        {
            "heading": "examPage.realLifeTask.heading",
            "titles": [
                {
                    "title": "examPage.realLifeTask.presentation.title",
                    "id": "presentation",
                    "exam_popup": "examPage.realLifeTask.presentation.tooltip"
                },
                {
                    "title": "examPage.realLifeTask.jobInterview.title",
                    "id": "interview",
                    "exam_popup": "examPage.realLifeTask.jobInterview.tooltip"
                }
            ]
        }
    ]
}



================================================
FILE: public/json/exercise_list.json
================================================
{
    "exerciseList": [
        {
            "heading": "exercise_page.dictationHeading",
            "file": "exercise_dictation.json",
            "titles": [
                {
                    "titleKey": "exercise_page.wordExercise",
                    "infoKey": "exercise_page.exerciseInfo.dictationWord",
                    "id": "words"
                },
                {
                    "titleKey": "exercise_page.missingWordExercise",
                    "infoKey": "exercise_page.exerciseInfo.dictationMissingWord",
                    "id": "missing-words"
                },
                {
                    "titleKey": "exercise_page.sentenceExercise",
                    "infoKey": "exercise_page.exerciseInfo.dictationSentence",
                    "id": "sentences"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.dictationRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.matchUpHeading",
            "file": "exercise_matchup.json",
            "titles": [
                {
                    "titleKey": "exercise_page.similarSoundExercise",
                    "infoKey": "exercise_page.exerciseInfo.matchUpSimilarSound",
                    "id": "similar-sounds"
                },
                {
                    "titleKey": "exercise_page.readingPhoneticsExercise",
                    "infoKey": "exercise_page.exerciseInfo.matchUpReadingPhonetics",
                    "id": "reading-phonetics"
                },
                {
                    "titleKey": "exercise_page.comprehensionExercise",
                    "infoKey": "exercise_page.exerciseInfo.matchUpComprehension",
                    "id": "comprehension"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.matchUpRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.reorderingHeading",
            "file": "exercise_reordering.json",
            "titles": [
                {
                    "titleKey": "exercise_page.wordExercise",
                    "infoKey": "exercise_page.exerciseInfo.reorderingWord",
                    "id": "words"
                },
                {
                    "titleKey": "exercise_page.sentenceExercise",
                    "infoKey": "exercise_page.exerciseInfo.reorderingSentence",
                    "id": "sentences"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.reorderingRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.soundSpellingHeading",
            "file": "exercise_sound_n_spelling.json",
            "infoKey": "exercise_page.exerciseInfo.soundSpellingInfo",
            "titles": [
                { "title": "/ iː /", "id": "i-long" },
                { "title": "/ əʊ /", "id": "ou-gb", "american": false },
                { "title": "/ oʊ /", "id": "ou-us", "british": false },
                { "title": "/ ə /", "id": "er" },
                { "title": "/ ɔː /", "id": "o-gb", "american": false },
                { "title": "/ ɔː(r) /", "id": "or-us", "british": false },
                { "title": "/ ɜː /", "id": "er-gb", "american": false },
                { "title": "/ ɜːr /", "id": "er-us", "british": false },
                { "title": "/ ɪə(r) /", "id": "ear-gb", "american": false },
                { "title": "/ ɪr /", "id": "ear-us", "british": false },
                { "title": "/ uː /", "id": "u-long" },
                { "title": "/ eɪ /", "id": "ay" },
                { "title": "/ aɪ /", "id": "eye" },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "id": "random",
                    "infoKey": "exercise_page.exerciseInfo.soundSpellingRandom"
                }
            ]
        },
        {
            "heading": "exercise_page.sortingHeading",
            "file": "exercise_sorting.json",
            "titles": [
                {
                    "titleKey": "exercise_page.similarSoundExercise",
                    "infoKey": "exercise_page.exerciseInfo.sortingSimilarSound",
                    "id": "similar-sounds"
                },
                {
                    "titleKey": "exercise_page.wordStressExercise",
                    "infoKey": "exercise_page.exerciseInfo.sortingWordStress",
                    "id": "word-stress"
                },
                {
                    "titleKey": "exercise_page.syllablesExercise",
                    "infoKey": "exercise_page.exerciseInfo.sortingSyllable",
                    "id": "syllables"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.sortingRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.oddOneOutHeading",
            "file": "exercise_odd_one_out.json",
            "titles": [
                {
                    "titleKey": "exercise_page.wordStressExercise",
                    "infoKey": "exercise_page.exerciseInfo.oddOneOutWordStress",
                    "id": "word-stress"
                },
                {
                    "titleKey": "exercise_page.rhymingWordExercise",
                    "infoKey": "exercise_page.exerciseInfo.oddOneOutRhymingWord",
                    "id": "rhyming"
                },
                {
                    "titleKey": "exercise_page.syllablesExercise",
                    "infoKey": "exercise_page.exerciseInfo.oddOneOutSyllable",
                    "id": "syllables"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.oddOneOutRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.snapHeading",
            "file": "exercise_snap.json",
            "titles": [
                {
                    "titleKey": "exercise_page.readingPhoneticsExercise",
                    "infoKey": "exercise_page.exerciseInfo.snapReadingPhonetics",
                    "id": "reading-phonetics"
                },
                {
                    "titleKey": "exercise_page.homophonesExercise",
                    "infoKey": "exercise_page.exerciseInfo.snapHomophones",
                    "id": "homophones"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.snapRandom",
                    "id": "random"
                }
            ]
        },
        {
            "heading": "exercise_page.memoryMatchHeading",
            "file": "exercise_memory_match.json",
            "titles": [
                {
                    "titleKey": "exercise_page.rhymingWordExercise",
                    "infoKey": "exercise_page.exerciseInfo.memoryMatchRhymingWord",
                    "id": "rhyming"
                },
                {
                    "titleKey": "exercise_page.homophonesExercise",
                    "infoKey": "exercise_page.exerciseInfo.memoryMatchHomophones",
                    "id": "homophones"
                },
                {
                    "titleKey": "exercise_page.randomExercise",
                    "infoKey": "exercise_page.exerciseInfo.memoryMatchRandom",
                    "id": "random"
                }
            ]
        }
    ]
}



================================================
FILE: public/json/exercise_memory_match.json
================================================
{
    "memory_match": [
        {
            "exercise": "Rhyming words",
            "id": "rhyming",
            "info": "Find words that end in the same sound",
            "b_s": "yes",
            "a_s": "yes",
            "british": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.rhyming",
                    "quiz": [
                        {
                            "data": [
                                { "value": "1", "text": "river" },
                                { "value": "1", "text": "quiver" },
                                { "value": "2", "text": "bear" },
                                { "value": "2", "text": "care" },
                                { "value": "3", "text": "first" },
                                { "value": "3", "text": "worst" },
                                { "value": "4", "text": "blame" },
                                { "value": "4", "text": "flame" },
                                { "value": "5", "text": "crash" },
                                { "value": "5", "text": "splash" },
                                { "value": "6", "text": "spray" },
                                { "value": "6", "text": "tray" },
                                { "value": "7", "text": "peel" },
                                { "value": "7", "text": "squeal" },
                                { "value": "8", "text": "built" },
                                { "value": "8", "text": "quilt" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "ink" },
                                { "value": "1", "text": "pink" },
                                { "value": "2", "text": "crazy" },
                                { "value": "2", "text": "lazy" },
                                { "value": "3", "text": "mask" },
                                { "value": "3", "text": "flask" },
                                { "value": "4", "text": "floor" },
                                { "value": "4", "text": "poor" },
                                { "value": "5", "text": "metal" },
                                { "value": "5", "text": "kettle" },
                                { "value": "6", "text": "funny" },
                                { "value": "6", "text": "sunny" },
                                { "value": "7", "text": "swift" },
                                { "value": "7", "text": "gift" },
                                { "value": "8", "text": "able" },
                                { "value": "8", "text": "cable" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "trouble" },
                                { "value": "1", "text": "double" },
                                { "value": "2", "text": "goose" },
                                { "value": "2", "text": "moose" },
                                { "value": "3", "text": "easy" },
                                { "value": "3", "text": "queasy" },
                                { "value": "4", "text": "praising" },
                                { "value": "4", "text": "lazing" },
                                { "value": "5", "text": "scoring" },
                                { "value": "5", "text": "boring" },
                                { "value": "6", "text": "herd" },
                                { "value": "6", "text": "word" },
                                { "value": "7", "text": "earn" },
                                { "value": "7", "text": "learn" },
                                { "value": "8", "text": "thread" },
                                { "value": "8", "text": "bed" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "oaf" },
                                { "value": "1", "text": "loaf" },
                                { "value": "2", "text": "still" },
                                { "value": "2", "text": "spill" },
                                { "value": "3", "text": "folder" },
                                { "value": "3", "text": "boulder" },
                                { "value": "4", "text": "happy" },
                                { "value": "4", "text": "snappy" },
                                { "value": "5", "text": "writer" },
                                { "value": "5", "text": "lighter" },
                                { "value": "6", "text": "cookery" },
                                { "value": "6", "text": "rookery" },
                                { "value": "7", "text": "butter" },
                                { "value": "7", "text": "cutter" },
                                { "value": "8", "text": "liver" },
                                { "value": "8", "text": "sliver" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "crowned" },
                                { "value": "1", "text": "found" },
                                { "value": "2", "text": "friend" },
                                { "value": "2", "text": "end" },
                                { "value": "3", "text": "lose" },
                                { "value": "3", "text": "whose" },
                                { "value": "4", "text": "season" },
                                { "value": "4", "text": "reason" },
                                { "value": "5", "text": "station" },
                                { "value": "5", "text": "nation" },
                                { "value": "6", "text": "strings" },
                                { "value": "6", "text": "brings" },
                                { "value": "7", "text": "coffee" },
                                { "value": "7", "text": "toffee" },
                                { "value": "8", "text": "box" },
                                { "value": "8", "text": "docks" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "aiming" },
                                { "value": "1", "text": "blaming" },
                                { "value": "2", "text": "saved" },
                                { "value": "2", "text": "paved" },
                                { "value": "3", "text": "threat" },
                                { "value": "3", "text": "pet" },
                                { "value": "4", "text": "bite " },
                                { "value": "4", "text": "fright  " },
                                { "value": "5", "text": "shower" },
                                { "value": "5", "text": "sour" },
                                { "value": "6", "text": "toil" },
                                { "value": "6", "text": "foil" },
                                { "value": "7", "text": "eye" },
                                { "value": "7", "text": "shy" },
                                { "value": "8", "text": "straight" },
                                { "value": "8", "text": "weight" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "onion" },
                                { "value": "1", "text": "bunion" },
                                { "value": "2", "text": "wireless" },
                                { "value": "2", "text": "tireless" },
                                { "value": "3", "text": "fully" },
                                { "value": "3", "text": "pulley" },
                                { "value": "4", "text": "lightning" },
                                { "value": "4", "text": "frightening" },
                                { "value": "5", "text": "ooze" },
                                { "value": "5", "text": "snooze" },
                                { "value": "6", "text": "dinner" },
                                { "value": "6", "text": "winner" },
                                { "value": "7", "text": "eggs" },
                                { "value": "7", "text": "legs" },
                                { "value": "8", "text": "great" },
                                { "value": "8", "text": "eight" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "power" },
                                { "value": "1", "text": "tower" },
                                { "value": "2", "text": "dancer" },
                                { "value": "2", "text": "answer" },
                                { "value": "3", "text": "raised" },
                                { "value": "3", "text": "dazed" },
                                { "value": "4", "text": "give" },
                                { "value": "4", "text": "sieve" },
                                { "value": "5", "text": "ache" },
                                { "value": "5", "text": "fake" },
                                { "value": "6", "text": "pair" },
                                { "value": "6", "text": "where" },
                                { "value": "7", "text": "around" },
                                { "value": "7", "text": "aground" },
                                { "value": "8", "text": "swept" },
                                { "value": "8", "text": "kept" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "tearful" },
                                { "value": "1", "text": "fearful" },
                                { "value": "2", "text": "choose" },
                                { "value": "2", "text": "bruise" },
                                { "value": "3", "text": "cattle" },
                                { "value": "3", "text": "battle" },
                                { "value": "4", "text": "widget" },
                                { "value": "4", "text": "fidget" },
                                { "value": "5", "text": "mash" },
                                { "value": "5", "text": "stash" },
                                { "value": "6", "text": "axed" },
                                { "value": "6", "text": "taxed" },
                                { "value": "7", "text": "easing" },
                                { "value": "7", "text": "squeezing" },
                                { "value": "8", "text": "closed" },
                                { "value": "8", "text": "dozed" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "rule" },
                                { "value": "1", "text": "fool" },
                                { "value": "2", "text": "frame" },
                                { "value": "2", "text": "tame" },
                                { "value": "3", "text": "leaf" },
                                { "value": "3", "text": "brief" },
                                { "value": "4", "text": "leaving" },
                                { "value": "4", "text": "thieving" },
                                { "value": "5", "text": "hurt" },
                                { "value": "5", "text": "shirt" },
                                { "value": "6", "text": "maybe" },
                                { "value": "6", "text": "baby" },
                                { "value": "7", "text": "frayed" },
                                { "value": "7", "text": "trade" },
                                { "value": "8", "text": "love" },
                                { "value": "8", "text": "glove" }
                            ]
                        }
                    ]
                }
            ],
            "american": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.rhyming",
                    "quiz": [
                        {
                            "data": [
                                { "value": "1", "text": "river" },
                                { "value": "1", "text": "quiver" },
                                { "value": "2", "text": "bear" },
                                { "value": "2", "text": "care" },
                                { "value": "3", "text": "first" },
                                { "value": "3", "text": "worst" },
                                { "value": "4", "text": "blame" },
                                { "value": "4", "text": "flame" },
                                { "value": "5", "text": "crash" },
                                { "value": "5", "text": "splash" },
                                { "value": "6", "text": "spray" },
                                { "value": "6", "text": "tray" },
                                { "value": "7", "text": "peel" },
                                { "value": "7", "text": "squeal" },
                                { "value": "8", "text": "built" },
                                { "value": "8", "text": "quilt" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "ink" },
                                { "value": "1", "text": "pink" },
                                { "value": "2", "text": "crazy" },
                                { "value": "2", "text": "lazy" },
                                { "value": "3", "text": "mask" },
                                { "value": "3", "text": "flask" },
                                { "value": "4", "text": "floor" },
                                { "value": "4", "text": "poor" },
                                { "value": "5", "text": "metal" },
                                { "value": "5", "text": "kettle" },
                                { "value": "6", "text": "funny" },
                                { "value": "6", "text": "sunny" },
                                { "value": "7", "text": "swift" },
                                { "value": "7", "text": "gift" },
                                { "value": "8", "text": "able" },
                                { "value": "8", "text": "cable" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "trouble" },
                                { "value": "1", "text": "double" },
                                { "value": "2", "text": "goose" },
                                { "value": "2", "text": "moose" },
                                { "value": "3", "text": "easy" },
                                { "value": "3", "text": "queasy" },
                                { "value": "4", "text": "praising" },
                                { "value": "4", "text": "lazing" },
                                { "value": "5", "text": "scoring" },
                                { "value": "5", "text": "boring" },
                                { "value": "6", "text": "herd" },
                                { "value": "6", "text": "word" },
                                { "value": "7", "text": "earn" },
                                { "value": "7", "text": "learn" },
                                { "value": "8", "text": "thread" },
                                { "value": "8", "text": "bed" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "oaf" },
                                { "value": "1", "text": "loaf" },
                                { "value": "2", "text": "still" },
                                { "value": "2", "text": "spill" },
                                { "value": "3", "text": "folder" },
                                { "value": "3", "text": "boulder" },
                                { "value": "4", "text": "happy" },
                                { "value": "4", "text": "snappy" },
                                { "value": "5", "text": "writer" },
                                { "value": "5", "text": "lighter" },
                                { "value": "6", "text": "cookery" },
                                { "value": "6", "text": "rookery" },
                                { "value": "7", "text": "butter" },
                                { "value": "7", "text": "cutter" },
                                { "value": "8", "text": "liver" },
                                { "value": "8", "text": "sliver" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "crowned" },
                                { "value": "1", "text": "found" },
                                { "value": "2", "text": "friend" },
                                { "value": "2", "text": "end" },
                                { "value": "3", "text": "lose" },
                                { "value": "3", "text": "whose" },
                                { "value": "4", "text": "season" },
                                { "value": "4", "text": "reason" },
                                { "value": "5", "text": "station" },
                                { "value": "5", "text": "nation" },
                                { "value": "6", "text": "strings" },
                                { "value": "6", "text": "brings" },
                                { "value": "7", "text": "coffee" },
                                { "value": "7", "text": "toffee" },
                                { "value": "8", "text": "box" },
                                { "value": "8", "text": "docks" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "aiming" },
                                { "value": "1", "text": "blaming" },
                                { "value": "2", "text": "saved" },
                                { "value": "2", "text": "paved" },
                                { "value": "3", "text": "threat" },
                                { "value": "3", "text": "pet" },
                                { "value": "4", "text": "bite " },
                                { "value": "4", "text": "fright  " },
                                { "value": "5", "text": "shower" },
                                { "value": "5", "text": "sour" },
                                { "value": "6", "text": "toil" },
                                { "value": "6", "text": "foil" },
                                { "value": "7", "text": "eye" },
                                { "value": "7", "text": "shy" },
                                { "value": "8", "text": "straight" },
                                { "value": "8", "text": "weight" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "onion" },
                                { "value": "1", "text": "bunion" },
                                { "value": "2", "text": "wireless" },
                                { "value": "2", "text": "tireless" },
                                { "value": "3", "text": "fully" },
                                { "value": "3", "text": "pulley" },
                                { "value": "4", "text": "lightning" },
                                { "value": "4", "text": "frightening" },
                                { "value": "5", "text": "ooze" },
                                { "value": "5", "text": "snooze" },
                                { "value": "6", "text": "dinner" },
                                { "value": "6", "text": "winner" },
                                { "value": "7", "text": "eggs" },
                                { "value": "7", "text": "legs" },
                                { "value": "8", "text": "great" },
                                { "value": "8", "text": "eight" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "power" },
                                { "value": "1", "text": "tower" },
                                { "value": "2", "text": "dancer" },
                                { "value": "2", "text": "answer" },
                                { "value": "3", "text": "raised" },
                                { "value": "3", "text": "dazed" },
                                { "value": "4", "text": "give" },
                                { "value": "4", "text": "sieve" },
                                { "value": "5", "text": "ache" },
                                { "value": "5", "text": "fake" },
                                { "value": "6", "text": "pair" },
                                { "value": "6", "text": "where" },
                                { "value": "7", "text": "around" },
                                { "value": "7", "text": "aground" },
                                { "value": "8", "text": "swept" },
                                { "value": "8", "text": "kept" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "tearful" },
                                { "value": "1", "text": "fearful" },
                                { "value": "2", "text": "choose" },
                                { "value": "2", "text": "bruise" },
                                { "value": "3", "text": "cattle" },
                                { "value": "3", "text": "battle" },
                                { "value": "4", "text": "widget" },
                                { "value": "4", "text": "fidget" },
                                { "value": "5", "text": "mash" },
                                { "value": "5", "text": "stash" },
                                { "value": "6", "text": "axed" },
                                { "value": "6", "text": "taxed" },
                                { "value": "7", "text": "easing" },
                                { "value": "7", "text": "squeezing" },
                                { "value": "8", "text": "closed" },
                                { "value": "8", "text": "dozed" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "rule" },
                                { "value": "1", "text": "fool" },
                                { "value": "2", "text": "frame" },
                                { "value": "2", "text": "tame" },
                                { "value": "3", "text": "leaf" },
                                { "value": "3", "text": "brief" },
                                { "value": "4", "text": "leaving" },
                                { "value": "4", "text": "thieving" },
                                { "value": "5", "text": "hurt" },
                                { "value": "5", "text": "shirt" },
                                { "value": "6", "text": "maybe" },
                                { "value": "6", "text": "baby" },
                                { "value": "7", "text": "frayed" },
                                { "value": "7", "text": "trade" },
                                { "value": "8", "text": "love" },
                                { "value": "8", "text": "glove" }
                            ]
                        }
                    ]
                }
            ]
        },
        {
            "exercise": "Homophones",
            "id": "homophones",
            "info": "Find words that sound the same but have different spellings",
            "b_s": "yes",
            "a_s": "yes",
            "british": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.homophones",
                    "quiz": [
                        {
                            "data": [
                                { "value": "1", "text": "bare" },
                                { "value": "1", "text": "bear" },
                                { "value": "2", "text": "bean" },
                                { "value": "2", "text": "been" },
                                { "value": "3", "text": "beech" },
                                { "value": "3", "text": "beach" },
                                { "value": "4", "text": "berry" },
                                { "value": "4", "text": "bury" },
                                { "value": "5", "text": "boar" },
                                { "value": "5", "text": "bore" },
                                { "value": "6", "text": "blue" },
                                { "value": "6", "text": "blew" },
                                { "value": "7", "text": "break" },
                                { "value": "7", "text": "brake" },
                                { "value": "8", "text": "buoy" },
                                { "value": "8", "text": "boy" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "pain" },
                                { "value": "1", "text": "pane" },
                                { "value": "2", "text": "please" },
                                { "value": "2", "text": "pleas" },
                                { "value": "3", "text": "pedal" },
                                { "value": "3", "text": "peddle" },
                                { "value": "4", "text": "past" },
                                { "value": "4", "text": "passed" },
                                { "value": "5", "text": "pore" },
                                { "value": "5", "text": "pour" },
                                { "value": "6", "text": "principle" },
                                { "value": "6", "text": "principal" },
                                { "value": "7", "text": "pair" },
                                { "value": "7", "text": "pear" },
                                { "value": "8", "text": "poll" },
                                { "value": "8", "text": "pole" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "tea" },
                                { "value": "1", "text": "tee" },
                                { "value": "2", "text": "tear" },
                                { "value": "2", "text": "tier" },
                                { "value": "3", "text": "taut" },
                                { "value": "3", "text": "taught" },
                                { "value": "4", "text": "toad" },
                                { "value": "4", "text": "towed" },
                                { "value": "5", "text": "too" },
                                { "value": "5", "text": "two" },
                                { "value": "6", "text": "tow" },
                                { "value": "6", "text": "toe" },
                                { "value": "7", "text": "tied" },
                                { "value": "7", "text": "tide" },
                                { "value": "8", "text": "time" },
                                { "value": "8", "text": "thyme" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "dear" },
                                { "value": "1", "text": "deer" },
                                { "value": "2", "text": "dough" },
                                { "value": "2", "text": "doe" },
                                { "value": "3", "text": "die" },
                                { "value": "3", "text": "dye" },
                                { "value": "4", "text": "due" },
                                { "value": "4", "text": "dew" },
                                { "value": "5", "text": "draft" },
                                { "value": "5", "text": "draught" },
                                { "value": "6", "text": "dual" },
                                { "value": "6", "text": "duel" },
                                { "value": "7", "text": "dyed" },
                                { "value": "7", "text": "died" },
                                { "value": "8", "text": "days" },
                                { "value": "8", "text": "daze" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "made" },
                                { "value": "1", "text": "maid" },
                                { "value": "2", "text": "mode" },
                                { "value": "2", "text": "mowed" },
                                { "value": "3", "text": "miner" },
                                { "value": "3", "text": "minor" },
                                { "value": "4", "text": "meat" },
                                { "value": "4", "text": "meet" },
                                { "value": "5", "text": "mind" },
                                { "value": "5", "text": "mined" },
                                { "value": "6", "text": "mussel" },
                                { "value": "6", "text": "muscle" },
                                { "value": "7", "text": "morning" },
                                { "value": "7", "text": "mourning" },
                                { "value": "8", "text": "mail" },
                                { "value": "8", "text": "male" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "see" },
                                { "value": "1", "text": "sea" },
                                { "value": "2", "text": "sowing" },
                                { "value": "2", "text": "sewing" },
                                { "value": "3", "text": "soul" },
                                { "value": "3", "text": "sole" },
                                { "value": "4", "text": "stair" },
                                { "value": "4", "text": "stare" },
                                { "value": "5", "text": "seam" },
                                { "value": "5", "text": "seem" },
                                { "value": "6", "text": "sun" },
                                { "value": "6", "text": "son" },
                                { "value": "7", "text": "stationary" },
                                { "value": "7", "text": "stationery" },
                                { "value": "8", "text": "sighs" },
                                { "value": "8", "text": "size" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "hall" },
                                { "value": "1", "text": "haul" },
                                { "value": "2", "text": "him" },
                                { "value": "2", "text": "hymn" },
                                { "value": "3", "text": "hare" },
                                { "value": "3", "text": "hair" },
                                { "value": "4", "text": "hi" },
                                { "value": "4", "text": "high" },
                                { "value": "5", "text": "higher" },
                                { "value": "5", "text": "hire" },
                                { "value": "6", "text": "hoarse" },
                                { "value": "6", "text": "horse" },
                                { "value": "7", "text": "heel" },
                                { "value": "7", "text": "heal" },
                                { "value": "8", "text": "hear" },
                                { "value": "8", "text": "here" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "feat" },
                                { "value": "1", "text": "feet" },
                                { "value": "2", "text": "flee" },
                                { "value": "2", "text": "flea" },
                                { "value": "3", "text": "fare" },
                                { "value": "3", "text": "fair" },
                                { "value": "4", "text": "for" },
                                { "value": "4", "text": "four" },
                                { "value": "5", "text": "fined" },
                                { "value": "5", "text": "find" },
                                { "value": "6", "text": "fir" },
                                { "value": "6", "text": "fur" },
                                { "value": "7", "text": "flower" },
                                { "value": "7", "text": "flour" },
                                { "value": "8", "text": "forth" },
                                { "value": "8", "text": "fourth" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "raise" },
                                { "value": "1", "text": "rays" },
                                { "value": "2", "text": "rain" },
                                { "value": "2", "text": "reign" },
                                { "value": "3", "text": "red" },
                                { "value": "3", "text": "read" },
                                { "value": "4", "text": "reeds" },
                                { "value": "4", "text": "reads" },
                                { "value": "5", "text": "rites" },
                                { "value": "5", "text": "rights" },
                                { "value": "6", "text": "rode" },
                                { "value": "6", "text": "road" },
                                { "value": "7", "text": "rose" },
                                { "value": "7", "text": "rows" },
                                { "value": "8", "text": "role" },
                                { "value": "8", "text": "roll" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "bye" },
                                { "value": "1", "text": "by" },
                                { "value": "2", "text": "board" },
                                { "value": "2", "text": "bored" },
                                { "value": "3", "text": "border" },
                                { "value": "3", "text": "boarder" },
                                { "value": "4", "text": "byte" },
                                { "value": "4", "text": "bite" },
                                { "value": "5", "text": "bee" },
                                { "value": "5", "text": "be" },
                                { "value": "6", "text": "banned" },
                                { "value": "6", "text": "band" },
                                { "value": "7", "text": "bread" },
                                { "value": "7", "text": "bred" },
                                { "value": "8", "text": "beat" },
                                { "value": "8", "text": "beet" }
                            ]
                        }
                    ]
                }
            ],
            "american": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.homophones",
                    "quiz": [
                        {
                            "data": [
                                { "value": "1", "text": "bare" },
                                { "value": "1", "text": "bear" },
                                { "value": "2", "text": "bean" },
                                { "value": "2", "text": "been" },
                                { "value": "3", "text": "beech" },
                                { "value": "3", "text": "beach" },
                                { "value": "4", "text": "berry" },
                                { "value": "4", "text": "bury" },
                                { "value": "5", "text": "boar" },
                                { "value": "5", "text": "bore" },
                                { "value": "6", "text": "blue" },
                                { "value": "6", "text": "blew" },
                                { "value": "7", "text": "break" },
                                { "value": "7", "text": "brake" },
                                { "value": "8", "text": "buoy" },
                                { "value": "8", "text": "boy" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "pain" },
                                { "value": "1", "text": "pane" },
                                { "value": "2", "text": "please" },
                                { "value": "2", "text": "pleas" },
                                { "value": "3", "text": "pedal" },
                                { "value": "3", "text": "peddle" },
                                { "value": "4", "text": "past" },
                                { "value": "4", "text": "passed" },
                                { "value": "5", "text": "pore" },
                                { "value": "5", "text": "pour" },
                                { "value": "6", "text": "principle" },
                                { "value": "6", "text": "principal" },
                                { "value": "7", "text": "pair" },
                                { "value": "7", "text": "pear" },
                                { "value": "8", "text": "poll" },
                                { "value": "8", "text": "pole" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "tea" },
                                { "value": "1", "text": "tee" },
                                { "value": "2", "text": "tear" },
                                { "value": "2", "text": "tier" },
                                { "value": "3", "text": "taut" },
                                { "value": "3", "text": "taught" },
                                { "value": "4", "text": "toad" },
                                { "value": "4", "text": "towed" },
                                { "value": "5", "text": "too" },
                                { "value": "5", "text": "two" },
                                { "value": "6", "text": "tow" },
                                { "value": "6", "text": "toe" },
                                { "value": "7", "text": "tied" },
                                { "value": "7", "text": "tide" },
                                { "value": "8", "text": "time" },
                                { "value": "8", "text": "thyme" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "dear" },
                                { "value": "1", "text": "deer" },
                                { "value": "2", "text": "dough" },
                                { "value": "2", "text": "doe" },
                                { "value": "3", "text": "die" },
                                { "value": "3", "text": "dye" },
                                { "value": "4", "text": "due" },
                                { "value": "4", "text": "dew" },
                                { "value": "5", "text": "done" },
                                { "value": "5", "text": "dun" },
                                { "value": "6", "text": "dual" },
                                { "value": "6", "text": "duel" },
                                { "value": "7", "text": "dyed" },
                                { "value": "7", "text": "died" },
                                { "value": "8", "text": "days" },
                                { "value": "8", "text": "daze" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "made" },
                                { "value": "1", "text": "maid" },
                                { "value": "2", "text": "mode" },
                                { "value": "2", "text": "mowed" },
                                { "value": "3", "text": "miner" },
                                { "value": "3", "text": "minor" },
                                { "value": "4", "text": "meat" },
                                { "value": "4", "text": "meet" },
                                { "value": "5", "text": "mind" },
                                { "value": "5", "text": "mined" },
                                { "value": "6", "text": "mussel" },
                                { "value": "6", "text": "muscle" },
                                { "value": "7", "text": "morning" },
                                { "value": "7", "text": "mourning" },
                                { "value": "8", "text": "mail" },
                                { "value": "8", "text": "male" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "see" },
                                { "value": "1", "text": "sea" },
                                { "value": "2", "text": "sowing" },
                                { "value": "2", "text": "sewing" },
                                { "value": "3", "text": "soul" },
                                { "value": "3", "text": "sole" },
                                { "value": "4", "text": "stair" },
                                { "value": "4", "text": "stare" },
                                { "value": "5", "text": "seam" },
                                { "value": "5", "text": "seem" },
                                { "value": "6", "text": "sun" },
                                { "value": "6", "text": "son" },
                                { "value": "7", "text": "stationary" },
                                { "value": "7", "text": "stationery" },
                                { "value": "8", "text": "sighs" },
                                { "value": "8", "text": "size" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "hall" },
                                { "value": "1", "text": "haul" },
                                { "value": "2", "text": "him" },
                                { "value": "2", "text": "hymn" },
                                { "value": "3", "text": "hare" },
                                { "value": "3", "text": "hair" },
                                { "value": "4", "text": "hi" },
                                { "value": "4", "text": "high" },
                                { "value": "5", "text": "higher" },
                                { "value": "5", "text": "hire" },
                                { "value": "6", "text": "hoarse" },
                                { "value": "6", "text": "horse" },
                                { "value": "7", "text": "heel" },
                                { "value": "7", "text": "heal" },
                                { "value": "8", "text": "hear" },
                                { "value": "8", "text": "here" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "feat" },
                                { "value": "1", "text": "feet" },
                                { "value": "2", "text": "flee" },
                                { "value": "2", "text": "flea" },
                                { "value": "3", "text": "fare" },
                                { "value": "3", "text": "fair" },
                                { "value": "4", "text": "for" },
                                { "value": "4", "text": "four" },
                                { "value": "5", "text": "fined" },
                                { "value": "5", "text": "find" },
                                { "value": "6", "text": "fir" },
                                { "value": "6", "text": "fur" },
                                { "value": "7", "text": "flower" },
                                { "value": "7", "text": "flour" },
                                { "value": "8", "text": "forth" },
                                { "value": "8", "text": "fourth" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "raise" },
                                { "value": "1", "text": "raze" },
                                { "value": "2", "text": "rain" },
                                { "value": "2", "text": "reign" },
                                { "value": "3", "text": "red" },
                                { "value": "3", "text": "read" },
                                { "value": "4", "text": "reeds" },
                                { "value": "4", "text": "reads" },
                                { "value": "5", "text": "rites" },
                                { "value": "5", "text": "rights" },
                                { "value": "6", "text": "rode" },
                                { "value": "6", "text": "road" },
                                { "value": "7", "text": "rose" },
                                { "value": "7", "text": "rows" },
                                { "value": "8", "text": "role" },
                                { "value": "8", "text": "roll" }
                            ]
                        },
                        {
                            "data": [
                                { "value": "1", "text": "bye" },
                                { "value": "1", "text": "by" },
                                { "value": "2", "text": "board" },
                                { "value": "2", "text": "bored" },
                                { "value": "3", "text": "beau" },
                                { "value": "3", "text": "bow" },
                                { "value": "4", "text": "byte" },
                                { "value": "4", "text": "bite" },
                                { "value": "5", "text": "bee" },
                                { "value": "5", "text": "be" },
                                { "value": "6", "text": "bough" },
                                { "value": "6", "text": "bow" },
                                { "value": "7", "text": "bread" },
                                { "value": "7", "text": "bred" },
                                { "value": "8", "text": "beat" },
                                { "value": "8", "text": "beet" }
                            ]
                        }
                    ]
                }
            ]
        },
        {
            "exercise": "Random",
            "id": "random",
            "info": "A mixture of homophone and rhyming matches",
            "b_s": "yes",
            "a_s": "yes",
            "british": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.random",
                    "quiz": []
                }
            ],
            "american": [
                {
                    "accent": "British and American English",
                    "instructions": "exercise_page.exerciseInstruction.memory_match.random",
                    "quiz": []
                }
            ]
        }
    ]
}



================================================
FILE: public/json/json_file_hashes.json
================================================
[{"file":"conversation_data.json","sha256":"f5b8a7a9e2bf4dbbdeaa1c2891c1022cdb1af3115d795b0abd3a7521c4829e43"},{"file":"conversation_list.json","sha256":"b3b2ee257a9ccb540455b8f58e35edc1d01a02709110a9053862053b979a57bf"},{"file":"examspeaking_data.json","sha256":"a0e748ad87544006535431a7ff6c6d7ad341f8835e5d6ce8b47ca0af9f6d91b8"},{"file":"examspeaking_list.json","sha256":"97d9dfd5a0e6553c6299a9031eb4a5474a009ba1d714f986f0fdff07fc48ba06"},{"file":"exercise_dictation.json","sha256":"735628b3d3ad2a8f2a01d71c50dc27bf3dd21917b35756c6793d354a0127b368"},{"file":"exercise_list.json","sha256":"da0e8b802bd9dde836c03296465245dff4ddbde881f32d8ecb90b5493f506b43"},{"file":"exercise_matchup.json","sha256":"d0e6af1e56078368709e5578b39757d9dc75160549abd075dcb4702b1cd639cf"},{"file":"exercise_memory_match.json","sha256":"90504c5bf90c62a4590b8f7d473784154d5ab0fcfa302a6c19d1ed712a8322c8"},{"file":"exercise_odd_one_out.json","sha256":"e4159a3cc95e70cc3a444c0811db86c6e8c8b00ba2e3e72be8ec5cfb7e75543f"},{"file":"exercise_reordering.json","sha256":"f33de698bf8056907b1454ea9601a12109b6f3e271b0719662051dc365178b17"},{"file":"exercise_snap.json","sha256":"b10ae5fcf21e982555e712c5a2643a87781689176431e1a1b7136eefdd7a22b4"},{"file":"exercise_sorting.json","sha256":"0ae2b8ccba66eeb97bbbcf40c0bafc9d2ed1cfb758fb0615ad570fad980a4563"},{"file":"exercise_sound_n_spelling.json","sha256":"0c7ad8f7abd7efc1a16b135df3cea3e930d4f5c7c40b26df62c28abbde73e37f"},{"file":"ex_data.json","sha256":"b098514cfc8bb3e3a2e1b3bde0a3af5aa9949d34beab87ee528875e64230e1c2"},{"file":"sounds_data.json","sha256":"978c0394c7f2e4696a4d3db01d4eb1ce8fcf0bcfb9ed462eb48a11276130443e"}]


================================================
FILE: public/json/sounds_menu.json
================================================
{
    "phonemes": {
        "consonants": [
            {
                "phoneme": "/ p /",
                "word": "<span class='font-bold'>p</span>en",
                "british": true,
                "american": true,
                "id": 1,
                "key": "pPen"
            },
            {
                "phoneme": "/ b /",
                "word": "<span class='font-bold'>b</span>ad",
                "british": true,
                "american": true,
                "id": 2,
                "key": "bBad"
            },
            {
                "phoneme": "/ t /",
                "word": "<span class='font-bold'>t</span>ea",
                "british": true,
                "american": true,
                "id": 3,
                "key": "tTea"
            },
            {
                "phoneme": "/ d /",
                "word": "<span class='font-bold'>d</span>id",
                "british": true,
                "american": true,
                "id": 4,
                "key": "dDid"
            },
            {
                "phoneme": "/ k /",
                "word": "<span class='font-bold'>c</span>at",
                "british": true,
                "american": true,
                "id": 5,
                "key": "kCat"
            },
            {
                "phoneme": "/ ɡ /",
                "word": "<span class='font-bold'>g</span>et",
                "british": true,
                "american": true,
                "id": 6,
                "key": "gGet"
            },
            {
                "phoneme": "/ m /",
                "word": "<span class='font-bold'>m</span>an",
                "british": true,
                "american": true,
                "id": 7,
                "key": "mMan"
            },
            {
                "phoneme": "/ n /",
                "word": "<span class='font-bold'>n</span>ow",
                "british": true,
                "american": true,
                "id": 8,
                "key": "nNow"
            },
            {
                "phoneme": "/ ŋ /",
                "word": "si<span class='font-bold'>ng</span>",
                "british": true,
                "american": true,
                "id": 9,
                "key": "ngSing"
            },
            {
                "phoneme": "/ f /",
                "word": "<span class='font-bold'>f</span>all",
                "british": true,
                "american": true,
                "id": 10,
                "key": "fFall"
            },
            {
                "phoneme": "/ v /",
                "word": "<span class='font-bold'>v</span>an",
                "british": true,
                "american": true,
                "id": 11,
                "key": "vVan"
            },
            {
                "phoneme": "/ θ /",
                "word": "<span class='font-bold'>th</span>in",
                "british": true,
                "american": true,
                "id": 12,
                "key": "thThin"
            },
            {
                "phoneme": "/ ð /",
                "word": "<span class='font-bold'>th</span>is",
                "british": true,
                "american": true,
                "id": 13,
                "key": "thThis"
            },
            {
                "phoneme": "/ s /",
                "word": "<span class='font-bold'>s</span>ee",
                "british": true,
                "american": true,
                "id": 14,
                "key": "sSee"
            },
            {
                "phoneme": "/ z /",
                "word": "<span class='font-bold'>z</span>oo",
                "british": true,
                "american": true,
                "id": 15,
                "key": "zZoo"
            },
            {
                "phoneme": "/ ʃ /",
                "word": "<span class='font-bold'>sh</span>oe",
                "british": true,
                "american": true,
                "id": 16,
                "key": "shShoe"
            },
            {
                "phoneme": "/ ʒ /",
                "word": "vi<span class='font-bold'>s</span>ion",
                "british": true,
                "american": true,
                "id": 17,
                "key": "sVision"
            },
            {
                "phoneme": "/ tʃ /",
                "word": "<span class='font-bold'>ch</span>ain",
                "british": true,
                "american": true,
                "id": 18,
                "key": "chChain"
            },
            {
                "phoneme": "/ dʒ /",
                "word": "<span class='font-bold'>j</span>am",
                "british": true,
                "american": true,
                "id": 19,
                "key": "jJam"
            },
            {
                "phoneme": "/ l /",
                "word": "<span class='font-bold'>l</span>eg",
                "british": true,
                "american": true,
                "id": 20,
                "key": "lLeg"
            },
            {
                "phoneme": "/ r /",
                "word": "<span class='font-bold'>r</span>ed",
                "british": true,
                "american": true,
                "id": 21,
                "key": "rRed"
            },
            {
                "phoneme": "/ h /",
                "word": "<span class='font-bold'>h</span>at",
                "british": true,
                "american": true,
                "id": 22,
                "key": "hHat"
            },
            {
                "phoneme": "/ w /",
                "word": "<span class='font-bold'>w</span>et",
                "british": true,
                "american": true,
                "id": 25,
                "key": "wWet"
            },
            {
                "phoneme": "/ j /",
                "word": "<span class='font-bold'>y</span>es",
                "british": true,
                "american": true,
                "id": 24,
                "key": "yYes"
            },
            {
                "phoneme": "/ x /",
                "word": "lo<span class='font-bold'>ch</span>",
                "british": true,
                "american": true,
                "id": 23,
                "key": "chLoch"
            },
            {
                "phoneme": "/ t̬ /",
                "word": "ci<span class='font-bold'>t</span>y",
                "british": false,
                "american": true,
                "id": 26,
                "key": "tCity"
            }
        ],
        "vowels": [
            {
                "phoneme": "/ iː /",
                "word": "s<span class='font-bold'>ee</span>",
                "british": true,
                "american": true,
                "id": 1,
                "key": "eeSee"
            },
            {
                "phoneme": "/ i /",
                "word": "happ<span class='font-bold'>y</span>",
                "british": true,
                "american": true,
                "id": 2,
                "key": "yHappy"
            },
            {
                "phoneme": "/ ɪ /",
                "word": "s<span class='font-bold'>i</span>t",
                "british": true,
                "american": true,
                "id": 3,
                "key": "iSit"
            },
            {
                "phoneme": "/ e /",
                "word": "b<span class='font-bold'>e</span>d",
                "british": true,
                "american": true,
                "id": 4,
                "key": "eBed"
            },
            {
                "phoneme": "/ æ /",
                "word": "c<span class='font-bold'>a</span>t",
                "british": true,
                "american": true,
                "id": 5,
                "key": "aCat"
            },
            {
                "phoneme": "/ ə /",
                "word": "<span class='font-bold'>a</span>bout",
                "british": true,
                "american": true,
                "id": 6,
                "key": "aAbout"
            },
            {
                "phoneme": "/ ɜː /",
                "word": "f<span class='font-bold'>ur</span>",
                "british": true,
                "american": false,
                "id": 7,
                "key": "urFur"
            },
            {
                "phoneme": "/ ɜːr /",
                "word": "f<span class='font-bold'>ur</span>",
                "british": false,
                "american": true,
                "id": 8,
                "key": "urFur_r"
            },
            {
                "phoneme": "/ ʌ /",
                "word": "c<span class='font-bold'>u</span>p",
                "british": true,
                "american": true,
                "id": 9,
                "key": "uCup"
            },
            {
                "phoneme": "/ uː /",
                "word": "t<span class='font-bold'>oo</span>",
                "british": true,
                "american": true,
                "id": 10,
                "key": "ooToo"
            },
            {
                "phoneme": "/ u /",
                "word": "act<span class='font-bold'>u</span>al",
                "british": true,
                "american": true,
                "id": 11,
                "key": "uActual"
            },
            {
                "phoneme": "/ ʊ /",
                "word": "p<span class='font-bold'>u</span>t",
                "british": true,
                "american": true,
                "id": 12,
                "key": "uPut"
            },
            {
                "phoneme": "/ ɔː /",
                "word": "s<span class='font-bold'>aw</span>",
                "british": true,
                "american": true,
                "id": 13,
                "key": "awSaw"
            },
            {
                "phoneme": "/ ɒ /",
                "word": "g<span class='font-bold'>o</span>t",
                "british": true,
                "american": false,
                "id": 14,
                "key": "oGot"
            },
            {
                "phoneme": "/ ɑː /",
                "word": "f<span class='font-bold'>a</span>ther",
                "british": true,
                "american": true,
                "id": 15,
                "key": "aFather"
            },
            {
                "phoneme": "/ ɑ̃ /",
                "word": "<span class='font-bold'>en</span> suite",
                "british": true,
                "american": true,
                "id": 16,
                "key": "aEnSuite"
            }
        ],
        "diphthongs": [
            {
                "phoneme": "/ eɪ /",
                "word": "s<span class='font-bold'>ay</span>",
                "british": true,
                "american": true,
                "id": 1,
                "key": "aySay"
            },
            {
                "phoneme": "/ əʊ /",
                "word": "g<span class='font-bold'>o</span>",
                "british": true,
                "american": false,
                "id": 2,
                "key": "oGo"
            },
            {
                "phoneme": "/ oʊ /",
                "word": "g<span class='font-bold'>o</span>",
                "british": false,
                "american": true,
                "id": 3,
                "key": "oGo_r"
            },
            {
                "phoneme": "/ aɪ /",
                "word": "m<span class='font-bold'>y</span>",
                "british": true,
                "american": true,
                "id": 4,
                "key": "yMy"
            },
            {
                "phoneme": "/ aʊ /",
                "word": "n<span class='font-bold'>ow</span>",
                "british": true,
                "american": true,
                "id": 5,
                "key": "owNow"
            },
            {
                "phoneme": "/ ɔɪ /",
                "word": "b<span class='font-bold'>oy</span>",
                "british": true,
                "american": true,
                "id": 6,
                "key": "oyBoy"
            },
            {
                "phoneme": "/ ɪə /",
                "word": "n<span class='font-bold'>ear</span>",
                "british": true,
                "american": false,
                "id": 7,
                "key": "earNear"
            },
            {
                "phoneme": "/ ɪr /",
                "word": "n<span class='font-bold'>ear</span>",
                "british": false,
                "american": true,
                "id": 8,
                "key": "earNear_r"
            },
            {
                "phoneme": "/ eə /",
                "word": "h<span class='font-bold'>air</span>",
                "british": true,
                "american": false,
                "id": 9,
                "key": "airHair"
            },
            {
                "phoneme": "/ er /",
                "word": "h<span class='font-bold'>air</span>",
                "british": false,
                "american": true,
                "id": 10,
                "key": "airHair_r"
            },
            {
                "phoneme": "/ ʊə /",
                "word": "p<span class='font-bold'>ure</span>",
                "british": true,
                "american": false,
                "id": 11,
                "key": "urePure"
            },
            {
                "phoneme": "/ ʊr /",
                "word": "p<span class='font-bold'>ure</span>",
                "british": false,
                "american": true,
                "id": 12,
                "key": "urePure_r"
            }
        ]
    }
}



================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_1.srt
================================================
1
00:00:06,370 --> 00:00:08,235
Our website looks a bit dated,

2
00:00:08,240 --> 00:00:09,319
don't you think?

3
00:00:09,320 --> 00:00:11,479
Absolutely, we should get a web designer

4
00:00:11,480 --> 00:00:13,229
to take a look at it. 

5
00:00:13,230 --> 00:00:14,465
I think you're right.

6
00:00:14,466 --> 00:00:17,287
Let's put it on the agenda for next week.

7
00:00:17,290 --> 00:00:19,785
I would certainly go along with that idea.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_10.srt
================================================
1
00:00:07,820 --> 00:00:09,859
Hi Sarah! Congratulations on the promotion. 

2
00:00:09,860 --> 00:00:11,559
I just heard this morning.

3
00:00:11,560 --> 00:00:12,875
Oh, thank you very much. 

4
00:00:12,876 --> 00:00:15,610
When will you start in the new position?

5
00:00:15,611 --> 00:00:18,354
At the end of May, when Tony retires.

6
00:00:18,360 --> 00:00:19,719
Wow, that'll be great!




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_11.srt
================================================
1
00:00:08,660 --> 00:00:10,374
Excuse me, can you tell me

2
00:00:10,375 --> 00:00:12,569
the way to Green Lane? 

3
00:00:12,570 --> 00:00:16,137
Yes, go straight on and it's the first -

4
00:00:16,140 --> 00:00:20,626
or rather, the second on the... left

5
00:00:20,630 --> 00:00:22,030
- no, sorry, right!

6
00:00:22,031 --> 00:00:24,832
I'm terrible at left and right.

7
00:00:24,840 --> 00:00:26,707
Yeah, I'm the same.

8
00:00:26,708 --> 00:00:29,519
So, it's second on the right. Thanks!




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_12.srt
================================================
1
00:00:06,840 --> 00:00:08,983
Excuse me, but can I just ask where

2
00:00:08,984 --> 00:00:10,659
we're going to publicize this?

3
00:00:10,660 --> 00:00:12,129
Well, that's something we probably 

4
00:00:12,130 --> 00:00:14,345
should look into further. Yes, Jo?

5
00:00:14,346 --> 00:00:18,178
I was wondering if we have a launch date yet.

6
00:00:18,180 --> 00:00:19,952
Can I come back to that point later, 

7
00:00:19,953 --> 00:00:21,706
when we deal with the project scheduling? 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_13.srt
================================================
1
00:00:06,750 --> 00:00:08,576
I think we should sell the app

2
00:00:08,577 --> 00:00:10,979
at a low cost to maximize sales.

3
00:00:10,980 --> 00:00:13,697
I can't go along with that idea.

4
00:00:13,698 --> 00:00:15,805
If it's cheap, people will think it's poor quality. 

5
00:00:15,810 --> 00:00:18,329
I take your point, but people won't

6
00:00:18,330 --> 00:00:20,836
pay much for an app these days.

7
00:00:20,840 --> 00:00:22,666
Well, actually, I'm not sure that's true. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_14.srt
================================================
1
00:00:06,220 --> 00:00:10,746
Oh, is that the time? I'd better be off.

2
00:00:10,750 --> 00:00:12,195
It's been lovely talking to you.

3
00:00:12,200 --> 00:00:13,893
Yes, it was nice to meet you.

4
00:00:13,900 --> 00:00:14,859
Maybe see you later?




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_15.srt
================================================
1
00:00:06,920 --> 00:00:09,749
Do you know what time it is in New York?

2
00:00:09,750 --> 00:00:12,305
I'm not at all sure to be honest. Why? 

3
00:00:12,306 --> 00:00:15,953
I'm supposed to phone the New York office.

4
00:00:15,960 --> 00:00:17,324
Well, I think it's probably

5
00:00:17,325 --> 00:00:18,143
very early morning.

6
00:00:18,150 --> 00:00:20,333
I doubt they'll be at work right now.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_16.srt
================================================
1
00:00:08,640 --> 00:00:11,303
Excuse me, is Alice out of the office today?

2
00:00:11,310 --> 00:00:13,399
Not to my knowledge, no.

3
00:00:13,400 --> 00:00:15,859
Do you know where she is?

4
00:00:15,860 --> 00:00:18,159
I have absolutely no idea, I'm afraid.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_17.srt
================================================
1
00:00:07,690 --> 00:00:11,659
Are you likely to be in Oxford on the 15th?

2
00:00:11,660 --> 00:00:14,494
I'm probably going to be in London on that day.

3
00:00:14,495 --> 00:00:18,067
Will you be back by six?

4
00:00:18,070 --> 00:00:19,659
That's unlikely - there's bound to be 

5
00:00:19,660 --> 00:00:21,577
a lot of traffic on the way home.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_18.srt
================================================
1
00:00:06,610 --> 00:00:08,601
There's a Chinese restaurant next door

2
00:00:08,602 --> 00:00:11,037
or an Italian just across the street.

3
00:00:11,040 --> 00:00:13,426
I think I'd prefer the Italian myself.

4
00:00:13,430 --> 00:00:15,816
What about you? I don't really mind either way.

5
00:00:15,817 --> 00:00:17,859
OK, the Italian then.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_19.srt
================================================
1
00:00:06,360 --> 00:00:08,991
I'm afraid I'm going to have to miss

2
00:00:08,992 --> 00:00:10,633
the conference. My father's not well.

3
00:00:10,634 --> 00:00:12,601
Oh, I'm sorry to hear that. 

4
00:00:12,610 --> 00:00:15,561
I'm sorry, I know it's a bit short notice.

5
00:00:15,570 --> 00:00:17,549
No, please don't worry. Family comes first. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_2.srt
================================================
1
00:00:08,520 --> 00:00:10,087
Oh, I do apologize.

2
00:00:10,088 --> 00:00:12,439
I should have been looking where

3
00:00:12,524 --> 00:00:14,461
I was going. Are you alright?

4
00:00:14,462 --> 00:00:16,181
Yes, don't worry about it.

5
00:00:16,182 --> 00:00:18,347
But you spilt some coffee. 

6
00:00:18,350 --> 00:00:20,617
Can I get you another one?

7
00:00:20,620 --> 00:00:22,127
No, it's fine, really.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_20.srt
================================================
1
00:00:05,700 --> 00:00:06,759
So unless they get somebody

2
00:00:06,760 --> 00:00:07,989
else in we're not gonna..

3
00:00:07,990 --> 00:00:09,237
we're not gonna do it. 

4
00:00:12,810 --> 00:00:15,589
Could I ask you not to talk out here, please? 

5
00:00:15,590 --> 00:00:17,535
We're doing some recording in this room.

6
00:00:17,540 --> 00:00:20,039
Oh, I see. OK, we'll move away then. Thanks. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_21.srt
================================================
1
00:00:07,740 --> 00:00:09,275
Hi, I'm here to see the

2
00:00:09,276 --> 00:00:10,299
head of Human Resources. 

3
00:00:10,300 --> 00:00:12,084
Could you wait here for a moment,

4
00:00:12,090 --> 00:00:13,879
I'll just see if she's free... OK. 

5
00:00:16,370 --> 00:00:17,479
Your visitor's here. 

6
00:00:19,680 --> 00:00:21,077
OK, she can see you now.

7
00:00:21,080 --> 00:00:22,489
Would you go right in, please? 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_22.srt
================================================
1
00:00:08,430 --> 00:00:11,735
Why don't you get a tablet?

2
00:00:11,740 --> 00:00:14,035
Well, first, I use the keyboard a lot, 

3
00:00:14,040 --> 00:00:18,017
and second, you can't do as much on it. 

4
00:00:18,020 --> 00:00:20,567
Hmm, maybe. But I like it just because 

5
00:00:20,568 --> 00:00:23,569
it's so much easier to carry around.

6
00:00:23,570 --> 00:00:25,585
Well, it depends on your priorities, I guess. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_23.srt
================================================
1
00:00:10,240 --> 00:00:12,414
I still haven't received my

2
00:00:12,415 --> 00:00:14,154
contract from Mr Spencer.

3
00:00:14,160 --> 00:00:15,509
Really? That's awful. 

4
00:00:15,510 --> 00:00:18,859
If I were you, I'd remind him about it.

5
00:00:18,860 --> 00:00:20,409
You shouldn't do the work

6
00:00:20,410 --> 00:00:21,649
without a signed contract.

7
00:00:21,650 --> 00:00:22,579
No, you're right.

8
00:00:22,580 --> 00:00:24,439
Yes, I think you should make

9
00:00:24,440 --> 00:00:26,176
an appointment to see him

10
00:00:26,177 --> 00:00:27,536
as soon as possible.

11
00:00:27,540 --> 00:00:29,228
OK, I will. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_24.srt
================================================
1
00:00:06,510 --> 00:00:08,345
If we don't finish everything today,

2
00:00:08,350 --> 00:00:10,295
we can work on it next week

3
00:00:10,296 --> 00:00:11,729
and supply it the week aft--

4
00:00:11,730 --> 00:00:12,608
Sorry to interrupt,

5
00:00:12,609 --> 00:00:15,409
but I don't think we can do that.

6
00:00:15,410 --> 00:00:16,641
They're expecting it on Monday. 

7
00:00:16,642 --> 00:00:17,551
Oh, of course...

8
00:00:17,552 --> 00:00:19,056
well, maybe we should get

9
00:00:19,057 --> 00:00:20,778
some extra help in that case?




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_25.srt
================================================
1
00:00:07,890 --> 00:00:10,066
Hi, I haven't seen you for ages!

2
00:00:10,070 --> 00:00:11,947
Oh, hi, Sarah. This is Simon - 

3
00:00:11,950 --> 00:00:14,126
I don't know if you've met?

4
00:00:14,130 --> 00:00:15,377
Simon - this is Sarah. 

5
00:00:15,378 --> 00:00:17,561
No, I don't think we've met. Hi Simon. 

6
00:00:17,562 --> 00:00:19,427
Hi Sarah, nice to meet you.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_26.srt
================================================
1
00:00:06,990 --> 00:00:09,041
We're organizing a trip to 

2
00:00:09,042 --> 00:00:10,409
Stratford on Wednesday afternoon.

3
00:00:10,410 --> 00:00:12,796
Would you be interested in coming along?

4
00:00:12,800 --> 00:00:15,562
I'd love to, but I don't think I can

5
00:00:15,563 --> 00:00:18,274
make it any sooner than two o'clock.

6
00:00:18,280 --> 00:00:20,325
Is that too late? I'm afraid so,

7
00:00:20,330 --> 00:00:22,375
we're all meeting at one. 

8
00:00:22,380 --> 00:00:23,408
OK. Thanks anyway.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_27.srt
================================================
1
00:00:06,570 --> 00:00:09,224
Hello, ABC print here. How can I help you? 

2
00:00:09,230 --> 00:00:11,589
Yes, can I speak to Adam Talbot, please? 

3
00:00:11,590 --> 00:00:14,539
I'm afraid he's not in. Can I take a message? 

4
00:00:14,540 --> 00:00:16,899
Yes, er, can you ask him to

5
00:00:16,900 --> 00:00:18,374
call me back, please? It's Jo Walker. 

6
00:00:18,375 --> 00:00:20,439
OK I'll let him know. Thank you.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_28.srt
================================================
1
00:00:07,770 --> 00:00:10,454
I'd like to speak to the manager, please.

2
00:00:10,455 --> 00:00:12,464
I'd like to make a complaint.

3
00:00:12,470 --> 00:00:14,485
I'm afraid she's busy all day.

4
00:00:14,490 --> 00:00:16,499
Would you like to make an appointment

5
00:00:16,500 --> 00:00:17,504
for tomorrow?

6
00:00:17,510 --> 00:00:19,525
I'm sorry this isn't good enough.

7
00:00:19,530 --> 00:00:21,879
I'm only in town today.

8
00:00:21,880 --> 00:00:24,224
I'm sorry, but there's really nothing I can do. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_29.srt
================================================
1
00:00:08,910 --> 00:00:10,549
Why don't you visit

2
00:00:10,550 --> 00:00:12,189
somewhere in central Europe?

3
00:00:12,190 --> 00:00:14,529
What would you recommend?

4
00:00:14,530 --> 00:00:16,538
Well, if you've never been there before,

5
00:00:16,540 --> 00:00:17,676
I'd recommend Prague.

6
00:00:17,677 --> 00:00:19,950
It's great for a long weekend

7
00:00:19,951 --> 00:00:22,225
at this time of year. Prague. 

8
00:00:22,230 --> 00:00:23,987
Yeah, OK, I'll look into that. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_3.srt
================================================
1
00:00:06,970 --> 00:00:09,219
Do I have to wear this visitor badge

2
00:00:09,220 --> 00:00:11,393
even here in the canteen? Yes, I think so. 

3
00:00:11,394 --> 00:00:13,996
And am I supposed to hand it in

4
00:00:13,997 --> 00:00:16,329
to the security desk if I pop out for a coffee?

5
00:00:16,330 --> 00:00:18,929
No, just hand it in at the end of the day.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_30.srt
================================================
1
00:00:06,370 --> 00:00:08,657
Can I have a glass of water, please? 

2
00:00:08,660 --> 00:00:10,861
Yes, of course. Sorry, it's not very cold.

3
00:00:10,862 --> 00:00:12,919
That's alright. Thank you.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_31.srt
================================================
1
00:00:06,200 --> 00:00:07,644
How about meeting up around eight

2
00:00:07,645 --> 00:00:09,819
in front of the restaurant? 

3
00:00:09,820 --> 00:00:12,283
Yes, OK. Or we could meet a bit earlier

4
00:00:12,284 --> 00:00:14,244
and have a drink first if you like.

5
00:00:14,245 --> 00:00:16,573
Sure. Shall we say 

6
00:00:16,574 --> 00:00:18,368
seven o'clock in the lobby?

7
00:00:18,370 --> 00:00:19,689
OK, see you there.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_32.srt
================================================
1
00:00:09,830 --> 00:00:14,824
Oh no! Would you like me to help you with that?

2
00:00:14,830 --> 00:00:16,117
That's alright, thank you. 

3
00:00:16,118 --> 00:00:18,432
I need to put everything in the

4
00:00:18,433 --> 00:00:20,369
right order. Thanks anyway. It's OK. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_33.srt
================================================
1
00:00:06,740 --> 00:00:08,635
Can I get you a tea

2
00:00:08,636 --> 00:00:09,738
or a coffee before we begin? 

3
00:00:09,740 --> 00:00:11,489
Yes, a coffee would be nice, thank you.

4
00:00:11,490 --> 00:00:14,065
How about something to eat?

5
00:00:14,070 --> 00:00:16,405
I might get a piece of cake myself.

6
00:00:16,410 --> 00:00:17,869
No, not for me, thanks.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_34.srt
================================================
1
00:00:09,370 --> 00:00:11,357
Excuse me, do you mind if I join you?

2
00:00:11,358 --> 00:00:13,514
All the tables seem to be full.

3
00:00:13,515 --> 00:00:15,709
No, of course not!

4
00:00:15,710 --> 00:00:19,519
Here, let me move my bag out of the way.

5
00:00:19,520 --> 00:00:21,535
Are you here for the exhibition?

6
00:00:21,540 --> 00:00:23,555
Yes, and you? Yes, me too. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_35.srt
================================================
1
00:00:07,240 --> 00:00:09,367
I'm going to an outdoor concert tonight.

2
00:00:11,770 --> 00:00:14,913
I wonder what will happen if it rains.

3
00:00:14,920 --> 00:00:18,177
I would imagine that they'll cancel.

4
00:00:18,180 --> 00:00:19,655
But then how will everybody know?

5
00:00:21,880 --> 00:00:24,049
I don't know. I would guess they'll

6
00:00:24,050 --> 00:00:25,739
post something on the website.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_36.srt
================================================
1
00:00:06,350 --> 00:00:09,157
... and then you just press Control S

2
00:00:09,158 --> 00:00:12,178
to save, and that's it. 

3
00:00:12,180 --> 00:00:14,524
OK, I think I've got it now.

4
00:00:14,530 --> 00:00:15,873
Thank you very much.

5
00:00:15,874 --> 00:00:17,553
I do appreciate your help.

6
00:00:17,560 --> 00:00:19,569
I'm hopeless with some of this software.

7
00:00:19,570 --> 00:00:21,249
That's alright. No problem.

8
00:00:21,250 --> 00:00:22,924
If you need anything else,

9
00:00:22,930 --> 00:00:24,277
just let me know.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_37.srt
================================================
1
00:00:06,210 --> 00:00:08,045
(...)

2
00:00:08,046 --> 00:00:09,890
Look out! Oh, sorry!

3
00:00:09,891 --> 00:00:11,425
I nearly spilled coffee everywhere!

4
00:00:11,430 --> 00:00:13,487
I know it's not a good place

5
00:00:13,488 --> 00:00:15,419
to stand and talk, is it?




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_38.srt
================================================
1
00:00:07,820 --> 00:00:09,679
I've got my appraisal this afternoon.

2
00:00:09,680 --> 00:00:12,159
I'm dreading it. Oh, I hope it goes well. 

3
00:00:12,160 --> 00:00:14,639
I'll keep my fingers crossed for you! Thanks. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_39.srt
================================================
1
00:00:06,870 --> 00:00:09,214
Well, I'm afraid time is running out,

2
00:00:09,220 --> 00:00:12,659
so unless anyone has anything else to add...

3
00:00:12,660 --> 00:00:14,654
Sorry, did you want to say something, Jo? 

4
00:00:14,660 --> 00:00:16,444
Yes, can I just remind everybody 

5
00:00:16,445 --> 00:00:18,667
to send me your receipts for expenses?

6
00:00:18,670 --> 00:00:19,813
Thanks. OK, thanks Jo.

7
00:00:19,814 --> 00:00:21,529
We'll have to leave it there.

8
00:00:21,530 --> 00:00:22,959
Have a good trip home.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_4.srt
================================================
1
00:00:08,180 --> 00:00:10,331
It says on the box, 'Take two tablets,

2
00:00:10,332 --> 00:00:12,667
in the morning and in the evening.'

3
00:00:12,670 --> 00:00:14,457
I'm not very clear about that.

4
00:00:14,460 --> 00:00:18,099
Do they mean to say for a day?

5
00:00:18,100 --> 00:00:19,769
Sorry, I don't quite follow...

6
00:00:19,770 --> 00:00:22,946
I mean is it two in the morning and 

7
00:00:22,947 --> 00:00:27,667
two in the evening? Ah, I see.

8
00:00:27,670 --> 00:00:29,295
I'm not sure, to be honest.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_5.srt
================================================
1
00:00:08,830 --> 00:00:11,005
Oh, excuse me. I wonder if you could 

2
00:00:11,006 --> 00:00:12,776
possibly take a photo of us, please?

3
00:00:12,780 --> 00:00:15,137
Yes, of course. Where would you like to be? 

4
00:00:15,140 --> 00:00:17,171
Just here I think. Do you know 

5
00:00:17,172 --> 00:00:19,347
how to use this? Yes, I think so.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_6.srt
================================================
1
00:00:07,540 --> 00:00:09,296
I wonder whether you can help me.

2
00:00:09,300 --> 00:00:11,490
I'm trying to find out if there's

3
00:00:11,491 --> 00:00:13,377
an artists' materials shop near here...

4
00:00:13,380 --> 00:00:17,417
Artists' materials? I really don't know.

5
00:00:17,420 --> 00:00:19,617
There's a stationers's in the High Street -

6
00:00:19,620 --> 00:00:20,875
they might sell something there, I suppose.

7
00:00:20,876 --> 00:00:21,817
OK, thanks. 




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_7.srt
================================================
1
00:00:07,190 --> 00:00:08,904
I've got to go soon.

2
00:00:08,905 --> 00:00:10,276
I'm picking up Claire from the airport.

3
00:00:10,277 --> 00:00:12,158
Oh, I hate airports.

4
00:00:12,160 --> 00:00:13,305
Traveling by train's

5
00:00:13,306 --> 00:00:15,009
much nicer, isn't it? 

6
00:00:15,010 --> 00:00:16,889
Depends on the train.

7
00:00:16,890 --> 00:00:20,634
Sometimes they're dirty and crowded, aren't they?

8
00:00:20,640 --> 00:00:22,928
I guess so.

9
00:00:22,930 --> 00:00:24,544
What do you think about

10
00:00:24,545 --> 00:00:26,159
those new high-speed trains? 

11
00:00:26,160 --> 00:00:27,071
They're great,

12
00:00:27,072 --> 00:00:28,895
but they're terribly expensive.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_8.srt
================================================
1
00:00:07,620 --> 00:00:09,579
Would it be alright if I used

2
00:00:09,580 --> 00:00:11,399
your computer to check my email?

3
00:00:11,400 --> 00:00:13,145
Yes, of course, go right ahead. 

4
00:00:13,150 --> 00:00:15,605
Thanks. And would it be OK to print

5
00:00:15,606 --> 00:00:17,506
something out? Just a page or two...

6
00:00:17,510 --> 00:00:18,382
Yes, that's fine.

7
00:00:18,383 --> 00:00:20,419
It's only black and white, I'm afraid.




================================================
FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_9.srt
================================================
1
00:00:06,500 --> 00:00:08,467
I don't think these clients will pay

2
00:00:08,468 --> 00:00:10,967
if the price is too high.

3
00:00:10,968 --> 00:00:12,557
Yes, but these clients are sponsored. 

4
00:00:12,560 --> 00:00:14,159
They aren't paying for themselves.

5
00:00:14,160 --> 00:00:16,529
I see what you mean.

6
00:00:16,530 --> 00:00:18,287
I hadn't really appreciated that before.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_1.srt
================================================
1
00:00:07,170 --> 00:00:09,647
Our website looks a little dated,

2
00:00:09,650 --> 00:00:11,929
don't you think?

3
00:00:11,930 --> 00:00:14,161
Absolutely, we should get a web designer

4
00:00:14,162 --> 00:00:16,548
to take a look at it. 

5
00:00:16,550 --> 00:00:17,953
I think you're right.

6
00:00:17,954 --> 00:00:21,158
Let's put it on the agenda for next week.

7
00:00:21,160 --> 00:00:23,289
Definitely, that is a good idea.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_10.srt
================================================
1
00:00:07,230 --> 00:00:09,185
Hi Carol! Congratulations on the promotion. 

2
00:00:09,190 --> 00:00:10,829
I just heard this morning. 

3
00:00:10,830 --> 00:00:12,041
Oh, thank you very much. 

4
00:00:12,042 --> 00:00:14,624
When will you start in the new position?

5
00:00:14,625 --> 00:00:17,039
At the end of May, after Tony retires. 

6
00:00:17,040 --> 00:00:19,214
Great. You will make a 

7
00:00:19,215 --> 00:00:20,589
fantastic manager. Thanks.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_11.srt
================================================
1
00:00:07,430 --> 00:00:08,754
Excuse me, can you tell me

2
00:00:08,755 --> 00:00:10,618
how to get to Eighth Street? 

3
00:00:10,620 --> 00:00:12,224
Oh sure. You go straight 

4
00:00:12,225 --> 00:00:14,189
and it's the first on the -

5
00:00:14,190 --> 00:00:16,265
actually, the second on the left. -

6
00:00:16,270 --> 00:00:17,657
no, sorry, the right!

7
00:00:17,658 --> 00:00:19,739
I'm bad at left and right!

8
00:00:19,740 --> 00:00:22,091
Yeah, I'm the same. So it's the 

9
00:00:22,092 --> 00:00:24,599
the second street on the right? Thanks!




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_12.srt
================================================
1
00:00:06,870 --> 00:00:09,069
Excuse me, but can I just ask where

2
00:00:09,070 --> 00:00:10,737
we're going to publicize this?

3
00:00:10,740 --> 00:00:12,521
Well, that's something we probably need

4
00:00:12,522 --> 00:00:14,617
to look into before our next meeting.

5
00:00:14,620 --> 00:00:16,359
Yes, Jo? I was wondering if

6
00:00:16,360 --> 00:00:18,188
we have a launch date yet?

7
00:00:18,190 --> 00:00:19,529
I'll actually come back to

8
00:00:19,530 --> 00:00:21,271
that point later, when we deal 

9
00:00:21,272 --> 00:00:22,659
with the project scheduling.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_13.srt
================================================
1
00:00:06,530 --> 00:00:08,195
I think we should sell the app

2
00:00:08,196 --> 00:00:10,377
at a low cost to maximize sales.

3
00:00:10,380 --> 00:00:12,437
I don't know. If it's cheap, people

4
00:00:12,438 --> 00:00:14,169
will think it's poor quality.

5
00:00:14,170 --> 00:00:16,374
I see your point, but people won't

6
00:00:16,375 --> 00:00:18,599
pay much for an app these days.

7
00:00:18,600 --> 00:00:20,804
Well, actually, I'm not sure that's true. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_14.srt
================================================
1
00:00:06,710 --> 00:00:08,164
Oh wow, look at the time.

2
00:00:08,165 --> 00:00:10,000
I've got to run. It has

3
00:00:10,001 --> 00:00:11,854
been so good talking to you.

4
00:00:11,860 --> 00:00:13,677
Yes, it was nice to meet you.

5
00:00:13,680 --> 00:00:16,099
See you later, I hope. Bye! Bye!




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_15.srt
================================================
1
00:00:08,440 --> 00:00:11,193
Do you know what time it is in Australia?

2
00:00:11,200 --> 00:00:13,432
Uh, I'm not sure. Why? I'm supposed

3
00:00:13,433 --> 00:00:15,459
to call the Sydney office.

4
00:00:15,460 --> 00:00:17,944
Well, I think it's probably night time.

5
00:00:17,950 --> 00:00:20,427
I doubt they'll be at work now.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_16.srt
================================================
1
00:00:11,550 --> 00:00:13,529
Excuse me, is Alice out of

2
00:00:13,530 --> 00:00:15,113
the office today? 

3
00:00:15,120 --> 00:00:17,949
Not to my knowledge, no.

4
00:00:17,950 --> 00:00:20,355
Do you know where she is?

5
00:00:20,360 --> 00:00:22,969
I have absolutely no idea, sorry.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_17.srt
================================================
1
00:00:07,010 --> 00:00:09,879
Do you think you'll be in DC on the 15th?

2
00:00:09,880 --> 00:00:11,314
Ah, I'm probably going to

3
00:00:11,315 --> 00:00:12,749
be in Baltimore that day,

4
00:00:12,750 --> 00:00:14,758
Will you be back by six?

5
00:00:14,760 --> 00:00:16,189
I wouldn't bet on it -

6
00:00:16,190 --> 00:00:17,881
There'll probably be a lot of

7
00:00:17,882 --> 00:00:19,638
traffic on the way home. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_18.srt
================================================
1
00:00:06,260 --> 00:00:08,875
There's a Chinese restaurant next door,

2
00:00:08,876 --> 00:00:11,539
or Italian across the street. 

3
00:00:11,540 --> 00:00:13,675
I think I'd prefer Italian myself.

4
00:00:13,680 --> 00:00:17,239
What about you? I don't really care either way. 

5
00:00:17,240 --> 00:00:18,467
Let's do Italian then.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_19.srt
================================================
1
00:00:08,320 --> 00:00:09,694
I'm afraid I'm going to have to

2
00:00:09,695 --> 00:00:10,794
miss the conference. 

3
00:00:10,800 --> 00:00:13,637
My father's not doing well.

4
00:00:13,640 --> 00:00:15,855
Oh, I'm sorry to hear that.

5
00:00:15,856 --> 00:00:17,728
I hate to cancel at the last minute. 

6
00:00:17,730 --> 00:00:19,289
No, I completely understand.

7
00:00:19,290 --> 00:00:20,829
Family comes first. Thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_2.srt
================================================
1
00:00:05,940 --> 00:00:07,820
(I meant to ask you, are you going to 

2
00:00:07,821 --> 00:00:09,811
the staff picnic on the weekend or are

3
00:00:09,812 --> 00:00:11,968
you going to give it a miss, do  you reckon?)

4
00:00:11,970 --> 00:00:14,033
Oh, I'm so sorry.

5
00:00:14,034 --> 00:00:15,918
I should have been looking where I was going. 

6
00:00:15,920 --> 00:00:17,287
Are you alright? Yeah,

7
00:00:17,288 --> 00:00:18,655
don't worry about it.

8
00:00:18,660 --> 00:00:20,069
But you spilled some coffee. 

9
00:00:20,070 --> 00:00:21,755
Can I get you another one?

10
00:00:21,760 --> 00:00:22,887
No, it's fine, really.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_20.srt
================================================
1
00:00:05,730 --> 00:00:08,033
(... I told him to meet me at 7:00, 

2
00:00:08,040 --> 00:00:09,587
but of course he was late.

3
00:00:09,590 --> 00:00:11,637
Like an hour late, he's always late!)

4
00:00:11,640 --> 00:00:12,929
What is his problem?) Excuse me,

5
00:00:12,930 --> 00:00:15,499
Could I ask you not to talk out here, please? 

6
00:00:15,500 --> 00:00:17,298
We're doing some recording in this room.

7
00:00:17,300 --> 00:00:19,349
Oh sorry. We'll go down the hall then,. Thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_21.srt
================================================
1
00:00:07,120 --> 00:00:08,985
Hi, I'm here to see the

2
00:00:08,986 --> 00:00:10,229
head of Human Resources. 

3
00:00:10,230 --> 00:00:12,406
Could you wait here for a minute, 

4
00:00:12,410 --> 00:00:14,269
I'll just see if she's free...

5
00:00:14,270 --> 00:00:17,145
Sure, thanks.

6
00:00:17,146 --> 00:00:19,449
OK, she can see you now. Go right on in. 

7
00:00:19,450 --> 00:00:20,217
Great, thank you. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_22.srt
================================================
1
00:00:07,080 --> 00:00:10,459
Why don't you get a tablet?

2
00:00:10,460 --> 00:00:12,379
Well, first, I use the keyboard a lot,

3
00:00:12,380 --> 00:00:15,547
and second, you can't do as much with it. 

4
00:00:15,550 --> 00:00:18,048
Hmm, maybe. But I like it just because

5
00:00:18,049 --> 00:00:20,986
it's so much easier to carry around.

6
00:00:20,990 --> 00:00:22,879
Well, depends on your priorities, I guess. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_23.srt
================================================
1
00:00:06,190 --> 00:00:08,175
Yeah, Monday's fine. OK, bye. 

2
00:00:11,820 --> 00:00:13,689
I still haven't gotten my

3
00:00:13,690 --> 00:00:15,185
contract from Mr Spencer.

4
00:00:15,190 --> 00:00:16,395
Really? That's terrible. 

5
00:00:16,396 --> 00:00:18,405
If I were you, 

6
00:00:18,410 --> 00:00:19,669
I'd remind him about it.

7
00:00:19,670 --> 00:00:20,673
You shouldn't work without

8
00:00:20,674 --> 00:00:21,426
a signed contract.

9
00:00:21,430 --> 00:00:22,257
No, you're right.

10
00:00:22,258 --> 00:00:23,913
I think you should make an

11
00:00:23,914 --> 00:00:25,120
appointment to see him about it

12
00:00:25,121 --> 00:00:26,215
as soon as possible.

13
00:00:26,220 --> 00:00:26,978
OK, I will.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_24.srt
================================================
1
00:00:06,770 --> 00:00:08,317
If we don't finish everything today,

2
00:00:08,320 --> 00:00:10,006
we can work on it next week

3
00:00:10,007 --> 00:00:11,669
and supply it the week aft-

4
00:00:11,670 --> 00:00:12,422
Sorry to interrupt,

5
00:00:12,423 --> 00:00:14,509
but I don't think we can do that.

6
00:00:14,510 --> 00:00:16,735
They're expecting it Monday. 

7
00:00:16,740 --> 00:00:18,089
Oh, of course.... well, maybe we should get 

8
00:00:18,090 --> 00:00:19,779
some extra help in that case.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_25.srt
================================================
1
00:00:05,820 --> 00:00:07,947
(...)

2
00:00:07,948 --> 00:00:09,339
(...) Gwen, hi!

3
00:00:09,340 --> 00:00:10,909
It's been so long!

4
00:00:10,910 --> 00:00:12,469
Hi Carol,! This is Simon -

5
00:00:12,470 --> 00:00:14,965
I don't think you two have met?

6
00:00:14,970 --> 00:00:15,908
Simon, this is Carol. 

7
00:00:15,909 --> 00:00:17,786
No, I don't think we've met.

8
00:00:17,790 --> 00:00:18,415
Hi Simon.

9
00:00:18,416 --> 00:00:20,293
Hi Carol, nice to meet you.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_26.srt
================================================
1
00:00:05,920 --> 00:00:07,394
We're organizing a trip to

2
00:00:07,395 --> 00:00:08,574
Boston on Friday afternoon.

3
00:00:08,580 --> 00:00:10,637
Do you want to come with us?

4
00:00:10,640 --> 00:00:12,759
I'd love to, but I don't think

5
00:00:12,760 --> 00:00:14,767
I could make it before two o'clock. 

6
00:00:14,770 --> 00:00:15,953
Is that too late?

7
00:00:15,954 --> 00:00:17,131
Oh, that's too bad -

8
00:00:17,132 --> 00:00:18,307
we're meeting at twelve.

9
00:00:18,310 --> 00:00:19,489
Oh, OK. Thanks anyway.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_27.srt
================================================
1
00:00:16,200 --> 00:00:18,935
Hello, ABC Print here. How can I help you?

2
00:00:18,940 --> 00:00:21,363
Hi, can I please speak to Adam Talbot?

3
00:00:21,370 --> 00:00:24,096
I'm afraid he's not in. Can I take a message?

4
00:00:24,100 --> 00:00:27,139
Yes, can you ask him to call me back, please? 

5
00:00:27,140 --> 00:00:29,267
It's Jo Walker. He has my number. 

6
00:00:29,270 --> 00:00:31,099
OK, I'll let him know. Thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_28.srt
================================================
1
00:00:08,400 --> 00:00:10,134
I'd like to talk to the manager, please.

2
00:00:10,135 --> 00:00:11,904
I'd like to make a complaint.

3
00:00:11,910 --> 00:00:13,835
I'm afraid she's out all day.

4
00:00:13,840 --> 00:00:15,765
Would you like to make an appointment

5
00:00:15,766 --> 00:00:16,728
for tomorrow? 

6
00:00:16,730 --> 00:00:18,649
I'm sorry, that's not good enough. 

7
00:00:18,650 --> 00:00:20,896
I'm leaving town this afternoon.

8
00:00:20,900 --> 00:00:23,027
I'm so sorry, can I take your number

9
00:00:23,028 --> 00:00:25,389
and ask her to call you in the morning? 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_29.srt
================================================
1
00:00:06,780 --> 00:00:08,088
Why don't you go see one of

2
00:00:08,089 --> 00:00:09,389
the cities on the west coast? 

3
00:00:09,390 --> 00:00:11,257
What would you recommend?

4
00:00:11,260 --> 00:00:12,806
Well, if you haven't been there before,

5
00:00:12,810 --> 00:00:14,649
my favorite is San Francisco.

6
00:00:14,650 --> 00:00:16,347
It's great for long weekend

7
00:00:16,348 --> 00:00:18,369
this time of year. San Francisco.

8
00:00:18,370 --> 00:00:21,879
OK, yeah, I'll look into that.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_3.srt
================================================
1
00:00:06,660 --> 00:00:08,556
Do I have to wear this visitor

2
00:00:08,557 --> 00:00:10,437
badge even here in the cafeteria?

3
00:00:10,440 --> 00:00:13,787
Yes, I think so. And am I supposed

4
00:00:13,788 --> 00:00:15,699
to turn it in at the security desk

5
00:00:15,700 --> 00:00:17,471
if I run out for a sandwich?

6
00:00:17,472 --> 00:00:19,928
No, just turn it in at the end of the day. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_30.srt
================================================
1
00:00:06,560 --> 00:00:08,375
Could I have a glass of water, please? 

2
00:00:08,380 --> 00:00:12,031
Yes, of course. Um, sorry, it's not 

3
00:00:12,032 --> 00:00:14,189
very cold. That's alright, thank you. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_31.srt
================================================
1
00:00:06,000 --> 00:00:07,149
How about meeting up around eight

2
00:00:07,150 --> 00:00:08,879
in front of the restaurant? 

3
00:00:08,880 --> 00:00:10,463
Yeah, OK. Or we could meet 

4
00:00:10,464 --> 00:00:12,080
a little earlier and have

5
00:00:12,081 --> 00:00:13,592
a drink first if you want. 

6
00:00:13,600 --> 00:00:15,687
Sure, should we say seven o'clock in the lobby? 

7
00:00:15,690 --> 00:00:16,737
Yeah, see you there.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_32.srt
================================================
1
00:00:09,910 --> 00:00:14,493
Oh no! Can I help you with that? 

2
00:00:14,500 --> 00:00:16,707
No, that's alright. I've got to put 

3
00:00:16,708 --> 00:00:18,649
everything in the right order.

4
00:00:18,650 --> 00:00:21,288
It's very kind of you to offer, though.

5
00:00:21,290 --> 00:00:22,417
You're welcome. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_33.srt
================================================
1
00:00:06,390 --> 00:00:08,677
Can I get you a coffee or

2
00:00:08,678 --> 00:00:10,786
something else to drink before we start? 

3
00:00:10,790 --> 00:00:12,667
Yes, a coffee would be nice, thank you.

4
00:00:12,670 --> 00:00:14,867
How about something to eat? 

5
00:00:14,870 --> 00:00:16,747
I might get myself a muffin.

6
00:00:16,750 --> 00:00:18,639
Er no, not for me, thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_34.srt
================================================
1
00:00:07,840 --> 00:00:09,959
Excuse me, do you mind if I join you?

2
00:00:09,960 --> 00:00:11,889
All the tables are full.

3
00:00:11,890 --> 00:00:13,329
No, of course not! Here, 

4
00:00:13,330 --> 00:00:17,307
let me move my bag out of the way. 

5
00:00:17,310 --> 00:00:19,229
Are you here for the exhibition?

6
00:00:19,230 --> 00:00:21,469
Yes, are you? Yes me too. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_35.srt
================================================
1
00:00:12,520 --> 00:00:15,508
I'm going to an outdoor concert tonight.

2
00:00:15,510 --> 00:00:18,813
I wonder what'll happen if it rains.

3
00:00:18,820 --> 00:00:21,619
Hmm, they'll probably cancel it. 

4
00:00:21,620 --> 00:00:25,495
But then how will everybody know?

5
00:00:25,500 --> 00:00:28,070
I don't know. I guess they'll

6
00:00:28,071 --> 00:00:29,605
put something on the website.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_36.srt
================================================
1
00:00:06,910 --> 00:00:11,823
...and then you just press Control S to save. 

2
00:00:11,830 --> 00:00:14,489
OK, I think I've got it now.

3
00:00:14,490 --> 00:00:15,665
Thank you very much.

4
00:00:15,666 --> 00:00:17,135
I really appreciate your help -

5
00:00:17,140 --> 00:00:19,036
I get so lost with some of

6
00:00:19,037 --> 00:00:20,369
this software. That's alright. 

7
00:00:20,370 --> 00:00:22,127
It's no problem. If you need 

8
00:00:22,128 --> 00:00:23,899
anything else, just let me know.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_37.srt
================================================
1
00:00:06,760 --> 00:00:10,189
See you later watch out. Oh sorry,

2
00:00:10,190 --> 00:00:14,579
I almost spilled coffee everywhere!

3
00:00:14,580 --> 00:00:15,839
That is not a good place

4
00:00:15,840 --> 00:00:17,099
to stand and chat, is it?




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_38.srt
================================================
1
00:00:06,630 --> 00:00:08,137
I have my performance

2
00:00:08,138 --> 00:00:09,268
evaluation this afternoon.

3
00:00:09,270 --> 00:00:10,773
I am dreading it.

4
00:00:10,774 --> 00:00:12,653
Oh, I'm sure it'll be fine!

5
00:00:12,660 --> 00:00:14,167
I'll keep my fingers

6
00:00:14,168 --> 00:00:15,679
crossed for you. Thanks!




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_39.srt
================================================
1
00:00:06,570 --> 00:00:09,173
Well, I'm afraid time is running out,

2
00:00:09,180 --> 00:00:13,299
so unless anyone else has anything to add...

3
00:00:13,300 --> 00:00:16,071
Sorry Jo, did you want to say something?

4
00:00:16,080 --> 00:00:18,004
Yes, can I just remind everybody to please 

5
00:00:18,005 --> 00:00:20,099
send me your receipts for expenses? Thanks.

6
00:00:20,100 --> 00:00:21,843
OK, thanks Jo. 

7
00:00:21,844 --> 00:00:23,869
Well, we'll have to leave it there.

8
00:00:23,870 --> 00:00:25,189
Have a good trip home!




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_4.srt
================================================
1
00:00:07,180 --> 00:00:10,315
It says on the box, 'Take two tablets,

2
00:00:10,316 --> 00:00:13,716
in the morning and in the evening.'

3
00:00:13,720 --> 00:00:15,531
Well, that's confusing.

4
00:00:15,532 --> 00:00:20,109
Do they mean four a day?

5
00:00:20,110 --> 00:00:22,484
I'm sorry, what exactly are you asking? 

6
00:00:22,490 --> 00:00:25,432
I mean, is it two in the morning and 

7
00:00:25,433 --> 00:00:28,557
then two in the evening? Oh, I see, um...

8
00:00:33,680 --> 00:00:37,187
I don't know exactly.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_5.srt
================================================
1
00:00:05,870 --> 00:00:07,029
(...)

2
00:00:07,030 --> 00:00:09,579
(...) Oh, excuse me. 

3
00:00:09,580 --> 00:00:10,749
I wonder if you'd mind

4
00:00:10,750 --> 00:00:11,919
taking a picture of us, please?

5
00:00:11,920 --> 00:00:13,603
Sure, of course, 

6
00:00:13,604 --> 00:00:16,129
Where do you want to be? 

7
00:00:16,130 --> 00:00:18,054
Over here, I think.

8
00:00:18,055 --> 00:00:19,979
Do you know how to use this?

9
00:00:19,980 --> 00:00:21,447
Yeah, I think so.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_6.srt
================================================
1
00:00:10,010 --> 00:00:11,989
Excuse me, I wonder if you can help me.

2
00:00:11,990 --> 00:00:13,795
I'm trying to find out if there's

3
00:00:13,796 --> 00:00:15,508
an art supplies store near here. 

4
00:00:15,510 --> 00:00:18,689
Art supplies? I don't know. 

5
00:00:18,690 --> 00:00:19,773
There's an office supplies store

6
00:00:19,774 --> 00:00:20,857
down Main Street -

7
00:00:20,860 --> 00:00:22,756
You might find what you're looking for.

8
00:00:22,760 --> 00:00:24,109
there, maybe. OK, thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_7.srt
================================================
1
00:00:11,670 --> 00:00:12,719
Gotta go soon.

2
00:00:12,720 --> 00:00:14,819
Picking Sarah up from the airport.

3
00:00:14,820 --> 00:00:15,869
I hate airports.

4
00:00:15,870 --> 00:00:17,969
Trains are so much nicer, right?

5
00:00:17,970 --> 00:00:19,719
That depends on the train. Sometimes

6
00:00:19,720 --> 00:00:21,469
they're dirty and crowded, don't you think?

7
00:00:21,470 --> 00:00:24,487
I guess so.

8
00:00:24,490 --> 00:00:26,247
And what do you think about

9
00:00:26,248 --> 00:00:27,909
those new high-speed trains? 

10
00:00:27,910 --> 00:00:28,531
Ah, they're great, 

11
00:00:28,532 --> 00:00:29,775
but they're so expensive.




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_8.srt
================================================
1
00:00:08,290 --> 00:00:09,878
Would it be alright if I used

2
00:00:09,879 --> 00:00:11,219
your computer to check email?

3
00:00:11,220 --> 00:00:12,677
Yes, of course, go right ahead. 

4
00:00:12,680 --> 00:00:14,743
Thanks. And would it be OK to print 

5
00:00:14,744 --> 00:00:16,336
something out? Just a page or two...

6
00:00:16,340 --> 00:00:17,071
Yes, that's fine.

7
00:00:17,072 --> 00:00:18,535
It's only black and white though -

8
00:00:18,540 --> 00:00:20,599
I hope that's alright. Thanks. 




================================================
FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_9.srt
================================================
1
00:00:06,210 --> 00:00:08,549
I don't think these clients will commit

2
00:00:08,550 --> 00:00:11,088
if the price is too high. 

3
00:00:11,090 --> 00:00:12,959
Actually, these clients are sponsored.

4
00:00:12,960 --> 00:00:15,959
They aren't paying for themselves.

5
00:00:15,960 --> 00:00:18,467
Oh, I see what you mean. 

6
00:00:18,470 --> 00:00:19,477
I didn't know that.




================================================
FILE: public/media/exam/subtitles/1_talking_about_a_topic.srt
================================================
1
00:00:06,590 --> 00:00:09,368
[EXAMINER] Please describe a place
that you sometimes visit

2
00:00:09,369 --> 00:00:12,167
which is very important to you.

3
00:00:12,170 --> 00:00:14,871
You should say where it is, and

4
00:00:14,872 --> 00:00:17,617
how often and why you go there.

5
00:00:17,620 --> 00:00:20,489
[WOMAN]
One place that is very important in

6
00:00:20,490 --> 00:00:23,867
my life is my grandmother's house.

7
00:00:23,870 --> 00:00:25,927
She lives in a small village

8
00:00:25,928 --> 00:00:28,318
in the south of the country.

9
00:00:28,320 --> 00:00:31,973
I go there every summer, and any time

10
00:00:31,974 --> 00:00:36,378
when I can get away for a few days.

11
00:00:36,380 --> 00:00:39,879
I go there to visit my grandmother,

12
00:00:39,880 --> 00:00:44,023
get away from the city and relax.

13
00:00:44,030 --> 00:00:47,263
The village is just a group of

14
00:00:47,264 --> 00:00:49,859
white houses on a hillside

15
00:00:49,860 --> 00:00:53,009
and a couple of shops,

16
00:00:53,010 --> 00:00:55,849
and it's really peaceful.

17
00:00:55,850 --> 00:00:57,574
There's no traffic,

18
00:00:57,575 --> 00:01:01,024
just the sound of goat bells,

19
00:01:01,030 --> 00:01:04,578
birds and insects.

20
00:01:04,580 --> 00:01:07,841
One reason why I like it is

21
00:01:07,842 --> 00:01:11,129
because it's a beautiful place.

22
00:01:11,130 --> 00:01:15,149
My grandmother has a lovely garden

23
00:01:15,150 --> 00:01:17,873
and we always sit outside in

24
00:01:17,874 --> 00:01:21,009
the shade of her olive trees,

25
00:01:21,010 --> 00:01:24,219
drinking sweet tea and chatting.

26
00:01:24,220 --> 00:01:26,847
It's so peaceful.

27
00:01:26,850 --> 00:01:28,960
But the main reason why this place is so

28
00:01:28,960 --> 00:01:33,716
important to me is
because of my grandmother.

29
00:01:33,720 --> 00:01:37,429
She is so kind to me, and

30
00:01:37,430 --> 00:01:39,529
wonderful to talk to.

31
00:01:39,530 --> 00:01:42,369
Whenever I have a problem in my life,

32
00:01:42,370 --> 00:01:46,137
she has good advice for me.

33
00:01:46,140 --> 00:01:48,767
Also, she is a great cook

34
00:01:48,770 --> 00:01:52,159
and the meals she prepares are

35
00:01:52,160 --> 00:01:56,609
simple but so fresh and...

36
00:01:56,610 --> 00:01:59,697
rich? Er no, er...

37
00:01:59,698 --> 00:02:02,839
no, I mean tasty.

38
00:02:02,840 --> 00:02:05,579
And she's always giving me eh...

39
00:02:05,580 --> 00:02:08,319
What do you call them?

40
00:02:08,320 --> 00:02:12,596
- small plates of food during the day,

41
00:02:12,600 --> 00:02:15,360
so I always return home feeling

42
00:02:15,360 --> 00:02:19,319
calm and refreshed - and fat!

43
00:02:21,640 --> 00:02:24,229
I wouldn't like to live there, though.

44
00:02:24,230 --> 00:02:26,449
I prefer living in the city,

45
00:02:26,450 --> 00:02:30,879
definitely. I'm a city person.

46
00:02:30,880 --> 00:02:34,023
I couldn't live in a small

47
00:02:34,024 --> 00:02:36,119
village where everybody knows

48
00:02:36,217 --> 00:02:39,116
each other and nothing happens.

49
00:02:39,120 --> 00:02:44,357
I'd rather live in a busy,

50
00:02:44,360 --> 00:02:45,585
exciting place.

51
00:02:46,880 --> 00:02:49,600
But I really love visiting

52
00:02:49,600 --> 00:02:52,960
the village for the holidays.




================================================
FILE: public/media/exam/subtitles/2_discussing_opinions.srt
================================================
1
00:00:07,550 --> 00:00:11,000
[LIU] Well, in my opinion, the best thing

2
00:00:11,000 --> 00:00:14,599
about cycling is the exercise.

3
00:00:14,600 --> 00:00:18,103
You can go from A to B and

4
00:00:18,104 --> 00:00:21,237
keep fit at the same time.

5
00:00:21,240 --> 00:00:22,920
What do you think?

6
00:00:22,920 --> 00:00:24,200
[VIKI] I agree.

7
00:00:24,200 --> 00:00:27,639
And it's quiet and clean.

8
00:00:27,640 --> 00:00:30,615
I mean, bikes don't create pollution

9
00:00:30,616 --> 00:00:33,440
like cars, and they don't use oil.

10
00:00:33,440 --> 00:00:36,834
[LIU] Hmm, yes, bikes are
better for the environment.

11
00:00:36,840 --> 00:00:39,779
[VIKI] Yes, exactly. But on the other hand,

12
00:00:39,780 --> 00:00:42,983
there are bad points too. I mean,

13
00:00:42,984 --> 00:00:45,850
it can be quite dangerous, can't it?

14
00:00:45,851 --> 00:00:49,336
[LIU] Yes, that's true. Especially in

15
00:00:49,337 --> 00:00:52,909
the city with all the traffic.

16
00:00:55,080 --> 00:00:59,013
An another bad point is the weather -

17
00:00:59,020 --> 00:01:02,254
cycling isn't pleasant in

18
00:01:02,255 --> 00:01:03,560
rainy and cold conditions.

19
00:01:03,560 --> 00:01:05,120
[VIKI] Uh, uh.

20
00:01:05,120 --> 00:01:07,069
No. And it can be really

21
00:01:07,070 --> 00:01:09,548
tiring if you go up a hill.

22
00:01:09,550 --> 00:01:12,040
I don't cycle because I live in the

23
00:01:12,040 --> 00:01:16,536
mountains, and all the
roads are really steep.

24
00:01:16,540 --> 00:01:19,824
If I lived in a flat place like Holland,

25
00:01:19,830 --> 00:01:22,349
I might cycle more.

26
00:01:22,350 --> 00:01:25,040
What about you? Do you cycle?

27
00:01:25,040 --> 00:01:29,320
[LIU] Sometimes, but only on holidays,

28
00:01:29,320 --> 00:01:31,800
not to go to work.

29
00:01:32,730 --> 00:01:35,159
I think the people in the

30
00:01:35,160 --> 00:01:37,329
photos are going to work.

31
00:01:39,430 --> 00:01:42,480
[VIKI] So, which place do you think looks

32
00:01:42,480 --> 00:01:45,800
more dangerous for the cyclists?

33
00:01:47,160 --> 00:01:54,379
[LIU] Well, I would say it's
OK in the first picture,

34
00:01:54,380 --> 00:01:56,671
because there is a special part

35
00:01:56,672 --> 00:01:59,239
of the road for the cyclist,

36
00:01:59,240 --> 00:02:02,785
but not in the second picture -

37
00:02:02,790 --> 00:02:07,005
the cyclists are in the middle of the

38
00:02:07,006 --> 00:02:10,123
traffic, and it looks quite dangerous.

39
00:02:10,124 --> 00:02:13,590
[VIKI] Yes, and I think the man in the

40
00:02:13,591 --> 00:02:16,368
first picture is better prepared.

41
00:02:16,370 --> 00:02:20,249
He's wearing a bright colored coat

42
00:02:20,250 --> 00:02:25,080
and - what do you call it?
A cycling hat?

43
00:02:25,080 --> 00:02:26,760
[LIU] A helmet, you mean?

44
00:02:26,760 --> 00:02:30,160
[VIKI] Helmet, exactly. The people in the

45
00:02:30,160 --> 00:02:32,000
second picture haven't got helmets.

46
00:02:32,000 --> 00:02:33,869
[LIU] Hmm.

47
00:02:33,970 --> 00:02:36,708
[VIKI] But there again,

48
00:02:36,710 --> 00:02:38,360
it could be more dangerous

49
00:02:38,360 --> 00:02:41,092
in the first picture,

50
00:02:41,100 --> 00:02:43,885
because the traffic is going much faster,

51
00:02:43,890 --> 00:02:45,520
isn't it?

52
00:02:45,520 --> 00:02:49,399
[LIU] Hmm, yes, I suppose so.

53
00:02:49,400 --> 00:02:54,049
But there is plenty of space.

54
00:02:54,050 --> 00:02:56,809
Hmm, on the whole,
I think it's more dangerous

55
00:02:56,810 --> 00:02:58,600
in the second picture.

56
00:02:58,600 --> 00:03:00,419
[VIKI] Yeah.

57
00:03:00,420 --> 00:03:02,620
Yeah, I think you're
probably right.




================================================
FILE: public/media/exam/subtitles/3_negotiating.srt
================================================
1
00:00:06,320 --> 00:00:08,927
OK, so we have to choose one picture

2
00:00:08,928 --> 00:00:11,854
to put on the front of the brochure.

3
00:00:13,990 --> 00:00:15,009
Shall we get started, then?

4
00:00:17,650 --> 00:00:19,579
Er, let's see...

5
00:00:22,190 --> 00:00:25,318
It's really difficult.

6
00:00:25,320 --> 00:00:28,196
Personally, if I were choosing a holiday,

7
00:00:28,200 --> 00:00:31,511
I think I would pick up a brochure with

8
00:00:31,512 --> 00:00:35,208
a picture of the dancers on the front,

9
00:00:35,210 --> 00:00:38,086
but that's because I enjoy folk dancing.

10
00:00:38,090 --> 00:00:40,543
Maybe that would not be so

11
00:00:40,544 --> 00:00:42,619
good for most people.

12
00:00:42,620 --> 00:00:45,799
No, possibly not. To be honest,

13
00:00:45,800 --> 00:00:47,747
that one looks boring. 

14
00:00:47,748 --> 00:00:51,563
If you ask me, we should choose one

15
00:00:51,564 --> 00:00:54,359
which most people would find attractive.

16
00:00:54,360 --> 00:00:58,335
How about the photo of the food?

17
00:00:58,340 --> 00:00:59,767
Everybody likes eating! 

18
00:00:59,768 --> 00:01:02,619
That's true, but I don't think 

19
00:01:02,620 --> 00:01:04,994
Scotland is famous for food.

20
00:01:05,000 --> 00:01:08,807
I think if people wanted a food holiday,

21
00:01:08,810 --> 00:01:11,189
they would choose Italy or

22
00:01:11,190 --> 00:01:13,569
France or somewhere like that.

23
00:01:13,570 --> 00:01:17,309
OK, that's a good point.

24
00:01:17,310 --> 00:01:21,395
It's the same with the picture of the beach -

25
00:01:21,400 --> 00:01:22,355
it's beautiful, 

26
00:01:22,356 --> 00:01:25,701
but I've never heard of people going 

27
00:01:25,702 --> 00:01:28,899
to Scotland for a beach holiday.

28
00:01:28,900 --> 00:01:30,679
From what I've heard,

29
00:01:30,680 --> 00:01:32,904
it's always raining in Britain.

30
00:01:32,910 --> 00:01:34,247
Yes, that's right,

31
00:01:34,248 --> 00:01:37,369
everybody says that the weather's terrible there.

32
00:01:39,860 --> 00:01:42,385
Well, ...we could use the picture 

33
00:01:42,386 --> 00:01:44,069
of the mountain climbers.

34
00:01:44,070 --> 00:01:47,723
It would give people the idea of

35
00:01:47,724 --> 00:01:51,067
things you can do in Scotland.

36
00:01:51,070 --> 00:01:55,990
What that would be very popular? I doubt it.

37
00:01:55,991 --> 00:02:01,209
Maybe we should focus on history and culture.

38
00:02:01,210 --> 00:02:03,687
What's happening in the first picture,

39
00:02:03,690 --> 00:02:09,039
for example? I've no idea.

40
00:02:09,040 --> 00:02:13,687
It looks like a carnival or something.

41
00:02:13,690 --> 00:02:17,136
But I think I would opt for the castle. 

42
00:02:17,140 --> 00:02:20,195
To me, that's a typical image of Scotland, 

43
00:02:20,200 --> 00:02:22,663
and I can imagine it would look

44
00:02:22,664 --> 00:02:25,559
great on the cover of the brochure.

45
00:02:25,560 --> 00:02:27,095
What do you think?

46
00:02:27,096 --> 00:02:29,969
Well, as far as I'm concerned,

47
00:02:29,970 --> 00:02:35,345
the carnival or the castle would be fine. 

48
00:02:35,350 --> 00:02:39,515
So, shall we use the castle then? 

49
00:02:39,520 --> 00:02:44,507
Yes, OK. That's fine by me.

50
00:02:44,510 --> 00:02:47,827
Right, so I think we're agreed.

51
00:02:47,830 --> 00:02:49,726
We'll use the photo of the castle. 




================================================
FILE: public/media/exam/subtitles/4_describing_a_picture.srt
================================================
1
00:00:06,540 --> 00:00:08,131
Please describe this photograph.

2
00:00:08,132 --> 00:00:10,519
What do you think is happening

3
00:00:10,589 --> 00:00:12,737
and what is your opinion about it?

4
00:00:12,740 --> 00:00:16,639
This picture shows two young men

5
00:00:16,640 --> 00:00:19,589
chatting on a park bench. 

6
00:00:19,590 --> 00:00:21,575
They are wearing casual clothes

7
00:00:21,576 --> 00:00:24,223
and training shoes. 

8
00:00:24,230 --> 00:00:26,959
One of them has glasses.

9
00:00:26,960 --> 00:00:29,959
It is a cloudy day.

10
00:00:29,960 --> 00:00:31,144
In the background,

11
00:00:31,145 --> 00:00:33,514
we can see trees and grass.

12
00:00:35,710 --> 00:00:37,809
There are a lot of red and orange

13
00:00:37,810 --> 00:00:39,549
leaves on the ground, 

14
00:00:39,550 --> 00:00:43,799
so I would say it's autumn in a cold country,

15
00:00:43,800 --> 00:00:46,067
perhaps in the north of Europe, 

16
00:00:46,070 --> 00:00:49,659
or maybe in North America.

17
00:00:49,660 --> 00:00:52,431
There is a pram to the left of the bench

18
00:00:52,432 --> 00:00:55,048
and another one to the right. 

19
00:00:55,050 --> 00:00:58,262
I would guess that they're babies in the prams,

20
00:00:58,270 --> 00:01:01,575
although we can't actually see them. 

21
00:01:01,580 --> 00:01:03,689
And I suppose that the

22
00:01:03,690 --> 00:01:05,799
young men are the fathers.

23
00:01:05,800 --> 00:01:08,859
The picture is slightly unusual,

24
00:01:08,860 --> 00:01:11,265
because we normally expect to see 

25
00:01:11,266 --> 00:01:12,869
mothers looking after babies.

26
00:01:12,870 --> 00:01:14,627
In most parts of the world,

27
00:01:14,630 --> 00:01:16,399
childcare is traditionally the mother's job.

28
00:01:18,510 --> 00:01:21,819
But I think the idea for this photo is good,

29
00:01:21,820 --> 00:01:23,479
and because in my opinion, 

30
00:01:23,480 --> 00:01:25,704
both parents should have equal

31
00:01:25,705 --> 00:01:27,484
responsibility for the children.

32
00:01:27,490 --> 00:01:28,665
The picture

33
00:01:28,666 --> 00:01:32,781
also makes us think about another question. 

34
00:01:32,790 --> 00:01:35,218
It is the middle of the day.

35
00:01:35,220 --> 00:01:37,835
Why aren't the men at work? 

36
00:01:37,840 --> 00:01:40,503
It could be the weekend, of course, 

37
00:01:40,504 --> 00:01:43,663
or perhaps a public holiday.

38
00:01:43,670 --> 00:01:47,039
But there is another possibility.

39
00:01:47,040 --> 00:01:49,373
Perhaps the men look after their

40
00:01:49,374 --> 00:01:51,317
homes and the children full time, 

41
00:01:51,318 --> 00:01:53,529
and their wives go to work. 

42
00:01:53,530 --> 00:01:55,819
We normally talk about housewives, 

43
00:01:55,820 --> 00:02:00,418
but in this case maybe they are house husbands. 

44
00:02:00,420 --> 00:02:02,079
I know this is unusual,

45
00:02:02,080 --> 00:02:03,249
but why not?

46
00:02:03,250 --> 00:02:04,809
I love that idea.




================================================
FILE: public/media/exam/subtitles/5_giving_personal_information.srt
================================================
1
00:00:06,600 --> 00:00:08,213
Hello, I'm Martin.

2
00:00:08,214 --> 00:00:10,365
What's your name please?

3
00:00:10,370 --> 00:00:13,549
I'm Cristina. And where are you

4
00:00:13,550 --> 00:00:16,055
from, Cristina? I'm from Spain. 

5
00:00:16,056 --> 00:00:19,119
Oh, really? Whereabouts in Spain?

6
00:00:19,120 --> 00:00:22,809
From a town called Iznalloz. 

7
00:00:22,810 --> 00:00:25,485
It's near the city of Granada.

8
00:00:25,490 --> 00:00:28,191
Oh right. And can you tell me a 

9
00:00:28,192 --> 00:00:30,417
little bit about what you do?

10
00:00:30,420 --> 00:00:35,855
Yes, I am a student. I am

11
00:00:35,856 --> 00:00:39,407
studying economics at university. 

12
00:00:39,410 --> 00:00:42,355
I also have a part-time 

13
00:00:42,356 --> 00:00:44,319
job in a hairdresser's. 

14
00:00:44,320 --> 00:00:46,909
And where do you live?

15
00:00:46,910 --> 00:00:50,077
Actually, I live in two places.

16
00:00:50,080 --> 00:00:52,963
During the week, when there are classes,

17
00:00:52,970 --> 00:00:56,336
I live with an aunt in Granada,

18
00:00:56,340 --> 00:00:58,907
near to the university,

19
00:00:58,910 --> 00:01:02,373
and at the weekends I usually go home 

20
00:01:02,374 --> 00:01:06,166
to my parents' place in Iznalloz. 

21
00:01:06,170 --> 00:01:09,967
But next year I hope to rent a flat

22
00:01:09,968 --> 00:01:12,598
together with a friend of mine.

23
00:01:12,600 --> 00:01:16,666
OK. And how long have you been 

24
00:01:16,667 --> 00:01:19,898
studying English. Oh for a long time.

25
00:01:19,900 --> 00:01:22,819
I have been studying English

26
00:01:22,820 --> 00:01:25,155
at school since primary,

27
00:01:25,160 --> 00:01:28,834
and I have also done evening classes

28
00:01:28,840 --> 00:01:31,167
at a language school. 

29
00:01:31,170 --> 00:01:35,189
Nowadays, I try to practise by 

30
00:01:35,190 --> 00:01:38,739
making friends on social media

31
00:01:38,740 --> 00:01:40,871
and chatting in English. 

32
00:01:40,872 --> 00:01:44,069
How important do you think English

33
00:01:44,169 --> 00:01:48,469
will be in your career? Very important.

34
00:01:48,470 --> 00:01:52,039
When I graduate, I hope to apply for a job

35
00:01:52,040 --> 00:01:55,527
in a multinational company. 

36
00:01:55,530 --> 00:01:58,799
I want to work abroad, 

37
00:01:58,800 --> 00:02:03,629
in Britain or maybe another country,

38
00:02:03,630 --> 00:02:05,109
you know. 

39
00:02:05,110 --> 00:02:10,285
I would really love to see new places,

40
00:02:10,290 --> 00:02:13,049
meet new people, see the world. 

41
00:02:13,050 --> 00:02:16,919
I've never been outside Spain before,

42
00:02:16,920 --> 00:02:18,497
it's too expensive,

43
00:02:18,498 --> 00:02:21,653
But I've always wanted to travel.




================================================
FILE: public/media/exam/subtitles/6_job_interview.srt
================================================
1
00:00:06,860 --> 00:00:08,395
Good morning, Mr Mason.

2
00:00:08,396 --> 00:00:10,699
Take a seat, please. Thank you. 

3
00:00:12,740 --> 00:00:14,706
Now, I can see from your CV 

4
00:00:14,707 --> 00:00:16,317
that you graduated from Bristol

5
00:00:16,318 --> 00:00:18,423
University just over a year ago.

6
00:00:18,430 --> 00:00:19,290
Yes, that's right.

7
00:00:19,291 --> 00:00:21,711
So, can you tell me a little bit 

8
00:00:21,712 --> 00:00:24,112
about what you've been doing since then?

9
00:00:24,120 --> 00:00:25,191
Yes, first of all,

10
00:00:25,192 --> 00:00:27,944
I got a summer job as a group leader

11
00:00:27,945 --> 00:00:30,436
at a children's summer camp in Canada.

12
00:00:30,440 --> 00:00:31,699
That's something that I'd

13
00:00:31,700 --> 00:00:32,959
previously done in France, 

14
00:00:32,960 --> 00:00:36,067
and I've always enjoyed working with kids.

15
00:00:36,070 --> 00:00:37,381
When that was over,

16
00:00:37,382 --> 00:00:39,733
I came back to London and got

17
00:00:39,734 --> 00:00:41,875
a job as a studio assistant in

18
00:00:41,876 --> 00:00:44,099
a local community TV station.

19
00:00:44,100 --> 00:00:46,049
What were your duties there?

20
00:00:46,050 --> 00:00:48,257
Well, basically I did everything, you know,

21
00:00:48,258 --> 00:00:50,611
from taking calls to responding 

22
00:00:50,612 --> 00:00:53,047
to emails and maintaining the website,

23
00:00:53,050 --> 00:00:55,709
right the way through to interviewing and

24
00:00:55,710 --> 00:00:58,488
helping out with the filming and editing.

25
00:00:58,490 --> 00:01:01,721
It's a small company so I got a 

26
00:01:01,722 --> 00:01:05,466
chance to try a little bit of everything.

27
00:01:05,470 --> 00:01:08,109
What did you enjoy most?

28
00:01:08,110 --> 00:01:11,182
I guess the interviewing. I think I'm 

29
00:01:11,183 --> 00:01:13,909
quite outgoing, so I love meeting 

30
00:01:13,910 --> 00:01:16,561
people and finding out about them.

31
00:01:16,570 --> 00:01:20,665
I see, right. So what about the future? 

32
00:01:20,670 --> 00:01:25,655
Where do you see yourself in five years' time? 

33
00:01:25,660 --> 00:01:29,268
I'd like to work for a larger TV network,

34
00:01:29,270 --> 00:01:31,274
perhaps as an interviewer for

35
00:01:31,275 --> 00:01:32,477
a regular programme, 

36
00:01:32,480 --> 00:01:34,494
either interviewing guests in the

37
00:01:34,495 --> 00:01:37,063
studio or going out and about,

38
00:01:37,064 --> 00:01:39,478
interviewing members of the public.

39
00:01:39,480 --> 00:01:41,838
And what made you decide to apply

40
00:01:41,839 --> 00:01:43,559
for a job with us?

41
00:01:43,560 --> 00:01:46,079
I think this is a well-known company 

42
00:01:46,080 --> 00:01:48,317
that's got a very good reputation,

43
00:01:48,320 --> 00:01:50,425
so I felt it would be the ideal place

44
00:01:50,426 --> 00:01:52,836
to develop my skills, and hopefully 

45
00:01:52,837 --> 00:01:54,524
contribute something with my 

46
00:01:54,525 --> 00:01:56,479
enthusiasm and willingness to learn.

47
00:01:56,480 --> 00:01:59,199
OK, well, thank you for coming in today.

48
00:01:59,200 --> 00:02:02,013
Obviously, we have a few more candidates 

49
00:02:02,014 --> 00:02:04,854
to consider, so we will be making a 

50
00:02:04,855 --> 00:02:07,365
selection in a week or so, and we'll

51
00:02:07,366 --> 00:02:10,184
let you know one way or the other.

52
00:02:10,185 --> 00:02:10,969
OK, thanks.




================================================
FILE: public/media/exam/subtitles/7_giving_a_presentation.srt
================================================
1
00:00:05,790 --> 00:00:08,190
So I'm here today to present you

2
00:00:08,191 --> 00:00:10,347
with the results of that survey.

3
00:00:10,350 --> 00:00:13,415
Now to do this, I've divided my 

4
00:00:13,416 --> 00:00:15,279
presentation into three parts.

5
00:00:15,280 --> 00:00:17,264
Firstly, I'll talk about the

6
00:00:17,265 --> 00:00:18,852
background to the research,

7
00:00:18,860 --> 00:00:21,091
then we'll look at how we 

8
00:00:21,092 --> 00:00:23,239
approach the research and thirdly

9
00:00:23,240 --> 00:00:25,224
I'll summarize the key findings

10
00:00:25,225 --> 00:00:26,415
of the research.

11
00:00:26,420 --> 00:00:29,749
So let's start with a brief

12
00:00:29,750 --> 00:00:31,969
description of the background.

13
00:00:31,970 --> 00:00:35,147
So once we selected the 90,000 customers

14
00:00:35,148 --> 00:00:37,618
across your company's three regions,

15
00:00:37,620 --> 00:00:40,997
we then emailed them the survey.

16
00:00:41,000 --> 00:00:43,519
We allowed four weeks for them to

17
00:00:43,520 --> 00:00:46,209
respond to the surveys and after that

18
00:00:46,210 --> 00:00:49,171
period we received a 4% response rate.

19
00:00:49,172 --> 00:00:52,297
Now this is quite good for

20
00:00:52,298 --> 00:00:54,468
this type of survey.

21
00:00:54,470 --> 00:00:57,103
We then analyzed those responses and

22
00:00:57,104 --> 00:01:00,138
compiled them in a report for you.

23
00:01:00,140 --> 00:01:01,854
Let's look at those responses

24
00:01:01,855 --> 00:01:03,569
in a bit more detail.

25
00:01:03,570 --> 00:01:08,273
Now, of that 4%, this blue section

26
00:01:08,280 --> 00:01:10,909
shows that 57% of those

27
00:01:10,910 --> 00:01:13,539
responses came from the region

28
00:01:13,540 --> 00:01:16,234
with the difficulties. That's significant,

29
00:01:16,235 --> 00:01:20,177
as you are more likely to receive

30
00:01:20,178 --> 00:01:23,627
responses from customers who are either

31
00:01:23,628 --> 00:01:26,639
very satisfied or very unsatisfied.

32
00:01:26,640 --> 00:01:26,947
Anyway,

33
00:01:26,948 --> 00:01:29,411
this leads me on to the third and

34
00:01:29,412 --> 00:01:31,398
main part of my presentation:

35
00:01:31,400 --> 00:01:35,107
the actual results of the survey. 

36
00:01:35,110 --> 00:01:36,839
Now in front of you,

37
00:01:36,840 --> 00:01:39,247
there's a copy of the survey's findings.

38
00:01:44,580 --> 00:01:46,919
I'm going to summarize the key

39
00:01:46,920 --> 00:01:49,789
findings for each of these questions.

40
00:01:49,790 --> 00:01:52,387
So let's begin with question one,

41
00:01:52,390 --> 00:01:55,532
which asks your customers how likely they

42
00:01:55,533 --> 00:01:58,897
are to recommend your company to a friend.

43
00:01:58,900 --> 00:02:02,724
As you can see, we have used a scale,

44
00:02:02,725 --> 00:02:06,246
with zero being not at all likely 

45
00:02:06,247 --> 00:02:09,069
and ten being extremely likely. 

46
00:02:09,070 --> 00:02:11,861
According to this chart,

47
00:02:11,862 --> 00:02:14,653
the overall response indicates

48
00:02:14,654 --> 00:02:17,947
that customers are fairly likely

49
00:02:17,950 --> 00:02:19,937
to recommend your company. 

50
00:02:21,950 --> 00:02:25,159
However, this chart represents an average

51
00:02:25,160 --> 00:02:28,019
response across the three regions.

52
00:02:28,020 --> 00:02:31,439
If we break these responses down

53
00:02:31,440 --> 00:02:35,225
into the three regions like this,

54
00:02:35,230 --> 00:02:38,459
it illustrates the difference in 

55
00:02:38,460 --> 00:02:41,689
customer satisfaction region by region.

56
00:02:41,690 --> 00:02:45,913
Now remember, red and yellow are the regions

57
00:02:45,914 --> 00:02:48,908
with increasing numbers of customers.

58
00:02:48,910 --> 00:02:52,809
Consequently, the responses are quite high,

59
00:02:52,810 --> 00:02:56,609
even as high as ten,

60
00:02:56,610 --> 00:02:58,967
but the lower scores in the 

61
00:02:58,968 --> 00:03:01,429
previous chart were caused by the

62
00:03:01,430 --> 00:03:03,459
decline in these blue responses here. 

63
00:03:03,460 --> 00:03:05,745
They go down to the 

64
00:03:05,746 --> 00:03:07,889
zero level in some cases.




================================================
FILE: public/styles/style.css
================================================
@font-face {
    font-family: Inter;
    src: url("../fonts/InterVariable.woff2");
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
}

@font-face {
    font-family: Inter;
    src: url("../fonts/InterVariable-Italic.woff2");
    font-style: italic;
    font-weight: 300 900;
    font-display: swap;
}

@font-face {
    font-family: "Noto Sans SC";
    src: url("../fonts/NotoSansSC-VariableFont_wght.woff2");
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
}

html,
body {
    font-family:
        "Inter",
        system-ui,
        -apple-system,
        Roboto,
        "Segoe UI",
        Oxygen,
        Ubuntu,
        Cantarell,
        "Open Sans",
        "Helvetica Neue",
        sans-serif;
    font-variation-settings: "opsz" 15;
    font-feature-settings: "cv02", "cv03", "cv04", "cv05", "cv08", "cv10", "cv11";
}

/* Specific font for Chinese text */
:lang(zh) {
    font-family: "Noto Sans SC", system-ui, sans-serif;
}



================================================
FILE: scripts/gh_releases_body.py
================================================
import requests
import os
import json
import sys

# Get environment variables with fallbacks
repo = "learnercraft/ispeakerreact"
# release_id = os.environ.get("RELEASE_ID")

# Define the GitHub API URL and headers
# if release_id:
# url = f"https://api.github.com/repos/{repo}/releases/{release_id}"
# else:
# Fallback to latest release if no specific release ID is provided
url = f"https://api.github.com/repos/{repo}/releases/latest"

token = os.environ.get("GITHUB_TOKEN")
headers = {
    "User-Agent": "release-updater-action",
    "Accept": "application/vnd.github.v3+json",
}

# Add authorization header if token is available
if token:
    headers["Authorization"] = f"token {token}"

# Fetch the release data
try:
    response = requests.get(url, headers=headers)
    response.raise_for_status()  # Raise exception for 4XX/5XX responses
    release_data = response.json()

    # Debug: Print the keys in the response
    #print(f"DEBUG: Response keys: {list(release_data.keys())}", file=sys.stderr)

except requests.exceptions.RequestException as e:
    print(f"ERROR: Failed to fetch release data: {e}", file=sys.stderr)
    print(f"Response status code: {response.status_code}", file=sys.stderr)
    print(f"Response content: {response.text}", file=sys.stderr)
    sys.exit(1)

# Extract necessary details
try:
    tag = release_data.get("tag_name")
    if not tag:
        print(f"ERROR: No tag_name in response data", file=sys.stderr)
        sys.exit(1)

    name = release_data.get("name", tag)
    assets = release_data.get("assets", [])

    if not assets:
        print("WARNING: No assets found in the release", file=sys.stderr)

    # For the changelog URL, handle possible tag format variations
    base_tag = tag.split("-")[0] if "-" in tag else tag
    changelog_url = f"https://github.com/{repo}/compare/{base_tag}...{tag}"

except KeyError as e:
    print(f"ERROR: Missing required field in response data: {e}", file=sys.stderr)
    print(f"Full response: {json.dumps(release_data, indent=2)}", file=sys.stderr)
    sys.exit(1)

# Organize assets by platform
windows_downloads = []
macos_downloads = []
linux_downloads = []

for asset in assets:
    asset_name = asset["name"]
    download_url = asset["browser_download_url"]
    # Windows
    if asset_name.startswith("iSpeakerReact-win32-x64") and asset_name.endswith(".zip"):
        windows_downloads.append(f"- [Windows x64 ZIP]({download_url})")
    elif asset_name.startswith("iSpeakerReact-win32-arm64") and asset_name.endswith(".zip"):
        windows_downloads.append(f"- [Windows ARM64 ZIP]({download_url})")
    # macOS
    elif asset_name.startswith("iSpeakerReact-darwin-x64") and asset_name.endswith(".zip"):
        macos_downloads.append(f"- [macOS Intel (x64) ZIP]({download_url})")
    elif asset_name.startswith("iSpeakerReact-darwin-arm64") and asset_name.endswith(".zip"):
        macos_downloads.append(f"- [macOS Apple Silicon (arm64) ZIP]({download_url})")
    # Linux
    elif asset_name.startswith("iSpeakerReact-linux-x64") and asset_name.endswith(".zip"):
        linux_downloads.append(f"- [Linux x64 ZIP]({download_url})")
    elif asset_name.endswith(".deb"):
        linux_downloads.append(f"- [DEB package]({download_url})")
    elif asset_name.endswith(".rpm"):
        linux_downloads.append(f"- [RPM package]({download_url})")

# Build markdown
markdown = f"""## Download

### Windows
{chr(10).join(windows_downloads)}

This app requires Windows 10 or later.

### macOS
{chr(10).join(macos_downloads)}

### Linux
{chr(10).join(linux_downloads)}

There is no AppImage version for this app yet.

## App updates

/* Please add some here manually */

**Full Changelog**: {changelog_url}
"""

print(markdown.strip())



================================================
FILE: scripts/minify-dist-jsons.js
================================================
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const minifyJsonFiles = (dir) => {
    if (!fs.existsSync(dir)) return;
    fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
        const fullPath = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            minifyJsonFiles(fullPath);
        } else if (entry.isFile() && entry.name.endsWith(".json")) {
            const content = fs.readFileSync(fullPath, "utf8");
            try {
                const minified = JSON.stringify(JSON.parse(content));
                fs.writeFileSync(fullPath, minified, "utf8");
                console.log(`Minified: ${fullPath}`);
            } catch (e) {
                console.error(`Failed to minify: ${fullPath}`, e);
            }
        }
    });
};

// Minify dist/json
minifyJsonFiles(path.join(__dirname, "../dist/json"));
// Minify dist/locales
minifyJsonFiles(path.join(__dirname, "../dist/locales"));
// Minify data/
//minifyJsonFiles(path.join(__dirname, "../data"));



================================================
FILE: src/App.jsx
================================================
import { Suspense, lazy } from "react";
import { BrowserRouter, HashRouter, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import LoadingOverlay from "./components/general/LoadingOverlay";
import NotFound from "./components/general/NotFound";
import Homepage from "./components/Homepage";
import ErrorBoundary from "./ErrorBoundary";
import isElectron from "./utils/isElectron";
import ThemeProvider from "./utils/ThemeContext/ThemeProvider";
import { useTheme } from "./utils/ThemeContext/useTheme";
import VersionUpdateDialog from "./components/general/VersionUpdateDialog";
import { useState, useEffect } from "react";

const SoundList = lazy(() => import("./components/sound_page/SoundList"));
const WordList = lazy(() => import("./components/word_page/WordList"));
const ConversationMenu = lazy(() => import("./components/conversation_page/ConversationMenu"));
const ExamPage = lazy(() => import("./components/exam_page/ExamPage"));
const ExercisePage = lazy(() => import("./components/exercise_page/ExercisePage"));
const SettingsPage = lazy(() => import("./components/setting_page/Settings"));
const DownloadPage = lazy(() => import("./components/download_page/DownloadPage"));

const RouterComponent = isElectron() ? HashRouter : BrowserRouter;

const PROD_BASE_URL = "https://learnercraft.github.io/ispeakerreact";

const isProdWeb =
    import.meta.env.PROD &&
    !isElectron() &&
    window.location.href.startsWith(PROD_BASE_URL);

// Ensure baseUrl does not add unnecessary slashes
const baseUrl = isElectron()
    ? ""
    : (() => {
          switch (import.meta.env.BASE_URL) {
              case "/":
              case "./":
                  return ""; // Use no basename for "/" or "./"
              default:
                  return import.meta.env.BASE_URL;
          }
      })();

// Clear web cache if a newer version is found

const AppContent = () => {
    const [showUpdateDialog, setShowUpdateDialog] = useState(false);
    const { theme } = useTheme();
    const toastTheme =
        theme === "dark" ||
        (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches)
            ? "dark"
            : "light";

    useEffect(() => {
        if (isProdWeb) {
            setShowUpdateDialog(true);
        } else {
            console.warn("Dev mode or Electron version detected. Version check skipped.");
        }
    }, []);

    return (
        <>
            <VersionUpdateDialog
                open={showUpdateDialog}
                onRefresh={() => window.location.reload()}
            />
            <RouterComponent basename={!isElectron() ? baseUrl : ""}>
                <Routes>
                    <Route path="/" element={<Homepage />} />
                    <Route
                        path="/sounds"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <SoundList />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/words"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <WordList />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/conversations"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <ConversationMenu />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/exams"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <ExamPage />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/exercises"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <ExercisePage />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/settings"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <SettingsPage />
                            </Suspense>
                        }
                    />
                    <Route
                        path="/download"
                        element={
                            <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                                <DownloadPage />
                            </Suspense>
                        }
                    />
                    <Route path="*" element={<NotFound />} />
                </Routes>
            </RouterComponent>
            <Toaster
                className="flex justify-center"
                position="bottom-center"
                duration="7000"
                theme={toastTheme}
            />
        </>
    );
};

const App = () => (
    <ErrorBoundary>
        <ThemeProvider>
            <AppContent />
        </ThemeProvider>
    </ErrorBoundary>
);

export default App;



================================================
FILE: src/ErrorBoundary.jsx
================================================
import { useTranslation } from "react-i18next";
import ErrorBoundaryInner from "./ErrorBoundaryInner";

const ErrorBoundary = (props) => {
    const { t } = useTranslation();

    return <ErrorBoundaryInner {...props} t={t} />;
};

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 (
                <Container>
                    <div className="flex min-h-screen flex-row items-center justify-center">
                        <div className="w-full rounded-lg bg-red-50 p-6 text-red-700 dark:bg-red-300 dark:text-black">
                            <h2 className="mb-4 text-2xl font-bold">
                                🚨 {t("appCrash.appCrashedTitle")}
                            </h2>
                            <p className="mb-4">{t("appCrash.appCrashedDesc")}</p>
                            <div className="card bg-base-100 card-md max-h-100 overflow-auto whitespace-pre-wrap shadow-sm lg:max-h-64">
                                <div className="card-body">
                                    <code className="font-mono! dark:text-red-200">
                                        App version: v{__APP_VERSION__}
                                        {"\n"}
                                        {error?.toString()}
                                        {"\n"}
                                        {errorInfo?.componentStack}
                                    </code>
                                </div>
                            </div>
                            <div className="my-6 flex flex-wrap justify-center gap-2">
                                <button onClick={this.handleCopy} className="btn btn-primary">
                                    <HiOutlineClipboardCopy className="h-5 w-5" />
                                    {t("appCrash.copyBtn")}
                                </button>
                                <button
                                    onClick={() =>
                                        openExternal(
                                            "https://github.com/learnercraft/ispeakerreact/issues/new?template=bug_report.yml"
                                        )
                                    }
                                    className="btn btn-info"
                                >
                                    <FaGithub className="h-5 w-5" />
                                    {t("appCrash.openGitHubBtn")}
                                </button>
                                <button onClick={this.handleRefresh} className="btn btn-secondary">
                                    <FiRefreshCw className="h-5 w-5" />
                                    {t("appCrash.refreshBtn")}
                                </button>
                            </div>
                        </div>
                    </div>
                    <Toaster
                        className="flex justify-center"
                        position="bottom-center"
                        duration="7000"
                    />
                </Container>
            );
        }

        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(
    <StrictMode>
        <App />
    </StrictMode>
);



================================================
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 (
        <>
            <TopNavBar />
            <Container>
                <div className="mb-4 p-6">
                    <div className="flex items-center justify-center space-x-4">
                        <LogoLightOrDark width="64" height="64" />
                        <h1 lang="en" className="text-3xl font-bold md:text-4xl">
                            iSpeakerReact
                        </h1>
                    </div>
                    <p lang="en" className="mt-2 text-center text-sm">
                        v{__APP_VERSION__}
                    </p>
                </div>
                <div className="flex flex-wrap justify-center gap-5">
                    {cardsInfo
                        .filter((card) => !(isElectron() && card.hideForElectron))
                        .map((card, idx) => (
                            <div
                                key={idx}
                                className="card card-lg card-border flex h-auto w-full flex-col justify-between pb-6 shadow-md sm:w-1/2 md:w-1/3 lg:w-1/4 dark:border-slate-600"
                            >
                                <figure className="px-10 pt-10">
                                    <img
                                        alt={`${card.title} section icon`}
                                        className="w-24"
                                        src={card.icon}
                                    />
                                </figure>
                                <div className="card-body grow items-center text-center">
                                    <h2 className="card-title">{card.title}</h2>
                                    <p>{card.description}</p>
                                </div>
                                <div className="card-actions px-6">
                                    <button
                                        type="button"
                                        className="btn btn-primary w-full"
                                        onClick={() => handleNavigate(card.path)}
                                        aria-label={`Open the ${card.title} section`}
                                    >
                                        {t("homepage.openBtn")}
                                    </button>
                                </div>
                            </div>
                        ))}
                </div>
            </Container>
            <Footer />
        </>
    );
};

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 (
        <>
            <h3 className="mt-4 mb-2 text-2xl font-semibold">
                {t("conversationPage.topicHeading")} {t(title)}
            </h3>
            <p>
                {t("accent.accentSettings")}:{" "}
                {t(accent === "british" ? "accent.accentBritish" : "accent.accentAmerican")}
            </p>
            <button type="button" className="btn btn-secondary my-4" onClick={onBack}>
                <IoChevronBackOutline className="h-5 w-5" />
                {t("buttonConversationExam.conversationBackBtn")}
            </button>
            {loading || videoLoading ? (
                <LoadingOverlay />
            ) : (
                <>
                    <div className="bg-base-100 sticky top-[calc(5rem)] z-10 py-8">
                        <div className="flex justify-center">
                            <div role="tablist" className="tabs tabs-box justify-center">
                                <a
                                    role="tab"
                                    onClick={() => {
                                        setActiveTab("watchStudyTab");
                                        scrollTo();
                                    }}
                                    className={`tab md:text-base ${
                                        activeTab === "watchStudyTab"
                                            ? "tab-active font-semibold"
                                            : ""
                                    }`}
                                >
                                    <MdOutlineOndemandVideo className="me-1 h-6 w-6" />
                                    {t("buttonConversationExam.watchBtn")}
                                </a>
                                <a
                                    role="tab"
                                    onClick={() => {
                                        setActiveTab("listenTab");
                                        scrollTo();
                                    }}
                                    className={`tab md:text-base ${
                                        activeTab === "listenTab" ? "tab-active font-semibold" : ""
                                    }`}
                                >
                                    <MdHeadphones className="me-1 h-6 w-6" />
                                    {t("buttonConversationExam.listenBtn")}
                                </a>
                                <a
                                    role="tab"
                                    onClick={() => {
                                        setActiveTab("practiceTab");
                                        scrollTo();
                                    }}
                                    className={`tab md:text-base ${
                                        activeTab === "practiceTab"
                                            ? "tab-active font-semibold"
                                            : ""
                                    }`}
                                >
                                    <MdKeyboardVoice className="me-1 h-6 w-6" />
                                    {t("buttonConversationExam.practiceBtn")}
                                </a>
                                <a
                                    role="tab"
                                    onClick={() => {
                                        setActiveTab("reviewTab");
                                        scrollTo();
                                    }}
                                    className={`tab md:text-base ${
                                        activeTab === "reviewTab" ? "tab-active font-semibold" : ""
                                    }`}
                                >
                                    <MdChecklist className="me-1 h-6 w-6" />
                                    {t("buttonConversationExam.reviewBtn")}
                                </a>
                            </div>
                        </div>
                    </div>
                    <div
                        ref={scrollRef}
                        className="card card-lg card-border mb-6 w-full shadow-md dark:border-slate-600"
                    >
                        <div className="card-body">
                            {activeTab === "watchStudyTab" && (
                                <WatchAndStudyTab
                                    videoUrl={videoUrl}
                                    subtitleUrl={subtitleUrl}
                                    dialog={accentData.watch_and_study.study.dialog}
                                    skillCheckmark={
                                        accentData.watch_and_study.study.skill_checkmark
                                    }
                                    scrollTo={scrollTo}
                                />
                            )}

                            {activeTab === "listenTab" && (
                                <ListeningTab
                                    sentences={accentData.listen.subtopics}
                                    scrollTo={scrollTo}
                                />
                            )}

                            {activeTab === "practiceTab" && (
                                <PracticeTab
                                    accent={accent}
                                    conversationId={id}
                                    scrollTo={scrollTo}
                                />
                            )}

                            {activeTab === "reviewTab" && (
                                <ReviewTab
                                    reviews={accentData.reviews}
                                    accent={accent}
                                    conversationId={id}
                                    scrollTo={scrollTo}
                                />
                            )}
                        </div>
                    </div>
                </>
            )}
        </>
    );
};

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 */}
            <div
                className="tooltip tooltip-secondary dark:tooltip-accent hidden sm:inline"
                data-tip={info}
            >
                <IoInformationCircleOutline className="hover:text-primary dark:hover:text-accent h-5 w-5 cursor-pointer" />
            </div>

            {/* Modal trigger button for small screens */}
            <button
                type="button"
                title={t("conversationPage.expandInfoBtn")}
                className="btn btn-circle btn-sm focus:ring-2 sm:hidden"
                onClick={onClick}
            >
                <IoInformationCircleOutline className="h-5 w-5 cursor-pointer" />
            </button>
        </>
    );

    TooltipIcon.propTypes = {
        info: PropTypes.string.isRequired,
        onClick: PropTypes.func.isRequired,
    };

    const ConversationCard = ({ heading, titles, onShowModal }) => (
        <div className="card card-lg card-border flex h-auto w-full flex-col justify-between shadow-md md:w-1/3 lg:w-1/4 dark:border-slate-600">
            <div className="card-body grow">
                <div className="card-title font-semibold">{t(heading)}</div>
                <div className="divider divider-secondary m-0"></div>
                {titles.map(({ title, id, info }, index) => (
                    <div key={index} className="mb-2 flex items-center space-x-2">
                        <a
                            className="link hover:link-primary dark:hover:link-accent"
                            onClick={() => handleSelectConversation(id, title)}
                        >
                            {t(title)}
                        </a>
                        <TooltipIcon info={t(info)} onClick={() => onShowModal(t(info))} />
                    </div>
                ))}
            </div>
        </div>
    );

    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 (
        <>
            <TopNavBar />
            <Container>
                <h1 className="py-6 text-3xl font-bold md:text-4xl">
                    {t("navigation.conversations")}
                </h1>
                {selectedConversation ? (
                    <Suspense fallback={<LoadingOverlay />}>
                        <ConversationDetailPage
                            id={selectedConversation.id}
                            accent={selectedAccent}
                            title={selectedConversation.title}
                            onBack={() => setSelectedConversation(null)}
                        />
                    </Suspense>
                ) : (
                    <>
                        <AccentDropdown onAccentChange={setSelectedAccent} />
                        <p className="my-4">{t("conversationPage.selectType")}</p>
                        {loading ? (
                            <LoadingOverlay />
                        ) : (
                            <div className="my-10 flex flex-wrap justify-center gap-7">
                                {data.map((section, index) => (
                                    <ConversationCard
                                        key={index}
                                        heading={section.heading}
                                        titles={section.titles}
                                        onShowModal={handleShowModal}
                                    />
                                ))}
                            </div>
                        )}

                        <dialog ref={modalRef} className="modal">
                            <div className="modal-box">
                                <h3 className="text-lg font-bold">
                                    {t("conversationPage.conversationModalInfo")}
                                </h3>
                                <p className="py-4">{modalInfo}</p>
                                <div className="modal-action">
                                    <button
                                        type="button"
                                        className="btn"
                                        onClick={handleCloseModal}
                                    >
                                        {t("sound_page.closeBtn")}
                                    </button>
                                </div>
                            </div>
                        </dialog>
                    </>
                )}
            </Container>
        </>
    );
};

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 (
        <>
            <div className="flex flex-wrap justify-center gap-4 lg:flex-nowrap">
                {sentences.map((subtopic, index) => (
                    <div
                        key={index}
                        className="card card-lg card-border w-full shadow-md lg:w-1/2 dark:border-slate-600"
                    >
                        <div className="card-body px-4 md:px-8">
                            <div className="card-title font-semibold">{t(subtopic.title)}</div>
                            <div className="divider divider-secondary m-0"></div>
                            <ul
                                role="list"
                                className="divide-y divide-gray-300 dark:divide-gray-600"
                            >
                                {subtopic.sentences.map((sentenceObj, idx) => {
                                    const uniqueIdx = `${index}-${idx}`; // Create a unique index for each sentence
                                    return (
                                        <li
                                            key={uniqueIdx}
                                            className={`flex cursor-pointer justify-between gap-x-6 py-3 ${playingIndex === uniqueIdx ? "bg-secondary text-secondary-content" : ""}`}
                                            role="button"
                                            onClick={() =>
                                                handlePlayPause(uniqueIdx, sentenceObj.audioSrc)
                                            }
                                            aria-pressed={playingIndex === uniqueIdx}
                                            aria-disabled={
                                                loadingIndex !== null && loadingIndex !== uniqueIdx
                                            }
                                        >
                                            <div className="flex min-w-0 gap-x-4">
                                                <div className="min-w-0 flex-auto">
                                                    <p
                                                        lang="en"
                                                        className="italic"
                                                        dangerouslySetInnerHTML={{
                                                            __html: sentenceObj.sentence,
                                                        }}
                                                    ></p>
                                                </div>
                                            </div>
                                            <div className="flex items-center gap-x-2">
                                                {loadingIndex === uniqueIdx ? (
                                                    <span className="loading loading-spinner loading-md"></span>
                                                ) : playingIndex === uniqueIdx ? (
                                                    <IoVolumeHigh className="h-6 w-6" />
                                                ) : (
                                                    <IoVolumeHighOutline className="h-6 w-6" />
                                                )}
                                            </div>
                                        </li>
                                    );
                                })}
                            </ul>
                        </div>
                    </div>
                ))}
            </div>
        </>
    );
};

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 (
        <div className="container-lg mx-auto">
            {t("tabConversationExam.practiceConversationText", { returnObjects: true }).map(
                (text, index) => (
                    <p className="mb-2" key={index}>
                        {text}
                    </p>
                )
            )}

            <fieldset className="fieldset my-4">
                <legend className="fieldset-legend text-sm">
                    {t("tabConversationExam.practiceConversationBox")}
                </legend>
                <textarea
                    className="textarea w-full text-base"
                    ref={textAreaRef}
                    value={textValue}
                    onChange={(e) => setTextValue(e.target.value)}
                    onInput={autoExpand}
                ></textarea>
            </fieldset>

            <div className="flex flex-wrap justify-center gap-2">
                <button
                    type="button"
                    className="btn btn-primary"
                    onClick={handleSaveText}
                    disabled={!textValue}
                >
                    <BsFloppy className="h-5 w-5" /> {t("buttonConversationExam.saveBtn")}
                </button>
                <button
                    type="button"
                    className="btn btn-error"
                    onClick={handleClearText}
                    disabled={!textValue}
                >
                    <BsTrash className="h-5 w-5" /> {t("buttonConversationExam.clearBtn")}
                </button>
            </div>

            <div className="divider"></div>

            <div className="mt-4">
                <p className="mb-4">{t("tabConversationExam.recordSectionText")}</p>
                <div className="flex flex-wrap justify-center gap-2">
                    <button
                        type="button"
                        className="btn btn-primary"
                        onClick={handleRecording}
                        disabled={isRecordingPlaying}
                    >
                        {isRecording ? (
                            <>
                                <BsStopCircle className="h-5 w-5" />{" "}
                                {t("buttonConversationExam.stopRecordBtn")}
                            </>
                        ) : (
                            <>
                                <BsRecordCircle className="h-5 w-5" />{" "}
                                {t("buttonConversationExam.recordBtn")}
                            </>
                        )}
                    </button>
                    <button
                        type="button"
                        className="btn btn-accent"
                        onClick={handlePlayRecording}
                        disabled={!recordingExists || isRecording}
                    >
                        {isRecordingPlaying ? (
                            <>
                                <BsStopCircle className="h-5 w-5" />{" "}
                                {t("buttonConversationExam.stopPlayBtn")}
                            </>
                        ) : (
                            <>
                                <BsPlayCircle className="h-5 w-5" />{" "}
                                {t("buttonConversationExam.playBtn")}
                            </>
                        )}
                    </button>
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="container-lg mx-auto">
            {reviews.map((review, index) => (
                <div key={index} className="mb-2">
                    <label htmlFor={`review-${index}`} className="cursor-pointer">
                        <span>{t(review.text)}</span>
                        <input
                            id={`review-${index}`}
                            type="checkbox"
                            className="checkbox checkbox-sm ms-2 align-text-bottom"
                            checked={!!reviewState[index + 1]}
                            onChange={() => handleCheckboxChange(index + 1)}
                        />
                    </label>
                </div>
            ))}
        </div>
    );
};

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 (
        <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
            <div>
                <div className="text-xl font-semibold">{t("tabConversationExam.watchCard")}</div>
                <div className="divider divider-secondary mt-0 mb-4"></div>
                <div className="bg-base-100 top-[calc(14rem)] md:sticky md:z-10">
                    <div className="aspect-video">
                        <div className="relative h-full w-full">
                            {isElectron() && videoUrl && videoUrl.startsWith("http://localhost") ? (
                                <MediaPlayer src={videoUrl}>
                                    <MediaProvider />
                                    <DefaultVideoLayout
                                        icons={defaultLayoutIcons}
                                        colorScheme={autoDetectedTheme}
                                    />
                                    <Track
                                        src={subtitleUrl}
                                        kind="subtitles"
                                        label="English"
                                        lang="en"
                                        type="srt"
                                        default
                                    />
                                </MediaPlayer>
                            ) : (
                                <>
                                    {iframeLoading && (
                                        <div className="skeleton absolute inset-0 h-full w-full"></div>
                                    )}
                                    <iframe
                                        src={videoUrl}
                                        title="Conversation video"
                                        loading="lazy"
                                        allowFullScreen
                                        onLoad={handleIframeLoad}
                                        className={`h-full w-full transition-opacity duration-300 ${
                                            iframeLoading ? "opacity-0" : "opacity-100"
                                        }`}
                                    ></iframe>
                                </>
                            )}
                        </div>
                    </div>
                    {isElectron() && !videoUrl.startsWith("http://localhost") ? (
                        <div role="alert" className="alert mt-5">
                            <IoInformationCircleOutline className="h-6 w-6" />
                            <span>{t("alert.alertOnlineVideo")}</span>
                        </div>
                    ) : (
                        ""
                    )}
                </div>
            </div>
            <div>
                <div className="text-xl font-semibold">{t("tabConversationExam.studyCard")}</div>
                <div className="divider divider-secondary mt-0 mb-4"></div>
                <div className="collapse-arrow bg-base-200 collapse dark:bg-slate-700">
                    <input type="checkbox" />
                    <button type="button" className="collapse-title text-start font-semibold">
                        {t("tabConversationExam.studyExpandBtn")}
                    </button>
                    <div className="collapse-content">
                        <div className="dialog-section">
                            {dialog.map((line, index) => (
                                <p lang="en" key={index} className="mb-2">
                                    <strong>{line.speaker}:</strong>{" "}
                                    <span
                                        dangerouslySetInnerHTML={{
                                            __html: line.speech.replace(
                                                /highlight-dialog-(\d+)/g,
                                                (match, p1) =>
                                                    highlightState[p1]
                                                        ? `${p1 === "1" ? "bg-primary text-primary-content font-semibold" : "bg-secondary text-secondary-content font-semibold"}`
                                                        : ""
                                            ),
                                        }}
                                    ></span>
                                </p>
                            ))}
                        </div>
                        <div className="divider"></div>
                        <div className="px-3">
                            {skillCheckmark.map((skill, index) => (
                                <div key={index} className="mb-2">
                                    <label
                                        htmlFor={`skilllabel-${index}`}
                                        className="cursor-pointer"
                                    >
                                        <span
                                            className={`${
                                                highlightState[index + 1]
                                                    ? index === 0
                                                        ? "bg-primary text-primary-content"
                                                        : "bg-secondary text-secondary-content"
                                                    : ""
                                            }`}
                                        >
                                            {t(skill.label)}
                                        </span>
                                        <input
                                            id={`skilllabel-${index}`}
                                            type="checkbox"
                                            className={`checkbox checkbox-sm ms-2 align-text-bottom ${
                                                highlightState[index + 1]
                                                    ? index === 0
                                                        ? "checkbox-primary"
                                                        : "checkbox-secondary"
                                                    : ""
                                            }`}
                                            checked={!!highlightState[index + 1]}
                                            onChange={() => handleCheckboxChange(index + 1)}
                                        />
                                    </label>
                                </div>
                            ))}
                        </div>
                    </div>
                </div>
            </div>
        </div>
    );
};

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 (
        <>
            <TopNavBar />
            <Container>
                {/* Hero Section with Gradient */}
                <div className="animate-fade-in relative isolate rounded-3xl bg-gradient-to-br from-blue-100 via-indigo-100 to-white px-6 pt-14 shadow-lg lg:px-8 dark:bg-gradient-to-br dark:from-indigo-900 dark:via-blue-900 dark:to-purple-900 dark:bg-blend-multiply">
                    <div className="mx-auto max-w-2xl py-20">
                        <div className="mb-8 flex justify-center">
                            <img
                                className="scale-110 object-contain drop-shadow-xl transition-transform duration-300 hover:scale-125"
                                src={`${import.meta.env.BASE_URL}images/icons/windows11/StoreLogo.scale-200.png`}
                                alt="App logo"
                            />
                        </div>
                        <div className="text-center">
                            <h1 className="animate-fade-in bg-gradient-to-r from-indigo-500 via-blue-500 to-purple-500 bg-clip-text text-4xl font-extrabold tracking-tight text-balance text-transparent md:text-6xl dark:bg-gradient-to-r dark:from-orange-300 dark:via-green-300 dark:to-red-500 dark:bg-clip-text dark:text-transparent">
                                {t("downloadPage.titleMain")}
                            </h1>
                            <p className="animate-fade-in mt-8 text-lg font-medium text-pretty text-gray-500 sm:text-xl/8 dark:text-slate-300">
                                {t("downloadPage.descriptionMain")}
                            </p>
                            <div className="animate-fade-in mt-10 flex items-center justify-center gap-x-6">
                                <a
                                    href="https://github.com/learnercraft/ispeakerreact/releases/latest"
                                    className="btn btn-primary btn-lg shadow-lg transition-transform duration-200 hover:scale-105"
                                    target="_blank"
                                >
                                    {t("downloadPage.downloadBtn")}
                                </a>
                            </div>
                            <div className="mt-10 items-center">
                                <p className="text-lg">{t("downloadPage.availablePlatformText")}</p>
                            </div>
                            <div className="mt-5 flex flex-wrap items-center justify-center gap-6">
                                <img
                                    alt="Windows icon"
                                    className="w-24 transition-transform duration-200 hover:scale-110"
                                    src={`${import.meta.env.BASE_URL}images/logos/windows-logo.svg`}
                                />
                                <img
                                    alt="Apple icon"
                                    className="w-24 transition-transform duration-200 hover:scale-110"
                                    src={`${import.meta.env.BASE_URL}images/logos/apple-logo.svg`}
                                />
                                <img
                                    alt="Linux icon"
                                    className="w-24 transition-transform duration-200 hover:scale-110"
                                    src={`${import.meta.env.BASE_URL}images/logos/linux-logo.svg`}
                                />
                                <a
                                    href="https://apps.microsoft.com/detail/9nwk49glxgfp?mode=direct"
                                    target="_blank"
                                >
                                    <img
                                        src={`${import.meta.env.BASE_URL}images/logos/ms-store-badge.svg`}
                                        width="200"
                                        className="transition-transform duration-200 hover:scale-105"
                                    />
                                </a>
                            </div>
                        </div>
                    </div>
                </div>

                {/* Pronunciation Checker Highlight */}
                <div className="animate-fade-in mt-12 flex justify-center">
                    <div className="card bg-base-100 border-primary/20 w-full max-w-2xl border shadow-xl">
                        <div className="card-body items-center text-center">
                            <div className="mb-2 flex items-center gap-2">
                                <span className="badge badge-accent badge-sm mr-2 animate-bounce">
                                    {t("downloadPage.newBadge")}
                                </span>
                                <span className="badge badge-primary badge-lg">
                                    {t("downloadPage.desktopOnly")}
                                </span>
                            </div>
                            <h2 className="card-title text-2xl font-bold">
                                {t("downloadPage.featurePronunciationCheckerText")}
                            </h2>
                            <p className="text-base text-gray-600 dark:text-slate-400">
                                {t("downloadPage.featurePronunciationCheckerDesc")}
                            </p>
                            <p className="text-sm text-gray-600 dark:text-slate-400">
                                <Trans
                                    i18nKey="downloadPage.featurePronunciationCheckerDescNote"
                                    components={[
                                        <button
                                            key="python-link"
                                            type="button"
                                            className="link font-semibold underline link-info"
                                            onClick={() =>
                                                window.open(
                                                    "https://www.python.org/downloads/",
                                                    "_blank"
                                                )
                                            }
                                        />,
                                    ]}
                                />
                            </p>
                        </div>
                    </div>
                </div>

                {/* Features */}
                <div className="animate-fade-in mt-16 overflow-hidden pb-24">
                    <div className="mx-auto max-w-7xl px-6 lg:px-8">
                        <div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
                            <div className="lg:pt-4 lg:pr-8">
                                <div className="lg:max-w-lg">
                                    <p className="mt-2 text-4xl font-semibold tracking-tight text-pretty text-gray-900 sm:text-5xl dark:text-slate-300">
                                        {t("downloadPage.featureMain")}
                                    </p>
                                    <dl className="mt-10 max-w-xl space-y-8 text-base/7 text-gray-600 lg:max-w-none dark:text-slate-400">
                                        {features.map((feature) => (
                                            <div
                                                key={feature.name}
                                                className="card bg-base-200/60 relative mb-2 p-4 shadow-sm transition-shadow duration-200 hover:shadow-lg"
                                            >
                                                <dt className="inline font-semibold text-gray-900 dark:text-slate-300">
                                                    {feature.name}
                                                    {feature.desktopOnly && (
                                                        <span className="badge badge-primary badge-sm ml-2">
                                                            {t("downloadPage.desktopOnly")}
                                                        </span>
                                                    )}
                                                </dt>{" "}
                                                <dd className="inline">{feature.description}</dd>
                                            </div>
                                        ))}
                                    </dl>
                                </div>
                            </div>
                            {/* Screenshot on the right, vertically centered */}
                            <div className="flex flex-1 items-center py-8">
                                <img
                                    alt="Screenshot"
                                    src={`${import.meta.env.BASE_URL}images/screenshots/screenshot-08.webp`}
                                    width={1920}
                                    height={1080}
                                    className="w-[48rem] max-w-none rounded-lg shadow-xl transition-transform duration-300 sm:w-[57rem] md:-ml-4 lg:-ml-0"
                                />
                            </div>
                        </div>
                    </div>
                </div>

                <div className="mb-12 flex items-center justify-center">
                    <p className="mt-2 text-4xl font-semibold tracking-tight text-pretty text-gray-900 sm:text-5xl dark:text-slate-300">
                        {t("downloadPage.faq")}
                    </p>
                </div>
                <div className="mb-12 flex items-center justify-center">
                    <div className="join join-vertical w-full max-w-2xl">
                        {faqItems.map((item, idx) => (
                            <div
                                key={idx}
                                className="collapse-arrow join-item border-base-300 collapse border dark:border-slate-600"
                            >
                                <input type="checkbox" name="faq-accordion" />
                                <div className="collapse-title text-lg font-semibold">
                                    {t(`downloadPage.${item.questionKey}`)}
                                </div>
                                <div className="collapse-content text-gray-600 dark:text-slate-400">
                                    {t(`downloadPage.${item.answerKey}`)}
                                </div>
                            </div>
                        ))}
                    </div>
                </div>
            </Container>
            <Footer />
        </>
    );
};

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 <LoadingOverlay />;
    }

    // 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 (
        <>
            <h3 className="mt-4 mb-2 text-2xl font-semibold">
                {t("tabConversationExam.taskCard")}: {t(title)}
                <button
                    type="button"
                    className="btn btn-circle btn-ghost btn-sm ms-1 align-middle"
                    onClick={() => examMainInfoModal.current?.showModal()}
                >
                    <IoInformationCircleOutline className="h-6 w-6" />
                </button>
            </h3>
            <p>
                {t("accent.accentSettings")}:{" "}
                {t(accent === "british" ? "accent.accentBritish" : "accent.accentAmerican")}
            </p>
            <button type="button" className="btn btn-secondary my-4" onClick={onBack}>
                <IoChevronBackOutline className="h-5 w-5" />{" "}
                {t("buttonConversationExam.examBackBtn")}
            </button>

            <div className="bg-base-100 sticky top-[calc(5rem)] z-10 py-8">
                <div className="flex justify-center">
                    <div role="tablist" className="tabs tabs-box justify-center">
                        <a
                            role="tab"
                            onClick={() => {
                                setActiveTab("watchStudyTab");
                                scrollTo();
                            }}
                            className={`tab md:text-base ${
                                activeTab === "watchStudyTab" ? "tab-active font-semibold" : ""
                            }`}
                        >
                            <MdOutlineOndemandVideo className="me-1 h-6 w-6" />
                            {t("buttonConversationExam.watchBtn")}
                        </a>
                        <a
                            role="tab"
                            onClick={() => {
                                setActiveTab("listenTab");
                                scrollTo();
                            }}
                            className={`tab md:text-base ${
                                activeTab === "listenTab" ? "tab-active font-semibold" : ""
                            }`}
                        >
                            <MdHeadphones className="me-1 h-6 w-6" />
                            {t("buttonConversationExam.listenBtn")}
                        </a>
                        <a
                            role="tab"
                            onClick={() => {
                                setActiveTab("practiceTab");
                                scrollTo();
                            }}
                            className={`tab md:text-base ${
                                activeTab === "practiceTab" ? "tab-active font-semibold" : ""
                            }`}
                        >
                            <MdKeyboardVoice className="me-1 h-6 w-6" />
                            {t("buttonConversationExam.practiceBtn")}
                        </a>
                        <a
                            role="tab"
                            onClick={() => {
                                setActiveTab("reviewTab");
                                scrollTo();
                            }}
                            className={`tab md:text-base ${
                                activeTab === "reviewTab" ? "tab-active font-semibold" : ""
                            }`}
                        >
                            <MdChecklist className="me-1 h-6 w-6" />
                            {t("buttonConversationExam.reviewBtn")}
                        </a>
                    </div>
                </div>
            </div>

            <div
                ref={scrollRef}
                className="card card-lg card-border mb-6 w-full shadow-md dark:border-slate-600"
            >
                <div className="card-body">
                    {activeTab === "watchStudyTab" && (
                        <WatchAndStudyTab
                            videoUrl={videoUrl}
                            subtitleUrl={subtitleUrl}
                            taskData={examDetails.watch_and_study.taskData}
                            dialog={examDetails.watch_and_study.study.dialog}
                            skills={examDetails.watch_and_study.study.skills}
                            scrollTo={scrollTo}
                        />
                    )}
                    {activeTab === "listenTab" && (
                        <ListeningTab
                            subtopicsBre={examDetails.listen.BrE?.subtopics || []}
                            subtopicsAme={examDetails.listen.AmE?.subtopics || []}
                            currentAccent={accent}
                            scrollTo={scrollTo}
                        />
                    )}
                    {activeTab === "practiceTab" && (
                        <PracticeTab
                            examId={id}
                            accent={accent}
                            taskData={examDetails.practise.task}
                            tips={examDetails.practise.tips}
                            scrollTo={scrollTo}
                        />
                    )}
                    {activeTab === "reviewTab" && (
                        <ReviewTab
                            reviews={examDetails.reviews}
                            examId={id}
                            accent={accent}
                            scrollTo={scrollTo}
                        />
                    )}
                </div>
            </div>

            <dialog ref={examMainInfoModal} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">{t("examPage.taskInfo")}</h3>
                    <div className="py-4">
                        {examLocalizedDescArray.map((desc, index) => (
                            <p
                                key={index}
                                className={
                                    index === examLocalizedDescArray.length - 1 ? "mb-0" : "mb-2"
                                }
                            >
                                {desc}
                            </p>
                        ))}
                    </div>
                    <div className="modal-action">
                        <form method="dialog">
                            <button className="btn">Close</button>
                        </form>
                    </div>
                </div>
            </dialog>
        </>
    );
};

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) => <p key={index}>{line}</p>);

        return (
            <>
                <div
                    className="tooltip tooltip-secondary dark:tooltip-accent hidden sm:inline"
                    data-tip=""
                >
                    <div className="tooltip-content">{tooltipText}</div>
                    <IoInformationCircleOutline className="hover:text-primary dark:hover:text-accent h-5 w-5 cursor-pointer" />
                </div>

                {/* Modal trigger button for small screens */}
                <button
                    type="button"
                    title={t("examPage.expandInfoBtn")}
                    className="btn btn-circle btn-sm items-center sm:hidden"
                    onClick={() => handleShowTooltip(tooltipText)}
                >
                    <IoInformationCircleOutline className="h-5 w-5" />
                </button>
            </>
        );
    };

    TooltipIcon.propTypes = {
        exam_popup: PropTypes.string.isRequired,
    };

    const ExamCard = ({ heading, titles }) => (
        <div className="card card-lg card-border flex h-auto w-full flex-col justify-between shadow-md md:w-1/3 lg:w-1/4 dark:border-slate-600">
            <div className="card-body grow">
                <div className="card-title font-semibold">{t(heading)}</div>
                <div className="divider divider-secondary m-0"></div>
                {titles.map(({ title, exam_popup, id }, index) => (
                    <div key={index} className="mb-2 flex items-center gap-2 align-middle">
                        <a
                            className="link hover:link-primary dark:hover:link-accent"
                            onClick={() => handleSelectExam(id, title)}
                        >
                            {t(title)}
                        </a>
                        <TooltipIcon exam_popup={exam_popup} />
                    </div>
                ))}
            </div>
        </div>
    );

    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 (
        <>
            <TopNavBar />
            <Container>
                <h1 className="py-6 text-3xl font-bold md:text-4xl">{t("navigation.exams")}</h1>
                {selectedExam ? (
                    <Suspense fallback={<LoadingOverlay />}>
                        <ExamDetailPage
                            id={selectedExam.id}
                            accent={selectedAccent}
                            title={selectedExam.title}
                            onBack={() => setSelectedExam(null)}
                        />
                    </Suspense>
                ) : (
                    <>
                        <AccentDropdown onAccentChange={setSelectedAccent} />
                        <p className="my-4">{t("examPage.selectType")}</p>
                        {loading ? (
                            <LoadingOverlay />
                        ) : (
                            <div className="my-10 flex flex-wrap justify-center gap-7">
                                {data.map((section, index) => (
                                    <ExamCard
                                        key={index}
                                        heading={section.heading}
                                        titles={section.titles}
                                    />
                                ))}
                            </div>
                        )}
                        {/* Tooltip Modal */}
                        <dialog ref={modalRef} className="modal">
                            <div className="modal-box">
                                <h3 className="text-lg font-bold">{t("examPage.examModalInfo")}</h3>
                                <div className="py-4">{tooltipContent}</div>
                                <div className="modal-action">
                                    <button
                                        type="button"
                                        className="btn"
                                        onClick={handleCloseTooltip}
                                    >
                                        {t("sound_page.closeBtn")}
                                    </button>
                                </div>
                            </div>
                        </dialog>
                    </>
                )}
            </Container>
        </>
    );
};

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 (
        <>
            <div className="flex flex-wrap justify-center gap-4">
                {subtopics.map((subtopic, topicIndex) => (
                    <div
                        key={topicIndex}
                        className="card card-lg card-border w-full shadow-md md:w-2/5 lg:w-[30%] dark:border-slate-600"
                    >
                        <div className="card-body px-4 md:px-8">
                            <div className="card-title font-semibold">{t(subtopic.title)}</div>
                            <div className="divider divider-secondary m-0"></div>

                            <ul
                                role="list"
                                className="divide-y divide-gray-300 dark:divide-gray-600"
                            >
                                {subtopic.sentences.map((sentenceObj, sentenceIndex) => {
                                    const uniqueIndex = `${topicIndex}-${sentenceIndex}`;
                                    return (
                                        <li
                                            lang="en"
                                            role="button"
                                            className={`flex cursor-pointer justify-between gap-x-6 py-3 ${playingIndex === uniqueIndex ? "bg-secondary text-secondary-content" : ""}`}
                                            key={uniqueIndex}
                                            onClick={() =>
                                                handlePlayPause(uniqueIndex, sentenceObj.audioSrc)
                                            }
                                            // Disable all items if loading or playing another audio
                                            disabled={
                                                (loadingIndex !== null &&
                                                    loadingIndex !== uniqueIndex) ||
                                                (playingIndex !== null &&
                                                    playingIndex !== uniqueIndex)
                                            }
                                            type="button"
                                            aria-pressed={playingIndex === uniqueIndex}
                                            aria-disabled={
                                                loadingIndex !== null &&
                                                loadingIndex !== uniqueIndex
                                            }
                                        >
                                            <div className="flex min-w-0 gap-x-4">
                                                <div className="min-w-0 flex-auto">
                                                    <p
                                                        className="italic"
                                                        dangerouslySetInnerHTML={{
                                                            __html: sentenceObj.sentence,
                                                        }}
                                                    ></p>
                                                </div>
                                            </div>
                                            <div className="flex items-center gap-x-2">
                                                {loadingIndex === uniqueIndex ? (
                                                    <span className="loading loading-spinner loading-md"></span>
                                                ) : playingIndex === uniqueIndex ? (
                                                    <IoVolumeHigh className="h-6 w-6" />
                                                ) : (
                                                    <IoVolumeHighOutline className="h-6 w-6" />
                                                )}
                                            </div>
                                        </li>
                                    );
                                })}
                            </ul>
                        </div>
                    </div>
                ))}
            </div>
        </>
    );
};

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 (
        <>
            <div className="flex flex-wrap justify-center gap-4 lg:flex-nowrap">
                {taskData.map((task, taskIndex) => {
                    const examLocalizedPara = t(task.para, { returnObjects: true });
                    const examLocalizedListItems =
                        task.listItems && t(task.listItems, { returnObjects: true });

                    return (
                        <div
                            className="card card-lg card-border flex shadow-2xs dark:border-slate-600"
                            key={taskIndex}
                        >
                            <div className="card-body p-4">
                                <div className="text-xl font-semibold">
                                    {t("tabConversationExam.taskCard")} {taskIndex + 1}
                                </div>
                                <div className="divider divider-secondary mt-0 mb-4"></div>
                                {task.images.length > 0 && (
                                    <div className="mb-3 flex flex-row flex-wrap justify-center gap-4">
                                        {task.images.map((image, index) => (
                                            <div className="flex w-full md:w-[30%]" key={index}>
                                                <div className="aspect-w-16 aspect-h-9">
                                                    <img
                                                        className="h-full w-full cursor-pointer object-cover"
                                                        role="button"
                                                        src={`${
                                                            import.meta.env.BASE_URL
                                                        }images/ispeaker/exam_images/thumb/${image}.webp`}
                                                        onClick={() => handleImageClick(image)}
                                                    />
                                                </div>
                                            </div>
                                        ))}
                                    </div>
                                )}
                                <div>
                                    {examLocalizedPara.map((paragraph, index) => (
                                        <p key={index}>{paragraph}</p>
                                    ))}
                                    {examLocalizedListItems.length > 0 && (
                                        <ul className="ms-2 list-inside list-disc">
                                            {examLocalizedListItems.map((item, index) => (
                                                <li key={index}>{item}</li>
                                            ))}
                                        </ul>
                                    )}
                                </div>

                                <fieldset className="fieldset my-4">
                                    <legend className="fieldset-legend text-sm">
                                        {t("tabConversationExam.practiceExamTextbox")}
                                    </legend>
                                    <textarea
                                        key={taskIndex}
                                        id={taskIndex}
                                        className="textarea w-full text-base"
                                        ref={(el) => (textAreaRefs.current[taskIndex] = el)}
                                        value={textValues[taskIndex]}
                                        onChange={(e) =>
                                            setTextValues((prevValues) => {
                                                const newValues = [...prevValues];
                                                newValues[taskIndex] = e.target.value;
                                                return newValues;
                                            })
                                        }
                                        onInput={autoExpand}
                                    ></textarea>
                                </fieldset>

                                <div className="flex flex-wrap justify-center gap-2">
                                    <button
                                        type="button"
                                        className="btn btn-primary"
                                        onClick={() => handleSaveText(taskIndex)}
                                        disabled={!textValues[taskIndex]}
                                    >
                                        <BsFloppy className="h-5 w-5" />{" "}
                                        {t("buttonConversationExam.saveBtn")}
                                    </button>
                                    <button
                                        type="button"
                                        className="btn btn-error"
                                        onClick={() => handleClearText(taskIndex)}
                                        disabled={!textValues[taskIndex]}
                                    >
                                        <BsTrash className="h-5 w-5" />{" "}
                                        {t("buttonConversationExam.clearBtn")}
                                    </button>
                                </div>

                                <div className="divider"></div>

                                <div className="mt-4">
                                    <p className="mb-4">
                                        {t("tabConversationExam.recordSectionText")}
                                    </p>
                                    <div className="flex flex-wrap justify-center gap-2">
                                        <button
                                            type="button"
                                            className="btn btn-primary"
                                            onClick={() => handleRecording(taskIndex)}
                                            disabled={
                                                isRecordingPlaying ||
                                                (activeTaskIndex !== null &&
                                                    activeTaskIndex !== taskIndex)
                                            }
                                        >
                                            {isRecording && activeTaskIndex === taskIndex ? (
                                                <>
                                                    <BsStopCircle className="h-5 w-5" />{" "}
                                                    {t("buttonConversationExam.stopRecordBtn")}
                                                </>
                                            ) : (
                                                <>
                                                    <BsRecordCircle className="h-5 w-5" />{" "}
                                                    {t("buttonConversationExam.recordBtn")}
                                                </>
                                            )}
                                        </button>
                                        <button
                                            type="button"
                                            className="btn btn-accent"
                                            onClick={() => handlePlayRecording(taskIndex)}
                                            disabled={
                                                isRecording ||
                                                (activeTaskIndex !== null &&
                                                    activeTaskIndex !== taskIndex) ||
                                                !recordingExists[taskIndex]
                                            }
                                        >
                                            {isRecordingPlaying && activeTaskIndex === taskIndex ? (
                                                <>
                                                    <BsStopCircle className="h-5 w-5" />{" "}
                                                    {t("buttonConversationExam.stopPlayBtn")}
                                                </>
                                            ) : (
                                                <>
                                                    <BsPlayCircle className="h-5 w-5" />{" "}
                                                    {t("buttonConversationExam.playBtn")}
                                                </>
                                            )}
                                        </button>
                                    </div>
                                </div>
                            </div>
                        </div>
                    );
                })}
            </div>

            <div className="my-4">
                <div className="text-xl font-semibold">{t("tabConversationExam.tipCardExam")}</div>
                <div className="divider divider-secondary mt-0 mb-4"></div>
                <div className="flex w-full flex-col gap-4 px-4 lg:flex-row">
                    <div className="w-full">
                        <p className="mb-4 text-lg font-semibold">
                            {t("tabConversationExam.doCardExam")}
                        </p>
                        <ul className="space-y-2">
                            {examTipDoLocalized.map((tip, index) => (
                                <li className="flex gap-x-2" key={index}>
                                    <div className="flex gap-x-2">
                                        <IoCheckmark className="text-success h-6 w-6 items-center" />
                                    </div>

                                    <div className="flex min-w-0 gap-x-4">
                                        <div className="min-w-0 flex-auto">{tip}</div>
                                    </div>
                                </li>
                            ))}
                        </ul>
                    </div>
                    <div className="divider lg:divider-horizontal"></div>
                    <div className="w-full">
                        <p className="mb-4 text-lg font-semibold">
                            {t("tabConversationExam.dontsCardExam")}
                        </p>
                        <ul className="space-y-2">
                            {examTipDontLocalized.map((tip, index) => (
                                <li className="flex gap-x-2" key={index}>
                                    <div className="flex gap-x-2">
                                        <IoCloseOutline className="text-error h-6 w-6 items-center" />
                                    </div>

                                    <div className="flex min-w-0 gap-x-4">
                                        <div className="min-w-0 flex-auto">{tip}</div>
                                    </div>
                                </li>
                            ))}
                        </ul>
                    </div>
                </div>
            </div>

            <dialog ref={imageModalRef} className="modal">
                <div className="modal-box w-11/12 max-w-5xl">
                    <form method="dialog">
                        <button className="btn btn-circle btn-ghost btn-sm absolute top-2 right-2">
                            <IoCloseOutline className="h-6 w-6" />
                        </button>
                    </form>
                    <h3 className="text-lg font-bold">
                        {t("tabConversationExam.imageFullSizeModal")}
                    </h3>
                    <div className="relative flex w-full items-center justify-center py-4">
                        {imageLoading && ( // Show skeleton loader if image is loading
                            <div
                                className={`skeleton absolute h-full max-h-[600px] w-full max-w-6xl ${imageLoading ? "z-30" : ""}`}
                            ></div>
                        )}
                        {modalImage && (
                            <img
                                src={modalImage}
                                className={`max-h-[600px] object-contain transition-opacity duration-300 ${
                                    imageLoading ? "opacity-0" : "opacity-100"
                                }`}
                                loading="lazy"
                                onLoad={() => setImageLoading(false)} // Stop loading when image is loaded
                                onError={() => setImageLoading(false)} // Handle errors
                                alt="Full size"
                            />
                        )}
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
        <div className="container-lg mx-auto">
            {reviews.map((review, index) => (
                <div key={index} className="mb-2">
                    <label htmlFor={`review-${index}`} className="cursor-pointer">
                        <span>{t(review.text)}</span>
                        <input
                            id={`review-${index}`}
                            type="checkbox"
                            className="checkbox checkbox-sm ms-2 align-text-bottom"
                            checked={!!checkedReviews[`${examId}-${index}`]}
                            onChange={() => handleCheckboxChange(index)}
                        />
                    </label>
                </div>
            ))}
        </div>
    );
};

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 (
        <>
            <div>
                <div className="text-xl font-semibold">{t("tabConversationExam.taskCard")}</div>
                <div className="divider divider-secondary mt-0 mb-4"></div>

                <div className="flex flex-row flex-wrap justify-center gap-4">
                    {taskData.images.map((image, index) => (
                        <div className="flex w-full md:w-[30%]" key={index}>
                            <div className="aspect-w-16 aspect-h-9">
                                <img
                                    role="button"
                                    src={`${
                                        import.meta.env.BASE_URL
                                    }images/ispeaker/exam_images/thumb/${image}.webp`}
                                    className="h-full w-full cursor-pointer object-cover"
                                    onClick={() => handleImageClick(image)}
                                />
                            </div>
                        </div>
                    ))}
                </div>

                <div className="mt-3">
                    {examTaskQuestion.map((paragraph, index) => (
                        <p key={index}>{paragraph}</p>
                    ))}
                    {examTaskList && (
                        <ul className="ms-2 list-inside list-disc">
                            {examTaskList.map((item, index) => (
                                <li className="my-1" key={index}>
                                    {item}
                                </li>
                            ))}
                        </ul>
                    )}
                </div>
            </div>
            <div className="divider"></div>
            <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
                <div>
                    <div className="text-xl font-semibold">
                        {t("tabConversationExam.watchCard")}
                    </div>
                    <div className="divider divider-secondary mt-0 mb-4"></div>

                    <div className="bg-base-100 top-[calc(14rem)] md:sticky md:z-10">
                        <div className="aspect-video">
                            <div className="relative h-full w-full">
                                {isElectron() &&
                                videoUrl &&
                                videoUrl.startsWith("http://localhost") ? (
                                    <MediaPlayer src={videoUrl}>
                                        <MediaProvider />
                                        <DefaultVideoLayout
                                            icons={defaultLayoutIcons}
                                            colorScheme={autoDetectedTheme}
                                        />
                                        <Track
                                            src={subtitleUrl}
                                            kind="subtitles"
                                            label="English"
                                            lang="en"
                                            type="srt"
                                            default
                                        />
                                    </MediaPlayer>
                                ) : (
                                    <>
                                        {iframeLoading && (
                                            <div className="skeleton absolute inset-0 h-full w-full"></div>
                                        )}
                                        <iframe
                                            src={videoUrl}
                                            title="Exam video"
                                            loading="lazy"
                                            allowFullScreen
                                            onLoad={handleIframeLoad}
                                            style={
                                                iframeLoading
                                                    ? { visibility: "hidden" }
                                                    : { visibility: "visible" }
                                            }
                                            className={`h-full w-full transition-opacity duration-300 ${
                                                iframeLoading ? "opacity-0" : "opacity-100"
                                            }`}
                                        ></iframe>
                                    </>
                                )}
                            </div>
                        </div>
                        {isElectron() && !videoUrl.startsWith("http://localhost") ? (
                            <div role="alert" className="alert mt-5">
                                <IoInformationCircleOutline className="h-6 w-6" />
                                <span>{t("alert.alertOnlineVideo")}</span>
                            </div>
                        ) : (
                            ""
                        )}
                    </div>
                </div>

                <div>
                    <div className="text-xl font-semibold">
                        {t("tabConversationExam.studyCard")}
                    </div>
                    <div className="divider divider-secondary mt-0 mb-4"></div>
                    <div className="collapse-arrow bg-base-200 collapse dark:bg-slate-700">
                        <input type="checkbox" />
                        <button type="button" className="collapse-title text-start font-semibold">
                            {t("tabConversationExam.studyExpandBtn")}
                        </button>
                        <div className="collapse-content">
                            <div className="dialog-section">
                                {dialog.map((line, index) => (
                                    <div lang="en" key={index} className="mb-2">
                                        <span className="font-bold">{line.speaker}:</span>{" "}
                                        <span
                                            dangerouslySetInnerHTML={{
                                                __html: highlightDialog(line.speech),
                                            }}
                                        ></span>
                                    </div>
                                ))}
                            </div>
                            <div className="divider"></div>
                            <div className="px-3">
                                {skills.map((skill, index) => (
                                    <div key={index} className="mb-2">
                                        <label
                                            htmlFor={`skilllabel-${index}`}
                                            className="cursor-pointer"
                                        >
                                            <span
                                                className={`${
                                                    highlightState[index + 1]
                                                        ? getHighlightClass(index + 1)
                                                        : ""
                                                }`}
                                            >
                                                {t(skill.label)}
                                            </span>
                                            <input
                                                id={`skilllabel-${index}`}
                                                type="checkbox"
                                                className={`checkbox checkbox-sm ms-2 align-text-bottom ${
                                                    highlightState[index + 1]
                                                        ? getCheckboxHighlightClass(index + 1)
                                                        : ""
                                                }`}
                                                checked={!!highlightState[index + 1]}
                                                onChange={() => handleCheckboxChange(index + 1)}
                                            />
                                        </label>
                                    </div>
                                ))}
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <dialog ref={imageModalRef} className="modal">
                <div className="modal-box w-11/12 max-w-5xl">
                    <form method="dialog">
                        <button className="btn btn-circle btn-ghost btn-sm absolute top-2 right-2">
                            <IoCloseOutline className="h-6 w-6" />
                        </button>
                    </form>
                    <h3 className="text-lg font-bold">
                        {t("tabConversationExam.imageFullSizeModal")}
                    </h3>
                    <div className="relative flex w-full items-center justify-center py-4">
                        {imageLoading && ( // Show skeleton loader if image is loading
                            <div
                                className={`skeleton absolute h-full max-h-[600px] w-full max-w-6xl ${imageLoading ? "z-30" : "z-0"}`}
                            ></div>
                        )}
                        {modalImage && (
                            <img
                                src={modalImage}
                                className={`max-h-[600px] object-contain transition-opacity duration-300 ${
                                    imageLoading ? "z-0 opacity-0" : "z-30 opacity-100"
                                }`}
                                loading="lazy"
                                onLoad={() => setImageLoading(false)} // Stop loading when image is loaded
                                onError={() => setImageLoading(false)} // Handle errors
                                alt="Full size"
                            />
                        )}
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
                    <span lang="en" className="mx-2" key={index}>
                        {word.value}
                    </span>
                );
            }

            if (word.textbox) {
                const isCorrect = answer.trim().toLowerCase() === word.textbox.toLowerCase();

                return (
                    <span
                        key={index}
                        className={`my-2 inline-block ${hasValueAndTextbox ? "w-48" : "w-full lg:w-3/4"}`}
                    >
                        <label lang="en" className="input items-center gap-0 md:gap-2">
                            <input
                                type="text"
                                value={answer}
                                onChange={(e) => {
                                    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 ? (
                                        <AiOutlineCheckCircle className="text-primary h-6 w-6" />
                                    ) : (
                                        <AiOutlineCloseCircle className="text-error h-6 w-6" />
                                    )}
                                </>
                            )}
                        </label>
                    </span>
                );
            }

            return null;
        });
    };

    return (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>
                <div className="flex justify-center">
                    <button
                        type="button"
                        title={t("exercise_page.buttons.playAudioBtn")}
                        onClick={handleAudioPlay}
                        className="btn btn-circle btn-success my-3"
                        disabled={isLoading}
                    >
                        {isLoading ? (
                            <span className="loading loading-spinner loading-md"></span>
                        ) : isPlaying ? (
                            <IoVolumeHigh className="h-6 w-6" />
                        ) : (
                            <IoVolumeHighOutline className="h-6 w-6" />
                        )}
                    </button>
                </div>
                <form onSubmit={handleSubmit}>
                    <div className="text-center">{renderWords()}</div>
                </form>
                {showValidation && validationVariant === "danger" && (
                    <div className="flex justify-center">
                        <div
                            role="alert"
                            className="alert alert-info my-4 flex w-full gap-1 md:gap-2 lg:w-1/2"
                        >
                            <IoInformationCircleOutline className="h-6 w-6" />
                            <div className="w-4/5 lg:w-auto">
                                <h3>{t("exercise_page.result.correctAnswer")}</h3>
                                <p className="text-xl font-bold italic" lang="en">
                                    {validationMessage}
                                </p>
                            </div>
                        </div>
                    </div>
                )}
                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button
                            type="button"
                            className="btn btn-primary"
                            disabled={isSubmitButtonEnabled}
                            onClick={handleSubmit}
                        >
                            <LiaCheckCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.checkBtn")}
                        </button>
                        {currentQuestionIndex < quiz.length - 1 && (
                            <button type="button" className="btn btn-accent" onClick={handleNext}>
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
                <div>
                    {t("exercise_page.encouragementMsg.level0")}
                    <img src={rocketEmoji} className="ms-2 inline h-5 w-5" />
                </div>
            );

        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 (
            <div>
                {t(`exercise_page.encouragementMsg.level${level}`)}
                <img src={emojis[level]} className="ms-2 inline h-5 w-5" />
            </div>
        );
    };

    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 (
            <Suspense fallback={<LoadingOverlay />}>
                {QuizComponent ? (
                    <QuizComponent
                        quiz={quiz}
                        instructions={instructions}
                        onAnswer={handleAnswer}
                        onQuit={handleQuizQuit}
                        {...(exerciseType === "reordering" ? { split } : {})} // Pass `split` prop for reordering
                        timer={timer}
                        setTimeIsUp={setTimeIsUp}
                        onMatchFinished={handleMatchFinished}
                    />
                ) : (
                    <div className="card-body">This quiz type is not yet implemented.</div>
                )}
            </Suspense>
        );
    };

    return (
        <>
            {isloading ? (
                <LoadingOverlay />
            ) : (
                <>
                    <h3 className="mt-4 text-2xl font-semibold">{t(heading)}</h3>
                    <p className="mb-6 text-lg">{title}</p>
                    <div className="flex flex-wrap gap-8 md:flex-nowrap">
                        <div className="w-full md:w-1/3">
                            <p className="mb-4">
                                <strong>{t("accent.accentSettings")}:</strong>{" "}
                                {accent === "American English"
                                    ? t("accent.accentAmerican")
                                    : t("accent.accentBritish")}
                            </p>

                            <dialog ref={instructionModal} className="modal">
                                <div className="modal-box">
                                    <h3 className="text-lg font-bold">
                                        {t("exercise_page.buttons.instructionBtn")}
                                    </h3>
                                    <div className="py-4">
                                        {instructions &&
                                        Array.isArray(instructions) &&
                                        instructions.length > 0 ? (
                                            instructions.map((instruction, index) => (
                                                <p
                                                    key={index}
                                                    className={`mb-2 ${
                                                        index === instructions.length - 1
                                                            ? "mb-0"
                                                            : ""
                                                    }`}
                                                >
                                                    {instruction}
                                                </p>
                                            ))
                                        ) : (
                                            <p className="mb-0">
                                                [Instructions for this type of exercise is not yet
                                                translated. Please update accordingly.]
                                            </p>
                                        )}
                                    </div>
                                    <div className="modal-action">
                                        <form method="dialog">
                                            <button
                                                type="button"
                                                className="btn"
                                                onClick={() => instructionModal.current?.close()}
                                            >
                                                {t("sound_page.closeBtn")}
                                            </button>
                                        </form>
                                    </div>
                                </div>
                            </dialog>

                            <button
                                type="button"
                                className="btn btn-neutral dark:btn-outline block md:hidden"
                                onClick={() => instructionModal.current?.showModal()}
                            >
                                {t("exercise_page.buttons.expandBtn")}
                            </button>

                            <div className="collapse-arrow bg-base-200 collapse hidden md:grid dark:bg-slate-700">
                                <input type="checkbox" defaultChecked />
                                <button
                                    type="button"
                                    className="collapse-title text-start font-semibold"
                                >
                                    {t("exercise_page.buttons.expandBtn")}
                                </button>
                                <div className="collapse-content">
                                    {instructions &&
                                    Array.isArray(instructions) &&
                                    instructions.length > 0 ? (
                                        instructions.map((instruction, index) => (
                                            <p
                                                key={index}
                                                className={`mb-2 ${index === instructions.length - 1 ? "mb-0" : ""}`}
                                            >
                                                {instruction}
                                            </p>
                                        ))
                                    ) : (
                                        <p className="mb-0">
                                            [Instructions for this type of exercise is not yet
                                            translated. Please update accordingly.]
                                        </p>
                                    )}
                                </div>
                            </div>

                            <button
                                type="button"
                                className="btn btn-secondary my-8"
                                onClick={onBack}
                            >
                                <BsChevronLeft className="h-5 w-5" />{" "}
                                {t("exercise_page.buttons.backBtn")}
                            </button>
                        </div>

                        <div className="w-full md:w-2/3">
                            <div className="card card-lg card-border shadow-md dark:border-slate-600">
                                {timeIsUp || quizCompleted || onMatchFinished ? (
                                    <>
                                        <div className="card-body">
                                            <div className="card-title font-semibold">
                                                {t("exercise_page.result.cardHeading")}
                                            </div>
                                            <div className="divider divider-secondary m-0"></div>
                                            {onMatchFinished ? (
                                                <p>{t("exercise_page.result.matchUpFinished")}</p>
                                            ) : (
                                                ""
                                            )}
                                            {timeIsUp && !onMatchFinished ? (
                                                <p>{t("exercise_page.result.timeUp")}</p>
                                            ) : (
                                                ""
                                            )}
                                            {score === 0 &&
                                            totalAnswered === 0 &&
                                            currentExerciseType !== "memory_match" ? (
                                                <p>{t("exercise_page.result.notAnswered")}</p>
                                            ) : currentExerciseType !== "memory_match" ? (
                                                <>
                                                    <p>
                                                        {t("exercise_page.result.answerResult", {
                                                            score,
                                                            totalAnswered,
                                                        })}
                                                    </p>
                                                    {encouragementMessage}
                                                    <p>{t("exercise_page.result.answerBottom")}</p>
                                                </>
                                            ) : (
                                                <p>{t("exercise_page.result.answerBottom")}</p>
                                            )}
                                            <div className="card-actions justify-center">
                                                <button
                                                    type="button"
                                                    className="btn btn-accent mt-4"
                                                    onClick={handleQuizRestart}
                                                >
                                                    <PiArrowsCounterClockwise className="h-5 w-5" />{" "}
                                                    {t("exercise_page.buttons.restartBtn")}
                                                </button>
                                            </div>
                                        </div>
                                    </>
                                ) : (
                                    <>{renderQuizComponent()}</>
                                )}
                            </div>

                            {timeIsUp || quizCompleted || currentExerciseType == "memory_match" ? (
                                ""
                            ) : (
                                <div className="card card-lg card-border mt-4 shadow-md dark:border-slate-600">
                                    <div className="card-body">
                                        <div className="card-title font-semibold">
                                            {t("sound_page.reviewCard")}
                                        </div>
                                        <div className="divider divider-secondary m-0"></div>
                                        {score === 0 && totalAnswered === 0 ? (
                                            ""
                                        ) : (
                                            <p>
                                                {t("exercise_page.result.answerResult", {
                                                    score,
                                                    totalAnswered,
                                                })}
                                            </p>
                                        )}

                                        {getEncouragementMessage()}

                                        {score === 0 && totalAnswered === 0 ? (
                                            ""
                                        ) : (
                                            <p>{t("exercise_page.result.tryAgainBottom")}</p>
                                        )}
                                    </div>
                                </div>
                            )}
                        </div>
                    </div>
                </>
            )}
        </>
    );
};

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 */}
                <div
                    className="tooltip tooltip-secondary dark:tooltip-accent hidden sm:inline"
                    data-tip={info}
                >
                    <IoInformationCircleOutline className="hover:text-primary dark:hover:text-accent h-5 w-5 cursor-pointer" />
                </div>

                {/* Modal trigger button for small screens */}
                <button
                    type="button"
                    title={t("exercise_page.buttons.expandBtn")}
                    className="btn btn-circle btn-sm focus:ring-2 sm:hidden"
                    onClick={onClick}
                >
                    <IoInformationCircleOutline className="h-5 w-5 cursor-pointer" />
                </button>
            </>
        );
    };

    const ExerciseCard = ({ heading, titles, infoKey, file, onShowModal }) => (
        <div className="card card-lg card-border flex h-auto w-full flex-col justify-between shadow-md md:w-1/3 lg:w-1/4 dark:border-slate-600">
            <div className="card-body grow">
                <div className="card-title font-semibold">{t(heading)}</div>
                <div className="divider divider-secondary m-0"></div>
                {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 (
                            <div key={index} className="mb-2 flex items-center space-x-2">
                                <a
                                    className="link hover:link-primary dark:hover:link-accent"
                                    onClick={() =>
                                        handleSelectExercise({ ...exercise, file }, heading)
                                    }
                                >
                                    {t(exercise.titleKey) || exercise.title}
                                </a>
                                <TooltipIcon
                                    info={info}
                                    modalId={modalId}
                                    onClick={() => onShowModal(info)}
                                />
                            </div>
                        );
                    })}
            </div>
        </div>
    );

    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 (
        <>
            <TopNavBar />
            <Container>
                <h1 className="py-6 text-3xl font-bold md:text-4xl">{t("navigation.exercises")}</h1>
                {selectedExercise ? (
                    <ExerciseDetailPage
                        heading={selectedExercise.heading}
                        id={selectedExercise.id}
                        title={selectedExercise.title}
                        accent={selectedExercise.accent}
                        instructions={selectedExercise.instructions}
                        file={selectedExercise.file}
                        onBack={handleGoBack}
                    />
                ) : (
                    <>
                        <AccentDropdown onAccentChange={setSelectedAccent} />
                        <p className="my-4">{t("exercise_page.exerciseSubheading")}</p>
                        {loading ? (
                            <LoadingOverlay />
                        ) : (
                            <div className="my-10 flex flex-wrap justify-center gap-7">
                                {data.map((section, index) => (
                                    <ExerciseCard
                                        key={index}
                                        heading={section.heading}
                                        titles={section.titles}
                                        infoKey={section.infoKey}
                                        file={section.file}
                                        onShowModal={handleShowModal}
                                    />
                                ))}
                            </div>
                        )}

                        <dialog ref={modalRef} className="modal">
                            <div className="modal-box">
                                <h3 className="text-lg font-bold">
                                    {t("exercise_page.modalInfoHeader")}
                                </h3>
                                <p className="py-4">{modalInfo}</p>
                                <div className="modal-action">
                                    <form method="dialog">
                                        <button
                                            type="button"
                                            className="btn"
                                            onClick={handleCloseModal}
                                        >
                                            {t("sound_page.closeBtn")}
                                        </button>
                                    </form>
                                </div>
                            </div>
                        </dialog>
                    </>
                )}
            </Container>
        </>
    );
};

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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary mt-0 mb-3"></div>
                <div className="my-3 flex items-center justify-center gap-4">
                    {/* Audio Buttons Column */}
                    <div className="flex w-auto justify-end lg:w-2/5 2xl:w-1/3">
                        <div className="flex flex-col items-center gap-4">
                            {audioItems.map((audio, index) => (
                                <div key={index}>
                                    <button
                                        type="button"
                                        title={t("exercise_page.buttons.playAudioBtn")}
                                        className="btn btn-lg btn-circle btn-success"
                                        onClick={() => handleAudioPlay(audio.src, index)}
                                        disabled={isLoading[index]}
                                    >
                                        {isLoading[index] ? (
                                            <span className="loading loading-spinner loading-md"></span>
                                        ) : isPlaying === index ? (
                                            <IoVolumeHigh className="h-6 w-6" />
                                        ) : (
                                            <IoVolumeHighOutline className="h-6 w-6" />
                                        )}
                                    </button>
                                </div>
                            ))}
                        </div>
                    </div>

                    <div className="flex w-full lg:w-3/4 2xl:w-2/3">
                        {/* Sortable Words Column */}
                        <DndContext
                            sensors={sensors}
                            collisionDetection={closestCenter}
                            autoScroll={{ layoutShiftCompensation: false, enable: false }}
                            onDragStart={handleDragStart}
                            onDragEnd={handleDragEnd}
                        >
                            <div className="flex w-full flex-col justify-start gap-4 lg:w-3/4">
                                <SortableContext
                                    items={shuffledWords.map((word) => word.id)}
                                    strategy={verticalListSortingStrategy}
                                >
                                    {shuffledWords.map((word, index) => (
                                        <SortableWord
                                            key={word.id}
                                            word={word}
                                            index={index}
                                            isCorrect={isCorrectArray[index]}
                                            disabled={buttonsDisabled}
                                        />
                                    ))}
                                </SortableContext>
                                <DragOverlay>
                                    {activeId ? (
                                        <SortableWord
                                            word={{
                                                id: activeId,
                                                text: shuffledWords.find(
                                                    (item) => item.id === activeId
                                                )?.text,
                                            }}
                                            isOverlay={true} // Pass a prop to indicate it's in the overlay
                                        />
                                    ) : null}
                                </DragOverlay>
                            </div>
                        </DndContext>
                    </div>
                </div>

                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button
                            type="button"
                            className="btn btn-primary"
                            onClick={handleSubmit}
                            disabled={buttonsDisabled}
                        >
                            <LiaCheckCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.checkBtn")}
                        </button>
                        {currentQuestionIndex < shuffledQuiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    <div className="flex items-center">
                        <div className="flex-1 md:flex-none">
                            {t("exercise_page.memoryMatchHeading")}
                        </div>
                        <div className="ms-auto flex justify-end">
                            {t("exercise_page.timer")} {formatTime()}
                        </div>
                    </div>
                </div>
                <div className="divider divider-secondary m-0"></div>
                <Flipper flipKey={flippedCards.concat(matchedCards)}>
                    <div className="grid grid-cols-4 gap-2">
                        {shuffledQuiz.map((card) => (
                            <div className="flex flex-row flex-wrap justify-center" key={card.id}>
                                <Flipped flipId={card.id}>
                                    <div
                                        onClick={() => 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"
                                                  : ""
                                        }`}
                                    >
                                        <div className="card-inner">
                                            <div className="card-front"></div>
                                            <div
                                                className="card-back"
                                                style={{ transform: "rotateY(180deg)" }}
                                            >
                                                <span lang="en">{he.decode(card.text)}</span>
                                                {cardFeedback[card.id] === "correctPair" && (
                                                    <div className="absolute top-2 right-2 z-50 text-white">
                                                        <BsCheckCircleFill className="h-6 w-6" />
                                                    </div>
                                                )}
                                                {cardFeedback[card.id] === "incorrectPair" && (
                                                    <div className="absolute top-2 right-2 z-50 text-white">
                                                        <BsXCircleFill className="h-6 w-6" />
                                                    </div>
                                                )}
                                            </div>
                                        </div>
                                    </div>
                                </Flipped>
                            </div>
                        ))}
                    </div>
                </Flipper>

                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>
                <div className="grid grid-cols-2 place-content-stretch gap-4">
                    {shuffledOptions.map((option, index) => (
                        <div key={index}>
                            <button
                                type="button"
                                className={`btn btn-lg w-full p-3 text-center font-bold ${
                                    submitted && selectedOption === index
                                        ? option.answer === "true"
                                            ? "btn-success"
                                            : "btn-error"
                                        : selectedOption === index
                                          ? "btn-accent"
                                          : ""
                                } ${buttonsDisabled ? "pointer-events-none" : ""} ${
                                    submitted &&
                                    option.answer === "true" &&
                                    selectedOption !== index
                                        ? "btn-warning"
                                        : ""
                                }`}
                                onClick={() => handleOptionClick(index)}
                            >
                                <span lang="en">{he.decode(option.value)}</span>
                                {submitted ? (
                                    submitted && option.answer === "true" ? (
                                        <BsCheckCircleFill />
                                    ) : (
                                        <BsXCircleFill />
                                    )
                                ) : (
                                    ""
                                )}
                            </button>
                        </div>
                    ))}
                </div>

                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button
                            type="button"
                            className="btn btn-primary"
                            disabled={selectedOption === null || buttonsDisabled}
                            onClick={handleSubmit}
                        >
                            <LiaCheckCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.checkBtn")}
                        </button>
                        {currentQuestionIndex < quiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>

                <div className="my-3 flex w-full justify-center">
                    <button
                        type="button"
                        title={t("exercise_page.buttons.playAudioBtn")}
                        className="btn btn-circle btn-success"
                        onClick={handleAudioPlay}
                        disabled={isLoading}
                    >
                        {isLoading ? (
                            <span className="loading loading-spinner loading-md"></span>
                        ) : isPlaying ? (
                            <IoVolumeHigh className="h-6 w-6" />
                        ) : (
                            <IoVolumeHighOutline className="h-6 w-6" />
                        )}
                    </button>
                </div>

                <div className="my-3 flex grow flex-row flex-wrap justify-center gap-2">
                    <DndContext
                        sensors={sensors}
                        collisionDetection={closestCenter}
                        onDragStart={handleDragStart}
                        onDragEnd={handleDragEnd}
                    >
                        <SortableContext
                            items={shuffledItems}
                            strategy={horizontalListSortingStrategy}
                        >
                            {shuffledItems.map((item) => (
                                <SortableWord
                                    key={item.id}
                                    word={{ text: he.encode(item.value), id: item.id }}
                                    disabled={buttonsDisabled}
                                    isCorrect={item.isCorrect ?? null}
                                />
                            ))}
                        </SortableContext>
                        <DragOverlay>
                            {activeId ? (
                                <SortableWord
                                    key={activeId}
                                    word={{
                                        text: he.encode(
                                            shuffledItems.find((item) => item.id === activeId)
                                                ?.value || ""
                                        ),
                                        id: activeId,
                                    }}
                                    isOverlay={true}
                                />
                            ) : null}
                        </DragOverlay>
                    </DndContext>
                </div>

                {showAlert && (
                    <div className="flex justify-center">
                        <div
                            role="alert"
                            className="alert alert-info my-4 flex w-full gap-1 md:gap-2 lg:w-4/5 xl:w-3/5"
                        >
                            <IoInformationCircleOutline className="h-6 w-6" />
                            <div className="w-4/5 lg:w-auto">
                                <h3>{t("exercise_page.result.correctAnswer")}</h3>
                                <p className="text-xl font-bold italic">{correctAnswer}</p>
                            </div>
                        </div>
                    </div>
                )}
                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button
                            type="button"
                            className="btn btn-primary"
                            onClick={handleSubmit}
                            disabled={buttonsDisabled}
                        >
                            <LiaCheckCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.checkBtn")}
                        </button>
                        {currentQuestionIndex < quiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
            <button
                type="button"
                ref={setNodeRef}
                style={style}
                {...attributes}
                {...listeners}
                className={`btn btn-primary no-animation w-full text-base font-bold transition-none ${isDropped ? "z-30" : "z-20"}`}
                disabled={isDropped}
            >
                {t("exercise_page.dragThisItem")}
            </button>
        );
    };

    // 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")} <BsCheckCircleFill />
                </>
            ) : (
                <>
                    {t("exercise_page.snapIncorrect")} <BsXCircleFill />
                </>
            );

        // Translate "Yes" and "No" strings
        const translatedValue =
            feedback?.value === "Yes"
                ? t("exercise_page.snapYes")
                : feedback?.value === "No"
                  ? t("exercise_page.snapNo")
                  : feedback?.value;

        return (
            <button
                type="button"
                ref={setNodeRef}
                className={`btn btn-outline no-animation w-full text-lg font-bold transition-none ${bgColor} pointer-events-none ${
                    isDropped && droppedOn === feedback?.value ? buttonVariant : ""
                }`}
            >
                {isDropped && droppedOn === feedback?.value ? (
                    buttonText
                ) : (
                    <span>{translatedValue}</span> // Show Yes/No only if item hasn't been dropped
                )}
            </button>
        );
    };

    DraggableItem.propTypes = {
        isDropped: PropTypes.bool.isRequired,
    };

    DroppableArea.propTypes = {
        feedback: PropTypes.object.isRequired,
        isDropped: PropTypes.bool.isRequired,
        result: PropTypes.string,
        droppedOn: PropTypes.string,
    };

    return (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>
                {/* Present Word and Phonetic Transcription */}
                <div className="my-4 grid grid-rows-2 justify-center gap-2 text-center">
                    <p lang="en" className="text-xl font-semibold">
                        {he.decode(currentQuiz?.data?.[0]?.value || "")}
                    </p>
                    <p lang="en" className="text-xl font-semibold">
                        {he.decode(currentQuiz?.data?.[1]?.value || "")}
                    </p>
                </div>

                <div className="flex flex-col items-center justify-center gap-2 align-middle md:flex-row">
                    <DndContext onDragEnd={handleDragEnd}>
                        {/* Yes drop zone */}
                        <div className="w-full">
                            <DroppableArea
                                feedback={currentQuiz?.feedbacks?.find((f) => f.value === "Yes")}
                                isDropped={isDropped}
                                result={result}
                                droppedOn={droppedOn} // Track where the item is dropped
                            />
                        </div>

                        {/* Draggable item */}
                        <div className="w-full">
                            {!isDropped && <DraggableItem isDropped={isDropped} />}
                        </div>

                        {/* No drop zone */}
                        <div className="w-full">
                            <DroppableArea
                                feedback={currentQuiz?.feedbacks?.find((f) => f.value === "No")}
                                isDropped={isDropped}
                                result={result}
                                droppedOn={droppedOn} // Track where the item is dropped
                            />
                        </div>
                    </DndContext>
                </div>

                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        {currentQuestionIndex < quiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 ? (
            <BsCheckCircleFill className="ms-1 inline-block h-6 w-6" />
        ) : (
            <BsXCircleFill className="ms-1 inline-block h-6 w-6" />
        );
    };

    return !disabled ? (
        <button
            type="button"
            ref={(node) => {
                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" : ""}`}
        >
            <span lang="en">{he.decode(word?.text || item?.value)}</span>
            {renderTrueFalseIcon()}
        </button>
    ) : (
        <button
            type="button"
            className={`btn btn-lg no-animation w-full justify-center text-lg break-all transition-none ${
                item ? "" : "lg:w-4/5 xl:w-3/4"
            } pointer-events-none ${isCorrect ? "btn-success" : "btn-error"}`}
        >
            <p className="text-center font-bold" lang="en">
                {he.decode(word?.text || item?.value)} {renderTrueFalseIcon()}
            </p>
        </button>
    );
};

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,
    }) => (
        <div className="w-full justify-center lg:w-4/5 xl:w-3/5">
            <div className="text-center font-semibold">
                {heading && (
                    <span
                        lang="en"
                        dangerouslySetInnerHTML={{
                            __html: he.decode(heading.text),
                        }}
                    />
                )}
            </div>
            <div className="divider divider-accent mt-0 mb-2"></div>
            <div className="flex flex-col items-center gap-4">
                <SortableContext items={items} strategy={sortableStrategy}>
                    {items.length > 0 ? (
                        items.map((item) => (
                            <SortableWord
                                key={item.id}
                                item={item}
                                isCorrect={hasSubmitted ? item.columnPos === columnPos : null}
                                disabled={buttonsDisabled}
                            />
                        ))
                    ) : (
                        <div className="w-full p-4 text-center">
                            <div className="text-secondary rounded-xs border p-4">
                                {t("exercise_page.dropLayer")}
                            </div>
                        </div>
                    )}
                </SortableContext>
            </div>
        </div>
    );

    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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>

                <div className="my-3 flex flex-row justify-center gap-2 md:gap-6">
                    <DndContext
                        sensors={sensors}
                        collisionDetection={closestCenter}
                        onDragStart={handleDragStart}
                        onDragEnd={handleDragEnd}
                    >
                        {[itemsLeft, itemsRight].map((items, index) => (
                            <SortableColumn
                                key={index}
                                items={items}
                                heading={currentTableHeading[index]}
                                columnPos={index + 1}
                                sortableStrategy={sortableStrategy}
                                hasSubmitted={hasSubmitted}
                                buttonsDisabled={buttonsDisabled}
                                t={t}
                            />
                        ))}

                        <DragOverlay>
                            {activeId ? (
                                <SortableWord
                                    item={itemsLeft
                                        .concat(itemsRight)
                                        .find((item) => item.id === activeId)}
                                    isOverlay
                                />
                            ) : null}
                        </DragOverlay>
                    </DndContext>
                </div>

                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        <button
                            type="button"
                            className="btn btn-primary"
                            disabled={buttonsDisabled}
                            onClick={handleSubmit}
                        >
                            <LiaCheckCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.checkBtn")}
                        </button>
                        {currentQuestionIndex < shuffledQuiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <>
            <div className="card-body">
                <div className="text-lg font-semibold">
                    {timer > 0 ? (
                        <div className="flex items-center">
                            <div className="flex-1 md:flex-none">
                                {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                            </div>
                            <div className="ms-auto flex justify-end">
                                {t("exercise_page.timer")} {formatTime()}
                            </div>
                        </div>
                    ) : (
                        <p>
                            {t("exercise_page.questionNo")} #{currentQuestionIndex + 1}
                        </p>
                    )}
                </div>
                <div className="divider divider-secondary m-0"></div>
                <div className="flex flex-col items-center justify-center gap-4">
                    <div>
                        <button
                            type="button"
                            title={t("exercise_page.buttons.playAudioBtn")}
                            className="btn btn-circle btn-success"
                            onClick={handleAudioPlay}
                            disabled={isLoading}
                        >
                            {isLoading ? (
                                <span className="loading loading-spinner loading-md"></span>
                            ) : isPlaying ? (
                                <IoVolumeHigh className="h-6 w-6" />
                            ) : (
                                <IoVolumeHighOutline className="h-6 w-6" />
                            )}
                        </button>
                    </div>

                    <div
                        lang="en"
                        className={`flex items-center gap-2 rounded-lg px-6 py-3 text-xl ${
                            selectedOption
                                ? selectedOption.isCorrect
                                    ? "bg-success text-success-content"
                                    : "bg-error text-error-content"
                                : ""
                        }`}
                    >
                        {he.decode(currentQuestionText)}
                        {selectedOption ? (
                            selectedOption.isCorrect ? (
                                <BsCheckCircleFill className="h-5 w-5" />
                            ) : (
                                <BsXCircleFill className="h-5 w-5" />
                            )
                        ) : (
                            ""
                        )}
                    </div>

                    <div className="flex flex-row flex-wrap justify-center gap-3">
                        {shuffledOptions.map((option, index) => {
                            const isSelected = selectedOption?.index === index;
                            return (
                                <button
                                    type="button"
                                    className={`btn ${isSelected ? "btn-primary" : "btn-outline"} text-lg font-bold ${
                                        buttonsDisabled ? "pointer-events-none" : ""
                                    }`}
                                    key={index}
                                    onClick={() =>
                                        handleOptionClick(option.answer === "true", index)
                                    }
                                    disabled={!!selectedOption && !isSelected}
                                >
                                    <span lang="en">{option.value}</span>
                                </button>
                            );
                        })}
                    </div>
                </div>
                <div className="card-actions justify-center">
                    <div className="my-3 flex flex-wrap justify-center gap-2">
                        {currentQuestionIndex < shuffledQuiz.length - 1 && (
                            <button
                                type="button"
                                className="btn btn-accent"
                                onClick={handleNextQuiz}
                            >
                                <LiaChevronCircleRightSolid className="h-6 w-6" />{" "}
                                {t("exercise_page.buttons.nextBtn")}
                            </button>
                        )}
                        <button type="button" className="btn btn-error" onClick={handleQuit}>
                            <LiaTimesCircle className="h-6 w-6" />{" "}
                            {t("exercise_page.buttons.quitBtn")}
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <>
            <div className="flex items-center space-x-4">
                <p className="font-semibold">{t("accent.accentSettings")}:</p>
                <div className="dropdown">
                    <div tabIndex={0} role="button" className="btn btn-accent m-1">
                        <img
                            src={
                                selectedAccentOptions.find((item) => item.value === selectedAccent)
                                    .emoji
                            }
                            className="inline-block h-6 w-6"
                        />
                        {selectedAccentOptions.find((item) => item.value === selectedAccent).name}
                    </div>
                    <ul
                        tabIndex={0}
                        className="menu dropdown-content rounded-box bg-base-100 border-base-200 z-300 w-60 border p-2 shadow-md dark:border-slate-600"
                    >
                        {selectedAccentOptions.map((item) => (
                            <li key={item.value}>
                                <a
                                    className={`${
                                        selectedAccent === item.value ? "menu-active" : ""
                                    } justify-start`}
                                    onClick={() => handleAccentChange(item.value)}
                                >
                                    <img src={item.emoji} className="inline-block h-6 w-6" />{" "}
                                    {item.name}
                                </a>
                            </li>
                        ))}
                    </ul>
                </div>
            </div>
        </>
    );
};

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 (
        <footer
            lang="en"
            className="footer sm:footer-horizontal bg-base-200 text-base-content p-6 pb-20 md:p-10"
        >
            <aside>
                <LogoLightOrDark width="100" height="100" />
                <p>
                    Created by{" "}
                    <button
                        type="button"
                        className="link after:content-['_↗']"
                        onClick={() => openExternal("https://yell0wsuit.page")}
                        target="_blank"
                    >
                        yell0wsuit
                    </button>
                    <br />
                    Maintained by the community and contributors.{" "}
                    <button
                        type="button"
                        className="link after:content-['_↗']"
                        onClick={() =>
                            openExternal(
                                "https://github.com/learnercraft/ispeakerreact/graphs/contributors"
                            )
                        }
                        target="_blank"
                    >
                        See contributors
                    </button>
                </p>
                <p>
                    Licensed under the Apache License, Version 2.0
                    <br />
                    Video and audio materials © Oxford University Press
                </p>
            </aside>
            <nav>
                <h6 className="footer-title">More English learning materials</h6>
                <button
                    type="button"
                    className="link after:content-['_↗']"
                    onClick={() => openExternal("https://yell0wsuit.github.io/docugrammar/")}
                    target="_blank"
                >
                    DocuGrammar
                </button>
                <p className="text-xs text-stone-700 dark:text-stone-400">
                    A collection of grammar references in web format, powered by Docusaurus.
                </p>
            </nav>
        </footer>
    );
};

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 (
        <div className="loading-overlay bg-base-100 fixed inset-0 z-10000 flex flex-col items-center justify-center">
            <LogoLightOrDark width={200} height={200} />
            <progress className="progress progress-primary my-12 w-80" />
            <span className="invisible">{t("loadingOverlay.loading")}</span>
            {showSlow && (
                <>
                    <div className="my-4 p-4 text-center text-base text-gray-500 dark:text-gray-400">
                        {t("loadingOverlay.slowText")}
                    </div>
                    <button
                        type="button"
                        className="btn btn-secondary"
                        onClick={() => window.location.reload()}
                    >
                        <FiRefreshCw className="h-5 w-5" />
                        {t("appCrash.refreshBtn")}
                    </button>
                </>
            )}
        </div>
    );
};

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 <img alt="iSpeakerReact logo" src={logoSrc} width={width} height={height} />;
};

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 (
        <>
            <TopNavBar />
            <Container>
                <div className="my-8 items-center p-8 text-center">
                    <h1 className="mb-4 text-2xl font-bold">404 - Page Not Found</h1>
                    <p className="text-lg my-4">
                        We couldn’t find the page you were looking for. Please check to ensure the
                        URL is correct.
                    </p>
                    <img
                        className="mx-auto my-4 block object-contain"
                        src={`${import.meta.env.BASE_URL}images/undraw_by_the_road_re_vvs7.svg`}
                    />
                </div>
            </Container>
        </>
    );
};
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: <IoHomeOutline className="h-6 w-6" />,
            label: t("navigation.home"),
            childMenu: null,
        },
        {
            to: null,
            icon: <IoMicOutline className="h-6 w-6" />,
            label: t("navigation.practice"),
            childMenu: [
                {
                    to: "/sounds",
                    icon: <IoMicOutline className="h-6 w-6" />,
                    label: t("navigation.sounds"),
                },
                {
                    to: "/words",
                    icon: <BsAlphabet className="h-6 w-6" />,
                    label: t("navigation.words"),
                },
            ],
        },
        {
            to: "/exercises",
            icon: <BsCardChecklist className="h-6 w-6" />,
            label: t("navigation.exercises"),
            childMenu: null,
        },
        {
            to: "/conversations",
            icon: <BsChatText className="h-6 w-6" />,
            label: t("navigation.conversations"),
            childMenu: null,
        },
        {
            to: "/exams",
            icon: <PiExam className="h-6 w-6" />,
            label: t("navigation.exams"),
            childMenu: null,
        },
        {
            to: "/settings",
            icon: <LiaToolsSolid className="h-6 w-6" />,
            label: t("navigation.settings"),
            childMenu: null,
        },
    ];

    return (
        <nav
            className={`navbar sticky top-0 z-300 w-full flex-none backdrop-blur-md backdrop-brightness-125 backdrop-saturate-200 ${navbarClass}`}
        >
            <div className="navbar md:navbar-start">
                {/* Mobile Drawer */}
                <div className="lg:hidden">
                    <label htmlFor="mobile-menu" className="btn btn-ghost drawer-button">
                        <CgMenuLeft size="1.5em" />
                    </label>
                    <input type="checkbox" id="mobile-menu" className="drawer-toggle hidden" />
                    <div className="drawer-side">
                        <label htmlFor="mobile-menu" className="drawer-overlay"></label>
                        <ul className="menu bg-base-200 text-base-content min-h-full w-80 p-4 text-base">
                            {menuItems.map((item) =>
                                item.childMenu ? (
                                    item.childMenu.map((child) => (
                                        <li key={child.to}>
                                            <NavLink
                                                to={child.to}
                                                aria-label={child.label}
                                                className={({ isActive }) =>
                                                    isActive ? "menu-active" : ""
                                                }
                                            >
                                                {child.icon} {child.label}
                                            </NavLink>
                                        </li>
                                    ))
                                ) : (
                                    <li key={item.to}>
                                        <NavLink
                                            to={item.to}
                                            aria-label={item.label}
                                            className={({ isActive }) =>
                                                isActive ? "menu-active" : ""
                                            }
                                        >
                                            {item.icon} {item.label}
                                        </NavLink>
                                    </li>
                                )
                            )}
                        </ul>
                    </div>
                </div>

                {/* Logo */}
                <NavLink
                    className="btn btn-ghost no-animation flex w-full items-center justify-center md:w-auto lg:text-xl"
                    to="/"
                >
                    <img
                        alt="iSpeakerReact logo"
                        src={logoSrc}
                        width="60"
                        height="60"
                        className="h-12 w-12"
                    />
                    <span lang="en" className="hidden md:block">
                        iSpeakerReact
                    </span>
                </NavLink>
            </div>

            {/* Desktop Navigation */}
            <div className="navbar-center hidden lg:flex">
                <ul className="menu menu-horizontal px-1 lg:text-base">
                    {menuItems.map((item) => {
                        const isActive =
                            item.childMenu?.some((child) => location.pathname === child.to) ||
                            location.pathname === item.to;

                        return item.childMenu ? (
                            <li
                                key={item.label}
                                className="dropdown dropdown-bottom cursor-pointer"
                            >
                                <div
                                    tabIndex={0}
                                    role="button"
                                    className={`flex items-center space-x-1 ${
                                        isActive ? "menu-active" : ""
                                    }`}
                                    aria-label={item.label}
                                >
                                    {item.icon} {item.label}
                                </div>
                                <ul
                                    tabIndex={0}
                                    className="menu border-base-200 dropdown-content rounded-box bg-base-100 w-52 border p-2 shadow-sm"
                                >
                                    {item.childMenu.map((child) => (
                                        <li key={child.to}>
                                            <NavLink
                                                to={child.to}
                                                aria-label={child.label}
                                                className={({ isActive }) =>
                                                    isActive ? "menu-active" : ""
                                                }
                                            >
                                                {child.icon} {child.label}
                                            </NavLink>
                                        </li>
                                    ))}
                                </ul>
                            </li>
                        ) : (
                            <li key={item.to}>
                                <NavLink
                                    to={item.to}
                                    aria-label={item.label}
                                    className={({ isActive }) => (isActive ? "menu-active" : "")}
                                >
                                    {item.icon} {item.label}
                                </NavLink>
                            </li>
                        );
                    })}
                </ul>
            </div>

            {/* GitHub Link */}
            <div className="navbar-end">
                <button
                    type="button"
                    className="btn btn-ghost no-animation flex items-center"
                    onClick={() => openExternal("https://github.com/learnercraft/ispeakerreact/")}
                >
                    <FaGithub size="1.5em" />
                    <span className="ms-1 hidden items-center space-x-1 md:inline-flex">
                        <span lang="en">GitHub</span>
                        <FiExternalLink />
                    </span>
                </button>
            </div>
        </nav>
    );
};

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 (
        <dialog ref={dialogRef} className="modal">
            <div className="modal-box">
                <h3 className="text-lg font-bold">
                    {error
                        ? t("alert.appNewVersionErrorDialogTitle")
                        : t("alert.appNewVersionDialogTitle")}
                </h3>
                <div className="py-4">
                    {checking && <span className="loading loading-spinner loading-md"></span>}

                    {error ? (
                        <>
                            <p className="text-error">{t("alert.appUpdateError")}</p>
                            <p className="mt-2 text-sm">
                                {t("alert.appNewVersionErrorReason")}{" "}
                                <span className="italic">{error}</span>
                            </p>
                        </>
                    ) : (
                        <>
                            {isRefreshing ? (
                                <>
                                    <p>{t("alert.appNewVersionDialogChecking")}</p>
                                    <div className="mt-4">
                                        <progress className="progress progress-primary w-full"></progress>
                                    </div>
                                </>
                            ) : (
                                !checking &&
                                latestVersion && (
                                    <>
                                        {latestVersion !== currentVersion ? (
                                            <p>
                                                {t("alert.appNewVersionDialogBody", {
                                                    version: latestVersion,
                                                })}
                                            </p>
                                        ) : (
                                            <></>
                                        )}
                                    </>
                                )
                            )}
                        </>
                    )}
                </div>
                <div className="modal-action">
                    {error ? (
                        <button
                            type="button"
                            className="btn"
                            onClick={() => {
                                if (dialogRef.current) dialogRef.current.close();
                            }}
                        >
                            {t("sound_page.closeBtn")}
                        </button>
                    ) : (
                        latestVersion &&
                        latestVersion !== currentVersion &&
                        !isRefreshing && (
                            <button
                                type="button"
                                className="btn btn-primary"
                                onClick={handleRefresh}
                            >
                                {t("alert.appNewVersionDialogRefreshBtn")}
                            </button>
                        )
                    )}
                </div>
            </div>
        </dialog>
    );
};

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: <LuSunMoon className="h-5 w-5" />,
    },
    light: {
        labelKey: "settingPage.appearanceSettings.themeLight",
        icon: <MdOutlineLightMode className="h-5 w-5" />,
    },
    dark: {
        labelKey: "settingPage.appearanceSettings.themeDark",
        icon: <MdOutlineDarkMode className="h-5 w-5" />,
    },
};

const AppearanceSettings = () => {
    const { t } = useTranslation();
    const { theme, setTheme } = useTheme();

    const handleThemeSelect = (selectedTheme) => {
        setTheme(selectedTheme);
        sonnerSuccessToast(t("settingPage.changeSaved"));
    };

    return (
        <div className="flex flex-row gap-x-8 gap-y-6">
            <div className="flex basis-1/2 items-center">
                <p className="text-base font-semibold">
                    {t("settingPage.appearanceSettings.themeOption")}
                </p>
            </div>
            <div className="flex grow basis-1/2 justify-end">
                <div className="dropdown dropdown-end">
                    <div tabIndex={0} role="button" className="btn m-1 flex items-center gap-2">
                        {themeOptions[theme]?.icon} {t(themeOptions[theme]?.labelKey)}
                    </div>
                    <ul
                        tabIndex={0}
                        className="menu dropdown-content rounded-box bg-base-100 z-300 w-52 border-slate-50 p-2 shadow-md"
                    >
                        {Object.entries(themeOptions).map(([key, { labelKey, icon }]) => (
                            <li key={key}>
                                <a
                                    type="button"
                                    onClick={() => handleThemeSelect(key)}
                                    className={`${theme === key ? "menu-active" : ""} flex items-center justify-start gap-2`}
                                    aria-pressed={theme === key}
                                >
                                    {icon} {t(labelKey)}
                                </a>
                            </li>
                        ))}
                    </ul>
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="mt-4">
            {alertVisible && (
                <div role="alert" className={`alert mb-4 ${alertVariant}`}>
                    <IoInformationCircleOutline className="h-6 w-6" />
                    {alertMessage.includes("GitHub") ? (
                        <>
                            <p>{alertMessage}</p>
                            <button className="btn btn-accent btn-sm" onClick={openGithubPage}>
                                {t("settingPage.openGitHubAlertLink")}
                            </button>
                        </>
                    ) : (
                        alertMessage
                    )}
                    <div>
                        <button
                            className="btn btn-circle btn-ghost btn-sm"
                            onClick={() => setAlertVisible(false)}
                        >
                            <IoCloseOutline className="h-6 w-6" />
                        </button>
                    </div>
                </div>
            )}
            <div className="flex gap-4">
                <div className="flex items-center justify-center">
                    <img
                        className="object-contain"
                        src={`${import.meta.env.BASE_URL}images/icons/windows11/StoreLogo.scale-200.png`}
                        alt="App logo"
                    />
                </div>
                <div className="h-full">
                    <h4 lang="en" className="text-xl font-semibold">
                        iSpeakerReact
                    </h4>
                    <p lang="en" className="mb-2 text-slate-600 dark:text-slate-400">
                        Version {currentVersion}
                    </p>
                    {window.electron.isUwp() ? (
                        <button
                            type="button"
                            className="btn btn-primary btn-sm"
                            onClick={openMsStore}
                        >
                            {t("settingPage.checkUpdateMSBtn")}{" "}
                            <LuExternalLink className="h-5 w-5" />
                        </button>
                    ) : (
                        <button
                            type="button"
                            className="btn btn-primary btn-sm"
                            onClick={checkForUpdates}
                        >
                            {isLoading && <div className="loading loading-spinner loading-sm" />}
                            {t("settingPage.checkUpdateBtn")}
                        </button>
                    )}
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="mt-4">
            <div className="flex gap-x-8 gap-y-6">
                <div className="basis-2/3 space-y-1">
                    <label className="cursor-pointer text-base font-semibold" htmlFor="enableTimer">
                        {t("settingPage.exerciseSettings.timerOption")}
                    </label>
                    <p className="text-sm text-zinc-500 dark:text-zinc-400">
                        {t("settingPage.exerciseSettings.timerDescription")}
                    </p>
                </div>
                <div className="flex basis-1/3 justify-end">
                    <input
                        type="checkbox"
                        className="toggle"
                        id="enableTimer"
                        checked={timerSettings.enabled}
                        onChange={(e) => handleTimerToggle(e.target.checked)}
                    />
                </div>
            </div>

            <div className="my-4 flex flex-row flex-wrap justify-center gap-4 px-8">
                {Object.keys(exerciseNames).map((exercise) => (
                    <div key={exercise} className="basis-full md:basis-1/3 lg:basis-1/4">
                        <fieldset className="fieldset w-full max-w-xs">
                            <legend className="fieldset-legend text-sm font-normal">
                                <span>{exerciseNames[exercise]}</span>
                            </legend>

                            <input
                                type="text"
                                value={tempSettings[exercise]}
                                maxLength={2}
                                onChange={(e) => handleInputChange(e, exercise)}
                                className={`input input-bordered w-full max-w-xs ${
                                    tempSettings[exercise] === "" ||
                                    tempSettings[exercise] < 0 ||
                                    tempSettings[exercise] > 10
                                        ? "input-error"
                                        : ""
                                }`}
                                disabled={!inputEnabled}
                            />

                            {tempSettings[exercise] === "" ||
                            tempSettings[exercise] < 0 ||
                            tempSettings[exercise] > 10 ? (
                                <p className="fieldset-label text-error text-sm">
                                    {t("settingPage.exerciseSettings.textboxError")}
                                </p>
                            ) : null}
                        </fieldset>
                    </div>
                ))}
            </div>

            <p className="px-8 text-sm">{t("settingPage.exerciseSettings.hint")}</p>

            <div className="my-6 flex flex-wrap justify-center gap-2 px-8">
                <button
                    type="button"
                    className="btn btn-primary btn-wide"
                    onClick={handleApply}
                    disabled={!isValid || !isModified}
                >
                    {t("settingPage.exerciseSettings.applyBtn")}
                </button>
                <button
                    type="button"
                    className="btn btn-secondary btn-wide"
                    onClick={handleCancel}
                    disabled={!isModified}
                >
                    {t("settingPage.exerciseSettings.cancelBtn")}
                </button>
            </div>
        </div>
    );
};

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 (
        <div className="flex flex-row gap-x-8 gap-y-6">
            <div className="flex basis-1/2 items-center">
                <p className="text-base font-semibold">
                    {t("settingPage.languageSettings.languageOption")}
                </p>
            </div>
            <div className="flex grow basis-1/2 justify-end">
                <div className="dropdown dropdown-end">
                    <div tabIndex={0} role="button" className="btn">
                        <img
                            src={`${import.meta.env.BASE_URL}images/ispeaker/country_emojis/${currentLanguage.emoji}.svg`}
                            alt={currentLanguage.label}
                            className="h-5 w-5"
                        />
                        {currentLanguage.label}
                    </div>
                    <ul
                        tabIndex={0}
                        className="menu dropdown-content rounded-box bg-base-100 z-300 w-52 border-slate-50 p-2 shadow-md"
                    >
                        {supportedLanguages.map((lang) => (
                            <li lang={lang.code} key={lang.code}>
                                <a
                                    type="button"
                                    className={`justify-start ${
                                        i18n.language === lang.code ? "menu-active" : ""
                                    }`}
                                    onClick={() => handleLanguageChange(lang.code)}
                                >
                                    <img
                                        src={`${import.meta.env.BASE_URL}images/ispeaker/country_emojis/${lang.emoji}.svg`}
                                        alt={lang.label}
                                        className="h-5 w-5"
                                    />
                                    {lang.label}
                                </a>
                            </li>
                        ))}
                        <li lang="en">
                            <a
                                type="button"
                                className="justify-start"
                                onClick={() =>
                                    openExternal(
                                        "https://github.com/learnercraft/ispeakerreact/issues/18"
                                    )
                                }
                            >
                                Help us translate
                                <LuExternalLink className="h-5 w-5" />
                            </a>
                        </li>
                    </ul>
                </div>
            </div>
        </div>
    );
};

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 (
            <div>
                <span className="loading loading-spinner loading-md"></span>
            </div>
        );
    }

    return (
        <>
            <div className="flex flex-wrap gap-x-8 gap-y-6 md:flex-nowrap">
                <div className="basis-full space-y-1 md:basis-1/2">
                    <p className="text-base font-semibold">
                        {t("settingPage.logSettings.logSettingsHeading")}
                    </p>
                    <p className="text-sm text-zinc-500 dark:text-zinc-400">
                        {t("settingPage.logSettings.logSettingsDescription")}
                    </p>
                </div>
                <div className="flex grow basis-1/2 justify-center md:justify-end">
                    <div>
                        <p className="mb-2 text-base">
                            {t("settingPage.logSettings.numOfLogsOption")}
                        </p>
                        <div className="dropdown mb-6">
                            <div tabIndex={0} role="button" className="btn btn-wide justify-start">
                                {getLabel(maxLogOptions, maxLogWritten)}
                            </div>
                            <ul
                                tabIndex={0}
                                className="menu dropdown-content rounded-box bg-base-100 z-300 w-52 border-slate-50 p-2 shadow-md"
                            >
                                {maxLogOptions.map((option) => (
                                    <li key={option.value}>
                                        <a
                                            type="button"
                                            className={`justify-start ${maxLogWritten === option.value ? "menu-active" : ""}`}
                                            key={option.value}
                                            onClick={() => {
                                                setMaxLogWritten(option.value);
                                                handleApplySettings(
                                                    option.value,
                                                    deleteLogsOlderThan
                                                );
                                            }}
                                        >
                                            {option.label}
                                        </a>
                                    </li>
                                ))}
                            </ul>
                        </div>

                        <p className="mb-2 text-base">
                            {t("settingPage.logSettings.deleteOldLogsOption")}
                        </p>
                        <div className="dropdown mb-6">
                            <div tabIndex={0} role="button" className="btn btn-wide">
                                {getLabel(deleteLogsOptions, deleteLogsOlderThan)}
                            </div>
                            <ul
                                tabIndex={0}
                                className="menu dropdown-content rounded-box bg-base-100 z-300 w-52 border-slate-50 p-2 shadow-md"
                            >
                                {deleteLogsOptions.map((option) => (
                                    <li key={option.value}>
                                        <a
                                            type="button"
                                            className={`justify-start ${deleteLogsOlderThan === option.value ? "menu-active" : ""}`}
                                            onClick={() => {
                                                setDeleteLogsOlderThan(option.value);
                                                handleApplySettings(maxLogWritten, option.value);
                                            }}
                                        >
                                            {option.label}
                                        </a>
                                    </li>
                                ))}
                            </ul>
                        </div>

                        <button
                            type="button"
                            className="btn btn-wide"
                            onClick={handleOpenLogFolder}
                        >
                            {t("settingPage.logSettings.openLogBtn")}
                            <LuExternalLink className="h-5 w-5" />
                        </button>
                    </div>
                </div>
            </div>
        </>
    );
};

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 (
        <div className="modal-box">
            <h3 className="text-lg font-bold">
                {t("settingPage.pronunciationSettings.pronunciationModalHeading")}
            </h3>
            <div className="py-4">
                {/* Model selection dropdown */}
                <div className="mb-4">
                    <label className="mb-2 block font-semibold">
                        {t("settingPage.pronunciationSettings.modelSelectLabel")}
                        <div className="dropdown dropdown-bottom dropdown-center">
                            <div
                                tabIndex={0}
                                role="button"
                                className="btn btn-circle btn-ghost btn-sm align-middle"
                            >
                                <IoInformationCircleOutline className="h-5 w-5" />
                            </div>
                            <div
                                tabIndex={0}
                                className="dropdown-content card bg-base-100 z-3 w-96 text-base font-normal shadow-md"
                            >
                                <div className="card-body">
                                    <h3 className="text-base font-semibold">
                                        {t(
                                            "settingPage.pronunciationSettings.modelExplanationTitle"
                                        )}
                                    </h3>
                                    <p>
                                        {t(
                                            "settingPage.pronunciationSettings.modelExplanationBody"
                                        )}
                                    </p>
                                </div>
                            </div>
                        </div>
                    </label>
                    <select
                        className="select select-bordered w-full"
                        value={modelValue}
                        onChange={(e) => onModelChange(e.target.value)}
                        disabled={checking}
                    >
                        {modelOptions.map((opt) => (
                            <option key={opt.value} value={opt.value}>
                                {opt.value} ({opt.size})
                            </option>
                        ))}
                    </select>
                    {/* Show description for selected model */}
                    <div className="mt-2 min-h-[1.5em] text-sm text-gray-600 dark:text-slate-400">
                        {modelOptions.find((opt) => opt.value === modelValue) &&
                            t(modelOptions.find((opt) => opt.value === modelValue).description)}
                    </div>
                </div>
                {installState === "failed" && (
                    <div className="alert alert-error mb-4 text-base">
                        <IoInformationCircleOutline className="h-6 w-6" />
                        <div>
                            <p className="mb-2">
                                {t("settingPage.pronunciationSettings.previousInstallFailedMsg")}
                            </p>
                            <p>
                                <Trans
                                    i18nKey="settingPage.pronunciationSettings.pronunciationModalBodyPython2"
                                    components={[
                                        <button
                                            key="python-link"
                                            type="button"
                                            className="link font-semibold underline"
                                            onClick={() =>
                                                openExternal(
                                                    "https://learnercraft.github.io/blog/2025-05-20-how-to-install-python-for-beginners/"
                                                )
                                            }
                                        />,
                                    ]}
                                />
                            </p>
                        </div>
                    </div>
                )}
                {installState === "complete" && (
                    <div className="alert alert-info mb-4 text-base">
                        <IoInformationCircleOutline className="h-6 w-6" />
                        <span>{t("settingPage.pronunciationSettings.previousInstallMsg")}</span>
                    </div>
                )}
                {installState === "not_installed" && (
                    <div role="alert" className="alert alert-info mb-4 text-base">
                        <IoInformationCircleOutline className="h-6 w-6" />
                        <div>
                            <p className="mb-2">
                                <Trans
                                    i18nKey="settingPage.pronunciationSettings.pronunciationModalBodyPython"
                                    components={[
                                        <button
                                            key="python-link"
                                            type="button"
                                            className="link font-semibold underline"
                                            onClick={() =>
                                                openExternal("https://www.python.org/downloads/")
                                            }
                                        />,
                                    ]}
                                />
                            </p>
                            <p>
                                <Trans
                                    i18nKey="settingPage.pronunciationSettings.pronunciationModalBodyPython2"
                                    components={[
                                        <button
                                            key="python-link"
                                            type="button"
                                            className="link font-semibold underline"
                                            onClick={() =>
                                                openExternal(
                                                    "https://learnercraft.github.io/blog/2025-05-20-how-to-install-python-for-beginners/"
                                                )
                                            }
                                        />,
                                    ]}
                                />
                            </p>
                        </div>
                    </div>
                )}
                <p>{t("settingPage.pronunciationSettings.pronunciationModalBody")}</p>
            </div>

            <div className="modal-action">
                <button
                    type="button"
                    className="btn"
                    onClick={closeConfirmDialog}
                    disabled={checking}
                >
                    {t("settingPage.exerciseSettings.cancelBtn")}
                </button>
                {!checking && (
                    <button type="button" className="btn btn-primary" onClick={handleProceed}>
                        {t("settingPage.pronunciationSettings.pronunciationModalBtn")}
                    </button>
                )}
            </div>
        </div>
    );
};

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 <span className="loading loading-spinner loading-sm"></span>;
        if (status === "success")
            return <IoCheckmark className="text-success h-5 w-5 items-center" />;
        if (status === "error")
            return <IoCloseOutline className="text-error h-5 w-5 items-center" />;
        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 (
        <div className="mt-2">
            <ol className="mb-4 space-y-2">
                {steps.map((step) => (
                    <li key={step.key} className="flex items-start gap-x-2">
                        <div className="flex gap-x-2 pt-1">{getStatusIcon(step.status)}</div>
                        <div className="flex min-w-0 flex-auto flex-col gap-x-4">
                            <div className="flex min-w-0 flex-auto items-center gap-x-2">
                                {step.label}
                                {step.status === "success" && (
                                    <span className="text-success font-semibold">
                                        {t("settingPage.pronunciationSettings.stepDone")}
                                    </span>
                                )}
                                {step.status === "error" && (
                                    <span className="text-error font-semibold">
                                        {t("settingPage.pronunciationSettings.stepFailed")}
                                    </span>
                                )}
                            </div>
                        </div>
                    </li>
                ))}
            </ol>
            <div className="bg-base-100 border-base-300 collapse-arrow collapse border dark:border-slate-600">
                <input type="checkbox" />
                <div className="collapse-title font-semibold">
                    {t("settingPage.pronunciationSettings.showDetailsCollapse")}
                </div>
                <div className="collapse-content text-sm">
                    {checking && (
                        <div className="mb-2">
                            <span className="loading loading-spinner loading-sm"></span>
                        </div>
                    )}
                    {error && <div className="text-error mb-2">{error}</div>}
                    {pythonCheckResult && (
                        <div>
                            {logOutput ? (
                                <>
                                    <div
                                        ref={logRef}
                                        className="bg-base-200 border-base-300 mb-4 max-h-60 overflow-y-auto rounded border p-2 font-mono whitespace-pre-wrap"
                                    >
                                        {logOutput}
                                    </div>

                                    <button
                                        className="btn btn-sm btn-outline mb-2"
                                        type="button"
                                        onClick={async () => {
                                            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"
                                              )}
                                    </button>
                                </>
                            ) : pythonCheckResult.stderr ? (
                                <div>
                                    <span className="font-bold">Stderr:</span>{" "}
                                    <pre className="inline whitespace-pre-wrap">
                                        {pythonCheckResult.stderr}
                                    </pre>
                                </div>
                            ) : null}
                        </div>
                    )}
                </div>
            </div>
        </div>
    );
};

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 (
        <>
            <div className="mt-4">
                <div className="flex gap-x-8 gap-y-6">
                    <div className="flex basis-1/2 items-center space-y-1">
                        <p className="font-semibold">
                            {t("settingPage.pronunciationSettings.pronunciationHeading")}
                        </p>
                    </div>
                    <div className="flex basis-1/2 justify-end">
                        {installState === "complete" ? (
                            <button className="btn" onClick={openConfirmDialog}>
                                {t("settingPage.pronunciationSettings.reinstallBtn")}
                            </button>
                        ) : (
                            <button className="btn" onClick={openConfirmDialog}>
                                {t("settingPage.pronunciationSettings.pronunciationBtn")}
                            </button>
                        )}
                    </div>
                </div>
            </div>

            {/* Confirmation dialog */}
            <dialog ref={confirmDialogRef} className="modal">
                <PronunciationCheckerDialogContent
                    t={t}
                    checking={checking}
                    closeConfirmDialog={closeConfirmDialog}
                    handleProceed={handleProceed}
                    installState={installState}
                    modelValue={modelValue}
                    onModelChange={onModelChange}
                    error={error}
                />
            </dialog>

            {/* Checker dialog */}
            <dialog ref={checkerDialogRef} className="modal">
                <div className="modal-box w-3/4 max-w-2xl">
                    <h3 className="text-lg font-bold">
                        {t("settingPage.pronunciationSettings.installationProcess")}
                    </h3>

                    {!allStepsDone ? (
                        <div className="py-4">
                            <div role="alert" className="alert alert-warning text-base">
                                <IoWarningOutline className="h-6 w-6" />
                                <span>
                                    {t(
                                        "settingPage.pronunciationSettings.installationProcessWarning"
                                    )}
                                </span>
                            </div>
                        </div>
                    ) : installationFailed ? (
                        <div className="py-4">
                            <div role="alert" className="alert alert-error text-base">
                                <IoCloseCircleOutline className="h-6 w-6" />
                                <span>
                                    {t(
                                        "settingPage.pronunciationSettings.installationProcessFailed"
                                    )}
                                </span>
                            </div>
                        </div>
                    ) : (
                        <div className="py-4">
                            <div role="alert" className="alert alert-success text-base">
                                <IoCheckmarkCircleOutline className="h-6 w-6" />
                                <span>
                                    {t(
                                        "settingPage.pronunciationSettings.installationProcessSuccess"
                                    )}
                                </span>
                            </div>
                        </div>
                    )}

                    {isCancelling && (
                        <div className="py-4">
                            <div role="alert" className="alert alert-info text-base">
                                <IoInformationCircleOutline className="h-6 w-6" />
                                {t(
                                    "settingPage.pronunciationSettings.installationProcessCancelling"
                                )}
                            </div>
                        </div>
                    )}

                    <PronunciationCheckerInfo
                        t={t}
                        checking={checking || isCancelling}
                        error={error}
                        pythonCheckResult={pythonCheckResult}
                        modelSize={selectedModelSize}
                    />
                    <div className="modal-action">
                        <button
                            type="button"
                            className="btn"
                            onClick={
                                !allStepsDone
                                    ? () => {
                                          if (window.electron?.ipcRenderer) {
                                              setIsCancelling(true);
                                              window.electron.ipcRenderer.invoke(
                                                  "pronunciation-cancel-process"
                                              );
                                          }
                                      }
                                    : closeCheckerDialog
                            }
                            disabled={isCancelling}
                        >
                            {!allStepsDone ? (
                                isCancelling ? (
                                    <>
                                        <span
                                            className="spinner-border spinner-border-sm mr-2"
                                            role="status"
                                        />
                                        {t("settingPage.exerciseSettings.cancelBtn")}
                                    </>
                                ) : (
                                    t("settingPage.exerciseSettings.cancelBtn")
                                )
                            ) : (
                                t("sound_page.closeBtn", "Close")
                            )}
                        </button>
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
        <>
            <div className="flex flex-wrap gap-x-8 gap-y-6 md:flex-nowrap">
                <div className="basis-1/2 space-y-1">
                    <p className="text-base font-semibold">
                        {t("settingPage.resetSettings.resetHeading")}
                    </p>
                </div>
                <div className="flex grow basis-1/2 flex-col justify-end">
                    <>
                        <button
                            type="button"
                            className="btn btn-error"
                            onClick={() => localStorageModal.current?.showModal()}
                            disabled={isResettingLocalStorage}
                        >
                            {isResettingLocalStorage && (
                                <span className="loading loading-spinner loading-md"></span>
                            )}
                            {t("settingPage.resetSettings.resetSettingsData")}
                        </button>

                        {!isElectron() && (
                            <button
                                type="button"
                                className="btn btn-error mt-4"
                                onClick={() => indexedDbModal.current?.showModal()}
                                disabled={isResettingIndexedDb}
                            >
                                {isResettingIndexedDb && (
                                    <span className="loading loading-spinner loading-md"></span>
                                )}
                                {t("settingPage.resetSettings.deleteRecordingData")}
                            </button>
                        )}
                    </>
                </div>
            </div>

            {/* LocalStorage Modal */}
            <dialog ref={localStorageModal} className="modal">
                <form method="dialog" className="modal-box">
                    <h3 className="text-lg font-bold">
                        {t("settingPage.resetSettings.resetModalHeading")}
                    </h3>
                    <p className="py-4">
                        {t("settingPage.resetSettings.resetSettingsDataModalMessage")}
                    </p>
                    <div className="modal-action">
                        <button
                            type="button"
                            className="btn"
                            onClick={() => localStorageModal.current?.close()}
                            disabled={isResettingLocalStorage}
                        >
                            {t("settingPage.exerciseSettings.cancelBtn")}
                        </button>
                        <button
                            type="button"
                            className="btn btn-error"
                            onClick={resetLocalStorage}
                            disabled={isResettingLocalStorage}
                        >
                            {isResettingLocalStorage && (
                                <span className="loading loading-spinner loading-md"></span>
                            )}
                            {t("settingPage.resetSettings.resetConfirmBtn")}
                        </button>
                    </div>
                </form>
            </dialog>

            {/* IndexedDB Modal */}
            {!isElectron() && (
                <dialog ref={indexedDbModal} className="modal">
                    <form method="dialog" className="modal-box">
                        <h3 className="text-lg font-bold">
                            {t("settingPage.resetSettings.resetModalHeading")}
                        </h3>
                        <p className="py-4">
                            {t("settingPage.resetSettings.deleteRecordingDataModalMessage")}
                        </p>
                        <div className="modal-action">
                            <button
                                type="button"
                                className="btn"
                                onClick={() => indexedDbModal.current?.close()}
                                disabled={isResettingIndexedDb}
                            >
                                {t("settingPage.exerciseSettings.cancelBtn")}
                            </button>
                            <button
                                type="button"
                                className="btn btn-error"
                                onClick={resetIndexedDb}
                                disabled={isResettingIndexedDb}
                            >
                                {isResettingIndexedDb && (
                                    <span className="loading loading-spinner loading-md"></span>
                                )}
                                {t("settingPage.resetSettings.resetConfirmBtn")}
                            </button>
                        </div>
                    </form>
                </dialog>
            )}
        </>
    );
};

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 (
        <div className="mt-4">
            <div className="flex gap-x-8 gap-y-6">
                <div className="flex basis-1/2 items-center space-y-1">
                    <p className="font-semibold">
                        {t(
                            "settingPage.savedRecordingLocationSettings.savedRecordingLocationHeading"
                        )}
                    </p>
                </div>
                <div className="flex basis-1/2 justify-end">
                    <button type="button" className="btn" onClick={handleOpenRecordingFolder}>
                        {t("settingPage.savedRecordingLocationSettings.savedRecordingLocationBtn")}
                        <LuExternalLink className="h-5 w-5" />
                    </button>
                </div>
            </div>
        </div>
    );
};

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 (
        <>
            <div className="flex flex-row flex-wrap gap-x-8 gap-y-6 md:flex-nowrap">
                <div className="space-y-1">
                    <p className="text-base font-semibold">
                        {t("settingPage.saveFolderSettings.saveFolderHeading")}
                    </p>
                    <p className="text-sm text-zinc-500 dark:text-zinc-400">
                        {t("settingPage.saveFolderSettings.saveFolderDescription")}
                    </p>
                    <p className="text-sm text-zinc-500 dark:text-zinc-400">
                        {t("settingPage.saveFolderSettings.saveFolderCurrentFolder")}{" "}
                        <span
                            lang="en"
                            className="rounded-md bg-zinc-100 px-2 py-1 font-mono! text-sm font-bold break-all text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400"
                        >
                            {currentFolder}
                        </span>
                    </p>
                </div>
                <div className="flex grow basis-1/2 flex-row justify-center gap-2 md:flex-wrap md:justify-end">
                    <button
                        type="button"
                        className="btn btn-block"
                        onClick={openChooseDialog}
                        disabled={loading}
                    >
                        {loading ? (
                            <span className="loading loading-spinner loading-md"></span>
                        ) : (
                            t("settingPage.saveFolderSettings.saveFolderChooseBtn")
                        )}
                    </button>
                    {customFolder && (
                        <button
                            type="button"
                            className="btn btn-secondary btn-block"
                            onClick={openResetDialog}
                            disabled={loading}
                        >
                            {t("settingPage.saveFolderSettings.saveFolderResetBtn")}
                        </button>
                    )}
                </div>
            </div>
            <dialog ref={confirmRef} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {t("settingPage.saveFolderSettings.saveFolderConfirmTitle")}
                    </h3>
                    <div className="py-4">
                        {t("settingPage.saveFolderSettings.saveFolderConfirmDescription", {
                            returnObjects: true,
                        }).map((desc, index) => (
                            <p className="mb-2" key={index}>
                                {desc}
                            </p>
                        ))}
                    </div>
                    <div className="modal-action">
                        <button type="button" className="btn btn-primary" onClick={handleConfirm}>
                            {t("settingPage.saveFolderSettings.saveFolderConfirmBtn")}
                        </button>
                        <button type="button" className="btn" onClick={handleCancel}>
                            {t("settingPage.saveFolderSettings.saveFolderConfirmCancelBtn")}
                        </button>
                    </div>
                </div>
            </dialog>
            {moveDialogOpen && (
                <dialog open ref={moveDialogRef} className="modal modal-open">
                    <div className="modal-box min-h-[350px] w-10/12 max-w-2xl">
                        <h3 className="text-lg font-bold">
                            {t("settingPage.saveFolderSettings.saveFolderMovingTitle")}
                        </h3>
                        <div className="py-4">
                            {processStarting ? (
                                <span className="loading loading-spinner loading-md"></span>
                            ) : venvDeleteStatus && venvDeleteStatus.status === "deleting" ? (
                                <>
                                    <div className="mb-2">
                                        {t("settingPage.saveFolderSettings.saveFolderDeleteVenv")}
                                    </div>
                                    <progress className="progress progress-primary w-full"></progress>
                                </>
                            ) : moveProgress ? (
                                <>
                                    <div className="mb-2">
                                        {t(
                                            `settingPage.saveFolderSettings.${
                                                moveProgress.phase === "copy"
                                                    ? "saveFolderCopyPhase"
                                                    : moveProgress.phase === "delete"
                                                      ? "saveFolderDeletePhase"
                                                      : "saveFolderDeleteEmptyDirPhase"
                                            }`
                                        )}{" "}
                                        <span lang="en" className="font-mono! break-all">
                                            {moveProgress.name}
                                        </span>
                                    </div>
                                    <progress
                                        className="progress progress-primary w-full"
                                        value={moveProgress.moved}
                                        max={moveProgress.total}
                                    ></progress>
                                    <div className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
                                        {moveProgress.moved} / {moveProgress.total}{" "}
                                        {t("settingPage.saveFolderSettings.saveFolderMovingFiles")}
                                    </div>
                                </>
                            ) : null}
                            <div role="alert" className="alert alert-warning mt-6">
                                <IoWarningOutline className="h-6 w-6" />
                                <div>
                                    {t("settingPage.saveFolderSettings.saveFolderMovingWarning")}
                                </div>
                            </div>
                        </div>
                    </div>
                </dialog>
            )}
        </>
    );
};

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 (
        <>
            <TopNavBar />
            <Container>
                <div className="my-8 flex justify-center">
                    <div className="w-full md:w-2/3 lg:w-1/2">
                        {currentPage === "settings" && (
                            <>
                                <h1 className="text-3xl font-semibold md:text-4xl">
                                    {t("settingPage.heading")}
                                </h1>
                                <div className="divider divider-secondary"></div>
                                <div className="mt-4">
                                    {isElectron() && (
                                        <>
                                            <AppInfo />
                                            <div className="divider"></div>
                                        </>
                                    )}
                                    <LanguageSwitcher />
                                    <div className="divider"></div>
                                    <ExerciseTimer />
                                    <div className="divider"></div>
                                    <AppearanceSettings />
                                    {isElectron() && (
                                        <>
                                            <div className="divider"></div>
                                            <SaveFolderSettings />
                                        </>
                                    )}
                                    {isElectron() && (
                                        <>
                                            <div className="divider"></div>
                                            <SavedRecordingLocationMenu />
                                        </>
                                    )}
                                    {isElectron() && (
                                        <>
                                            <div className="divider"></div>
                                            <VideoDownloadMenu
                                                onClick={handleVideoDownloadMenuPage}
                                            />
                                        </>
                                    )}
                                    {isElectron() && (
                                        <>
                                            <div className="divider"></div>
                                            <PronunciationSettings />
                                        </>
                                    )}
                                    {isElectron() && (
                                        <>
                                            <div className="divider"></div>
                                            <LogSettings />
                                        </>
                                    )}
                                    <div className="divider"></div>
                                    <ResetSettings />
                                </div>
                            </>
                        )}

                        {currentPage === "video-download" && (
                            <VideoDownloadSubPage onGoBack={handleGoBackToSettings} />
                        )}
                    </div>
                </div>
            </Container>
        </>
    );
};

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 (
        <div className="mt-4">
            <div className="flex gap-x-8 gap-y-6">
                <div className="flex basis-1/2 items-center space-y-1">
                    <p className="font-semibold">
                        {t("settingPage.videoDownloadSettings.videoDownloadHeading")}
                    </p>
                </div>
                <div className="flex basis-1/2 justify-end">
                    <button type="button" className="btn" onClick={onClick}>
                        {t("settingPage.videoDownloadSettings.videoDownloadOption")}
                    </button>
                </div>
            </div>
        </div>
    );
};

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 (
        <div>
            <div className="flex flex-row items-center">
                <BsArrowLeft className="me-2 h-6 w-6" />
                <div className="breadcrumbs text-xl">
                    <ul>
                        <li>
                            <a className="link" onClick={onGoBack}>
                                {t("navigation.settings")}
                            </a>
                        </li>
                        <li>{t("settingPage.videoDownloadSettings.videoDownloadHeading")}</li>
                    </ul>
                </div>
            </div>

            <h4 className="my-4 text-xl font-semibold">
                {t("settingPage.videoDownloadSettings.videoPageHeading")}
            </h4>

            <div className="card card-lg card-border my-4 shadow-xs dark:border-slate-600">
                <div className="card-body">
                    <div className="card-title font-semibold">
                        {t("settingPage.videoDownloadSettings.instructionCardHeading")}
                    </div>
                    <div className="divider divider-secondary mt-0"></div>
                    {stepKeys.map((key, index) => (
                        <p key={key}>
                            <Trans
                                i18nKey={key}
                                values={{ number: index + 1 }}
                                components={{ 1: <span className="font-bold" /> }}
                            />
                        </p>
                    ))}
                    <div className="alert alert-warning my-2">
                        <IoWarningOutline className="h-6 w-6" />
                        <div>
                            <h3 className="font-bold">
                                {t("settingPage.videoDownloadSettings.warningHeading")}
                            </h3>
                            <div>{t("settingPage.videoDownloadSettings.warningBody")}</div>
                        </div>
                    </div>
                </div>
            </div>

            <div className="my-4 flex justify-center gap-2">
                <button type="button" className="btn btn-primary" onClick={handleOpenFolder}>
                    {t("settingPage.videoDownloadSettings.openDownloadBtn")}
                    <LuExternalLink className="h-5 w-5" />
                </button>

                <button type="button" className="btn btn-secondary" onClick={checkDownloadedFiles}>
                    {t("settingPage.videoDownloadSettings.refreshDownloadStateBtn")}
                </button>
            </div>

            {tableLoading ? (
                <div className="my-12 flex justify-center">
                    <div className="loading loading-spinner loading-lg"></div>
                </div>
            ) : (
                <VideoDownloadTable
                    data={zipFileData}
                    isDownloaded={isDownloaded}
                    t={t}
                    onStatusChange={checkDownloadedFiles}
                />
            )}
        </div>
    );
};

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)}{" "}
                    <code lang="en" className="font-mono break-all italic">
                        {data.param}
                    </code>
                </>
            );
            setIsSuccess(true);
            setShowProgressModal(false); // Hide modal after success
            setVerificationErrors([]);
            if (onStatusChange) onStatusChange(); // Trigger parent to refresh status
        };

        const handleVerificationError = (event, data) => {
            setModalMessage(
                <Trans i18nKey={data.messageKey} values={{ param: data.param }}>
                    <code className="font-mono break-all italic" lang="en">
                        {data.param}
                    </code>
                    <span>{data.errorMessage ? `Error message: ${data.errorMessage}` : ""}</span>
                </Trans>
            );
            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 (
        <>
            <div className="overflow-x-auto">
                <table className="table">
                    <thead>
                        <tr className="text-center align-middle text-base">
                            <th>
                                {t("settingPage.videoDownloadSettings.downloadTable.nameHeading")}
                            </th>
                            <th>
                                {t(
                                    "settingPage.videoDownloadSettings.downloadTable.fileSizeTotalHeading"
                                )}
                            </th>
                            <th>
                                {t("settingPage.videoDownloadSettings.downloadTable.linkHeading")}
                            </th>
                            <th>
                                {t(
                                    "settingPage.videoDownloadSettings.downloadTable.downloadStatusHeading"
                                )}
                            </th>
                            <th>
                                {t("settingPage.videoDownloadSettings.downloadTable.actionHeading")}
                            </th>
                        </tr>
                    </thead>
                    <tbody>
                        {data.map((item) => {
                            // Find the corresponding download status for the current item
                            const fileStatus = isDownloaded.find(
                                (status) => status.zipFile === item.zipFile
                            );

                            return (
                                <tr className="hover align-middle text-base" key={item.zipFile}>
                                    <td>{t(item.name)}</td>
                                    <td className="text-center">
                                        {(item.fileSize / (1024 * 1024)).toFixed(2)} MB
                                    </td>
                                    <td className="text-center">
                                        <a
                                            className="link inline-flex items-center"
                                            onClick={() => window.electron.openExternal(item.link)}
                                        >
                                            {t(
                                                "settingPage.videoDownloadSettings.downloadTable.downloadLink"
                                            )}
                                        </a>
                                        <LuExternalLink className="ms-1 inline-flex items-center" />
                                    </td>
                                    <td>
                                        {fileStatus ? ( // Check if fileStatus is found
                                            fileStatus.isDownloaded ||
                                            fileStatus.hasExtractedFolder ? (
                                                <BsCheckCircleFill className="text-success mx-auto h-6 w-6" />
                                            ) : (
                                                <BsXCircleFill className="text-error mx-auto h-6 w-6" />
                                            )
                                        ) : (
                                            // Handle the case where fileStatus is not found
                                            // This can happen if `isDownloaded` array does not include all items in `data`
                                            <span>Loading...</span>
                                        )}
                                    </td>
                                    <td className="text-center">
                                        <button
                                            type="button"
                                            className="btn btn-accent"
                                            onClick={async () => 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"
                                                );
                                            })()}
                                        </button>
                                    </td>
                                </tr>
                            );
                        })}
                    </tbody>
                </table>
            </div>

            <dialog ref={verifyModal} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {t("settingPage.videoDownloadSettings.verifyModalHeading")}
                    </h3>
                    <div className="py-4">
                        {verifyFiles.length > 0 ? (
                            <>
                                <p>{t("settingPage.videoDownloadSettings.verifyTitle")}</p>
                                <ul className="my-2 list-inside list-disc px-4">
                                    {verifyFiles.map((file, idx) => (
                                        <li key={idx}>{t(file.name)}</li>
                                    ))}
                                </ul>
                                <p className="my-4">
                                    {t("settingPage.videoDownloadSettings.verifyFooter")}
                                </p>
                            </>
                        ) : (
                            <p>{t("settingPage.videoDownloadSettings.verifyFailedMessage")}</p>
                        )}
                    </div>
                    <div className="modal-action">
                        <button className="btn" onClick={handleCloseVerifyModal}>
                            {t("settingPage.exerciseSettings.cancelBtn")}
                        </button>
                        <button
                            className="btn btn-primary"
                            onClick={async () => await handleNextModal()}
                            disabled={verifyFiles.length === 0}
                        >
                            {t("settingPage.videoDownloadSettings.nextBtn")}
                        </button>
                    </div>
                </div>
            </dialog>

            <dialog ref={progressModal} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {showProgressModal === true
                            ? t("settingPage.videoDownloadSettings.inProgressModalHeading")
                            : isSuccess
                              ? t("settingPage.videoDownloadSettings.verifySuccess")
                              : t("settingPage.videoDownloadSettings.verifyFailed")}
                    </h3>
                    <div className="py-4">
                        {showProgressModal === true && !progressError ? (
                            <>
                                <p>{progressText}</p>
                                <progress
                                    className="progress progress-primary w-full"
                                    value={isPercentage ? progress : 0}
                                    max="100"
                                >
                                    {isPercentage ? `${progress}%` : null}
                                </progress>
                            </>
                        ) : verificationErrors.length > 0 ? (
                            <>
                                <p>
                                    {verificationErrors.some((err) => err.type === "missing")
                                        ? t("settingPage.videoDownloadSettings.fileMissing")
                                        : t("settingPage.videoDownloadSettings.hashMismatch")}
                                </p>
                                <ul className="my-2 list-inside list-disc px-4">
                                    {verificationErrors.map((err, idx) => (
                                        <li key={idx}>{err.name}</li>
                                    ))}
                                </ul>
                            </>
                        ) : (
                            <p>{modalMessage}</p>
                        )}
                    </div>
                    <div className="modal-action">
                        <button
                            className="btn"
                            onClick={handleCloseProgressModal}
                            disabled={showProgressModal === true && !progressError}
                        >
                            {t("sound_page.closeBtn")}
                        </button>
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
        <div className="card card-lg card-border flex h-auto flex-col justify-between pb-6 shadow-md dark:border-slate-600">
            <div className="card-body">
                <p className="mb-2 text-center">
                    <Trans
                        i18nKey="sound_page.reviewInstructions"
                        values={{ phoneme: he.decode(sound.phoneme) }}
                    >
                        <span lang="en">{he.decode(sound.phoneme)}</span>?
                    </Trans>
                </p>
                <div className="flex flex-row items-center justify-center space-x-4">
                    <a
                        onClick={() => handleReviewClick("good")}
                        className="cursor-pointer"
                        role="button"
                    >
                        {review === "good" ? (
                            <BsEmojiSmileFill size={52} className={emojiStyle("good")} />
                        ) : (
                            <BsEmojiSmile size={52} className={emojiStyle("good")} />
                        )}
                    </a>
                    <a
                        onClick={() => handleReviewClick("neutral")}
                        className="cursor-pointer"
                        role="button"
                    >
                        {review === "neutral" ? (
                            <BsEmojiNeutralFill size={52} className={emojiStyle("neutral")} />
                        ) : (
                            <BsEmojiNeutral size={52} className={emojiStyle("neutral")} />
                        )}
                    </a>
                    <a
                        onClick={() => handleReviewClick("bad")}
                        className="cursor-pointer"
                        role="button"
                    >
                        {review === "bad" ? (
                            <BsEmojiFrownFill size={52} className={emojiStyle("bad")} />
                        ) : (
                            <BsEmojiFrown size={52} className={emojiStyle("bad")} />
                        )}
                    </a>
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="bg-base-100 sticky top-[calc(5rem)] z-10 py-8">
            <div className="flex justify-center">
                <div role="tablist" className="tabs tabs-box">
                    {tabs.map((tab) => (
                        <a
                            key={tab}
                            role="tab"
                            onClick={() => {
                                onTabChange(tab);
                                scrollTo();
                            }}
                            className={`tab md:text-base ${
                                activeTab === tab ? "tab-active font-semibold" : ""
                            }`}
                        >
                            {t(`sound_page.${tab}`)}
                        </a>
                    ))}
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="indicator">
            {badgeColor && (
                <span className={`badge indicator-item indicator-center ${badgeColor}`}>
                    {reviewText}
                </span>
            )}
            <div className="card card-lg card-border flex h-auto flex-col justify-between pb-6 shadow-md dark:border-slate-600">
                <div className="card-body grow items-center text-center">
                    <h2 className="card-title" lang="en">
                        {he.decode(sound.phoneme)}
                    </h2>
                    <p
                        lang="en"
                        className="italic"
                        dangerouslySetInnerHTML={{ __html: sound.word }}
                    />
                </div>
                <div className="card-actions px-6">
                    <button
                        className="btn btn-primary w-full"
                        onClick={() => handlePracticeClick(sound, selectedAccent, index)}
                        aria-label={t("sound_page.practiceBtn", {
                            sound: he.decode(sound.phoneme),
                        })}
                    >
                        {t("sound_page.practiceBtn")}
                    </button>
                </div>
            </div>
        </div>
    );
};

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 (
        <>
            <TopNavBar />
            <Container>
                <h1 className="py-6 text-3xl font-bold md:text-4xl">{t("navigation.sounds")}</h1>
                {selectedSound ? (
                    <Suspense fallback={isElectron() ? null : <LoadingOverlay />}>
                        <SoundMain
                            sound={selectedSound.sound}
                            accent={selectedSound.accent}
                            phonemesData={phonemesData}
                            index={selectedSound.index}
                            onBack={handleGoBack}
                        />
                    </Suspense>
                ) : (
                    <>
                        <AccentDropdown onAccentChange={setSelectedAccent} />
                        <div>
                            {loading ? (
                                <LoadingOverlay />
                            ) : (
                                <>
                                    <TabNavigation
                                        activeTab={activeTab}
                                        onTabChange={setActiveTab}
                                        scrollTo={scrollTo}
                                        t={t}
                                    />
                                    <div
                                        ref={scrollRef}
                                        className="my-4 flex flex-wrap place-items-center justify-center gap-5"
                                    >
                                        {filteredSounds.map((sound, index) => (
                                            <SoundCard
                                                key={sound.id}
                                                sound={sound}
                                                index={index}
                                                selectedAccent={selectedAccent}
                                                handlePracticeClick={handlePracticeClick}
                                                getBadgeColor={getBadgeColor}
                                                getReviewText={getReviewText}
                                                reviews={reviews}
                                                getReviewKey={getReviewKey}
                                                t={t}
                                            />
                                        ))}
                                    </div>
                                </>
                            )}
                        </div>
                    </>
                )}
            </Container>
        </>
    );
};

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 <LoadingOverlay />;
    }

    return (
        <div className="flex flex-wrap gap-5 lg:flex-nowrap">
            <div className="w-full lg:w-1/4">
                <h3 className="mb-2 text-2xl font-semibold">
                    {t("sound_page.soundTop")} <span lang="en">{he.decode(sound.phoneme)}</span>
                </h3>
                <p className="mb-4">
                    {t("accent.accentSettings")}:{" "}
                    {accent === "american" ? t("accent.accentAmerican") : t("accent.accentBritish")}
                </p>
                <div className="divider divider-secondary"></div>
                {accentData && (
                    <>
                        <p className="mb-2 font-semibold">{t("sound_page.exampleWords")}</p>
                        {["initial", "medial", "final"].map((position) => (
                            <p
                                className="mb-2 italic"
                                lang="en"
                                key={position}
                                dangerouslySetInnerHTML={{
                                    __html: he.decode(accentData[position]),
                                }}
                            ></p>
                        ))}
                    </>
                )}
                <button type="button" className="btn btn-secondary my-6" onClick={onBack}>
                    <IoChevronBackOutline className="h-5 w-5" /> {t("sound_page.backBtn")}
                </button>
            </div>
            <div className="w-full lg:w-3/4">
                <div className="bg-base-100 sticky top-[calc(5rem)] z-10 py-4">
                    <div className="flex flex-col items-center">
                        <div role="tablist" className="tabs tabs-box">
                            <a
                                role="tab"
                                onClick={() => {
                                    setActiveTab("watchTab");
                                    scrollTo();
                                }}
                                className={`tab md:text-base ${
                                    activeTab === "watchTab" ? "tab-active font-semibold" : ""
                                }`}
                            >
                                <MdOutlineOndemandVideo className="me-1 h-6 w-6" />
                                {t("buttonConversationExam.watchBtn")}
                            </a>
                            <a
                                role="tab"
                                onClick={() => {
                                    setActiveTab("practieTab");
                                    scrollTo();
                                }}
                                className={`tab md:text-base ${
                                    activeTab === "practieTab" ? "tab-active font-semibold" : ""
                                }`}
                            >
                                <MdKeyboardVoice className="me-1 h-6 w-6" />
                                {t("buttonConversationExam.practiceBtn")}
                            </a>
                            <a
                                role="tab"
                                onClick={() => {
                                    setActiveTab("reviewTab");
                                    scrollTo();
                                }}
                                className={`tab md:text-base ${
                                    activeTab === "reviewTab" ? "tab-active font-semibold" : ""
                                }`}
                            >
                                <MdChecklist className="me-1 h-6 w-6" />
                                {t("buttonConversationExam.reviewBtn")}
                            </a>
                        </div>
                    </div>
                </div>

                <div ref={scrollRef} className="my-4">
                    {activeTab === "watchTab" && (
                        <WatchVideoCard
                            videoData={accentData}
                            accent={accent}
                            t={t}
                            phoneme={{
                                type:
                                    sound.type === "consonants"
                                        ? "consonant"
                                        : sound.type === "vowels"
                                          ? "vowel"
                                          : "diphthong",
                                key: sound.key,
                            }}
                        />
                    )}
                    {activeTab === "practieTab" && (
                        <SoundVideoDialogProvider t={t}>
                            <div className="space-y-4">
                                {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]) && (
                                                        <SoundPracticeCard
                                                            key={position}
                                                            textContent={textContent}
                                                            videoUrl={videoUrl}
                                                            offlineVideo={offlineVideo}
                                                            accent={accent}
                                                            t={t}
                                                            phoneme={sound.phoneme}
                                                            phonemeId={sound.id}
                                                            index={cardIndex}
                                                            type={type}
                                                            shouldShowPhoneme={
                                                                isMain
                                                                    ? soundData?.shouldShowPhoneme !==
                                                                      false
                                                                    : undefined
                                                            }
                                                        />
                                                    )
                                                );
                                            }
                                        )}
                                    </>
                                )}
                                <TongueTwister
                                    tongueTwisters={accentData.tongueTwister}
                                    t={t}
                                    sound={sound}
                                    accent={accent}
                                />
                            </div>
                        </SoundVideoDialogProvider>
                    )}
                    {activeTab === "reviewTab" && (
                        <ReviewCard
                            sound={sound}
                            accent={accent}
                            t={t}
                            onReviewUpdate={handleReviewUpdate}
                        />
                    )}
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="card bg-base-200">
            <div className="card-body">
                <div className="flex items-center justify-between">
                    <div className="flex items-center space-x-4">
                        <div className="avatar">
                            <div className="w-12 rounded-full">
                                <img
                                    src={imgPhonemeThumbSrc}
                                    alt="Phoneme thumbnail"
                                    loading="lazy"
                                />
                            </div>
                        </div>
                        <div>
                            <p
                                lang="en"
                                className="text-base"
                                dangerouslySetInnerHTML={{ __html: textContent }}
                            />
                        </div>
                    </div>
                    <div className="flex items-center space-x-2">
                        <button
                            className="btn btn-primary btn-circle"
                            onClick={handleShow}
                            disabled={isAnyCardActive || isRecording || isPlaying}
                        >
                            <MdOutlineOndemandVideo className="h-6 w-6" />
                        </button>
                        <button
                            className={`btn btn-circle ${isRecording ? "btn-error" : "btn-primary"}`}
                            onClick={isRecording ? stopRecording : startRecording}
                            disabled={(!isRecording && isAnyCardActive) || isPlaying}
                        >
                            {isRecording ? (
                                <MdStop className="h-6 w-6" />
                            ) : (
                                <MdMic className="h-6 w-6" />
                            )}
                        </button>
                        <button
                            className="btn btn-primary btn-circle"
                            onClick={handlePlayRecording}
                            disabled={
                                !hasRecording || (!isPlaying && isAnyCardActive) || isRecording
                            }
                        >
                            {isPlaying ? (
                                <MdStop className="h-6 w-6" />
                            ) : (
                                <MdPlayArrow className="h-6 w-6" />
                            )}
                        </button>
                    </div>
                </div>
            </div>
        </div>
    );
};

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 (
        <div className="my-6">
            <div className="divider divider-secondary"></div>
            <h4 className="my-2 text-2xl font-semibold">{t("sound_page.tongueTwister")}</h4>
            <p className="mb-4 text-gray-600 dark:text-gray-400">
                {t("sound_page.tongueTwisterInstructions")}
            </p>
            <div className="space-y-4">
                {tongueTwisters.map((twister, index) => (
                    <div key={index} className="card bg-base-200">
                        <div className="card-body text-base">
                            <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
                                <div className="flex-1">
                                    {twister.title && (
                                        <h5 className="mb-1 font-semibold">{twister.title}</h5>
                                    )}
                                    {twister.lines ? (
                                        <div className="space-y-1">
                                            {twister.lines.map((line, lineIndex) => (
                                                <p key={lineIndex} className="italic" lang="en">
                                                    {he.decode(line)}
                                                </p>
                                            ))}
                                        </div>
                                    ) : (
                                        <p className="italic" lang="en">
                                            {he.decode(twister.text)}
                                        </p>
                                    )}
                                </div>
                                <div className="flex items-center justify-end space-x-2 sm:justify-start">
                                    <button
                                        className={`btn btn-circle ${isRecording ? "btn-error" : "btn-primary"}`}
                                        onClick={
                                            isRecording
                                                ? stopRecording
                                                : () => startRecording(index)
                                        }
                                        disabled={
                                            isPlaying ||
                                            (isRecording && currentPlayingIndex !== index)
                                        }
                                    >
                                        {isRecording && currentPlayingIndex === index ? (
                                            <MdStop className="h-6 w-6" />
                                        ) : (
                                            <MdMic className="h-6 w-6" />
                                        )}
                                    </button>
                                    <button
                                        className="btn btn-primary btn-circle"
                                        onClick={() => handlePlayRecording(index)}
                                        disabled={
                                            !hasRecording[index] ||
                                            isRecording ||
                                            (isPlaying && currentPlayingIndex !== index)
                                        }
                                    >
                                        {isPlaying && currentPlayingIndex === index ? (
                                            <MdStop className="h-6 w-6" />
                                        ) : (
                                            <MdPlayArrow className="h-6 w-6" />
                                        )}
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                ))}
            </div>
        </div>
    );
};

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 (
        <>
            <div className="card card-lg card-border mb-6 w-full shadow-md dark:border-slate-600">
                <div className="card-body">
                    <div className="text-xl font-semibold">
                        {t("tabConversationExam.watchCard")}
                    </div>
                    <div className="divider divider-secondary mt-0 mb-4"></div>
                    <div className={`${iframeLoading ? "overflow-hidden" : ""}`}>
                        <div className="aspect-video">
                            <div className="relative h-full w-full">
                                {localVideoUrl && !useOnlineVideo ? (
                                    <MediaPlayer src={localVideoUrl} className="h-full w-full">
                                        <MediaProvider />
                                        <DefaultVideoLayout
                                            icons={defaultLayoutIcons}
                                            colorScheme={autoDetectedTheme}
                                        />
                                    </MediaPlayer>
                                ) : (
                                    <>
                                        {iframeLoading && (
                                            <div className="skeleton absolute inset-0 h-full w-full"></div>
                                        )}
                                        <iframe
                                            src={videoUrl}
                                            title="Phoneme Video"
                                            allowFullScreen
                                            onLoad={handleIframeLoad}
                                            className={`h-full w-full transition-opacity duration-300 ${
                                                iframeLoading ? "opacity-0" : "opacity-100"
                                            }`}
                                        ></iframe>
                                    </>
                                )}
                            </div>
                        </div>
                    </div>
                    {isElectron() && useOnlineVideo ? (
                        <div role="alert" className="alert mt-5">
                            <IoInformationCircleOutline className="h-6 w-6" />
                            <span>{t("alert.alertOnlineVideo")}</span>
                        </div>
                    ) : (
                        ""
                    )}
                </div>
            </div>

            {pronunciationInstructions && (
                <div className="card card-lg card-border mb-6 w-full shadow-md dark:border-slate-600">
                    <div className="card-body">
                        <h3 className="card-title font-semibold">
                            {t("sound_page.soundInstructions.howToPronounce")}
                        </h3>
                        <div className="divider divider-secondary m-0"></div>
                        <div className="space-y-4">
                            {pronunciationInstructions.map((instruction, index) => (
                                <p key={index} className="text-base">
                                    {instruction}
                                </p>
                            ))}
                        </div>
                    </div>
                </div>
            )}
        </>
    );
};

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 (
        <SoundVideoDialogContext.Provider
            value={{
                showDialog,
                closeDialog,
                handleIframeLoad,
                dialogState,
                activeCard,
                isAnyCardActive,
                setCardActive,
                t,
            }}
        >
            {children}
            <dialog className="modal" ref={dialogRef}>
                <div className="modal-box w-11/12 max-w-5xl">
                    <h3 className="pb-4 text-lg">
                        <span className="font-bold">{t("sound_page.clipModalTitle")}</span>:{" "}
                        <span
                            lang="en"
                            className="italic"
                            dangerouslySetInnerHTML={{ __html: dialogState.title }}
                        />
                    </h3>
                    <div className={`${dialogState.iframeLoading ? "overflow-hidden" : ""}`}>
                        <div className="aspect-video">
                            <div className="relative h-full w-full">
                                {dialogState.isOpen && dialogState.isLocalVideo ? (
                                    <MediaPlayer
                                        src={dialogState.videoUrl}
                                        className="h-full w-full"
                                        ref={mediaPlayerRef}
                                    >
                                        <MediaProvider />
                                        <DefaultVideoLayout
                                            icons={defaultLayoutIcons}
                                            colorScheme={autoDetectedTheme}
                                        />
                                    </MediaPlayer>
                                ) : dialogState.isOpen && dialogState.videoUrl ? (
                                    <>
                                        {dialogState.iframeLoading && (
                                            <div className="skeleton absolute inset-0 h-full w-full"></div>
                                        )}
                                        <iframe
                                            src={dialogState.videoUrl}
                                            title={`${dialogState.phoneme} - ${dialogState.title}`}
                                            allowFullScreen
                                            onLoad={handleIframeLoad}
                                            className={`h-full w-full transition-opacity duration-300 ${
                                                dialogState.iframeLoading
                                                    ? "opacity-0"
                                                    : "opacity-100"
                                            }`}
                                        />
                                    </>
                                ) : null}
                            </div>
                        </div>
                    </div>
                    {dialogState.showOnlineVideoAlert && (
                        <div role="alert" className="alert mt-5">
                            <IoInformationCircleOutline className="h-6 w-6" />
                            <span>{t("alert.alertOnlineVideo")}</span>
                        </div>
                    )}
                    <div className="modal-action">
                        <form method="dialog">
                            <button type="button" className="btn" onClick={closeDialog}>
                                {t("sound_page.closeBtn")}
                            </button>
                        </form>
                    </div>
                </div>
            </dialog>
        </SoundVideoDialogContext.Provider>
    );
};

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 (
        <div className="mt-6 flex flex-wrap justify-center gap-4">
            <button
                type="button"
                className="btn btn-outline btn-sm"
                onClick={() => {
                    goToPage(1);
                    scrollTo();
                }}
                disabled={currentPage === 1}
            >
                « {t("wordPage.firstPageBtn")}
            </button>
            <button
                type="button"
                className="btn btn-outline btn-sm"
                onClick={() => {
                    goToPage(currentPage - 1);
                    scrollTo();
                }}
                disabled={currentPage === 1}
            >
                ‹ {t("wordPage.prevPageBtn")}
            </button>
            {Array.from({ length: totalPages }, (_, i) => i + 1)
                .filter(
                    (page) => page >= currentPage - 1 && page <= currentPage + 1 // Show nearby pages
                )
                .map((page) => (
                    <button
                        key={page}
                        className={`btn btn-sm ${currentPage === page ? "btn-primary" : "btn-outline"}`}
                        onClick={() => {
                            goToPage(page);
                            scrollTo();
                        }}
                    >
                        {page}
                    </button>
                ))}
            <button
                type="button"
                className="btn btn-outline btn-sm"
                onClick={() => {
                    goToPage(currentPage + 1);
                    scrollTo();
                }}
                disabled={currentPage === totalPages}
            >
                {t("wordPage.nextPageBtn")} ›
            </button>
            <button
                type="button"
                className="btn btn-outline btn-sm"
                onClick={() => {
                    goToPage(totalPages);
                    scrollTo();
                }}
                disabled={currentPage === totalPages}
            >
                {t("wordPage.lastPageBtn")} »
            </button>
        </div>
    );
};

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: "<number>";
    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 = <p className="text-accent">{t("wordPage.pronunciationChecker.cannotHear")}</p>;
    } 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(<span key={`space-${idx}`}> </span>);
                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(
                    <span key={`extra-separator`} className="mx-1 text-xs text-gray-600 dark:text-slate-400">
                        |{/* separator for extra phonemes */}
                    </span>
                );
                // Only insert once
                afterLastSyllable = false;
            }
            if (d.type === "same") rendered.push(<span key={idx}>{d.value}</span>);
            if (d.type === "replace")
                rendered.push(
                    <span key={idx} className="bg-info text-info-content rounded px-1">
                        {d.value}
                    </span>
                );
            if (d.type === "substitution")
                rendered.push(
                    <span key={idx} className="bg-warning text-warning-content rounded px-1">
                        {d.value}
                    </span>
                );
            if (d.type === "insert")
                rendered.push(
                    <span key={idx} className="bg-accent text-accent-content rounded px-1">
                        {d.value}
                    </span>
                );
            if (d.type === "delete")
                rendered.push(
                    <span key={idx} className="bg-error text-error-content rounded px-1">
                        {d.value}
                    </span>
                );
            charCount++;
        }
    }

    return (
        <>
            <div className="my-4 flex w-full flex-col items-center gap-2">
                <button
                    type="button"
                    className="btn btn-info"
                    onClick={checkPronunciation}
                    disabled={disabled || loading}
                >
                    {icon} {t("wordPage.pronunciationChecker.checkPronunciationBtn")}
                </button>
            </div>

            <div
                className={`flex justify-center gap-2 overflow-hidden py-4 transition-all duration-500 ease-in-out ${showResult || loading ? "max-h-3/4 opacity-100" : "max-h-0 opacity-0"}`}
            >
                {(showResult || loading) && (
                    <div className="card card-border card-lg w-full shadow-md md:w-2xl dark:border-slate-600">
                        <div className="card-body">
                            <h2 className="card-title">
                                {t("wordPage.pronunciationChecker.pronunciationResult")}
                            </h2>
                            <div className="divider divider-secondary mt-0 mb-4"></div>
                            {loading ? (
                                <div className="my-2 flex flex-col items-center justify-center">
                                    <span className="loading loading-spinner loading-lg"></span>
                                    <span className="my-2">
                                        {progress || t("wordPage.pronunciationChecker.inProgress")}
                                    </span>
                                </div>
                            ) : (
                                <>
                                    {result && result.startsWith && result.startsWith("Error:") ? (
                                        <>
                                            <p className="text-error">
                                                {t("wordPage.pronunciationChecker.errorOccurred")}
                                            </p>
                                            <p className="font-mono break-words whitespace-pre-wrap">
                                                {result.replace(/^Error:\s*/, "")}
                                            </p>
                                            {errorDetails && (
                                                <div className="flex justify-center">
                                                    <button
                                                        className="link underline"
                                                        type="button"
                                                        onClick={() => setShowErrorDialog(true)}
                                                    >
                                                        {t(
                                                            "wordPage.pronunciationChecker.errorDetails"
                                                        )}
                                                    </button>
                                                </div>
                                            )}
                                        </>
                                    ) : result === null || resultTooFarOff ? (
                                        <p className="text-accent">
                                            {t("wordPage.pronunciationChecker.cannotHear")}
                                        </p>
                                    ) : (
                                        <>
                                            {accuracyScore !== null && (
                                                <div className="mb-4 flex flex-col items-center">
                                                    <style>{radialProgressStyle}</style>
                                                    <div
                                                        className={`radial-progress animate-value mb-2 ${
                                                            accuracyScore >= 70
                                                                ? "text-primary"
                                                                : accuracyScore >= 40
                                                                  ? "text-warning"
                                                                  : "text-error"
                                                        }`}
                                                        style={{
                                                            "--value": "var(--_value)",
                                                            "--size": "5rem",
                                                            "--target-value": accuracyScore,
                                                        }}
                                                        aria-valuenow={accuracyScore}
                                                        role="progressbar"
                                                    >
                                                        <span className="text-xl font-semibold">
                                                            {accuracyScore}
                                                        </span>
                                                    </div>
                                                    <p className="text-sm text-gray-600 italic dark:text-slate-400">
                                                        {t(
                                                            "wordPage.pronunciationChecker.approximateScore"
                                                        )}
                                                    </p>
                                                </div>
                                            )}
                                            <p>
                                                <span className="font-bold">
                                                    {t(
                                                        "wordPage.pronunciationChecker.receivedResult"
                                                    )}
                                                </span>{" "}
                                                {rendered}
                                            </p>
                                            <p>
                                                <span className="font-bold">
                                                    {t(
                                                        "wordPage.pronunciationChecker.correctResult"
                                                    )}
                                                </span>{" "}
                                                {phoneme}
                                            </p>
                                            <p className="font-bold">
                                                {t("wordPage.pronunciationChecker.colorLegend")}
                                            </p>
                                            <ul className="list-inside list-disc">
                                                <li>
                                                    <span className="bg-info text-info-content rounded px-1">
                                                        {t(
                                                            "wordPage.pronunciationChecker.colorLegendMispronounce"
                                                        )}
                                                    </span>
                                                </li>
                                                <li>
                                                    <span className="bg-error text-error-content rounded px-1">
                                                        {t(
                                                            "wordPage.pronunciationChecker.colorLegendExtra"
                                                        )}
                                                    </span>
                                                </li>
                                                <li>
                                                    <span className="bg-accent text-accent-content rounded px-1">
                                                        {t(
                                                            "wordPage.pronunciationChecker.colorLegendMissing"
                                                        )}
                                                    </span>
                                                </li>
                                                <li>
                                                    <span className="bg-warning text-warning-content rounded px-1">
                                                        {t(
                                                            "wordPage.pronunciationChecker.colorLegendSubstitution"
                                                        )}
                                                    </span>
                                                </li>
                                            </ul>
                                            {phonemeLevenshtein === 0 && (
                                                <p className="text-success">
                                                    {t(
                                                        "wordPage.pronunciationChecker.perfectResult"
                                                    )}
                                                </p>
                                            )}
                                            {isClose && phonemeLevenshtein !== 0 && (
                                                <p className="text-success">
                                                    {t("wordPage.pronunciationChecker.closeResult")}
                                                </p>
                                            )}
                                            {!isClose && phonemeLevenshtein !== 0 && (
                                                <p className="text-accent">
                                                    {t(
                                                        "wordPage.pronunciationChecker.notSoCloseResult"
                                                    )}
                                                </p>
                                            )}
                                            <p className="text-sm text-gray-600 italic dark:text-slate-400">
                                                {t("wordPage.pronunciationChecker.disclaimerText")}
                                            </p>
                                        </>
                                    )}
                                </>
                            )}
                        </div>
                    </div>
                )}
            </div>

            <dialog ref={notInstalledDialogRef} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {showFailedInstall
                            ? t("wordPage.pronunciationChecker.installationProcessFailed")
                            : t("wordPage.pronunciationChecker.pronunciationCheckerNotInstalled")}
                    </h3>
                    <p className="py-4">
                        {showFailedInstall
                            ? t("wordPage.pronunciationChecker.installationProcessFailedMsg")
                            : t(
                                  "wordPage.pronunciationChecker.pronunciationCheckerNotInstalledMsg"
                              )}
                    </p>
                    <div className="modal-action">
                        <button
                            className="btn btn-primary"
                            onClick={() => notInstalledDialogRef.current.close()}
                        >
                            {t("wordPage.pronunciationChecker.okBtn")}
                        </button>
                    </div>
                </div>
            </dialog>

            <dialog ref={webDialogRef} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {t("wordPage.pronunciationChecker.featureNotAvailable")}
                    </h3>
                    <p className="py-4">
                        <Trans
                            i18nKey="wordPage.pronunciationChecker.featureNotAvailableMsg"
                            components={[
                                <button
                                    key="download-link"
                                    type="button"
                                    className="link font-semibold underline"
                                    onClick={() =>
                                        openExternal(
                                            "https://learnercraft.github.io/ispeakerreact/download"
                                        )
                                    }
                                />,
                            ]}
                        />
                    </p>
                    <div className="modal-action">
                        <button
                            className="btn btn-primary"
                            onClick={() => webDialogRef.current.close()}
                        >
                            {t("wordPage.pronunciationChecker.okBtn")}
                        </button>
                    </div>
                </div>
            </dialog>

            {/* Error Details Dialog */}
            <dialog
                open={showErrorDialog}
                className="modal"
                onClose={() => setShowErrorDialog(false)}
            >
                <div className="modal-box">
                    <h3 className="text-lg font-bold">
                        {t("wordPage.pronunciationChecker.errorDetailsTitle")}
                    </h3>
                    <div className="py-4">
                        <pre className="max-h-64 overflow-auto rounded bg-gray-100 p-4 text-sm break-all whitespace-pre-wrap dark:bg-gray-800">
                            {errorDetails}
                        </pre>
                    </div>
                    <div className="modal-action">
                        <button
                            className="btn btn-primary"
                            onClick={() => setShowErrorDialog(false)}
                        >
                            {t("wordPage.pronunciationChecker.okBtn")}
                        </button>
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
        <div className="my-4">
            <div className="mb-4 w-full">
                <div
                    ref={waveformRef}
                    className={`waveform-container ${recording ? "recording" : ""}`}
                ></div>
            </div>

            <div className="flex w-full flex-wrap place-items-center justify-center gap-4 md:gap-2">
                <button
                    type="button"
                    id="record"
                    className="btn btn-accent"
                    onClick={handleRecordClick}
                    disabled={
                        disableControls || isPlaying || isAudioLoading || pronunciationLoading
                    }
                >
                    {recording ? (
                        <>
                            <BsStopCircle className="h-5 w-5" />{" "}
                            {t("buttonConversationExam.stopRecordBtn")}
                        </>
                    ) : (
                        <>
                            <BsRecordCircle className="h-5 w-5" />{" "}
                            {t("buttonConversationExam.recordBtn")}
                        </>
                    )}
                </button>

                <button
                    type="button"
                    id="play"
                    className="btn btn-primary"
                    onClick={handlePlayPause}
                    disabled={!recordedUrl || disableControls || isAudioLoading}
                >
                    {wavesurfer?.isPlaying() ? (
                        <>
                            <BsPauseCircle className="h-5 w-5" /> {t("wordPage.pauseRecordingBtn")}
                        </>
                    ) : (
                        <>
                            <BsPlayCircle className="h-5 w-5" />{" "}
                            {t("buttonConversationExam.playBtn")}
                        </>
                    )}
                </button>
            </div>

            <PronunciationChecker
                disabled={!recordedUrl || disableControls || isAudioLoading}
                icon={<BsClipboard2Check className="h-5 w-5" />}
                wordKey={wordKey}
                displayPronunciation={displayPronunciation}
                onLoadingChange={setPronunciationLoading}
            />

            {/*recordedUrl && (
                <a
                    href={recordedUrl}
                    download="recording.webm"
                    style={{ display: "block", marginTop: "1rem" }}
                >
                    Download recording
                </a>
            )*/}
        </div>
    );
};

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 (
        <div className="my-4">
            <p className="mb-2 text-center">{t("wordPage.ratePronunciation")}</p>
            <div className="flex flex-row items-center justify-center space-x-4">
                <button type="button" onClick={() => handleReviewClick("good")}>
                    {review === "good" ? (
                        <BsEmojiSmileFill
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("good")}`}
                        />
                    ) : (
                        <BsEmojiSmile
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("good")}`}
                        />
                    )}
                </button>
                <button type="button" onClick={() => handleReviewClick("neutral")}>
                    {review === "neutral" ? (
                        <BsEmojiNeutralFill
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("neutral")}`}
                        />
                    ) : (
                        <BsEmojiNeutral
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("neutral")}`}
                        />
                    )}
                </button>
                <button type="button" onClick={() => handleReviewClick("bad")}>
                    {review === "bad" ? (
                        <BsEmojiFrownFill
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("bad")}`}
                        />
                    ) : (
                        <BsEmojiFrown
                            size={52}
                            role="button"
                            className={`cursor-pointer ${emojiStyle("bad")}`}
                        />
                    )}
                </button>
            </div>
        </div>
    );
};

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 (
        <div className="my-4 text-center">
            <p className="mb-4">Web Speech API Test</p>
            <button
                type="button"
                onClick={startSpeechRecognition}
                disabled={isListening}
                className="btn btn-info"
            >
                {isListening ? "Listening..." : "Start Speech Recognition"}
            </button>
            {error && (
                <p className="text-error my-4">
                    <strong>{error}</strong>
                </p>
            )}
            <div className="my-4">
                <strong>Recognized text:</strong>
                <p>{transcript || "No speech recognized yet."}</p>
            </div>
        </div>
    );
};

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 (
        <>
            <button type="button" className="btn btn-secondary my-8" onClick={handleBack}>
                <IoChevronBackOutline className="h-5 w-5" /> {t("wordPage.backBtn")}
            </button>
            <div
                ref={scrollRef}
                className="card card-lg card-border p-6 shadow-md dark:border-slate-600"
            >
                <div className="card-body flex justify-center">
                    <div className="mb-4 flex flex-row items-center justify-center gap-2">
                        <h1 className="text-2xl font-bold" lang="en">
                            {displayName}{" "}
                            <span className="text-base font-normal italic" lang="en">
                                {word.pos.join(", ")}
                            </span>
                        </h1>

                        {word.level.map((wordLevel, id) => (
                            <span key={id} className="badge badge-ghost font-semibold" lang="en">
                                {wordLevel.toUpperCase()}
                            </span>
                        ))}
                    </div>

                    <div className="flex flex-wrap items-center justify-center space-x-1 md:space-x-2">
                        {syllables.map((syllable, index) => (
                            <div
                                key={index}
                                className={`my-2 ${
                                    syllable.primary || syllable.secondary ? "indicator" : ""
                                }`}
                            >
                                {syllable.primary && (
                                    <span className="badge indicator-item badge-warning indicator-center font-semibold">
                                        {t("wordPage.primaryBadge")}
                                    </span>
                                )}
                                {syllable.secondary && (
                                    <span className="badge indicator-item badge-accent indicator-center font-semibold">
                                        {t("wordPage.secondaryBadge")}
                                    </span>
                                )}
                                <button
                                    type="button"
                                    className={`btn btn-xl font-normal ${
                                        index === activeSyllable
                                            ? "btn-accent"
                                            : syllable.primary
                                              ? "btn-primary"
                                              : syllable.secondary
                                                ? "btn-secondary"
                                                : ""
                                    }`}
                                >
                                    <span lang="en">{syllable.text}</span>
                                </button>
                            </div>
                        ))}

                        <button
                            type="button"
                            className="btn btn-circle btn-ghost btn-sm"
                            title={t("wordPage.wordPronunInfoHeader")}
                        >
                            <IoInformationCircleOutline
                                className="h-6 w-6"
                                onClick={() =>
                                    wordPronunInfoModalRef.current &&
                                    wordPronunInfoModalRef.current.showModal()
                                }
                            />
                        </button>
                    </div>

                    <div className="my-4 flex justify-center">
                        <div className="form-control">
                            <label className="label cursor-pointer">
                                <span>{t("wordPage.slowModeOption")}</span>
                                <input
                                    type="checkbox"
                                    className="toggle ms-4"
                                    onChange={toggleSlowMode}
                                />
                            </label>
                        </div>
                    </div>

                    {/* Waveform */}
                    <div className="flex w-full place-items-center justify-center space-x-4">
                        <button
                            type="button"
                            title={t("exercise_page.buttons.playAudioBtn")}
                            className="btn btn-circle btn-lg"
                            onClick={onPlayPause}
                            disabled={isRecordingWaveformActive || isAudioLoading} // Disable until loaded
                        >
                            {isAudioLoading ? (
                                <span className="loading loading-spinner loading-md"></span>
                            ) : isPlaying ? (
                                <BsPauseFill className="h-6 w-6" />
                            ) : (
                                <BsPlayFill className="h-6 w-6" />
                            )}
                        </button>
                        <div className="w-full">
                            <WavesurferPlayer
                                key="playback"
                                height={80}
                                waveColor={waveformColor}
                                progressColor={progressColor}
                                cursorColor={cursorColor}
                                cursorWidth={2}
                                url={audioFile}
                                onReady={onReady}
                                onPlay={() => setIsPlaying(true)}
                                onPause={() => setIsPlaying(false)}
                                onError={() => setAudioError(true)}
                                onAudioprocess={(ws, currentTime) => onAudioprocess(currentTime)}
                            />
                        </div>
                    </div>

                    {/* Recording Controls */}
                    <RecordingWaveform
                        wordKey={`wordPronunciation-${word.name}-${accent}`}
                        maxDuration={8}
                        disableControls={isPlaying}
                        onActivityChange={handleWaveformActivityChange}
                        t={t}
                        onRecordingSaved={() => setIsRecordingExists(true)}
                        isAudioLoading={isAudioLoading}
                        displayPronunciation={displayPronunciation}
                    />

                    <ReviewRecording
                        wordName={word.name}
                        accent={accent}
                        isRecordingExists={isRecordingExists}
                        t={t}
                        onReviewUpdate={onReviewUpdate}
                    />

                    <div className="flex justify-center">
                        <button
                            className="btn btn-info my-4"
                            onClick={() =>
                                openExternal(
                                    "https://github.com/learnercraft/ispeakerreact/discussions/34"
                                )
                            }
                        >
                            <VscFeedback className="h-5 w-5" /> {t("wordPage.feedbackBtn")}
                        </button>
                    </div>
                </div>
            </div>

            <dialog ref={wordPronunInfoModalRef} className="modal">
                <div className="modal-box">
                    <h3 className="text-lg font-bold">{t("wordPage.wordPronunInfoHeader")}</h3>
                    <div className="py-4">
                        {wordPronunInfoBody.map((text, index) => (
                            <p className="mb-2" key={index}>
                                {text}
                            </p>
                        ))}
                    </div>
                    <div className="modal-action">
                        <form method="dialog">
                            <button className="btn">{t("sound_page.closeBtn")}</button>
                        </form>
                    </div>
                </div>
            </dialog>
        </>
    );
};

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 (
        <>
            <TopNavBar />
            <Container>
                <h1 className="py-6 text-3xl font-bold md:text-4xl">{t("navigation.words")}</h1>
                <AccentDropdown onAccentChange={handleAccentChange} />
                {viewState === "list" && (
                    <>
                        {/* Tabs for switching data */}
                        <div className="bg-base-100 sticky top-[calc(5rem)] z-10 py-8">
                            <div className="flex justify-center">
                                <div role="tablist" className="tabs tabs-box">
                                    <a
                                        role="tab"
                                        onClick={() => setActiveTab("oxford3000")}
                                        className={`tab md:text-base ${
                                            activeTab === "oxford3000"
                                                ? "tab-active font-semibold"
                                                : ""
                                        }`}
                                    >
                                        Oxford 3000
                                        <div
                                            className="tooltip tooltip-secondary font-normal"
                                            data-tip={t("wordPage.oxford3000Description")}
                                        >
                                            <IoInformationCircleOutline className="ms-1 h-5 w-5 cursor-pointer" />
                                        </div>
                                    </a>
                                    <a
                                        role="tab"
                                        className={`tab md:text-base ${
                                            activeTab === "oxford5000"
                                                ? "tab-active font-semibold"
                                                : ""
                                        }`}
                                        onClick={() => setActiveTab("oxford5000")}
                                    >
                                        Oxford 5000
                                        <div
                                            className="tooltip tooltip-left tooltip-secondary md:tooltip-top font-normal"
                                            data-tip={t("wordPage.oxford5000Description")}
                                        >
                                            <IoInformationCircleOutline className="ms-1 h-5 w-5 cursor-pointer" />
                                        </div>
                                    </a>
                                </div>
                            </div>
                            <div className="mt-6 flex flex-wrap items-center justify-center space-x-4">
                                <input
                                    type="text"
                                    placeholder={t("wordPage.searchBox")}
                                    value={searchQuery}
                                    onChange={(e) => setSearchQuery(e.target.value)}
                                    className="input input-bordered w-1/2 max-w-xs basis-1/2"
                                />
                                <select
                                    value={selectedLevel}
                                    onChange={(e) => setSelectedLevel(e.target.value)}
                                    className="select select-bordered w-1/2 max-w-xs basis-1/3"
                                >
                                    <option value="">{t("wordPage.allLevelSelectBox")}</option>
                                    <option value="a1">A1</option>
                                    <option value="a2">A2</option>
                                    <option value="b1">B1</option>
                                    <option value="b2">B2</option>
                                    <option value="c1">C1</option>
                                </select>
                            </div>
                        </div>

                        {filteredWords.length > 0 && !loading && (
                            <div ref={scrollRef}>
                                <Pagination
                                    t={t}
                                    currentPage={currentPage}
                                    totalPages={totalPages}
                                    onPageChange={setCurrentPage}
                                    scrollTo={scrollTo}
                                />
                            </div>
                        )}

                        {/* Content Area */}
                        {loading ? (
                            <div className="col-span-full text-center">
                                <p className="text-lg font-semibold">{t("wordPage.loadingText")}</p>
                            </div>
                        ) : currentWords.length > 0 ? (
                            <div className="my-4 flex flex-row flex-wrap place-items-center justify-center gap-5">
                                {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 (
                                        <div key={word.wordId}>
                                            <div className="indicator my-4">
                                                {wordReview && (
                                                    <span
                                                        className={`${getBadgeClass(
                                                            wordReview
                                                        )} indicator-item indicator-center`}
                                                    >
                                                        {wordReviewText}
                                                    </span>
                                                )}
                                                <div className="card card-lg card-border flex h-auto w-36 justify-between pb-6 break-words shadow-md md:w-48 dark:border-slate-600">
                                                    <div className="card-body grow items-center text-center">
                                                        <h2
                                                            className="card-title break-words hyphens-auto"
                                                            lang="en"
                                                        >
                                                            {wordAccent}
                                                        </h2>
                                                        <p className="italic" lang="en">
                                                            {word.pos.join(", ")}
                                                        </p>
                                                        <div className="space-x-2">
                                                            {word.level.map((wordLevel, id) => (
                                                                <span
                                                                    key={id}
                                                                    className="badge badge-ghost font-semibold"
                                                                    lang="en"
                                                                >
                                                                    {wordLevel.toUpperCase()}
                                                                </span>
                                                            ))}
                                                        </div>
                                                    </div>
                                                    <div className="card-actions px-6">
                                                        <button
                                                            className="btn btn-primary w-full"
                                                            onClick={() => {
                                                                handlePractice(word);
                                                                scrollTo();
                                                            }}
                                                        >
                                                            {t("sound_page.practiceBtn")}
                                                        </button>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    );
                                })}
                            </div>
                        ) : (
                            <div className="col-span-full my-6 text-center">
                                <p className="text-lg font-semibold">
                                    {t("wordPage.noResultText")}
                                </p>
                            </div>
                        )}

                        {filteredWords.length > 0 && !loading && (
                            <Pagination
                                t={t}
                                currentPage={currentPage}
                                totalPages={totalPages}
                                onPageChange={setCurrentPage}
                                scrollTo={scrollTo}
                            />
                        )}
                    </>
                )}

                {viewState === "details" && selectedWord && (
                    <WordDetails
                        word={selectedWord}
                        handleBack={handleBack}
                        t={t}
                        accent={accent}
                        onAccentChange={handleAccentChange}
                        onReviewUpdate={updateReviewData}
                        scrollTo={scrollTo}
                    />
                )}
            </Container>
        </>
    );
};

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 (
        <main className={`container mx-auto p-4 mb-5 ${className}`} {...props}>
            {children}
        </main>
    );
};

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(() => (
        <div className="alert alert-success shadow-lg" role="alert">
            <IoCheckmarkCircleOutline className="h-6 w-6" />
            <span>{message}</span>
        </div>
    ));
};

export const sonnerWarningToast = (message) => {
    toast.custom(() => (
        <div className="alert alert-warning shadow-lg" role="alert">
            <IoWarningOutline className="h-6 w-6" />
            <span>{message}</span>
        </div>
    ));
};

export const sonnerErrorToast = (message) => {
    toast.custom(() => (
        <div className="alert alert-error shadow-lg" role="alert">
            <IoAlertCircleOutline className="h-6 w-6" />
            <span>{message}</span>
        </div>
    ));
};



================================================
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 <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>;
};

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


