@applicaster/zapp-react-native-utils 13.0.7 → 13.0.8-alpha.1940580104
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/appUtils/accessibilityManager/const.ts +13 -0
- package/appUtils/accessibilityManager/hooks.ts +35 -1
- package/appUtils/accessibilityManager/index.ts +149 -28
- package/appUtils/accessibilityManager/utils.ts +24 -0
- package/appUtils/focusManager/__tests__/__snapshots__/focusManager.test.js.snap +2 -0
- package/appUtils/focusManager/index.ios.ts +10 -0
- package/appUtils/focusManager/index.ts +11 -0
- package/appUtils/focusManagerAux/utils/index.ts +18 -0
- package/appUtils/platform/platformUtils.ts +31 -1
- package/index.d.ts +3 -0
- package/package.json +2 -2
- package/playerUtils/PlayerTTS/PlayerTTS.ts +230 -0
- package/playerUtils/PlayerTTS/index.ts +1 -0
- package/playerUtils/usePlayerTTS.ts +21 -0
|
@@ -65,4 +65,17 @@ export const BUTTON_ACCESSIBILITY_KEYS = {
|
|
|
65
65
|
label: "accessibility_menu_item_label",
|
|
66
66
|
hint: "accessibility_menu_item_hint",
|
|
67
67
|
},
|
|
68
|
+
skip_intro: {
|
|
69
|
+
label: "accessibility_skip_intro_label",
|
|
70
|
+
hint: "accessibility_skip_intro_hint",
|
|
71
|
+
},
|
|
72
|
+
// Top Menu Bar-specific buttons
|
|
73
|
+
top_menu_bar_item_selected: {
|
|
74
|
+
label: "accessibility_top_menu_bar_item_selected_label",
|
|
75
|
+
hint: "accessibility_top_menu_bar_item_selected_hint",
|
|
76
|
+
},
|
|
77
|
+
top_menu_title: {
|
|
78
|
+
label: "accessibility_top_menu_title_label",
|
|
79
|
+
hint: "accessibility_top_menu_hint",
|
|
80
|
+
},
|
|
68
81
|
} as const;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { AccessibilityManager } from "./index";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -38,3 +38,37 @@ export const useAccessibilityManager = (
|
|
|
38
38
|
|
|
39
39
|
return accessibilityManager;
|
|
40
40
|
};
|
|
41
|
+
|
|
42
|
+
export const useInitialAnnouncementReady = (
|
|
43
|
+
accessibilityManager: AccessibilityManager
|
|
44
|
+
) => {
|
|
45
|
+
const [isReady, setIsReady] = useState(
|
|
46
|
+
accessibilityManager.isInitialPlayerAnnouncementReady
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const subscription = accessibilityManager
|
|
51
|
+
.getInitialAnnouncementReadyObservable()
|
|
52
|
+
.subscribe(setIsReady);
|
|
53
|
+
|
|
54
|
+
return () => subscription.unsubscribe();
|
|
55
|
+
}, [accessibilityManager]);
|
|
56
|
+
|
|
57
|
+
return isReady;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const useAnnouncementActive = (
|
|
61
|
+
accessibilityManager: AccessibilityManager
|
|
62
|
+
) => {
|
|
63
|
+
const [isActive, setIsActive] = useState(false);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const subscription = accessibilityManager
|
|
67
|
+
.getTTSStateObservable()
|
|
68
|
+
.subscribe(setIsActive);
|
|
69
|
+
|
|
70
|
+
return () => subscription.unsubscribe();
|
|
71
|
+
}, [accessibilityManager]);
|
|
72
|
+
|
|
73
|
+
return isActive;
|
|
74
|
+
};
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import * as R from "ramda";
|
|
1
2
|
import { BehaviorSubject } from "rxjs";
|
|
2
3
|
import { accessibilityManagerLogger as logger } from "./logger";
|
|
3
4
|
import { TTSManager } from "../platform/platformUtils";
|
|
4
5
|
import { BUTTON_ACCESSIBILITY_KEYS } from "./const";
|
|
6
|
+
import { calculateReadingTime } from "./utils";
|
|
5
7
|
import { AccessibilityRole } from "react-native";
|
|
6
8
|
import _ from "lodash";
|
|
7
9
|
|
|
8
10
|
export class AccessibilityManager {
|
|
9
11
|
private static _instance: AccessibilityManager | null = null;
|
|
10
12
|
private headingTimeout: NodeJS.Timeout | null = null;
|
|
11
|
-
private
|
|
13
|
+
private announcementDelayTimeout: NodeJS.Timeout | null = null;
|
|
14
|
+
private WORDS_PER_MINUTE = 140;
|
|
12
15
|
private MINIMUM_PAUSE = 500;
|
|
16
|
+
private ANNOUNCEMENT_DELAY = 700;
|
|
13
17
|
private state$ = new BehaviorSubject<AccessibilityState>({
|
|
14
18
|
screenReaderEnabled: false,
|
|
15
19
|
reduceMotionEnabled: false,
|
|
@@ -25,6 +29,12 @@ export class AccessibilityManager {
|
|
|
25
29
|
private ttsManager = TTSManager.getInstance();
|
|
26
30
|
private localizations: { [key: string]: string } = {};
|
|
27
31
|
private headingQueue: string[] = [];
|
|
32
|
+
private currentFocusId: string | null = null;
|
|
33
|
+
private headingFocusMap: Map<string, string> = new Map();
|
|
34
|
+
private pendingFocusId: string | null = null;
|
|
35
|
+
private isInitialPlayerAnnouncementReady$ = new BehaviorSubject<boolean>(
|
|
36
|
+
false
|
|
37
|
+
);
|
|
28
38
|
|
|
29
39
|
private constructor() {}
|
|
30
40
|
|
|
@@ -36,6 +46,26 @@ export class AccessibilityManager {
|
|
|
36
46
|
return AccessibilityManager._instance;
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
public get isInitialPlayerAnnouncementReady(): boolean {
|
|
50
|
+
return this.isInitialPlayerAnnouncementReady$.getValue();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public setInitialPlayerAnnouncementReady(): void {
|
|
54
|
+
this.isInitialPlayerAnnouncementReady$.next(true);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public resetInitialPlayerAnnouncementReady(): void {
|
|
58
|
+
this.isInitialPlayerAnnouncementReady$.next(false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public getInitialAnnouncementReadyObservable() {
|
|
62
|
+
return this.isInitialPlayerAnnouncementReady$.asObservable();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public getTTSStateObservable() {
|
|
66
|
+
return this.ttsManager.getStateAsObservable();
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* The method now accepts any object with localizations using a flattened structure
|
|
41
71
|
*
|
|
@@ -46,7 +76,9 @@ export class AccessibilityManager {
|
|
|
46
76
|
* i.e. localizations: [{ en: { accessibility_close_label: "Close", accessibility_close_hint: "Press here to close" } }]
|
|
47
77
|
*/
|
|
48
78
|
public updateLocalizations(localizations: { [key: string]: string }) {
|
|
49
|
-
|
|
79
|
+
if (!R.isEmpty(localizations)) {
|
|
80
|
+
this.localizations = localizations;
|
|
81
|
+
}
|
|
50
82
|
}
|
|
51
83
|
|
|
52
84
|
public getState(): AccessibilityState {
|
|
@@ -57,31 +89,25 @@ export class AccessibilityManager {
|
|
|
57
89
|
return this.state$.asObservable();
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
/** Calculates the reading time for a given text
|
|
61
|
-
* This method is a bit of a hack because we don't have a callback, or promise from VIZIO API
|
|
62
|
-
* @param text - The text to calculate the reading time for
|
|
63
|
-
* @returns The reading time in milliseconds
|
|
64
|
-
*/
|
|
65
|
-
private calculateReadingTime(text: string): number {
|
|
66
|
-
const words = text.trim().split(/\s+/).length;
|
|
67
|
-
|
|
68
|
-
return Math.max(
|
|
69
|
-
this.MINIMUM_PAUSE,
|
|
70
|
-
(words / this.WORDS_PER_MINUTE) * 60 * 1000
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
92
|
/**
|
|
75
93
|
* Adds a heading to the queue, headings will be read before the next text
|
|
76
94
|
* Each heading will be read once and removed from the queue
|
|
77
95
|
*/
|
|
78
96
|
public addHeading(heading: string) {
|
|
97
|
+
if (!this.pendingFocusId) {
|
|
98
|
+
this.pendingFocusId = Date.now().toString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.headingFocusMap.set(heading, this.pendingFocusId);
|
|
79
102
|
this.headingQueue.push(heading);
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
/**
|
|
83
106
|
* text you want to be read, if you want to use localized text pass keyOfLocalizedText instead
|
|
84
107
|
* keyOfLocalizedText is the key to the localized text
|
|
108
|
+
*
|
|
109
|
+
* Implements a delay mechanism to reduce noise during rapid navigation.
|
|
110
|
+
* Only the most recent announcement will be read after the delay period.
|
|
85
111
|
*/
|
|
86
112
|
public readText({
|
|
87
113
|
text,
|
|
@@ -112,19 +138,27 @@ export class AccessibilityManager {
|
|
|
112
138
|
textToRead = localizedMessage;
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
const focusId = this.pendingFocusId || Date.now().toString();
|
|
142
|
+
this.currentFocusId = focusId;
|
|
143
|
+
this.pendingFocusId = null;
|
|
118
144
|
|
|
119
|
-
|
|
120
|
-
clearTimeout(this.headingTimeout);
|
|
121
|
-
}
|
|
145
|
+
this.clearAnnouncement();
|
|
122
146
|
|
|
123
|
-
|
|
147
|
+
this.announcementDelayTimeout = setTimeout(() => {
|
|
148
|
+
this.executeAnnouncement(textToRead, keyOfLocalizedText, focusId);
|
|
149
|
+
}, this.ANNOUNCEMENT_DELAY);
|
|
150
|
+
}
|
|
124
151
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Internal method to execute the actual announcement after the delay
|
|
154
|
+
*/
|
|
155
|
+
private executeAnnouncement(
|
|
156
|
+
textToRead: string,
|
|
157
|
+
keyOfLocalizedText?: string,
|
|
158
|
+
focusId?: string
|
|
159
|
+
) {
|
|
160
|
+
if (this.headingQueue.length > 0) {
|
|
161
|
+
this.processHeadingQueue(textToRead, focusId);
|
|
128
162
|
} else {
|
|
129
163
|
this.ttsManager?.readText(textToRead);
|
|
130
164
|
}
|
|
@@ -136,6 +170,54 @@ export class AccessibilityManager {
|
|
|
136
170
|
});
|
|
137
171
|
}
|
|
138
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Recursively processes all headings in the queue, reading them one by one
|
|
175
|
+
*/
|
|
176
|
+
private processHeadingQueue(textToRead: string, focusId?: string) {
|
|
177
|
+
// If focus has changed, abort this announcement
|
|
178
|
+
if (focusId && this.currentFocusId !== focusId) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.headingQueue.length === 0) {
|
|
183
|
+
if (focusId && this.currentFocusId === focusId) {
|
|
184
|
+
this.ttsManager?.readText(textToRead);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const heading = this.headingQueue.shift()!;
|
|
191
|
+
|
|
192
|
+
const headingFocusId = this.headingFocusMap.get(heading);
|
|
193
|
+
|
|
194
|
+
if (headingFocusId && headingFocusId !== focusId) {
|
|
195
|
+
// This heading belongs to a previous focus, skip it
|
|
196
|
+
this.headingFocusMap.delete(heading);
|
|
197
|
+
this.processHeadingQueue(textToRead, focusId);
|
|
198
|
+
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.ttsManager?.readText(heading);
|
|
203
|
+
this.headingFocusMap.delete(heading); // Clean up after reading
|
|
204
|
+
|
|
205
|
+
if (this.headingTimeout) {
|
|
206
|
+
clearTimeout(this.headingTimeout);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const pauseTime = calculateReadingTime(
|
|
210
|
+
heading,
|
|
211
|
+
this.WORDS_PER_MINUTE,
|
|
212
|
+
this.MINIMUM_PAUSE,
|
|
213
|
+
this.ANNOUNCEMENT_DELAY
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
this.headingTimeout = setTimeout(() => {
|
|
217
|
+
this.processHeadingQueue(textToRead, focusId);
|
|
218
|
+
}, pauseTime);
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
public getButtonAccessibilityProps(name: string): AccessibilityProps {
|
|
140
222
|
const buttonName = _.toString(name);
|
|
141
223
|
|
|
@@ -143,12 +225,15 @@ export class AccessibilityManager {
|
|
|
143
225
|
|
|
144
226
|
if (!buttonConfig) {
|
|
145
227
|
return {
|
|
228
|
+
accessible: true,
|
|
146
229
|
accessibilityLabel: buttonName,
|
|
147
230
|
accessibilityHint: `Press button to perform action on ${buttonName}`,
|
|
148
231
|
"aria-label": buttonName,
|
|
149
232
|
"aria-description": `Press button to perform action on ${buttonName}`,
|
|
150
233
|
accessibilityRole: "button" as AccessibilityRole,
|
|
151
234
|
"aria-role": "button",
|
|
235
|
+
role: "button",
|
|
236
|
+
tabindex: 0,
|
|
152
237
|
};
|
|
153
238
|
}
|
|
154
239
|
|
|
@@ -162,23 +247,52 @@ export class AccessibilityManager {
|
|
|
162
247
|
`Press button to perform action on ${buttonName}`;
|
|
163
248
|
|
|
164
249
|
return {
|
|
250
|
+
accessible: true,
|
|
165
251
|
accessibilityLabel: label,
|
|
166
252
|
accessibilityHint: hint,
|
|
167
253
|
"aria-label": label,
|
|
168
254
|
"aria-description": hint,
|
|
169
255
|
accessibilityRole: "button" as AccessibilityRole,
|
|
170
256
|
"aria-role": "button",
|
|
257
|
+
role: "button",
|
|
258
|
+
tabindex: 0,
|
|
171
259
|
};
|
|
172
260
|
}
|
|
173
261
|
|
|
174
262
|
public getInputAccessibilityProps(inputName: string): AccessibilityProps {
|
|
175
263
|
return {
|
|
264
|
+
accessible: true,
|
|
176
265
|
accessibilityLabel: inputName,
|
|
177
266
|
accessibilityHint: `Enter text into ${inputName}`,
|
|
178
267
|
"aria-label": inputName,
|
|
179
268
|
"aria-description": `Enter text into ${inputName}`,
|
|
180
|
-
accessibilityRole: "
|
|
181
|
-
"aria-role": "
|
|
269
|
+
accessibilityRole: "text" as AccessibilityRole,
|
|
270
|
+
"aria-role": "text",
|
|
271
|
+
role: "text",
|
|
272
|
+
tabindex: 0,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Extracts accessibility props from component props and returns them as HTML attributes
|
|
278
|
+
* @param props - Component props containing accessibility properties
|
|
279
|
+
* @returns Object with accessibility HTML attributes
|
|
280
|
+
*/
|
|
281
|
+
public getWebAccessibilityProps(props: any): AccessibilityProps {
|
|
282
|
+
const {
|
|
283
|
+
"aria-label": ariaLabel,
|
|
284
|
+
"aria-description": ariaDescription,
|
|
285
|
+
"aria-role": ariaRole,
|
|
286
|
+
role,
|
|
287
|
+
tabindex,
|
|
288
|
+
} = props;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"aria-label": ariaLabel,
|
|
292
|
+
"aria-description": ariaDescription,
|
|
293
|
+
"aria-role": ariaRole,
|
|
294
|
+
role: role || ariaRole,
|
|
295
|
+
tabindex,
|
|
182
296
|
};
|
|
183
297
|
}
|
|
184
298
|
|
|
@@ -196,4 +310,11 @@ export class AccessibilityManager {
|
|
|
196
310
|
|
|
197
311
|
return this.localizations[key];
|
|
198
312
|
}
|
|
313
|
+
|
|
314
|
+
private clearAnnouncement() {
|
|
315
|
+
if (this.announcementDelayTimeout) {
|
|
316
|
+
clearTimeout(this.announcementDelayTimeout);
|
|
317
|
+
this.announcementDelayTimeout = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
199
320
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the reading time for a given text based on word count
|
|
3
|
+
* @param text - The text to calculate the reading time for
|
|
4
|
+
* @param wordsPerMinute - Words per minute reading speed (default: 160)
|
|
5
|
+
* @param minimumPause - Minimum pause time in milliseconds (default: 500)
|
|
6
|
+
* @param announcementDelay - Additional delay for announcement in milliseconds (default: 700)
|
|
7
|
+
* @returns The reading time in milliseconds
|
|
8
|
+
*/
|
|
9
|
+
export function calculateReadingTime(
|
|
10
|
+
text: string,
|
|
11
|
+
wordsPerMinute: number = 140,
|
|
12
|
+
minimumPause: number = 500,
|
|
13
|
+
announcementDelay: number = 700
|
|
14
|
+
): number {
|
|
15
|
+
const words = text
|
|
16
|
+
.trim()
|
|
17
|
+
.split(/(?<=\d)(?=[a-zA-Z])|(?<=[a-zA-Z])(?=\d)|[^\w\s]+|\s+/)
|
|
18
|
+
.filter(Boolean).length;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
Math.max(minimumPause, (words / wordsPerMinute) * 60 * 1000) +
|
|
22
|
+
announcementDelay
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -24,6 +24,7 @@ exports[`focusManager should be defined 1`] = `
|
|
|
24
24
|
"invokeHandler": [Function],
|
|
25
25
|
"isCurrentFocusOnTheTopScreen": [Function],
|
|
26
26
|
"isFocusDisabled": [Function],
|
|
27
|
+
"isFocusOn": [Function],
|
|
27
28
|
"isGroupItemFocused": [Function],
|
|
28
29
|
"longPress": [Function],
|
|
29
30
|
"moveFocus": [Function],
|
|
@@ -63,6 +64,7 @@ exports[`focusManagerIOS should be defined 1`] = `
|
|
|
63
64
|
"getGroupRootById": [Function],
|
|
64
65
|
"getPreferredFocusChild": [Function],
|
|
65
66
|
"invokeHandler": [Function],
|
|
67
|
+
"isFocusOn": [Function],
|
|
66
68
|
"isGroupItemFocused": [Function],
|
|
67
69
|
"moveFocus": [Function],
|
|
68
70
|
"on": [Function],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NativeModules } from "react-native";
|
|
2
2
|
import * as R from "ramda";
|
|
3
3
|
|
|
4
|
+
import { isCurrentFocusOn } from "../focusManagerAux/utils";
|
|
4
5
|
import { Tree } from "./treeDataStructure/Tree";
|
|
5
6
|
import { findFocusableNode } from "./treeDataStructure/Utils";
|
|
6
7
|
import { subscriber } from "../../functionUtils";
|
|
@@ -391,6 +392,14 @@ export const focusManager = (function () {
|
|
|
391
392
|
return node;
|
|
392
393
|
}
|
|
393
394
|
|
|
395
|
+
function isFocusOn(id): boolean {
|
|
396
|
+
const currentFocusNode = focusableTree.findInTree(
|
|
397
|
+
getCurrentFocus()?.props?.id
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return id && isCurrentFocusOn(id, currentFocusNode);
|
|
401
|
+
}
|
|
402
|
+
|
|
394
403
|
return {
|
|
395
404
|
on,
|
|
396
405
|
invokeHandler,
|
|
@@ -412,5 +421,6 @@ export const focusManager = (function () {
|
|
|
412
421
|
getGroupRootById,
|
|
413
422
|
isGroupItemFocused,
|
|
414
423
|
getPreferredFocusChild,
|
|
424
|
+
isFocusOn,
|
|
415
425
|
};
|
|
416
426
|
})();
|
|
@@ -14,6 +14,8 @@ import { subscriber } from "../../functionUtils";
|
|
|
14
14
|
import { coreLogger } from "../../logger";
|
|
15
15
|
import { ACTION } from "./utils/enums";
|
|
16
16
|
|
|
17
|
+
import { isCurrentFocusOn } from "../focusManagerAux/utils";
|
|
18
|
+
|
|
17
19
|
const logger = coreLogger.addSubsystem("focusManager");
|
|
18
20
|
|
|
19
21
|
const isFocusEnabled = (focusableItem): boolean => {
|
|
@@ -546,6 +548,14 @@ export const focusManager = (function () {
|
|
|
546
548
|
return preferredFocus[0];
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
function isFocusOn(id): boolean {
|
|
552
|
+
return (
|
|
553
|
+
id &&
|
|
554
|
+
isCurrentFocusOnTheTopScreen() &&
|
|
555
|
+
isCurrentFocusOn(id, currentFocusNode)
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
549
559
|
/**
|
|
550
560
|
* this is the list of the functions available externally
|
|
551
561
|
* when importing the focus manager
|
|
@@ -576,5 +586,6 @@ export const focusManager = (function () {
|
|
|
576
586
|
recoverFocus,
|
|
577
587
|
isCurrentFocusOnTheTopScreen,
|
|
578
588
|
findPreferredFocusChild,
|
|
589
|
+
isFocusOn,
|
|
579
590
|
};
|
|
580
591
|
})();
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
// run this check too often could lead to performance penalty on low-end devices
|
|
10
10
|
const HOW_OFTEN_TO_CHECK_CONDITION = 300; // ms
|
|
11
11
|
|
|
12
|
+
const isRoot = (node) => node?.id === "root";
|
|
13
|
+
|
|
12
14
|
type Props = {
|
|
13
15
|
maxTimeout: number;
|
|
14
16
|
conditionFn: () => boolean;
|
|
@@ -99,3 +101,19 @@ export const waitForContent = (focusableTree) => {
|
|
|
99
101
|
conditionFn: contentHasAnyChildren,
|
|
100
102
|
});
|
|
101
103
|
};
|
|
104
|
+
|
|
105
|
+
export const isCurrentFocusOn = (id, node) => {
|
|
106
|
+
if (!node) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isRoot(node)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (node?.id === id) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return isCurrentFocusOn(id, node.parent);
|
|
119
|
+
};
|
|
@@ -10,6 +10,7 @@ import { PLATFORM_KEYS, PLATFORMS, ZappPlatform } from "./const";
|
|
|
10
10
|
import { createLogger, utilsLogger } from "../../logger";
|
|
11
11
|
import { getPlatform } from "../../reactUtils";
|
|
12
12
|
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
|
|
13
|
+
import { calculateReadingTime } from "@applicaster/zapp-react-native-utils/appUtils/accessibilityManager/utils";
|
|
13
14
|
|
|
14
15
|
const { log_debug } = createLogger({
|
|
15
16
|
category: "General",
|
|
@@ -212,9 +213,16 @@ export class TTSManager {
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
readText(text: string) {
|
|
216
|
+
this.ttsState$.next(true);
|
|
217
|
+
|
|
215
218
|
if (isSamsungPlatform() && window.speechSynthesis) {
|
|
216
219
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
220
|
+
|
|
221
|
+
window.speechSynthesis.cancel(); // Cancel previous speech before speaking new text
|
|
217
222
|
window.speechSynthesis.speak(utterance);
|
|
223
|
+
|
|
224
|
+
// Estimate reading time and set inactive when done
|
|
225
|
+
this.scheduleTTSComplete(text);
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
if (isLgPlatform() && window.webOS?.service) {
|
|
@@ -225,23 +233,45 @@ export class TTSManager {
|
|
|
225
233
|
log_debug("There was a failure setting up webOS TTS service", {
|
|
226
234
|
error,
|
|
227
235
|
});
|
|
236
|
+
|
|
237
|
+
this.ttsState$.next(false);
|
|
228
238
|
},
|
|
229
239
|
onSuccess(response: any) {
|
|
230
240
|
log_debug("webOS TTS service is configured successfully", {
|
|
231
241
|
response,
|
|
232
242
|
});
|
|
243
|
+
|
|
244
|
+
// Estimate reading time and set inactive when done
|
|
245
|
+
this.scheduleTTSComplete(text);
|
|
233
246
|
},
|
|
234
247
|
parameters: {
|
|
235
248
|
text,
|
|
249
|
+
clear: true, // Clear any previous speech before speaking new text
|
|
236
250
|
},
|
|
237
251
|
});
|
|
238
252
|
} catch (error) {
|
|
239
253
|
log_debug("webOS TTS service error", { error });
|
|
254
|
+
this.ttsState$.next(false);
|
|
240
255
|
}
|
|
241
256
|
}
|
|
242
257
|
|
|
243
|
-
if (!window.VIZIO?.Chromevox)
|
|
258
|
+
if (!window.VIZIO?.Chromevox) {
|
|
259
|
+
// For platforms without TTS, estimate reading time
|
|
260
|
+
this.scheduleTTSComplete(text);
|
|
261
|
+
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
244
264
|
|
|
245
265
|
window.VIZIO.Chromevox.play(text);
|
|
266
|
+
// Estimate reading time and set inactive when done
|
|
267
|
+
this.scheduleTTSComplete(text);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private scheduleTTSComplete(text: string) {
|
|
271
|
+
const readingTime = calculateReadingTime(text);
|
|
272
|
+
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
this.ttsState$.next(false);
|
|
275
|
+
}, readingTime);
|
|
246
276
|
}
|
|
247
277
|
}
|
package/index.d.ts
CHANGED
|
@@ -139,10 +139,13 @@ declare type AccessibilityState = {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
declare type AccessibilityProps = {
|
|
142
|
+
accessible?: boolean;
|
|
142
143
|
accessibilityLabel?: string;
|
|
143
144
|
accessibilityHint?: string;
|
|
144
145
|
"aria-label"?: string;
|
|
145
146
|
"aria-description"?: string;
|
|
146
147
|
accessibilityRole?: string;
|
|
147
148
|
"aria-role"?: string;
|
|
149
|
+
role?: string;
|
|
150
|
+
tabindex?: number;
|
|
148
151
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/zapp-react-native-utils",
|
|
3
|
-
"version": "13.0.
|
|
3
|
+
"version": "13.0.8-alpha.1940580104",
|
|
4
4
|
"description": "Applicaster Zapp React Native utilities package",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://github.com/applicaster/quickbrick#readme",
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@applicaster/applicaster-types": "13.0.
|
|
30
|
+
"@applicaster/applicaster-types": "13.0.8-alpha.1940580104",
|
|
31
31
|
"buffer": "^5.2.1",
|
|
32
32
|
"camelize": "^1.0.0",
|
|
33
33
|
"dayjs": "^1.11.10",
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { AccessibilityManager } from "@applicaster/zapp-react-native-utils/appUtils/accessibilityManager";
|
|
2
|
+
import uuidv4 from "uuid/v4";
|
|
3
|
+
import { Player } from "../../appUtils/playerManager/player";
|
|
4
|
+
|
|
5
|
+
enum SEEK_DIRECTION {
|
|
6
|
+
FORWARD = "forward",
|
|
7
|
+
REWIND = "back",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class PlayerTTS {
|
|
11
|
+
private player: Player;
|
|
12
|
+
private accessibilityManager: AccessibilityManager;
|
|
13
|
+
private seekStartPosition: number | null = null;
|
|
14
|
+
private isSeeking: boolean = false;
|
|
15
|
+
private listenerId: string;
|
|
16
|
+
private isInitialPlayerOpen: boolean = true;
|
|
17
|
+
|
|
18
|
+
constructor(player: Player, accessibilityManager: AccessibilityManager) {
|
|
19
|
+
this.player = player;
|
|
20
|
+
this.accessibilityManager = accessibilityManager;
|
|
21
|
+
this.listenerId = `player-tts-${uuidv4()}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private numberToWords(num: number): string {
|
|
25
|
+
const ones = [
|
|
26
|
+
"",
|
|
27
|
+
"one",
|
|
28
|
+
"two",
|
|
29
|
+
"three",
|
|
30
|
+
"four",
|
|
31
|
+
"five",
|
|
32
|
+
"six",
|
|
33
|
+
"seven",
|
|
34
|
+
"eight",
|
|
35
|
+
"nine",
|
|
36
|
+
"ten",
|
|
37
|
+
"eleven",
|
|
38
|
+
"twelve",
|
|
39
|
+
"thirteen",
|
|
40
|
+
"fourteen",
|
|
41
|
+
"fifteen",
|
|
42
|
+
"sixteen",
|
|
43
|
+
"seventeen",
|
|
44
|
+
"eighteen",
|
|
45
|
+
"nineteen",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const tens = [
|
|
49
|
+
"",
|
|
50
|
+
"",
|
|
51
|
+
"twenty",
|
|
52
|
+
"thirty",
|
|
53
|
+
"forty",
|
|
54
|
+
"fifty",
|
|
55
|
+
"sixty",
|
|
56
|
+
"seventy",
|
|
57
|
+
"eighty",
|
|
58
|
+
"ninety",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
if (num === 0) return "zero";
|
|
62
|
+
if (num < 20) return ones[num];
|
|
63
|
+
|
|
64
|
+
const ten = Math.floor(num / 10);
|
|
65
|
+
const one = num % 10;
|
|
66
|
+
|
|
67
|
+
if (one === 0) return tens[ten];
|
|
68
|
+
|
|
69
|
+
return `${tens[ten]} ${ones[one]}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private secondsToTime(
|
|
73
|
+
seconds: number,
|
|
74
|
+
format: "natural" | "standard" = "natural"
|
|
75
|
+
): string {
|
|
76
|
+
if (seconds < 0) return format === "natural" ? "zero" : "0";
|
|
77
|
+
|
|
78
|
+
const minutes = Math.floor(seconds / 60);
|
|
79
|
+
const remainingSeconds = Math.floor(seconds % 60);
|
|
80
|
+
|
|
81
|
+
if (format === "standard") {
|
|
82
|
+
if (minutes === 0) {
|
|
83
|
+
if (remainingSeconds === 0) return "0";
|
|
84
|
+
|
|
85
|
+
return `${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (remainingSeconds === 0) {
|
|
89
|
+
return `${minutes} minute${minutes !== 1 ? "s" : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return `${minutes} minute${minutes !== 1 ? "s" : ""} ${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}`;
|
|
93
|
+
} else {
|
|
94
|
+
if (minutes === 0) {
|
|
95
|
+
if (remainingSeconds === 0) return "zero";
|
|
96
|
+
|
|
97
|
+
if (remainingSeconds < 10) {
|
|
98
|
+
return `zero o ${this.numberToWords(remainingSeconds)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return `zero ${this.numberToWords(remainingSeconds)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (remainingSeconds === 0) {
|
|
105
|
+
return `${this.numberToWords(minutes)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (remainingSeconds < 10) {
|
|
109
|
+
return `${this.numberToWords(minutes)} o ${this.numberToWords(remainingSeconds)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `${this.numberToWords(minutes)} ${this.numberToWords(remainingSeconds)}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private announceLoading = () => {
|
|
117
|
+
this.accessibilityManager.addHeading("Loading");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
private announcePause = () => {
|
|
121
|
+
if (!this.isSeeking) {
|
|
122
|
+
this.accessibilityManager.addHeading(
|
|
123
|
+
`Paused - ${this.secondsToTime(this.player.playerState.contentPosition, "standard")}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
private announceBufferComplete = (event: any) => {
|
|
129
|
+
if (this.isInitialPlayerOpen) {
|
|
130
|
+
const timeRemaining = event.duration - event.currentTime;
|
|
131
|
+
const title = (this.player.entry.title as string) || "";
|
|
132
|
+
const summary = (this.player.entry.summary as string) || "";
|
|
133
|
+
this.accessibilityManager.addHeading(`Playing - ${title}`);
|
|
134
|
+
this.accessibilityManager.addHeading(summary);
|
|
135
|
+
|
|
136
|
+
this.accessibilityManager.addHeading(
|
|
137
|
+
`Playing from ${this.secondsToTime(event.currentTime, "standard")}`
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
this.accessibilityManager.addHeading(
|
|
141
|
+
`${this.secondsToTime(timeRemaining, "standard")} remaining.`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
this.accessibilityManager.setInitialPlayerAnnouncementReady();
|
|
145
|
+
this.isInitialPlayerOpen = false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
private announceResume = () => {
|
|
150
|
+
if (!this.isSeeking && !this.isInitialPlayerOpen) {
|
|
151
|
+
this.accessibilityManager.addHeading(
|
|
152
|
+
`Playing - ${this.secondsToTime(this.player.playerState.contentPosition, "standard")}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
private handleVideoProgress = (event: any) => {
|
|
158
|
+
if (event.currentTime > 0) {
|
|
159
|
+
this.seekStartPosition = event.currentTime;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
private handleSeekComplete = (event: any) => {
|
|
164
|
+
if (this.seekStartPosition !== null) {
|
|
165
|
+
const seekDirection =
|
|
166
|
+
event.currentTime > this.seekStartPosition
|
|
167
|
+
? SEEK_DIRECTION.FORWARD
|
|
168
|
+
: SEEK_DIRECTION.REWIND;
|
|
169
|
+
|
|
170
|
+
const seekAmount = Math.round(
|
|
171
|
+
Math.abs(event.currentTime - this.seekStartPosition)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
this.accessibilityManager.readText({
|
|
175
|
+
text: `Skipped ${seekDirection} ${this.secondsToTime(seekAmount, "standard")}`,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
this.seekStartPosition = event.currentTime;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.isSeeking = false;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
private handleSeekStart = () => {
|
|
185
|
+
this.isSeeking = true;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
private handlePlayerClose = () => {
|
|
189
|
+
this.isInitialPlayerOpen = true;
|
|
190
|
+
this.accessibilityManager.resetInitialPlayerAnnouncementReady();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
private announceAdBegin = (event: any) => {
|
|
194
|
+
if (event?.ad?.data?.duration) {
|
|
195
|
+
this.accessibilityManager.readText({
|
|
196
|
+
text: `Sponsored. Ends in ${this.secondsToTime(event.ad.data.duration, "standard")}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
public init(): () => void {
|
|
202
|
+
if (!this.player) {
|
|
203
|
+
return () => {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return this.player.addListener({
|
|
207
|
+
id: this.listenerId,
|
|
208
|
+
listener: {
|
|
209
|
+
onBufferStart: this.announceLoading,
|
|
210
|
+
onBufferComplete: this.announceBufferComplete,
|
|
211
|
+
onPlayerResume: this.announceResume,
|
|
212
|
+
onPlayerPause: this.announcePause,
|
|
213
|
+
onVideoProgress: this.handleVideoProgress,
|
|
214
|
+
onPlayerSeekStart: this.handleSeekStart,
|
|
215
|
+
onPlayerSeekComplete: this.handleSeekComplete,
|
|
216
|
+
onPlayerClose: this.handlePlayerClose,
|
|
217
|
+
onAdBegin: this.announceAdBegin,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public destroy(): void {
|
|
223
|
+
if (this.player) {
|
|
224
|
+
this.player.removeListener(this.listenerId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.seekStartPosition = null;
|
|
228
|
+
this.handlePlayerClose();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PlayerTTS } from "./PlayerTTS";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
|
|
3
|
+
import { useAccessibilityManager } from "@applicaster/zapp-react-native-utils/appUtils/accessibilityManager/hooks";
|
|
4
|
+
import { PlayerTTS } from "@applicaster/zapp-react-native-utils/playerUtils/PlayerTTS";
|
|
5
|
+
|
|
6
|
+
export const usePlayerTTS = () => {
|
|
7
|
+
const player = usePlayer();
|
|
8
|
+
const accessibilityManager = useAccessibilityManager({});
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
if (player && accessibilityManager) {
|
|
12
|
+
const playerTTS = new PlayerTTS(player, accessibilityManager);
|
|
13
|
+
const unsubscribe = playerTTS.init();
|
|
14
|
+
|
|
15
|
+
return () => {
|
|
16
|
+
unsubscribe();
|
|
17
|
+
playerTTS.destroy();
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}, [player, accessibilityManager]);
|
|
21
|
+
};
|