@nualang/nualang-ui-components 0.1.1419 → 0.1.1421

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,37 @@ If webapp is caching previous versions (Add a character to the end of the .tgz,
35
35
  pnpm add ../ui-components/nualang-ui-components-0.1.XXXa.tgz
36
36
  ```
37
37
 
38
+ ## Local Development with `npm link`
39
+ Links this repo directly into `app`, so changes here show up instantly with no
40
+ repacking or reinstalling. Requires both repos cloned as sibling folders
41
+ (`ui-components` and `app` side by side, same parent directory).
42
+
43
+ 1. **Here in `ui-components`**, start the watcher (it cleans `dist/` before its first
44
+ compile, so it's always safe to run - no separate build step needed):
45
+ ```bash
46
+ pnpm run watch # leave this running
47
+ ```
48
+ 2. **In `app`**, link it in and start the dev server:
49
+ ```bash
50
+ npm run link:ui-components
51
+ npm start
52
+ ```
53
+ 3. Edit a component here under `src/` and save - it recompiles automatically, `app`'s
54
+ dev server rebuilds, and a browser reload shows your change.
55
+ 4. **In `app`** when you're done, switch back to the published version:
56
+ ```bash
57
+ npm run unlink:ui-components
58
+ ```
59
+
60
+ **If you delete or rename a file while the watcher is already running**, restart
61
+ `pnpm run watch`. Babel's `--watch` mode only ever adds/updates compiled output - it
62
+ never removes a stale file from `dist/` for a source file that's gone, which can leave
63
+ `app` resolving a module that no longer exists in `src/`. Restarting re-runs the clean
64
+ step and fixes it.
65
+
66
+ Heads up: an `npm install` in `app` will quietly replace the link with the published
67
+ version - just re-run `npm run link:ui-components` to relink.
68
+
38
69
  ## Publish to npm
39
70
  Normall, you should just update the version number in package.json and commit
40
71
  your updates. When you updated are merged to main, gitlab CI/CD will add a
@@ -9,6 +9,7 @@ import Tooltip from "@mui/material/Tooltip";
9
9
  import Divider from "@mui/material/Divider";
10
10
  import Paper from "@mui/material/Paper";
11
11
  import Typography from "@mui/material/Typography";
12
+ import CircularProgress from "@mui/material/CircularProgress";
12
13
  import MicIcon from "@mui/icons-material/Mic";
13
14
  import MicOffIcon from "@mui/icons-material/MicOff";
14
15
  import SendIcon from "@mui/icons-material/Send";
@@ -81,6 +82,7 @@ export default function BottomBar({
81
82
  handleKeyPress,
82
83
  handleUpdate,
83
84
  isListening,
85
+ isMicBusy = false,
84
86
  handleAttachFile,
85
87
  handleRecord,
86
88
  handleAdd,
@@ -131,6 +133,11 @@ export default function BottomBar({
131
133
  const textInput = useRef(null);
132
134
  const activeRef = inputRef ? inputRef : textInput;
133
135
  const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
136
+
137
+ // isMicBusy spans the async mic start/stop window (permission prompt,
138
+ // audio worklet setup, socket teardown) - without this the button just
139
+ // goes inert with no explanation.
140
+ const micButtonLabel = isMicBusy ? t("loading") : isListening ? t("click_to_stop") : t("click_to_speak");
134
141
  const TextInput = /*#__PURE__*/_jsx("div", {
135
142
  className: classes.input,
136
143
  id: "keyboardInput",
@@ -236,7 +243,6 @@ export default function BottomBar({
236
243
  children: isSmallScreen ? /*#__PURE__*/_jsx(_Fragment, {
237
244
  children: /*#__PURE__*/_jsx(IconButton, {
238
245
  sx: {
239
- color: isListening ? "red" : "inherit",
240
246
  animation: showMicReminder ? "reminder 1.6s ease-in-out infinite" : "none",
241
247
  "@keyframes reminder": {
242
248
  "0%": {
@@ -253,24 +259,27 @@ export default function BottomBar({
253
259
  onClick: onClickRecord,
254
260
  edge: "start",
255
261
  id: "voiceInput",
262
+ color: isListening ? "error" : "inherit",
256
263
  disabled: disableInput,
264
+ "aria-busy": isMicBusy,
265
+ "aria-label": micButtonLabel,
257
266
  size: "large",
258
267
  ref: buttonRef,
259
- children: isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {})
268
+ children: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
269
+ size: 20,
270
+ color: "inherit"
271
+ }) : isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {})
260
272
  })
261
273
  }) : /*#__PURE__*/_jsx(_Fragment, {
262
274
  children: /*#__PURE__*/_jsx(Button, {
263
275
  sx: {
264
- color: "white",
265
- backgroundColor: isListening ? "#d32f2f" : "#2d7b30",
266
276
  padding: 1,
267
277
  paddingLeft: 2,
268
278
  paddingRight: 2,
269
279
  borderRadius: 3,
270
- border: "1.5px solid black",
280
+ border: "1.5px solid transparent",
271
281
  "&:hover": {
272
- backgroundColor: isListening ? "#d32f2f" : "#2d7b30",
273
- border: "1.5px solid white"
282
+ border: "1.5px solid black"
274
283
  },
275
284
  animation: showMicReminder ? "reminder 1.6s ease-in-out infinite" : "none",
276
285
  "@keyframes reminder": {
@@ -286,18 +295,22 @@ export default function BottomBar({
286
295
  }
287
296
  },
288
297
  onClick: onClickRecord,
289
- startIcon: isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {}),
298
+ startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
299
+ size: 20,
300
+ color: "inherit"
301
+ }) : isListening ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(MicIcon, {}),
290
302
  color: isListening ? "error" : "success",
291
303
  disabled: disableInput,
304
+ "aria-busy": isMicBusy,
292
305
  variant: "contained",
293
306
  id: "voiceInput",
294
307
  ref: buttonRef,
295
- children: isListening ? t("click_to_stop") : t("click_to_speak")
308
+ children: micButtonLabel
296
309
  })
297
310
  })
298
311
  }), /*#__PURE__*/_jsx(Typography, {
299
312
  sx: {
300
- marginLeft: 1.5,
313
+ marginLeft: isSmallScreen ? 0.75 : 1.5,
301
314
  marginRight: 1,
302
315
  my: "auto",
303
316
  fontWeight: "bold"
package/dist/Chat/Chat.js CHANGED
@@ -23,6 +23,7 @@ function Chat({
23
23
  handleBack,
24
24
  handleClose,
25
25
  handleRecord,
26
+ isMicBusy = false,
26
27
  handleAdd,
27
28
  handleSend,
28
29
  handleUpdate,
@@ -178,6 +179,7 @@ function Chat({
178
179
  focusInput: focusInput,
179
180
  handleInputChange: handleInputChange,
180
181
  isListening: isListening,
182
+ isMicBusy: isMicBusy,
181
183
  handleRecord: handleRecord,
182
184
  handleSend: handleSend,
183
185
  handleAdd: handleAdd,
@@ -28,6 +28,10 @@ const Transition = /*#__PURE__*/forwardRef(function Transition(props, ref) {
28
28
  export default function ResponsiveDialog({
29
29
  open,
30
30
  handleClose,
31
+ // Backdrop-click / Escape handler. Defaults to `handleClose`, but can be
32
+ // supplied on its own so a dialog can be dismissable without rendering the
33
+ // built-in footer Close button (which `handleClose` drives).
34
+ onClose,
31
35
  handleSubmit,
32
36
  t = text => text,
33
37
  submitText,
@@ -62,7 +66,7 @@ export default function ResponsiveDialog({
62
66
  fullWidth: fullWidth,
63
67
  maxWidth: maxWidth,
64
68
  open: open,
65
- onClose: handleClose,
69
+ onClose: onClose || handleClose,
66
70
  "aria-label": dialogTitle,
67
71
  transitionDuration: transitionDuration,
68
72
  keepMounted: keepDialogMounted,
@@ -1,46 +1,421 @@
1
- import { useState } from "react";
1
+ import { useState, useMemo, useEffect } from "react";
2
+ import { Box, Typography, Button } from "@mui/material";
3
+ import { makeStyles } from "tss-react/mui";
4
+ import MenuBookIcon from "@mui/icons-material/MenuBook";
5
+ import ForumIcon from "@mui/icons-material/Forum";
6
+ import PlaylistAddCheckIcon from "@mui/icons-material/PlaylistAddCheck";
7
+ import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
8
+ import HearingIcon from "@mui/icons-material/Hearing";
9
+ import ExtensionIcon from "@mui/icons-material/Extension";
10
+ import EditNoteIcon from "@mui/icons-material/EditNote";
11
+ import LiveTvIcon from "@mui/icons-material/LiveTv";
12
+ import AutoStoriesIcon from "@mui/icons-material/AutoStories";
2
13
  import CourseSelection from "../../Lists/CourseSelection/CourseSelection";
3
14
  import ResponsiveDialog from "../ResponsiveDialog/ResponsiveDialog";
4
- import { Box } from "@mui/system";
5
- import { DialogContentText } from "@mui/material";
6
15
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
+ const useStyles = makeStyles()(() => ({
17
+ content: {
18
+ padding: 0
19
+ }
20
+ }));
21
+ const GAME_TYPES = [{
22
+ id: "translation",
23
+ labelKey: "translation",
24
+ Icon: SwapHorizIcon,
25
+ needsDefinitions: false
26
+ }, {
27
+ id: "listening",
28
+ labelKey: "listening",
29
+ Icon: HearingIcon,
30
+ needsDefinitions: false
31
+ }, {
32
+ id: "meaning",
33
+ labelKey: "meaning",
34
+ Icon: ExtensionIcon,
35
+ needsDefinitions: true
36
+ }, {
37
+ id: "meaning-read-and-write",
38
+ labelKey: "meaning_read_write",
39
+ Icon: EditNoteIcon,
40
+ needsDefinitions: true
41
+ }];
42
+
43
+ // The single game mode for scenarios, shown in the footer like the phrase-list
44
+ // exercise toggle.
45
+ const STORY_GAME_TYPE = {
46
+ id: "roleplay-story",
47
+ labelKey: "roleplay-story",
48
+ Icon: AutoStoriesIcon
49
+ };
50
+
51
+ // Fisher-Yates in-place shuffle (inlined to avoid pulling the utils barrel,
52
+ // which imports RiveScript, into the dialog bundle).
53
+ function shuffleInPlace(array) {
54
+ for (let i = array.length - 1; i > 0; i -= 1) {
55
+ const j = Math.floor(Math.random() * (i + 1));
56
+ [array[i], array[j]] = [array[j], array[i]];
57
+ }
58
+ return array;
59
+ }
60
+
61
+ // Merge every selected list's phrases into one pool, tagging each phrase with
62
+ // its source list so the game can later draw evenly across lists, and dropping
63
+ // exact-duplicate phrase strings.
64
+ function dedupeMergedPhrases(lists) {
65
+ const seen = new Set();
66
+ const out = [];
67
+ lists.forEach(list => {
68
+ (list.phrases || []).forEach(p => {
69
+ const key = (p?.phrase || "").trim().toLowerCase();
70
+ if (key && seen.has(key)) return;
71
+ if (key) seen.add(key);
72
+ out.push({
73
+ ...p,
74
+ sourceListId: list.phraseListId
75
+ });
76
+ });
77
+ });
78
+ return out;
79
+ }
80
+ function mergePhrases(lists) {
81
+ // Shuffle the merged pool by default so lists interleave rather than play in
82
+ // per-list blocks (the waiting-room game start reshuffles too).
83
+ return shuffleInPlace(dedupeMergedPhrases(lists));
84
+ }
7
85
  function SelectExercise({
8
86
  t = text => text,
9
87
  open,
10
88
  handleClose,
11
- courses,
89
+ courses = [],
12
90
  getCourseSections,
13
- getRoleplays,
14
91
  getPhraseLists,
15
92
  handleCreateGame,
16
93
  username,
17
- courseSectionTopicId = ""
94
+ courseSectionTopicId = "",
95
+ initialSourceType = "phrases"
18
96
  }) {
19
- const [isExerciseSelected, setIsExerciseSelected] = useState(false);
97
+ const {
98
+ classes
99
+ } = useStyles();
100
+ const [sourceType, setSourceType] = useState(initialSourceType);
101
+ // Map<phraseListId, enrichedList> — keeps the phrase counts and definition
102
+ // flags without re-fetching, order-independent (the pool is shuffled anyway).
103
+ const [selectedLists, setSelectedLists] = useState(() => new Map());
104
+ const [selectedScenario, setSelectedScenario] = useState(null);
105
+ const [gameType, setGameType] = useState("translation");
106
+
107
+ // Selection is one-off per game; reset every time the dialog opens.
108
+ useEffect(() => {
109
+ if (open) {
110
+ setSourceType(initialSourceType);
111
+ setSelectedLists(new Map());
112
+ setSelectedScenario(null);
113
+ setGameType("translation");
114
+ }
115
+ }, [open]);
116
+ const isPhrases = sourceType === "phrases";
117
+ const togglePhraseList = list => {
118
+ setSelectedLists(prev => {
119
+ const next = new Map(prev);
120
+ if (next.has(list.phraseListId)) next.delete(list.phraseListId);else next.set(list.phraseListId, list);
121
+ return next;
122
+ });
123
+ };
124
+ const toggleTopicAll = lists => {
125
+ setSelectedLists(prev => {
126
+ const next = new Map(prev);
127
+ const allSelected = lists.every(l => next.has(l.phraseListId));
128
+ lists.forEach(l => {
129
+ if (allSelected) next.delete(l.phraseListId);else next.set(l.phraseListId, l);
130
+ });
131
+ return next;
132
+ });
133
+ };
134
+ const selectedListsArr = useMemo(() => [...selectedLists.values()], [selectedLists]);
135
+ // Count the deduped merged pool (matching what actually gets played) when the
136
+ // phrases are loaded; otherwise fall back to the sum of the lists' counts.
137
+ const totalPhrases = useMemo(() => {
138
+ const allHavePhrases = selectedListsArr.every(l => Array.isArray(l.phrases));
139
+ return allHavePhrases ? dedupeMergedPhrases(selectedListsArr).length : selectedListsArr.reduce((n, l) => n + (l.phraseCount || 0), 0);
140
+ }, [selectedListsArr]);
141
+ const allHaveDefinitions = selectedListsArr.length > 0 && selectedListsArr.every(l => l.hasDefinitions);
142
+ const availableGameTypes = GAME_TYPES.filter(g => !g.needsDefinitions || allHaveDefinitions);
143
+ const effectiveGameType = availableGameTypes.some(g => g.id === gameType) ? gameType : availableGameTypes[0]?.id;
144
+
145
+ // Footer exercise segments + count are mode-aware: phrase lists show the game
146
+ // types and "n lists · n phrases"; scenarios show a single Story mode and the
147
+ // selected scenario's message count.
148
+ const footerGameTypes = isPhrases ? availableGameTypes : [STORY_GAME_TYPE];
149
+ const activeFooterGameType = isPhrases ? effectiveGameType : STORY_GAME_TYPE.id;
150
+ const canPlay = isPhrases ? selectedListsArr.length > 0 : !!selectedScenario;
151
+ const handlePlay = () => {
152
+ if (!canPlay) return;
153
+ if (isPhrases) {
154
+ const first = selectedListsArr[0];
155
+ handleCreateGame({
156
+ courseId: first.courseId,
157
+ courseSectionTopicId: first.courseSectionTopicId,
158
+ exercise: effectiveGameType,
159
+ phraseListIds: selectedListsArr.map(l => l.phraseListId),
160
+ // Hydrated list metadata for the waiting room's source strip.
161
+ phraseLists: selectedListsArr.map(l => ({
162
+ phraseListId: l.phraseListId,
163
+ phraseListName: l.phraseListName,
164
+ picture: l.picture || null,
165
+ phraseCount: l.phraseCount,
166
+ topicName: l.topicName ?? "",
167
+ courseSectionTopicId: l.courseSectionTopicId ?? null,
168
+ topicVideoUrl: l.topicVideoUrl ?? null
169
+ })),
170
+ phrases: mergePhrases(selectedListsArr),
171
+ shuffle: true,
172
+ timer: 30,
173
+ answerOption: 0,
174
+ topicName: first.topicName ?? ""
175
+ });
176
+ } else {
177
+ handleCreateGame({
178
+ courseSectionTopicId: selectedScenario.courseSectionTopicId,
179
+ exercise: "roleplay-story",
180
+ roleplayId: selectedScenario.roleplayId,
181
+ timer: 30,
182
+ answerOption: 0,
183
+ topicName: selectedScenario.topicName ?? ""
184
+ });
185
+ }
186
+ };
187
+ const sourceTab = active => ({
188
+ display: "flex",
189
+ alignItems: "center",
190
+ gap: 0.75,
191
+ px: 2,
192
+ py: 1,
193
+ fontSize: 14,
194
+ fontWeight: 600,
195
+ cursor: "pointer",
196
+ ...(active ? {
197
+ color: "primary.contrastText",
198
+ backgroundColor: "primary.main"
199
+ } : {
200
+ color: "text.secondary",
201
+ backgroundColor: "background.paper"
202
+ })
203
+ });
204
+ const countLabel = isPhrases ? selectedListsArr.length ? t("lists_and_phrases_selected", {
205
+ lists: selectedListsArr.length,
206
+ phrases: totalPhrases,
207
+ count: selectedListsArr.length
208
+ }) : t("no_lists_selected") : selectedScenario ? t("scenario_selected") : t("no_scenario_selected");
20
209
  return /*#__PURE__*/_jsxs(ResponsiveDialog, {
21
210
  open: open,
22
- handleClose: handleClose,
211
+ onClose: handleClose,
23
212
  dialogTitle: t("play_live"),
24
213
  maxWidth: "md",
25
- children: [/*#__PURE__*/_jsx(DialogContentText, {
26
- children: t("live_dialog_description")
214
+ dialogContentClassName: classes.content,
215
+ children: [/*#__PURE__*/_jsx(Box, {
216
+ sx: {
217
+ display: "flex",
218
+ alignItems: "center",
219
+ justifyContent: "space-between",
220
+ gap: 3,
221
+ flexWrap: "wrap",
222
+ px: 3,
223
+ pt: 1
224
+ },
225
+ children: /*#__PURE__*/_jsxs(Box, {
226
+ sx: {
227
+ display: "flex",
228
+ border: "1px solid",
229
+ borderColor: "divider",
230
+ borderRadius: 1,
231
+ overflow: "hidden",
232
+ flex: "none"
233
+ },
234
+ children: [/*#__PURE__*/_jsxs(Box, {
235
+ role: "button",
236
+ tabIndex: 0,
237
+ "aria-pressed": isPhrases,
238
+ onClick: () => setSourceType("phrases"),
239
+ onKeyDown: e => {
240
+ if (e.key === "Enter" || e.key === " ") {
241
+ e.preventDefault();
242
+ setSourceType("phrases");
243
+ }
244
+ },
245
+ sx: sourceTab(isPhrases),
246
+ children: [/*#__PURE__*/_jsx(MenuBookIcon, {
247
+ sx: {
248
+ fontSize: 18
249
+ }
250
+ }), t("source_type_phrase_lists")]
251
+ }), /*#__PURE__*/_jsxs(Box, {
252
+ role: "button",
253
+ tabIndex: 0,
254
+ "aria-pressed": !isPhrases,
255
+ onClick: () => setSourceType("scenarios"),
256
+ onKeyDown: e => {
257
+ if (e.key === "Enter" || e.key === " ") {
258
+ e.preventDefault();
259
+ setSourceType("scenarios");
260
+ }
261
+ },
262
+ sx: sourceTab(!isPhrases),
263
+ children: [/*#__PURE__*/_jsx(ForumIcon, {
264
+ sx: {
265
+ fontSize: 18
266
+ }
267
+ }), t("source_type_scenarios")]
268
+ })]
269
+ })
27
270
  }), /*#__PURE__*/_jsx(Box, {
28
271
  sx: {
29
- mt: 1
272
+ mt: 2,
273
+ px: 3,
274
+ pb: 2.5,
275
+ minHeight: 280
30
276
  },
31
277
  children: /*#__PURE__*/_jsx(CourseSelection, {
32
278
  courses: courses,
33
279
  getCourseSections: getCourseSections,
34
- getRoleplays: getRoleplays,
35
280
  getPhraseLists: getPhraseLists,
36
281
  username: username,
37
- handleCreateGame: handleCreateGame,
38
282
  t: t,
39
- isExerciseSelected: isExerciseSelected,
40
- setIsExerciseSelected: setIsExerciseSelected,
41
283
  isDialogOpen: open,
284
+ sourceType: sourceType,
285
+ selectedListIds: selectedLists,
286
+ onTogglePhraseList: togglePhraseList,
287
+ onToggleTopicAll: toggleTopicAll,
288
+ selectedScenarioId: selectedScenario?.roleplayId || null,
289
+ onSelectScenario: setSelectedScenario,
42
290
  courseSectionTopicId: courseSectionTopicId
43
291
  })
292
+ }), /*#__PURE__*/_jsxs(Box, {
293
+ sx: {
294
+ position: "sticky",
295
+ bottom: 0,
296
+ borderTop: "1px solid",
297
+ borderColor: "divider",
298
+ backgroundColor: "background.default",
299
+ px: 3,
300
+ py: 1.5,
301
+ display: "flex",
302
+ flexDirection: "column",
303
+ gap: 1.5,
304
+ zIndex: 1
305
+ },
306
+ children: [/*#__PURE__*/_jsxs(Box, {
307
+ sx: {
308
+ display: "flex",
309
+ alignItems: "center",
310
+ gap: 2,
311
+ flexWrap: "wrap"
312
+ },
313
+ children: [/*#__PURE__*/_jsxs(Box, {
314
+ sx: {
315
+ display: "flex",
316
+ alignItems: "center",
317
+ gap: 1
318
+ },
319
+ children: [isPhrases ? /*#__PURE__*/_jsx(PlaylistAddCheckIcon, {
320
+ sx: {
321
+ fontSize: 20,
322
+ color: "primary.main"
323
+ }
324
+ }) : /*#__PURE__*/_jsx(ForumIcon, {
325
+ sx: {
326
+ fontSize: 20,
327
+ color: "primary.main"
328
+ }
329
+ }), /*#__PURE__*/_jsx(Typography, {
330
+ sx: {
331
+ fontSize: 14,
332
+ fontWeight: 600,
333
+ color: "text.primary"
334
+ },
335
+ children: countLabel
336
+ })]
337
+ }), /*#__PURE__*/_jsx(Box, {
338
+ sx: {
339
+ display: "flex",
340
+ alignItems: "center",
341
+ border: "1px solid",
342
+ borderColor: "divider",
343
+ borderRadius: 1,
344
+ overflow: "hidden",
345
+ backgroundColor: "background.paper"
346
+ },
347
+ children: footerGameTypes.map(g => {
348
+ const active = activeFooterGameType === g.id;
349
+ const {
350
+ Icon
351
+ } = g;
352
+ return /*#__PURE__*/_jsxs(Box, {
353
+ onClick: () => isPhrases && setGameType(g.id),
354
+ sx: {
355
+ display: "flex",
356
+ alignItems: "center",
357
+ gap: 0.75,
358
+ px: 1.5,
359
+ py: 1,
360
+ fontSize: 14,
361
+ cursor: isPhrases ? "pointer" : "default",
362
+ transition: "background-color 200ms cubic-bezier(0.4,0,0.2,1)",
363
+ ...(active ? {
364
+ color: "primary.contrastText",
365
+ backgroundColor: "primary.main",
366
+ fontWeight: 600,
367
+ ...(isPhrases ? {
368
+ "&:hover": {
369
+ backgroundColor: "primary.dark"
370
+ }
371
+ } : {})
372
+ } : {
373
+ color: "text.secondary",
374
+ "&:hover": {
375
+ backgroundColor: "action.hover"
376
+ }
377
+ })
378
+ },
379
+ children: [/*#__PURE__*/_jsx(Icon, {
380
+ sx: {
381
+ fontSize: 18
382
+ }
383
+ }), t(g.labelKey)]
384
+ }, g.id);
385
+ })
386
+ })]
387
+ }), /*#__PURE__*/_jsxs(Box, {
388
+ sx: {
389
+ display: "flex",
390
+ alignItems: "center",
391
+ justifyContent: "flex-end",
392
+ gap: 1
393
+ },
394
+ children: [/*#__PURE__*/_jsx(Button, {
395
+ onClick: handleClose,
396
+ color: "primary",
397
+ sx: {
398
+ fontWeight: 600
399
+ },
400
+ children: t("close")
401
+ }), /*#__PURE__*/_jsx(Button, {
402
+ variant: "contained",
403
+ onClick: handlePlay,
404
+ disabled: !canPlay,
405
+ startIcon: /*#__PURE__*/_jsx(LiveTvIcon, {}),
406
+ "data-cy": "play-live",
407
+ sx: {
408
+ fontWeight: 600,
409
+ borderRadius: "25px",
410
+ px: 2.25,
411
+ backgroundColor: "secondary.main",
412
+ "&:hover": {
413
+ backgroundColor: "secondary.dark"
414
+ }
415
+ },
416
+ children: t("play_live")
417
+ })]
418
+ })]
44
419
  })]
45
420
  });
46
421
  }
@@ -606,6 +606,7 @@ function Bot({
606
606
  t: t,
607
607
  authenticated: authenticated,
608
608
  disableInput: !authenticated || isSpeaking || isRecognizingDisabled ? true : false,
609
+ isMicBusy: isRecognizingDisabled,
609
610
  messages: botMessages,
610
611
  placeholder: !recognizing ? placeholder : "",
611
612
  translationEnabled: isTranslationEnabled,
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
2
2
  import { Joyride, STATUS } from "react-joyride";
3
3
  import { makeStyles } from "tss-react/mui";
4
4
  import { useTheme } from "@mui/material/styles";
5
- import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip } from "@mui/material";
5
+ import { Grid, Slide, IconButton, Button, Toolbar, AppBar, Dialog, Typography, Box, Tooltip, CircularProgress } from "@mui/material";
6
6
  import { styled } from "@mui/system";
7
7
  import LightbulbIcon from "@mui/icons-material/Lightbulb";
8
8
  import Replay from "@mui/icons-material/Replay";
@@ -227,9 +227,12 @@ function ListenAndSpeak({
227
227
  });
228
228
  }
229
229
  if (recognizing && interimTranscript === "" && finalTranscript === "") {
230
- // Set a timer to stop listening if no audio is detected after 10 seconds
230
+ // Set a timer to stop listening if no audio is detected after 10 seconds.
231
+ // Without the snackbar here the mic just switches off with no
232
+ // explanation, indistinguishable from the student stopping it themselves.
231
233
  timer = setTimeout(() => {
232
234
  stopListening();
235
+ openSnackbar?.("We didn't hear anything, please try again.", "warning");
233
236
  }, 10000);
234
237
  }
235
238
  return () => clearTimeout(timer);
@@ -455,6 +458,12 @@ function ListenAndSpeak({
455
458
  }
456
459
  };
457
460
  const isListeningIndicatorTextVisible = recognizing && interimTranscript === "" && finalTranscript === "" ? true : false;
461
+
462
+ // isRecognizingDisabled spans the async mic start/stop window (permission
463
+ // prompt, audio worklet setup, socket teardown) - without a distinct label
464
+ // and icon here the button just goes inert with no explanation.
465
+ const isMicBusy = isRecognizingDisabled;
466
+ const micButtonLabel = isMicBusy ? t("loading") : recognizing ? t("click_to_stop") : t("click_to_speak");
458
467
  const navigate = useNavigate();
459
468
  const goBackToAssignment = () => {
460
469
  navigate(`/classrooms/${classroomId}#Assignments`);
@@ -696,32 +705,33 @@ function ListenAndSpeak({
696
705
  [theme.breakpoints.down("md")]: {
697
706
  height: "50px",
698
707
  width: "200px",
699
- color: "white",
700
708
  padding: 2,
701
709
  fontSize: "16px",
702
710
  borderRadius: 3
703
711
  },
704
712
  height: "70px",
705
713
  width: "250px",
706
- color: "white",
707
- backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
708
714
  padding: 1,
709
715
  fontSize: "20px",
710
716
  borderRadius: 5,
711
717
  border: "1.5px solid transparent",
712
718
  "&:hover": {
713
- backgroundColor: recognizing ? "#d32f2f" : "#2d7b30",
714
719
  border: "1.5px solid black"
715
720
  }
716
721
  }),
717
722
  onClick: () => handleMic(currentPhrase ? currentPhrase.phrase : ""),
718
- startIcon: recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
723
+ startIcon: isMicBusy ? /*#__PURE__*/_jsx(CircularProgress, {
724
+ size: 20,
725
+ color: "inherit"
726
+ }) : recognizing ? /*#__PURE__*/_jsx(MicOffIcon, {}) : /*#__PURE__*/_jsx(Mic, {}),
719
727
  color: recognizing ? "error" : "success",
720
728
  disabled: isSpeaking || !isSocketConnected || isRecognizingDisabled,
729
+ "aria-busy": isMicBusy,
730
+ "aria-label": micButtonLabel,
721
731
  id: "voiceInput",
722
732
  variant: "contained",
723
733
  ref: btnRef,
724
- children: recognizing ? t("click_to_stop") : t("click_to_speak")
734
+ children: micButtonLabel
725
735
  })
726
736
  }), /*#__PURE__*/_jsx(Grid, {
727
737
  children: /*#__PURE__*/_jsx(Typography, {