@lightningtv/solid 3.0.0-2 → 3.0.0-20

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.
Files changed (126) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +6 -0
  3. package/dist/src/jsx-runtime.d.ts +1 -3
  4. package/dist/src/primitives/Column.jsx +9 -10
  5. package/dist/src/primitives/Column.jsx.map +1 -1
  6. package/dist/src/primitives/Grid.d.ts +15 -6
  7. package/dist/src/primitives/Grid.jsx +35 -22
  8. package/dist/src/primitives/Grid.jsx.map +1 -1
  9. package/dist/src/primitives/Image.d.ts +8 -0
  10. package/dist/src/primitives/Image.jsx +24 -0
  11. package/dist/src/primitives/Image.jsx.map +1 -0
  12. package/dist/src/primitives/KeepAlive.d.ts +30 -0
  13. package/dist/src/primitives/KeepAlive.jsx +77 -0
  14. package/dist/src/primitives/KeepAlive.jsx.map +1 -0
  15. package/dist/src/primitives/Lazy.d.ts +8 -7
  16. package/dist/src/primitives/Lazy.jsx +49 -23
  17. package/dist/src/primitives/Lazy.jsx.map +1 -1
  18. package/dist/src/primitives/Marquee.d.ts +64 -0
  19. package/dist/src/primitives/Marquee.jsx +86 -0
  20. package/dist/src/primitives/Marquee.jsx.map +1 -0
  21. package/dist/src/primitives/Preserve.d.ts +4 -0
  22. package/dist/src/primitives/Preserve.jsx +11 -0
  23. package/dist/src/primitives/Preserve.jsx.map +1 -0
  24. package/dist/src/primitives/Row.jsx +9 -10
  25. package/dist/src/primitives/Row.jsx.map +1 -1
  26. package/dist/src/primitives/Suspense.d.ts +22 -0
  27. package/dist/src/primitives/Suspense.jsx +33 -0
  28. package/dist/src/primitives/Suspense.jsx.map +1 -0
  29. package/dist/src/primitives/Virtual.d.ts +18 -0
  30. package/dist/src/primitives/Virtual.jsx +434 -0
  31. package/dist/src/primitives/Virtual.jsx.map +1 -0
  32. package/dist/src/primitives/VirtualGrid.d.ts +13 -0
  33. package/dist/src/primitives/VirtualGrid.jsx +139 -0
  34. package/dist/src/primitives/VirtualGrid.jsx.map +1 -0
  35. package/dist/src/primitives/VirtualList.d.ts +11 -0
  36. package/dist/src/primitives/VirtualList.jsx +96 -0
  37. package/dist/src/primitives/VirtualList.jsx.map +1 -0
  38. package/dist/src/primitives/VirtualRow.d.ts +13 -0
  39. package/dist/src/primitives/VirtualRow.jsx +97 -0
  40. package/dist/src/primitives/VirtualRow.jsx.map +1 -0
  41. package/dist/src/primitives/Visible.d.ts +0 -1
  42. package/dist/src/primitives/Visible.jsx +1 -1
  43. package/dist/src/primitives/Visible.jsx.map +1 -1
  44. package/dist/src/primitives/announcer/announcer.d.ts +2 -0
  45. package/dist/src/primitives/announcer/announcer.js +7 -5
  46. package/dist/src/primitives/announcer/announcer.js.map +1 -1
  47. package/dist/src/primitives/announcer/index.d.ts +5 -1
  48. package/dist/src/primitives/announcer/index.js +8 -2
  49. package/dist/src/primitives/announcer/index.js.map +1 -1
  50. package/dist/src/primitives/announcer/speech.d.ts +2 -2
  51. package/dist/src/primitives/announcer/speech.js +157 -28
  52. package/dist/src/primitives/announcer/speech.js.map +1 -1
  53. package/dist/src/primitives/createFocusStack.d.ts +4 -4
  54. package/dist/src/primitives/createFocusStack.jsx +15 -6
  55. package/dist/src/primitives/createFocusStack.jsx.map +1 -1
  56. package/dist/src/primitives/createTag.d.ts +8 -0
  57. package/dist/src/primitives/createTag.jsx +20 -0
  58. package/dist/src/primitives/createTag.jsx.map +1 -0
  59. package/dist/src/primitives/index.d.ts +13 -3
  60. package/dist/src/primitives/index.js +13 -3
  61. package/dist/src/primitives/index.js.map +1 -1
  62. package/dist/src/primitives/types.d.ts +3 -0
  63. package/dist/src/primitives/useHold.d.ts +27 -0
  64. package/dist/src/primitives/useHold.js +54 -0
  65. package/dist/src/primitives/useHold.js.map +1 -0
  66. package/dist/src/primitives/useMouse.d.ts +24 -1
  67. package/dist/src/primitives/useMouse.js +153 -47
  68. package/dist/src/primitives/useMouse.js.map +1 -1
  69. package/dist/src/primitives/utils/chainFunctions.d.ts +30 -4
  70. package/dist/src/primitives/utils/chainFunctions.js +14 -3
  71. package/dist/src/primitives/utils/chainFunctions.js.map +1 -1
  72. package/dist/src/primitives/utils/createBlurredImage.d.ts +56 -0
  73. package/dist/src/primitives/utils/createBlurredImage.js +223 -0
  74. package/dist/src/primitives/utils/createBlurredImage.js.map +1 -0
  75. package/dist/src/primitives/utils/createSpriteMap.d.ts +2 -2
  76. package/dist/src/primitives/utils/createSpriteMap.js.map +1 -1
  77. package/dist/src/primitives/utils/handleNavigation.d.ts +85 -5
  78. package/dist/src/primitives/utils/handleNavigation.js +242 -69
  79. package/dist/src/primitives/utils/handleNavigation.js.map +1 -1
  80. package/dist/src/primitives/utils/withScrolling.d.ts +8 -1
  81. package/dist/src/primitives/utils/withScrolling.js +25 -6
  82. package/dist/src/primitives/utils/withScrolling.js.map +1 -1
  83. package/dist/src/render.d.ts +6 -5
  84. package/dist/src/render.js +4 -0
  85. package/dist/src/render.js.map +1 -1
  86. package/dist/src/solidOpts.d.ts +3 -2
  87. package/dist/src/solidOpts.js +31 -15
  88. package/dist/src/solidOpts.js.map +1 -1
  89. package/dist/src/universal.d.ts +25 -0
  90. package/dist/src/universal.js +232 -0
  91. package/dist/src/universal.js.map +1 -0
  92. package/dist/src/utils.d.ts +2 -0
  93. package/dist/src/utils.js +8 -0
  94. package/dist/src/utils.js.map +1 -1
  95. package/dist/tsconfig.tsbuildinfo +1 -1
  96. package/jsx-runtime.d.ts +2 -4
  97. package/package.json +19 -10
  98. package/src/primitives/Column.tsx +10 -12
  99. package/src/primitives/Grid.tsx +57 -33
  100. package/src/primitives/Image.tsx +36 -0
  101. package/src/primitives/KeepAlive.tsx +124 -0
  102. package/src/primitives/Lazy.tsx +60 -37
  103. package/src/primitives/Marquee.tsx +149 -0
  104. package/src/primitives/Preserve.tsx +18 -0
  105. package/src/primitives/Row.tsx +11 -12
  106. package/src/primitives/Suspense.tsx +39 -0
  107. package/src/primitives/Virtual.tsx +478 -0
  108. package/src/primitives/VirtualGrid.tsx +199 -0
  109. package/src/primitives/Visible.tsx +1 -2
  110. package/src/primitives/announcer/announcer.ts +16 -10
  111. package/src/primitives/announcer/index.ts +12 -2
  112. package/src/primitives/announcer/speech.ts +188 -27
  113. package/src/primitives/createFocusStack.tsx +18 -7
  114. package/src/primitives/createTag.tsx +31 -0
  115. package/src/primitives/index.ts +17 -3
  116. package/src/primitives/types.ts +10 -0
  117. package/src/primitives/useHold.ts +69 -0
  118. package/src/primitives/useMouse.ts +283 -66
  119. package/src/primitives/utils/chainFunctions.ts +40 -9
  120. package/src/primitives/utils/createBlurredImage.ts +366 -0
  121. package/src/primitives/utils/createSpriteMap.ts +6 -4
  122. package/src/primitives/utils/handleNavigation.ts +307 -84
  123. package/src/primitives/utils/withScrolling.ts +47 -16
  124. package/src/render.ts +9 -7
  125. package/src/solidOpts.ts +34 -19
  126. package/src/utils.ts +10 -0
@@ -0,0 +1,199 @@
1
+ import * as s from 'solid-js';
2
+ import * as lng from '@lightningtv/solid';
3
+ import * as lngp from '@lightningtv/solid/primitives';
4
+ import { List } from '@solid-primitives/list';
5
+ import * as utils from '../utils.js';
6
+
7
+ const columnScroll = lngp.withScrolling(false);
8
+
9
+ const rowStyles: lng.NodeStyles = {
10
+ display: 'flex',
11
+ flexWrap: 'wrap',
12
+ transition: {
13
+ y: true,
14
+ },
15
+ };
16
+
17
+ export type VirtualGridProps<T> = lng.NewOmit<lngp.RowProps, 'children'> & {
18
+ each: readonly T[] | undefined | null | false;
19
+ columns: number; // items per row
20
+ rows?: number; // number of visible rows (default: 1)
21
+ buffer?: number;
22
+ onEndReached?: () => void;
23
+ onEndReachedThreshold?: number;
24
+ children: (item: s.Accessor<T>, index: s.Accessor<number>) => s.JSX.Element;
25
+ };
26
+
27
+ export function VirtualGrid<T>(props: VirtualGridProps<T>): s.JSX.Element {
28
+ const bufferSize = () => props.buffer ?? 2;
29
+ const [ cursor, setCursor ] = s.createSignal(props.selected ?? 0);
30
+ const items = s.createMemo(() => props.each || []);
31
+ const itemsPerRow = () => props.columns;
32
+ const numberOfRows = () => props.rows ?? 1;
33
+ const totalVisibleItems = () => itemsPerRow() * numberOfRows();
34
+
35
+ const start = s.createMemo(() => {
36
+ const perRow = itemsPerRow();
37
+ const newRowIndex = Math.floor(cursor() / perRow);
38
+ const rawStart = newRowIndex * perRow - bufferSize() * perRow;
39
+ return Math.max(0, rawStart);
40
+ });
41
+
42
+ const end = s.createMemo(() => {
43
+ const perRow = itemsPerRow();
44
+ const newRowIndex = Math.floor(cursor() / perRow);
45
+ const rawEnd = (newRowIndex + bufferSize()) * perRow + totalVisibleItems();
46
+ return Math.min(items().length, rawEnd);
47
+ });
48
+
49
+ const [slice, setSlice] = s.createSignal(items().slice(start(), end()));
50
+
51
+ let viewRef!: lngp.NavigableElement;
52
+
53
+ function onVerticalNav(dir: -1 | 1): lngp.KeyHandler {
54
+ return function () {
55
+ const perRow = itemsPerRow();
56
+ const currentRowIndex = Math.floor(cursor() / perRow);
57
+ const maxRows = Math.floor(items().length / perRow);
58
+
59
+ if (
60
+ currentRowIndex === 0 && dir === -1
61
+ || currentRowIndex === maxRows && dir === 1
62
+ ) return;
63
+
64
+ const selected = this.selected || 0;
65
+ const offset = dir * perRow;
66
+ const newIndex = utils.clamp(selected + offset, 0, items().length - 1);
67
+ const lastIdx = selected;
68
+ this.selected = newIndex;
69
+ const active = this.children[this.selected];
70
+
71
+ if (active instanceof lng.ElementNode) {
72
+ active.setFocus();
73
+ chainedOnSelectedChanged.call(
74
+ this as lngp.NavigableElement,
75
+ this.selected,
76
+ this as lngp.NavigableElement,
77
+ active,
78
+ lastIdx
79
+ );
80
+ return true;
81
+ }
82
+ };
83
+ }
84
+
85
+ const onUp = onVerticalNav(-1);
86
+ const onDown = onVerticalNav(1);
87
+
88
+ const onSelectedChanged: lngp.OnSelectedChanged = function (_idx, elm, active, _lastIdx,) {
89
+ let idx = _idx;
90
+ let lastIdx = _lastIdx;
91
+ const perRow = itemsPerRow();
92
+ const newRowIndex = Math.floor(idx / perRow);
93
+ const prevRowIndex = Math.floor((lastIdx || 0) / perRow);
94
+ const prevStart = start();
95
+
96
+ setCursor(prevStart + idx);
97
+ if (newRowIndex === prevRowIndex) return;
98
+
99
+ setSlice(items().slice(start(), end()));
100
+
101
+ // this.selected is relative to the slice
102
+ // and it doesn't get corrected automatically after children change
103
+ const idxCorrection = prevStart - start();
104
+ if (lastIdx) lastIdx += idxCorrection;
105
+ idx += idxCorrection;
106
+ this.selected += idxCorrection;
107
+
108
+ if (props.onEndReachedThreshold !== undefined && cursor() >= items().length - props.onEndReachedThreshold) {
109
+ props.onEndReached?.();
110
+ }
111
+
112
+ queueMicrotask(() => {
113
+ const prevRowY = this.y + active.y;
114
+ this.updateLayout();
115
+ this.lng.y = prevRowY - active.y;
116
+ columnScroll(idx, elm, active, lastIdx);
117
+ });
118
+ };
119
+
120
+ const chainedOnSelectedChanged = lngp.chainFunctions(props.onSelectedChanged, onSelectedChanged)!;
121
+
122
+ let cachedSelected: number | undefined;
123
+ const updateSelected = ([selected, _items]: [number?, any?]) => {
124
+ if (!viewRef || selected == null) return;
125
+
126
+ if (cachedSelected !== undefined) {
127
+ selected = cachedSelected;
128
+ cachedSelected = undefined;
129
+ }
130
+
131
+ if (selected >= items().length && props.onEndReached) {
132
+ props.onEndReached?.();
133
+ cachedSelected = selected;
134
+ return;
135
+ }
136
+
137
+ const item = items()[selected];
138
+ let active = viewRef.children.find(x => x.item === item);
139
+ const lastSelected = viewRef.selected;
140
+
141
+ if (active instanceof lng.ElementNode) {
142
+ viewRef.selected = viewRef.children.indexOf(active);
143
+ active.setFocus();
144
+ chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
145
+ } else {
146
+ setCursor(selected);
147
+ setSlice(items().slice(start(), end()));
148
+
149
+ queueMicrotask(() => {
150
+ viewRef.updateLayout();
151
+ active = viewRef.children.find(x => x.item === item);
152
+ if (active instanceof lng.ElementNode) {
153
+ viewRef.selected = viewRef.children.indexOf(active);
154
+ active.setFocus();
155
+ chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
156
+ }
157
+ });
158
+ }
159
+ };
160
+
161
+ const scrollToIndex = (index: number) => {
162
+ s.untrack(() => updateSelected([index]));
163
+ }
164
+
165
+ s.createEffect(s.on([() => props.selected, items], updateSelected));
166
+
167
+ s.createEffect(
168
+ s.on(items, () => {
169
+ if (!viewRef) return;
170
+ if (cachedSelected !== undefined) {
171
+ updateSelected([cachedSelected]);
172
+ return;
173
+ }
174
+ setSlice(items().slice(start(), end()));
175
+ }, { defer: true })
176
+ );
177
+
178
+
179
+ return (
180
+ <view
181
+ {...props}
182
+ scroll={props.scroll || 'always'}
183
+ ref={lngp.chainRefs(el => { viewRef = el as lngp.NavigableElement; }, props.ref)}
184
+ selected={props.selected || 0}
185
+ cursor={cursor()}
186
+ onLeft={/* @once */ lngp.chainFunctions(props.onLeft, lngp.navigableHandleNavigation)}
187
+ onRight={/* @once */ lngp.chainFunctions(props.onRight, lngp.navigableHandleNavigation)}
188
+ onUp={/* @once */ lngp.chainFunctions(props.onUp, onUp)}
189
+ onDown={/* @once */ lngp.chainFunctions(props.onDown, onDown)}
190
+ forwardFocus={/* @once */ lngp.navigableForwardFocus}
191
+ onCreate={/* @once */ props.selected ? lngp.chainFunctions(props.onCreate, columnScroll) : props.onCreate}
192
+ scrollToIndex={/* @once */ scrollToIndex}
193
+ onSelectedChanged={/* @once */ chainedOnSelectedChanged}
194
+ style={/* @once */ lng.combineStyles(props.style, rowStyles)}
195
+ >
196
+ <List each={slice()}>{props.children}</List>
197
+ </view>
198
+ );
199
+ }
@@ -13,7 +13,6 @@ import { ElementNode } from '@lightningtv/solid';
13
13
  export function Visible<T>(props: {
14
14
  when: T | undefined | null | false;
15
15
  keyed?: boolean;
16
- fallback?: JSX.Element;
17
16
  children: JSX.Element;
18
17
  }): JSX.Element {
19
18
  let child: ChildrenReturn | undefined;
@@ -55,6 +54,6 @@ export function Visible<T>(props: {
55
54
  }
56
55
  });
57
56
 
58
- return c ? child : props.fallback;
57
+ return c || child ? child : null;
59
58
  }) as unknown as JSX.Element;
60
59
  };
@@ -73,8 +73,9 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
73
73
  prevFocusPath = focusPath.slice(0);
74
74
 
75
75
  const toAnnounceText: SpeechType[] = [];
76
- const toAnnounce = focusDiff.reduce(
77
- (acc: [string, string, SpeechType][], elm) => {
76
+ const toAnnounce = focusDiff
77
+ .reverse()
78
+ .reduce((acc: [string, string, SpeechType][], elm) => {
78
79
  if (elm.announce) {
79
80
  acc.push([getElmName(elm), 'Announce', elm.announce]);
80
81
  toAnnounceText.push(elm.announce);
@@ -85,9 +86,7 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
85
86
  acc.push([getElmName(elm), 'No Announce', '']);
86
87
  }
87
88
  return acc;
88
- },
89
- [],
90
- );
89
+ }, []);
91
90
 
92
91
  focusDiff.reverse().reduce((acc, elm) => {
93
92
  if (elm.announceContext) {
@@ -110,18 +109,25 @@ function onFocusChangeCore(focusPath: ElementNode[] = []) {
110
109
  }
111
110
  }
112
111
 
113
- function textToSpeech(toSpeak: SpeechType, lang: string) {
112
+ function textToSpeech(
113
+ toSpeak: SpeechType,
114
+ aria: boolean,
115
+ lang: string,
116
+ voice?: string,
117
+ ) {
114
118
  if (voiceOutDisabled) {
115
119
  return;
116
120
  }
117
121
 
118
- return (currentlySpeaking = SpeechEngine(toSpeak, lang));
122
+ return (currentlySpeaking = SpeechEngine(toSpeak, aria, lang, voice));
119
123
  }
120
124
 
121
125
  export interface Announcer {
122
126
  debug: boolean;
123
127
  enabled: boolean;
124
128
  lang: string;
129
+ aria: boolean;
130
+ voice?: string;
125
131
  cancel: VoidFunction;
126
132
  clearPrevFocus: (depth?: number) => void;
127
133
  speak: (
@@ -140,6 +146,7 @@ export const Announcer: Announcer = {
140
146
  debug: false,
141
147
  enabled: true,
142
148
  lang: 'en-US',
149
+ aria: false,
143
150
  cancel: function () {
144
151
  currentlySpeaking && currentlySpeaking.cancel();
145
152
  },
@@ -147,14 +154,13 @@ export const Announcer: Announcer = {
147
154
  prevFocusPath = prevFocusPath.slice(0, depth);
148
155
  resetFocusPathTimer();
149
156
  },
150
- speak: function (text, { append = false, notification = false} = {}) {
157
+ speak: function (text, { append = false, notification = false } = {}) {
151
158
  if (Announcer.onFocusChange && Announcer.enabled) {
152
- Announcer.onFocusChange.flush();
153
159
  if (append && currentlySpeaking && currentlySpeaking.active) {
154
160
  currentlySpeaking.append(text);
155
161
  } else {
156
162
  Announcer.cancel();
157
- textToSpeech(text, Announcer.lang);
163
+ textToSpeech(text, Announcer.aria, Announcer.lang, Announcer.voice);
158
164
  }
159
165
 
160
166
  if (notification) {
@@ -2,9 +2,19 @@ import { createEffect, on } from 'solid-js';
2
2
  import { Announcer } from './announcer.js';
3
3
  import { focusPath } from '../useFocusManager.js';
4
4
 
5
- export const useAnnouncer = () => {
6
- Announcer.setupTimers();
5
+ let doOnce = false;
6
+ export const useAnnouncer = (options?: {
7
+ focusDebounce?: number;
8
+ focusChangeTimeout?: number;
9
+ }) => {
10
+ if (doOnce) {
11
+ return Announcer;
12
+ }
13
+ doOnce = true;
14
+ Announcer.setupTimers(options);
7
15
  createEffect(on(focusPath, Announcer.onFocusChange!, { defer: true }));
8
16
 
9
17
  return Announcer;
10
18
  };
19
+
20
+ export { Announcer };
@@ -1,4 +1,8 @@
1
- type CoreSpeechType = string | (() => SpeechType) | SpeechType[];
1
+ type CoreSpeechType =
2
+ | string
3
+ | (() => SpeechType)
4
+ | SpeechType[]
5
+ | SpeechSynthesisUtterance;
2
6
  export type SpeechType = CoreSpeechType | Promise<CoreSpeechType>;
3
7
 
4
8
  export interface SeriesResult {
@@ -8,6 +12,11 @@ export interface SeriesResult {
8
12
  cancel: () => void;
9
13
  }
10
14
 
15
+ // Aria label
16
+ type AriaLabel = { text: string; lang: string };
17
+ const ARIA_PARENT_ID = 'aria-parent';
18
+ let ariaLabelPhrases: AriaLabel[] = [];
19
+
11
20
  /* global SpeechSynthesisErrorEvent */
12
21
  function flattenStrings(series: SpeechType[] = []): SpeechType[] {
13
22
  const flattenedSeries = [];
@@ -36,6 +45,82 @@ function delay(pause: number) {
36
45
  });
37
46
  }
38
47
 
48
+ /**
49
+ * @description This function is called at the end of the speak series
50
+ * @param Phrase is an object containing the text and the language
51
+ */
52
+ function addChildrenToAriaDiv(phrase: AriaLabel) {
53
+ if (phrase?.text?.trim().length === 0) return;
54
+ ariaLabelPhrases.push(phrase);
55
+ }
56
+
57
+ /**
58
+ * @description This function is triggered finally when the speak series is finished and we are to speak the aria labels
59
+ */
60
+ function focusElementForAria() {
61
+ const element = createAriaElement();
62
+
63
+ if (!element) {
64
+ console.error(`ARIA div not found: ${ARIA_PARENT_ID}`);
65
+ return;
66
+ }
67
+
68
+ for (const object of ariaLabelPhrases) {
69
+ const span = document.createElement('span');
70
+
71
+ // TODO: Not sure LG or Samsung support lang attribute on span or switching language
72
+ span.setAttribute('lang', object.lang);
73
+ span.setAttribute('aria-label', object.text);
74
+ element.appendChild(span);
75
+ }
76
+
77
+ // Cleanup
78
+ setTimeout(() => {
79
+ ariaLabelPhrases = [];
80
+ cleanAriaLabelParent();
81
+ focusCanvas();
82
+ }, 100);
83
+ }
84
+
85
+ /**
86
+ * @description Clean the aria label parent after speaking
87
+ */
88
+ function cleanAriaLabelParent(): void {
89
+ const parentTag = document.getElementById(ARIA_PARENT_ID);
90
+ if (parentTag) {
91
+ while (parentTag.firstChild) {
92
+ parentTag.removeChild(parentTag.firstChild);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * @description Focus the canvas element
99
+ */
100
+ function focusCanvas(): void {
101
+ const canvas = document.getElementById('app')?.firstChild as HTMLElement;
102
+ canvas?.focus();
103
+ }
104
+
105
+ /**
106
+ * @description Create the aria element in the DOM if it doesn't exist
107
+ * @private For xbox, we may need to create a different element each time we wanna use aria
108
+ */
109
+ function createAriaElement(): HTMLDivElement | HTMLElement {
110
+ const aria_container = document.getElementById(ARIA_PARENT_ID);
111
+
112
+ if (!aria_container) {
113
+ const element = document.createElement('div');
114
+ element.setAttribute('id', ARIA_PARENT_ID);
115
+ element.setAttribute('aria-live', 'assertive');
116
+ element.setAttribute('tabindex', '0');
117
+ document.body.appendChild(element);
118
+ return element;
119
+ }
120
+
121
+ return aria_container;
122
+ }
123
+
39
124
  /**
40
125
  * Speak a string
41
126
  *
@@ -48,11 +133,23 @@ function speak(
48
133
  phrase: string,
49
134
  utterances: SpeechSynthesisUtterance[],
50
135
  lang = 'en-US',
136
+ voiceName?: string,
51
137
  ) {
52
138
  const synth = window.speechSynthesis;
139
+
53
140
  return new Promise<void>((resolve, reject) => {
141
+ let selectedVoice;
142
+ if (voiceName) {
143
+ const availableVoices = synth.getVoices();
144
+ selectedVoice =
145
+ availableVoices.find((v) => v.name === voiceName) || availableVoices[0];
146
+ }
147
+
54
148
  const utterance = new SpeechSynthesisUtterance(phrase);
55
149
  utterance.lang = lang;
150
+ if (selectedVoice) {
151
+ utterance.voice = selectedVoice;
152
+ }
56
153
  utterance.onend = () => {
57
154
  resolve();
58
155
  };
@@ -66,7 +163,9 @@ function speak(
66
163
 
67
164
  function speakSeries(
68
165
  series: SpeechType,
166
+ aria: boolean,
69
167
  lang: string,
168
+ voice?: string,
70
169
  root = true,
71
170
  ): SeriesResult {
72
171
  const synth = window.speechSynthesis;
@@ -74,11 +173,6 @@ function speakSeries(
74
173
  Array.isArray(series) ? series : [series],
75
174
  );
76
175
  const nestedSeriesResults: SeriesResult[] = [];
77
- /*
78
- We hold this array of SpeechSynthesisUtterances in order to prevent them from being
79
- garbage collected prematurely on STB hardware which can cause the 'onend' events of
80
- utterances to not fire consistently.
81
- */
82
176
  const utterances: SpeechSynthesisUtterance[] = [];
83
177
  let active: boolean = true;
84
178
 
@@ -87,24 +181,66 @@ function speakSeries(
87
181
  while (active && remainingPhrases.length) {
88
182
  const phrase = await Promise.resolve(remainingPhrases.shift());
89
183
  if (!active) {
90
- // Exit
91
- // Need to check this after the await in case it was cancelled in between
92
- break;
93
- } else if (typeof phrase === 'string' && phrase.includes('PAUSE-')) {
94
- // Pause it
95
- let pause = Number(phrase.split('PAUSE-')[1]) * 1000;
96
- if (isNaN(pause)) {
97
- pause = 0;
184
+ break; // Exit if canceled
185
+ }
186
+
187
+ if (typeof phrase === 'string' && phrase.includes('PAUSE-')) {
188
+ // Handle pauses
189
+ const pause = Number(phrase.split('PAUSE-')[1]) * 1000;
190
+ if (!isNaN(pause)) {
191
+ await delay(pause);
192
+ }
193
+ } else if (typeof phrase === 'string') {
194
+ if (!phrase) {
195
+ continue; // Skip empty strings
98
196
  }
99
- await delay(pause);
100
- } else if (typeof phrase === 'string' && phrase.length) {
101
- // Speak it
197
+ // Handle regular strings with retry logic
102
198
  const totalRetries = 3;
103
199
  let retriesLeft = totalRetries;
200
+
201
+ while (active && retriesLeft > 0) {
202
+ try {
203
+ if (aria) addChildrenToAriaDiv({ text: phrase, lang });
204
+ else await speak(phrase, utterances, lang, voice);
205
+ retriesLeft = 0; // Exit retry loop on success
206
+ } catch (e) {
207
+ if (e instanceof SpeechSynthesisErrorEvent) {
208
+ if (e.error === 'network') {
209
+ retriesLeft--;
210
+ console.warn(
211
+ `Speech synthesis network error. Retries left: ${retriesLeft}`,
212
+ );
213
+ await delay(500 * (totalRetries - retriesLeft));
214
+ } else if (
215
+ e.error === 'canceled' ||
216
+ e.error === 'interrupted'
217
+ ) {
218
+ // Cancel or interrupt error (ignore)
219
+ retriesLeft = 0;
220
+ } else {
221
+ throw new Error(`SpeechSynthesisErrorEvent: ${e.error}`);
222
+ }
223
+ } else {
224
+ throw e;
225
+ }
226
+ }
227
+ }
228
+ } else if (phrase instanceof SpeechSynthesisUtterance) {
229
+ // Handle SpeechSynthesisUtterance objects with retry logic
230
+ const totalRetries = 3;
231
+ let retriesLeft = totalRetries;
232
+ const text = phrase.text;
233
+ const objectLang = phrase?.lang;
234
+ const objectVoice = phrase?.voice;
235
+
104
236
  while (active && retriesLeft > 0) {
105
237
  try {
106
- await speak(phrase, utterances, lang);
107
- retriesLeft = 0;
238
+ if (text) {
239
+ if (aria) addChildrenToAriaDiv({ text, lang: objectLang });
240
+ else
241
+ await speak(text, utterances, objectLang, objectVoice?.name);
242
+ retriesLeft = 0; // Exit retry loop on success
243
+ }
108
244
  } catch (e) {
109
245
  if (e instanceof SpeechSynthesisErrorEvent) {
110
246
  if (e.error === 'network') {
@@ -128,20 +264,26 @@ function speakSeries(
128
264
  }
129
265
  }
130
266
  } else if (typeof phrase === 'function') {
131
- const seriesResult = speakSeries(phrase(), lang, false);
267
+ // Handle functions
268
+ const seriesResult = speakSeries(phrase(), aria, lang, voice, false);
132
269
  nestedSeriesResults.push(seriesResult);
133
270
  await seriesResult.series;
134
271
  } else if (Array.isArray(phrase)) {
135
- // Speak it (recursively)
136
- const seriesResult = speakSeries(phrase, lang, false);
272
+ // Handle nested arrays
273
+ const seriesResult = speakSeries(phrase, aria, lang, voice, false);
137
274
  nestedSeriesResults.push(seriesResult);
138
275
  await seriesResult.series;
139
276
  }
140
277
  }
141
278
  } finally {
142
279
  active = false;
280
+ // Call completion logic only for the original (root) series
281
+ if (root && aria) {
282
+ focusElementForAria();
283
+ }
143
284
  }
144
285
  })();
286
+
145
287
  return {
146
288
  series: seriesChain,
147
289
  get active() {
@@ -154,11 +296,25 @@ function speakSeries(
154
296
  if (!active) {
155
297
  return;
156
298
  }
299
+
157
300
  if (root) {
158
- synth.cancel();
301
+ if (aria) {
302
+ const element = createAriaElement();
303
+
304
+ if (element) {
305
+ ariaLabelPhrases = [];
306
+ cleanAriaLabelParent();
307
+ element.focus();
308
+ focusCanvas();
309
+ }
310
+
311
+ return;
312
+ }
313
+
314
+ synth.cancel(); // Cancel all ongoing speech
159
315
  }
160
- nestedSeriesResults.forEach((nestedSeriesResults) => {
161
- nestedSeriesResults.cancel();
316
+ nestedSeriesResults.forEach((nestedSeriesResult) => {
317
+ nestedSeriesResult.cancel();
162
318
  });
163
319
  active = false;
164
320
  },
@@ -166,8 +322,13 @@ function speakSeries(
166
322
  }
167
323
 
168
324
  let currentSeries: SeriesResult | undefined;
169
- export default function (toSpeak: SpeechType, lang: string = 'en-US') {
325
+ export default function (
326
+ toSpeak: SpeechType,
327
+ aria: boolean,
328
+ lang: string = 'en-US',
329
+ voice?: string,
330
+ ) {
170
331
  currentSeries && currentSeries.cancel();
171
- currentSeries = speakSeries(toSpeak, lang);
332
+ currentSeries = speakSeries(toSpeak, aria, lang, voice);
172
333
  return currentSeries;
173
334
  }
@@ -17,7 +17,7 @@
17
17
  * - `restoreFocus()`: Restores focus to the last stored element and removes it from the stack. Returns `true` if successful, `false` otherwise.
18
18
  * - `clearFocusStack()`: Empties the focus stack.
19
19
  */
20
- import { createSignal, createContext, useContext, JSX } from 'solid-js';
20
+ import * as s from 'solid-js';
21
21
  import { type ElementNode } from '@lightningtv/solid';
22
22
 
23
23
  interface FocusStackContextType {
@@ -26,13 +26,16 @@ interface FocusStackContextType {
26
26
  clearFocusStack: () => void;
27
27
  }
28
28
 
29
- const FocusStackContext = createContext<FocusStackContextType | undefined>(undefined);
29
+ const FocusStackContext = s.createContext<FocusStackContextType | undefined>(undefined);
30
30
 
31
- export function FocusStackProvider(props: { children: JSX.Element}) {
32
- const [_focusStack, setFocusStack] = createSignal<ElementNode[]>([]);
31
+ export function FocusStackProvider(props: { children: s.JSX.Element}) {
32
+ const [_focusStack, setFocusStack] = s.createSignal<ElementNode[]>([]);
33
33
 
34
34
  function storeFocus(element: ElementNode, prevElement?: ElementNode) {
35
- setFocusStack((stack) => [...stack, prevElement || element]);
35
+ const elm = prevElement || element;
36
+ if (elm) {
37
+ setFocusStack(stack => [...stack, elm]);
38
+ }
36
39
  }
37
40
 
38
41
  function restoreFocus(): boolean {
@@ -59,10 +62,18 @@ export function FocusStackProvider(props: { children: JSX.Element}) {
59
62
  );
60
63
  }
61
64
 
62
- export function useFocusStack() {
63
- const context = useContext(FocusStackContext);
65
+ export function useFocusStack(autoClear = true) {
66
+ const context = s.useContext(FocusStackContext);
64
67
  if (!context) {
65
68
  throw new Error("useFocusStack must be used within a FocusStackProvider");
66
69
  }
70
+
71
+ if (autoClear) {
72
+ s.onCleanup(() => {
73
+ // delay clearing the focus stack so restoreFocus can happen first.
74
+ setTimeout(() => context.clearFocusStack(), 5);
75
+ });
76
+ }
77
+
67
78
  return context;
68
79
  }
@@ -0,0 +1,31 @@
1
+ import * as s from 'solid-js'
2
+ import * as lng from '@lightningtv/solid'
3
+
4
+ interface Destroyable {
5
+ (props: lng.NodeProps): s.JSX.Element;
6
+ destroy: () => void;
7
+ }
8
+
9
+ export function createTag(children: s.JSX.Element): Destroyable {
10
+ const [texture, setTexture] = s.createSignal<lng.Texture | null | undefined>(null);
11
+ const Tag = <view
12
+ display='flex'
13
+ onLayout={(n) => {
14
+ if (n.preFlexwidth && n.width !== n.preFlexwidth) {
15
+ n.rtt = true;
16
+ setTimeout(() => setTexture(n.texture), 1);
17
+ }
18
+ }}
19
+ parent={lng.rootNode} children={children}
20
+ textureOptions={{
21
+ preventCleanup: true
22
+ }} /> as any as lng.ElementNode
23
+ Tag.render(false);
24
+
25
+ const TagComponent = (props: lng.NodeProps) => {
26
+ return <view color={0xffffffff} autosize {...props} texture={texture()} />;
27
+ };
28
+ TagComponent.destroy = () => Tag.destroy();
29
+
30
+ return TagComponent;
31
+ }