@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 +31 -0
- package/dist/Chat/BottomBar/BottomBar.js +23 -10
- package/dist/Chat/Chat.js +2 -0
- package/dist/Dialogs/ResponsiveDialog/ResponsiveDialog.js +5 -1
- package/dist/Dialogs/SelectExercise/SelectExercise.js +390 -15
- package/dist/Exercises/Bot/Bot.js +1 -0
- package/dist/Exercises/ListenAndSpeak/ListenAndSpeak.js +18 -8
- package/dist/Exercises/Pronouncer/Pronouncer.js +18 -8
- package/dist/Exercises/Roleplay/Games/ActItOutGameListeningAndSpeaking/ActItOutGame.js +18 -8
- package/dist/Exercises/Roleplay/Games/ActItOutGameSpeaking/ActItOutGame.js +18 -9
- package/dist/Lists/CourseSelection/CourseSelection.js +43 -54
- package/dist/Lists/ExerciseSelection/ExerciseSelection.js +197 -414
- package/dist/Lists/Exercises/Exercises.js +56 -25
- package/dist/Lists/Leaderboard/Leaderboard.js +4 -4
- package/dist/Lists/PhraseListSelection/PhraseListSelection.js +95 -157
- package/dist/Lists/RoleplaySelection/RoleplaySelection.js +158 -174
- package/dist/Live/Game/Game.js +19 -45
- package/dist/Live/GameMembers/GameMembers.js +4 -0
- package/dist/Live/GameSettings/GameSettings.js +333 -176
- package/dist/Live/HowToJoin/HowToJoin.js +204 -103
- package/dist/Live/WaitingRoom/WaitingRoom.js +421 -290
- package/dist/hooks/useRecognition.js +208 -37
- package/package.json +2 -1
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:
|
|
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
|
|
280
|
+
border: "1.5px solid transparent",
|
|
271
281
|
"&:hover": {
|
|
272
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
211
|
+
onClose: handleClose,
|
|
23
212
|
dialogTitle: t("play_live"),
|
|
24
213
|
maxWidth: "md",
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
734
|
+
children: micButtonLabel
|
|
725
735
|
})
|
|
726
736
|
}), /*#__PURE__*/_jsx(Grid, {
|
|
727
737
|
children: /*#__PURE__*/_jsx(Typography, {
|