@nualang/nualang-ui-components 0.1.1422 → 0.1.1423

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.
@@ -119,10 +119,11 @@ function RoleplayGame({
119
119
  classes
120
120
  } = useStyles();
121
121
  const theme = useTheme();
122
+ const isDisabled = isGameHidden || isRoleplayEmpty;
122
123
  return /*#__PURE__*/_jsxs(ListItem, {
123
124
  alignItems: "flex-start",
124
- disabled: isGameHidden || isRoleplayEmpty,
125
- onClick: authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
125
+ disabled: isDisabled,
126
+ onClick: isDisabled ? undefined : authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
126
127
  button: true,
127
128
  divider: true,
128
129
  classes: {
@@ -143,7 +144,11 @@ function RoleplayGame({
143
144
  },
144
145
  "&:focus": {
145
146
  backgroundColor: "transparent"
146
- }
147
+ },
148
+ ...(isDisabled && {
149
+ opacity: 0.5,
150
+ pointerEvents: "none"
151
+ })
147
152
  },
148
153
  children: [/*#__PURE__*/_jsx(ListItemAvatar, {
149
154
  children: gameIcon ? /*#__PURE__*/_jsx(Avatar, {
@@ -9,14 +9,46 @@ const ENDPOINT = import.meta.env.REACT_APP_GCLOUD_SPEECH || "wss://speech-rec-se
9
9
  const socket = io(ENDPOINT, {
10
10
  withCredentials: true,
11
11
  autoConnect: false,
12
- transports: ["websocket", "polling"] // use WebSocket, fallback to polling if necessary
13
- });
14
- socket.on("connect_error", error => {
15
- // revert to classic upgrade
16
- // socket.io.opts.transports = ["polling", "websocket"];
17
- console.error(error);
12
+ transports: ["websocket", "polling"],
13
+ // use WebSocket, fallback to polling if necessary
14
+ // Without this, when websocket is listed first and fails outright (e.g. a
15
+ // school firewall blocking the WS upgrade), engine.io does NOT fall back to
16
+ // polling for that attempt - it just fails the whole connect_error and retries
17
+ // websocket again on the next reconnection attempt, looping forever on
18
+ // networks that block websocket entirely.
19
+ tryAllTransports: true
18
20
  });
19
21
 
22
+ /**
23
+ * Builds a single diagnostic message for a connect_error analytics event.
24
+ * `error.message` for a WebSocket transport failure is (by design of the
25
+ * browser WebSocket API) always the generic string "websocket error" - the
26
+ * spec deliberately withholds the underlying reason (DNS/TLS/refused/timed
27
+ * out) from JS to avoid leaking network topology info to a page. `error.type`
28
+ * and `error.description` are the only other fields engine.io-client
29
+ * attaches, and for polling transport failures `description` is often the
30
+ * XHR object (so `description.status` gives the HTTP status code) - folded
31
+ * into one field here rather than several, since they're only ever useful
32
+ * together and only for this one event.
33
+ */
34
+ function describeConnectError(error) {
35
+ const description = error?.description;
36
+ let descriptionDetail = "none";
37
+ if (description && typeof description === "object") {
38
+ if (typeof description.status === "number") {
39
+ descriptionDetail = `http_status_${description.status}`;
40
+ } else if (description.message) {
41
+ descriptionDetail = `${description.message}`;
42
+ }
43
+ } else if (description) {
44
+ descriptionDetail = `${description}`;
45
+ }
46
+ const transport = socket.io?.engine?.transport?.name;
47
+ return {
48
+ A4_error_message: `${error?.message} (type=${error?.type}, description=${descriptionDetail}, transport=${transport}, online=${navigator.onLine})`
49
+ };
50
+ }
51
+
20
52
  // audioStream constraints
21
53
  const constraints = {
22
54
  audio: true,
@@ -24,6 +56,21 @@ const constraints = {
24
56
  };
25
57
  const version = ["v1", "v2"].includes(import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION) ? import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION : "v1";
26
58
  const audioRecordingLimit = 60000;
59
+
60
+ // Safari (16.4+) exposes a page-global AudioSession whose category WebKit
61
+ // otherwise flips to playback-only when TTS/<audio> plays, after which
62
+ // getUserMedia throws "AudioSession category is not compatible with audio
63
+ // capture". Pinning it to "play-and-record" keeps mic capture available even
64
+ // after audio playback. No-op where the API is unsupported (Chrome/Firefox).
65
+ function ensurePlayAndRecordAudioSession() {
66
+ try {
67
+ if (typeof navigator !== "undefined" && navigator.audioSession) {
68
+ navigator.audioSession.type = "play-and-record";
69
+ }
70
+ } catch (error) {
71
+ logger.warn(`Failed to set audioSession type: ${error}`);
72
+ }
73
+ }
27
74
  export default function useRecognition(props = {}) {
28
75
  const {
29
76
  currentPhraseText,
@@ -98,6 +145,12 @@ export default function useRecognition(props = {}) {
98
145
  throw e;
99
146
  }
100
147
  }
148
+
149
+ // Set as early as the exercise mounts, before any TTS playback can claim a
150
+ // playback-only session on Safari.
151
+ useEffect(() => {
152
+ ensurePlayAndRecordAudioSession();
153
+ }, []);
101
154
  useEffect(() => {
102
155
  function onConnect() {
103
156
  hasAlertedConnectionErrorRef.current = false;
@@ -105,9 +158,13 @@ export default function useRecognition(props = {}) {
105
158
  // socket.io.engine.transport.name is "polling" here only when the
106
159
  // websocket attempt failed and it fell back - the clearest signal we
107
160
  // have that a student couldn't connect via websocket.
161
+ const transport = socket.io.engine.transport.name;
108
162
  trackConnectionDiagnostics("speech_recognition_socket_connected", {
109
- A4_transport: socket.io.engine.transport.name
163
+ A4_transport: transport
110
164
  });
165
+ if (transport === "polling") {
166
+ recognitionAlert("Using a slower backup connection for speech - ask your school's IT team to allow WebSocket connections.", "warning");
167
+ }
111
168
  }
112
169
  function onDisconnect() {
113
170
  setIsSocketConnected(false);
@@ -151,9 +208,7 @@ export default function useRecognition(props = {}) {
151
208
  }
152
209
  function onConnectError(error) {
153
210
  console.error(error);
154
- trackConnectionDiagnostics("speech_recognition_connect_error", {
155
- A4_error_message: `${error?.message}`
156
- });
211
+ trackConnectionDiagnostics("speech_recognition_connect_error", describeConnectError(error));
157
212
  // Without this, a blocked/failed connection (e.g. a school network
158
213
  // firewall blocking the websocket) leaves the mic button silently
159
214
  // disabled with no feedback to the student.
@@ -342,6 +397,8 @@ export default function useRecognition(props = {}) {
342
397
  }
343
398
  };
344
399
  if (navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
400
+ // Re-assert in case TTS playback downgraded the session since mount.
401
+ ensurePlayAndRecordAudioSession();
345
402
  navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).then(() => {
346
403
  isStartingRef.current = false;
347
404
  // socket.connected is checked directly (not the isSocketConnected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1422",
3
+ "version": "0.1.1423",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "react-swipeable-views-utils": "^0.14.0",
84
84
  "regenerator-runtime": "^0.14.1",
85
85
  "rehype-raw": "^7.0.0",
86
- "socket.io-client": "^4.7.1",
86
+ "socket.io-client": "^4.8.3",
87
87
  "styled-components": "^6.3.8",
88
88
  "tss-react": "^4.9.16",
89
89
  "uifx": "^2.0",