@redacto.io/consent-sdk-react 1.4.0 → 2.0.0
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/CHANGELOG.md +6 -0
- package/dist/index.js +651 -620
- package/dist/index.mjs +639 -608
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +544 -189
- package/src/RedactoNoticeConsent/api/index.ts +67 -0
- package/src/RedactoNoticeConsent/api/types.ts +39 -0
- package/src/RedactoNoticeConsent/injectStyles.ts +30 -8
- package/src/RedactoNoticeConsent/types.ts +0 -23
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +5 -0
- package/src/RedactoNoticeConsent/useTextToSpeech.ts +0 -378
|
@@ -3,10 +3,12 @@ import type {
|
|
|
3
3
|
ConsentContent,
|
|
4
4
|
ConsentEventPayload,
|
|
5
5
|
FetchConsentContentParams,
|
|
6
|
+
FetchTTSAudioUrlsParams,
|
|
6
7
|
GuardianInfoPayload,
|
|
7
8
|
RedactoJwtPayload,
|
|
8
9
|
SubmitConsentEventParams,
|
|
9
10
|
SubmitGuardianInfoParams,
|
|
11
|
+
TTSAudioUrlsResponse,
|
|
10
12
|
} from "./types";
|
|
11
13
|
|
|
12
14
|
const BASE_URL = "https://api.redacto.io/consent";
|
|
@@ -352,3 +354,68 @@ export const submitGuardianInfo = async ({
|
|
|
352
354
|
throw error;
|
|
353
355
|
}
|
|
354
356
|
};
|
|
357
|
+
|
|
358
|
+
export const fetchTTSAudioUrls = async ({
|
|
359
|
+
accessToken,
|
|
360
|
+
baseUrl,
|
|
361
|
+
noticeUuid,
|
|
362
|
+
language,
|
|
363
|
+
signal, // AbortSignal for request cancellation
|
|
364
|
+
}: FetchTTSAudioUrlsParams & { signal?: AbortSignal }): Promise<TTSAudioUrlsResponse> => {
|
|
365
|
+
try {
|
|
366
|
+
// Validate required parameters
|
|
367
|
+
if (!accessToken || !noticeUuid || !language) {
|
|
368
|
+
throw new Error("accessToken, noticeUuid, and language are required");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const decodedToken = decodeTokenSafely(accessToken);
|
|
372
|
+
if (!decodedToken) {
|
|
373
|
+
throw new Error("Invalid access token");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
|
|
377
|
+
if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
|
|
378
|
+
throw new Error("Invalid token: missing organization or workspace UUID");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const apiBaseUrl = baseUrl || BASE_URL;
|
|
382
|
+
|
|
383
|
+
const url = `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`;
|
|
384
|
+
|
|
385
|
+
const response = await fetch(url, {
|
|
386
|
+
method: "GET",
|
|
387
|
+
headers: {
|
|
388
|
+
Authorization: `Bearer ${accessToken}`,
|
|
389
|
+
Accept: "application/json",
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
},
|
|
392
|
+
signal, // Add abort signal for request cancellation
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
let errorMessage = `Failed to fetch TTS audio URLs: ${response.status} ${response.statusText}`;
|
|
397
|
+
|
|
398
|
+
if (response.status === 401) {
|
|
399
|
+
errorMessage = "Unauthorized: Invalid or expired token";
|
|
400
|
+
} else if (response.status === 403) {
|
|
401
|
+
errorMessage = "Forbidden: Access denied";
|
|
402
|
+
} else if (response.status === 404) {
|
|
403
|
+
errorMessage = "Notice or audio not found";
|
|
404
|
+
} else if (response.status >= 500) {
|
|
405
|
+
errorMessage = "Server error: Please try again later";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const error = new Error(errorMessage) as Error & { status?: number };
|
|
409
|
+
error.status = response.status;
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const data: TTSAudioUrlsResponse = await response.json();
|
|
414
|
+
|
|
415
|
+
return data;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
// Log error for debugging but don't expose sensitive information
|
|
418
|
+
console.error("Error fetching TTS audio URLs:", error instanceof Error ? error.message : error);
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
@@ -262,3 +262,42 @@ export type PurposeItemProps = {
|
|
|
262
262
|
getTranslatedText: (key: string, defaultText: string, itemId?: string) => string;
|
|
263
263
|
isAlreadyConsented: boolean;
|
|
264
264
|
};
|
|
265
|
+
|
|
266
|
+
// TTS Audio Types
|
|
267
|
+
export type PurposeAudioUrls = {
|
|
268
|
+
name_audio_url: string;
|
|
269
|
+
description_audio_url: string;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export type DataElementAudioUrls = {
|
|
273
|
+
name_audio_url: string;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export type TTSAudioUrlsResponse = {
|
|
277
|
+
code: number;
|
|
278
|
+
status: string;
|
|
279
|
+
detail: {
|
|
280
|
+
uuid: string;
|
|
281
|
+
language: string;
|
|
282
|
+
notice_text_audio_url: string;
|
|
283
|
+
additional_text_audio_url: string;
|
|
284
|
+
notice_summary_audio_url: string;
|
|
285
|
+
confirm_button_text_audio_url: string;
|
|
286
|
+
decline_button_text_audio_url: string;
|
|
287
|
+
privacy_policy_prefix_text_audio_url: string;
|
|
288
|
+
vendor_list_prefix_text_audio_url: string;
|
|
289
|
+
privacy_policy_anchor_text_audio_url: string;
|
|
290
|
+
vendors_list_anchor_text_audio_url: string;
|
|
291
|
+
purpose_section_heading_audio_url: string;
|
|
292
|
+
notice_banner_heading_audio_url: string;
|
|
293
|
+
purposes_audio: Record<string, PurposeAudioUrls>;
|
|
294
|
+
data_elements_audio: Record<string, DataElementAudioUrls>;
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export type FetchTTSAudioUrlsParams = {
|
|
299
|
+
accessToken: string;
|
|
300
|
+
baseUrl?: string;
|
|
301
|
+
noticeUuid: string;
|
|
302
|
+
language: string;
|
|
303
|
+
};
|
|
@@ -96,20 +96,42 @@ export const injectCheckboxStyles = () => {
|
|
|
96
96
|
outline-offset: 2px !important;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/*
|
|
99
|
+
/* TTS Highlight Styles - Visible highlighting without layout shift */
|
|
100
100
|
.redacto-tts-highlight {
|
|
101
|
+
position: relative !important;
|
|
102
|
+
transition: background-color 0.3s ease, outline 0.3s ease !important;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Block-level elements - headings */
|
|
106
|
+
h2.redacto-tts-highlight,
|
|
107
|
+
h3.redacto-tts-highlight {
|
|
101
108
|
background-color: #FFF9C4 !important;
|
|
102
|
-
|
|
103
|
-
border-radius: 2px !important;
|
|
109
|
+
border-radius: 3px !important;
|
|
104
110
|
padding: 2px 4px !important;
|
|
111
|
+
margin: -2px -4px !important;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
/*
|
|
108
|
-
.redacto-tts-
|
|
114
|
+
/* Inline elements - spans, links */
|
|
115
|
+
span.redacto-tts-highlight,
|
|
116
|
+
a.redacto-tts-highlight {
|
|
109
117
|
background-color: #FFF9C4 !important;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
border-radius: 3px !important;
|
|
119
|
+
padding: 2px 4px !important;
|
|
120
|
+
margin: -2px -4px !important;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Paragraphs - handle carefully to avoid layout shift */
|
|
124
|
+
p.redacto-tts-highlight {
|
|
125
|
+
background-color: #FFF9C4 !important;
|
|
126
|
+
border-radius: 3px !important;
|
|
127
|
+
padding: 2px 4px !important;
|
|
128
|
+
margin: -2px -4px !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Buttons - use outline to keep button styles intact */
|
|
132
|
+
button.redacto-tts-highlight {
|
|
133
|
+
outline: 3px solid #FFF9C4 !important;
|
|
134
|
+
outline-offset: 2px !important;
|
|
113
135
|
}
|
|
114
136
|
`;
|
|
115
137
|
|
|
@@ -43,26 +43,3 @@ export type TranslationObject = {
|
|
|
43
43
|
| Record<string, string>
|
|
44
44
|
| undefined;
|
|
45
45
|
};
|
|
46
|
-
|
|
47
|
-
export type TextSegment = {
|
|
48
|
-
text: string;
|
|
49
|
-
elementId: string;
|
|
50
|
-
elementRef?: React.RefObject<HTMLElement>;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type UseTextToSpeechReturn = {
|
|
54
|
-
isPlaying: boolean;
|
|
55
|
-
isPaused: boolean;
|
|
56
|
-
currentIndex: number;
|
|
57
|
-
play: () => void;
|
|
58
|
-
pause: () => void;
|
|
59
|
-
stop: () => void;
|
|
60
|
-
toggle: () => void;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export type WordSpan = {
|
|
64
|
-
word: string;
|
|
65
|
-
span: HTMLSpanElement;
|
|
66
|
-
startIndex: number;
|
|
67
|
-
endIndex: number;
|
|
68
|
-
};
|
|
@@ -465,6 +465,11 @@ const RedactoConsentInlineComponent = ({
|
|
|
465
465
|
tabIndex={0}
|
|
466
466
|
aria-expanded={!collapsedPurposes[purpose.uuid]}
|
|
467
467
|
aria-controls={`purpose-${purpose.uuid}`}
|
|
468
|
+
aria-label={`${collapsedPurposes[purpose.uuid] ? "Expand" : "Collapse"} ${getTranslatedText(
|
|
469
|
+
`purposes.name`,
|
|
470
|
+
purpose.name,
|
|
471
|
+
purpose.uuid
|
|
472
|
+
)} details`}
|
|
468
473
|
onKeyDown={(e) => {
|
|
469
474
|
if (e.key === "Enter" || e.key === " ") {
|
|
470
475
|
e.preventDefault();
|
|
@@ -1,378 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import type { TextSegment, UseTextToSpeechReturn, WordSpan } from "./types";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Custom hook for text-to-speech functionality with synchronized word-by-word highlighting
|
|
6
|
-
*/
|
|
7
|
-
export const useTextToSpeech = (
|
|
8
|
-
segments: TextSegment[],
|
|
9
|
-
language: string = "en"
|
|
10
|
-
): UseTextToSpeechReturn => {
|
|
11
|
-
const [isPlaying, setIsPlaying] = useState(false);
|
|
12
|
-
const [isPaused, setIsPaused] = useState(false);
|
|
13
|
-
const [currentIndex, setCurrentIndex] = useState(0);
|
|
14
|
-
|
|
15
|
-
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
|
16
|
-
const synthRef = useRef<SpeechSynthesis | null>(null);
|
|
17
|
-
const currentSegmentRef = useRef<number>(0);
|
|
18
|
-
const wordSpansRef = useRef<WordSpan[]>([]);
|
|
19
|
-
const originalTextsRef = useRef<Map<string, string>>(new Map());
|
|
20
|
-
const originalHTMLRef = useRef<Map<string, string>>(new Map());
|
|
21
|
-
const currentWordIndexRef = useRef<number>(-1);
|
|
22
|
-
const isInitializedRef = useRef<boolean>(false);
|
|
23
|
-
|
|
24
|
-
// Initialize speech synthesis
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
|
27
|
-
synthRef.current = window.speechSynthesis;
|
|
28
|
-
}
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
// Cleanup on unmount
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
return () => {
|
|
34
|
-
cleanupWordSpans();
|
|
35
|
-
if (synthRef.current) {
|
|
36
|
-
synthRef.current.cancel();
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
}, []);
|
|
40
|
-
|
|
41
|
-
// Cleanup word spans and restore original text
|
|
42
|
-
const cleanupWordSpans = useCallback(() => {
|
|
43
|
-
wordSpansRef.current.forEach((wordSpan) => {
|
|
44
|
-
if (wordSpan.span.parentNode) {
|
|
45
|
-
wordSpan.span.classList.remove("redacto-tts-word-highlight");
|
|
46
|
-
wordSpan.span.style.backgroundColor = "";
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
wordSpansRef.current = [];
|
|
50
|
-
currentWordIndexRef.current = -1;
|
|
51
|
-
isInitializedRef.current = false;
|
|
52
|
-
|
|
53
|
-
// Restore original HTML structure
|
|
54
|
-
originalHTMLRef.current.forEach((originalHTML, elementId) => {
|
|
55
|
-
const element = document.getElementById(elementId);
|
|
56
|
-
if (element && originalHTML) {
|
|
57
|
-
// Restore the original HTML structure
|
|
58
|
-
element.innerHTML = originalHTML;
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
originalHTMLRef.current.clear();
|
|
62
|
-
originalTextsRef.current.clear();
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
// Remove highlight from all words
|
|
66
|
-
const removeAllHighlights = useCallback(() => {
|
|
67
|
-
wordSpansRef.current.forEach((wordSpan) => {
|
|
68
|
-
wordSpan.span.classList.remove("redacto-tts-word-highlight");
|
|
69
|
-
wordSpan.span.style.backgroundColor = "";
|
|
70
|
-
});
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
// Create word spans for an element
|
|
74
|
-
const createWordSpans = useCallback((elementId: string, text: string): WordSpan[] => {
|
|
75
|
-
const element = document.getElementById(elementId);
|
|
76
|
-
if (!element) return [];
|
|
77
|
-
|
|
78
|
-
// Store original HTML structure if not already stored
|
|
79
|
-
if (!originalHTMLRef.current.has(elementId)) {
|
|
80
|
-
const originalHTML = element.innerHTML || "";
|
|
81
|
-
originalHTMLRef.current.set(elementId, originalHTML);
|
|
82
|
-
const originalText = element.textContent || element.innerText || "";
|
|
83
|
-
originalTextsRef.current.set(elementId, originalText);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Split text into words while preserving spaces
|
|
87
|
-
const words = text.split(/(\s+)/);
|
|
88
|
-
const wordSpans: WordSpan[] = [];
|
|
89
|
-
let charIndex = 0;
|
|
90
|
-
|
|
91
|
-
// Clear element content and rebuild with word spans
|
|
92
|
-
element.innerHTML = "";
|
|
93
|
-
|
|
94
|
-
words.forEach((word) => {
|
|
95
|
-
if (word.trim()) {
|
|
96
|
-
// Create span for word
|
|
97
|
-
const span = document.createElement("span");
|
|
98
|
-
span.textContent = word;
|
|
99
|
-
span.style.display = "inline";
|
|
100
|
-
span.style.transition = "background-color 0.2s ease";
|
|
101
|
-
span.style.padding = "0 1px";
|
|
102
|
-
element.appendChild(span);
|
|
103
|
-
|
|
104
|
-
const startIndex = charIndex;
|
|
105
|
-
const endIndex = charIndex + word.length;
|
|
106
|
-
wordSpans.push({
|
|
107
|
-
word,
|
|
108
|
-
span,
|
|
109
|
-
startIndex,
|
|
110
|
-
endIndex,
|
|
111
|
-
});
|
|
112
|
-
} else if (word) {
|
|
113
|
-
// Preserve whitespace
|
|
114
|
-
element.appendChild(document.createTextNode(word));
|
|
115
|
-
}
|
|
116
|
-
charIndex += word.length;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
return wordSpans;
|
|
120
|
-
}, []);
|
|
121
|
-
|
|
122
|
-
// Highlight a specific word by index
|
|
123
|
-
const highlightWordByIndex = useCallback((wordIndex: number) => {
|
|
124
|
-
if (wordIndex >= 0 && wordIndex < wordSpansRef.current.length) {
|
|
125
|
-
removeAllHighlights();
|
|
126
|
-
const wordSpan = wordSpansRef.current[wordIndex];
|
|
127
|
-
wordSpan.span.classList.add("redacto-tts-word-highlight");
|
|
128
|
-
wordSpan.span.style.backgroundColor = "#FFF9C4";
|
|
129
|
-
|
|
130
|
-
// Scroll into view if needed
|
|
131
|
-
wordSpan.span.scrollIntoView({
|
|
132
|
-
behavior: "smooth",
|
|
133
|
-
block: "center",
|
|
134
|
-
inline: "nearest",
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}, [removeAllHighlights]);
|
|
138
|
-
|
|
139
|
-
// Speak a single segment with word-by-word highlighting
|
|
140
|
-
const speakSegment = useCallback(
|
|
141
|
-
(index: number) => {
|
|
142
|
-
if (!synthRef.current || index >= segments.length) {
|
|
143
|
-
setIsPlaying(false);
|
|
144
|
-
setIsPaused(false);
|
|
145
|
-
setCurrentIndex(0);
|
|
146
|
-
currentSegmentRef.current = 0;
|
|
147
|
-
cleanupWordSpans();
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const segment = segments[index];
|
|
152
|
-
if (!segment.text.trim()) {
|
|
153
|
-
// Skip empty segments
|
|
154
|
-
speakSegment(index + 1);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
setCurrentIndex(index);
|
|
159
|
-
currentSegmentRef.current = index;
|
|
160
|
-
|
|
161
|
-
// Create word spans for this segment if not already created
|
|
162
|
-
// If element doesn't exist in DOM (e.g., collapsed), we'll still speak without highlighting
|
|
163
|
-
if (!isInitializedRef.current || wordSpansRef.current.length === 0) {
|
|
164
|
-
const wordSpans = createWordSpans(segment.elementId, segment.text);
|
|
165
|
-
wordSpansRef.current = wordSpans;
|
|
166
|
-
currentWordIndexRef.current = -1;
|
|
167
|
-
isInitializedRef.current = true;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// If no word spans were created (element not in DOM), still speak the text
|
|
171
|
-
// This ensures TTS works even when elements are collapsed/hidden
|
|
172
|
-
if (wordSpansRef.current.length === 0) {
|
|
173
|
-
// Element doesn't exist in DOM - speak without highlighting
|
|
174
|
-
const utterance = new SpeechSynthesisUtterance(segment.text);
|
|
175
|
-
|
|
176
|
-
// Set language
|
|
177
|
-
const langCode = language === "English" || language === "en"
|
|
178
|
-
? "en-US"
|
|
179
|
-
: language;
|
|
180
|
-
utterance.lang = langCode;
|
|
181
|
-
|
|
182
|
-
// Set voice properties
|
|
183
|
-
utterance.rate = 1.0;
|
|
184
|
-
utterance.pitch = 1.0;
|
|
185
|
-
utterance.volume = 1.0;
|
|
186
|
-
|
|
187
|
-
utterance.onend = () => {
|
|
188
|
-
// Move to next segment
|
|
189
|
-
const nextIndex = index + 1;
|
|
190
|
-
currentSegmentRef.current = nextIndex;
|
|
191
|
-
if (nextIndex < segments.length) {
|
|
192
|
-
speakSegment(nextIndex);
|
|
193
|
-
} else {
|
|
194
|
-
// Finished all segments
|
|
195
|
-
setIsPlaying(false);
|
|
196
|
-
setIsPaused(false);
|
|
197
|
-
setCurrentIndex(0);
|
|
198
|
-
currentSegmentRef.current = 0;
|
|
199
|
-
cleanupWordSpans();
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
utterance.onerror = (event) => {
|
|
204
|
-
console.error("Speech synthesis error:", event);
|
|
205
|
-
setIsPlaying(false);
|
|
206
|
-
setIsPaused(false);
|
|
207
|
-
cleanupWordSpans();
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
utteranceRef.current = utterance;
|
|
211
|
-
synthRef.current.speak(utterance);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const utterance = new SpeechSynthesisUtterance(segment.text);
|
|
216
|
-
|
|
217
|
-
// Set language
|
|
218
|
-
const langCode = language === "English" || language === "en"
|
|
219
|
-
? "en-US"
|
|
220
|
-
: language;
|
|
221
|
-
utterance.lang = langCode;
|
|
222
|
-
|
|
223
|
-
// Set voice properties
|
|
224
|
-
utterance.rate = 1.0;
|
|
225
|
-
utterance.pitch = 1.0;
|
|
226
|
-
utterance.volume = 1.0;
|
|
227
|
-
|
|
228
|
-
// Handle word boundary events for word-by-word highlighting
|
|
229
|
-
utterance.onboundary = (event) => {
|
|
230
|
-
if (event.name === "word" && event.charIndex !== undefined) {
|
|
231
|
-
// Find the word span that corresponds to this character index
|
|
232
|
-
const wordIndex = wordSpansRef.current.findIndex(
|
|
233
|
-
(ws) =>
|
|
234
|
-
event.charIndex >= ws.startIndex &&
|
|
235
|
-
event.charIndex < ws.endIndex
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
if (wordIndex !== -1) {
|
|
239
|
-
currentWordIndexRef.current = wordIndex;
|
|
240
|
-
highlightWordByIndex(wordIndex);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
utterance.onstart = () => {
|
|
246
|
-
// Highlight first word when speech starts
|
|
247
|
-
if (currentWordIndexRef.current === -1 && wordSpansRef.current.length > 0) {
|
|
248
|
-
highlightWordByIndex(0);
|
|
249
|
-
currentWordIndexRef.current = 0;
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
utterance.onend = () => {
|
|
254
|
-
// Remove highlights
|
|
255
|
-
removeAllHighlights();
|
|
256
|
-
|
|
257
|
-
// Reset for next segment
|
|
258
|
-
isInitializedRef.current = false;
|
|
259
|
-
wordSpansRef.current = [];
|
|
260
|
-
currentWordIndexRef.current = -1;
|
|
261
|
-
|
|
262
|
-
// Move to next segment
|
|
263
|
-
const nextIndex = index + 1;
|
|
264
|
-
currentSegmentRef.current = nextIndex;
|
|
265
|
-
if (nextIndex < segments.length) {
|
|
266
|
-
speakSegment(nextIndex);
|
|
267
|
-
} else {
|
|
268
|
-
// Finished all segments
|
|
269
|
-
setIsPlaying(false);
|
|
270
|
-
setIsPaused(false);
|
|
271
|
-
setCurrentIndex(0);
|
|
272
|
-
currentSegmentRef.current = 0;
|
|
273
|
-
cleanupWordSpans();
|
|
274
|
-
}
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
utterance.onerror = (event) => {
|
|
278
|
-
console.error("Speech synthesis error:", event);
|
|
279
|
-
setIsPlaying(false);
|
|
280
|
-
setIsPaused(false);
|
|
281
|
-
cleanupWordSpans();
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
utteranceRef.current = utterance;
|
|
285
|
-
synthRef.current.speak(utterance);
|
|
286
|
-
},
|
|
287
|
-
[segments, language, createWordSpans, highlightWordByIndex, removeAllHighlights, cleanupWordSpans]
|
|
288
|
-
);
|
|
289
|
-
|
|
290
|
-
// Play function
|
|
291
|
-
const play = useCallback(() => {
|
|
292
|
-
if (!synthRef.current || segments.length === 0) return;
|
|
293
|
-
|
|
294
|
-
if (isPaused) {
|
|
295
|
-
// Resume from where we paused using native resume
|
|
296
|
-
try {
|
|
297
|
-
setIsPaused(false);
|
|
298
|
-
setIsPlaying(true);
|
|
299
|
-
|
|
300
|
-
// Resume the paused utterance
|
|
301
|
-
if (synthRef.current.paused) {
|
|
302
|
-
synthRef.current.resume();
|
|
303
|
-
} else {
|
|
304
|
-
// If not paused, we might need to restart
|
|
305
|
-
// This can happen if the utterance ended while paused
|
|
306
|
-
const currentSegment = currentSegmentRef.current;
|
|
307
|
-
if (currentSegment < segments.length) {
|
|
308
|
-
isInitializedRef.current = false;
|
|
309
|
-
speakSegment(currentSegment);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
} catch (error) {
|
|
313
|
-
console.error("Error resuming speech:", error);
|
|
314
|
-
// If resume fails, restart from current segment
|
|
315
|
-
const currentSegment = currentSegmentRef.current;
|
|
316
|
-
if (currentSegment < segments.length) {
|
|
317
|
-
isInitializedRef.current = false;
|
|
318
|
-
speakSegment(currentSegment);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
} else {
|
|
322
|
-
// Start from beginning
|
|
323
|
-
setIsPlaying(true);
|
|
324
|
-
setIsPaused(false);
|
|
325
|
-
currentSegmentRef.current = 0;
|
|
326
|
-
currentWordIndexRef.current = -1;
|
|
327
|
-
isInitializedRef.current = false;
|
|
328
|
-
cleanupWordSpans();
|
|
329
|
-
speakSegment(0);
|
|
330
|
-
}
|
|
331
|
-
}, [isPaused, segments, speakSegment, cleanupWordSpans]);
|
|
332
|
-
|
|
333
|
-
// Pause function
|
|
334
|
-
const pause = useCallback(() => {
|
|
335
|
-
if (synthRef.current && isPlaying && !isPaused) {
|
|
336
|
-
try {
|
|
337
|
-
synthRef.current.pause();
|
|
338
|
-
setIsPaused(true);
|
|
339
|
-
} catch (error) {
|
|
340
|
-
console.error("Error pausing speech:", error);
|
|
341
|
-
// If pause fails, stop instead
|
|
342
|
-
stop();
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}, [isPlaying, isPaused]);
|
|
346
|
-
|
|
347
|
-
// Stop function
|
|
348
|
-
const stop = useCallback(() => {
|
|
349
|
-
if (synthRef.current) {
|
|
350
|
-
synthRef.current.cancel();
|
|
351
|
-
setIsPlaying(false);
|
|
352
|
-
setIsPaused(false);
|
|
353
|
-
setCurrentIndex(0);
|
|
354
|
-
currentSegmentRef.current = 0;
|
|
355
|
-
currentWordIndexRef.current = -1;
|
|
356
|
-
cleanupWordSpans();
|
|
357
|
-
}
|
|
358
|
-
}, [cleanupWordSpans]);
|
|
359
|
-
|
|
360
|
-
// Toggle play/pause
|
|
361
|
-
const toggle = useCallback(() => {
|
|
362
|
-
if (isPlaying && !isPaused) {
|
|
363
|
-
pause();
|
|
364
|
-
} else {
|
|
365
|
-
play();
|
|
366
|
-
}
|
|
367
|
-
}, [isPlaying, isPaused, play, pause]);
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
isPlaying,
|
|
371
|
-
isPaused,
|
|
372
|
-
currentIndex,
|
|
373
|
-
play,
|
|
374
|
-
pause,
|
|
375
|
-
stop,
|
|
376
|
-
toggle,
|
|
377
|
-
};
|
|
378
|
-
};
|