@lightningtv/solid 0.0.2 → 0.0.4

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 (33) hide show
  1. package/dist/esm/index.js +466 -5
  2. package/dist/esm/index.js.map +1 -1
  3. package/dist/source/index.js +1 -0
  4. package/dist/source/primitives/announcer/announcer.js +121 -0
  5. package/dist/source/primitives/announcer/index.js +8 -0
  6. package/dist/source/primitives/announcer/speech.js +152 -0
  7. package/dist/source/primitives/createInfiniteItems.js +45 -0
  8. package/dist/source/primitives/createSpriteMap.js +17 -0
  9. package/dist/source/primitives/index.js +5 -0
  10. package/dist/source/primitives/useFocusManager.js +88 -0
  11. package/dist/source/primitives/withPadding.js +48 -0
  12. package/dist/source/solidOpts.js +2 -2
  13. package/dist/types/index.d.ts +1 -0
  14. package/dist/types/primitives/announcer/announcer.d.ts +37 -0
  15. package/dist/types/primitives/announcer/index.d.ts +2 -0
  16. package/dist/types/primitives/announcer/speech.d.ts +10 -0
  17. package/dist/types/primitives/createInfiniteItems.d.ts +27 -0
  18. package/dist/types/primitives/createSpriteMap.d.ts +8 -0
  19. package/dist/types/primitives/index.d.ts +5 -0
  20. package/dist/types/primitives/useFocusManager.d.ts +46 -0
  21. package/dist/types/primitives/withPadding.d.ts +3 -0
  22. package/package.json +5 -2
  23. package/src/index.ts +1 -0
  24. package/src/primitives/announcer/announcer.ts +190 -0
  25. package/src/primitives/announcer/index.ts +10 -0
  26. package/src/primitives/announcer/speech.ts +174 -0
  27. package/src/primitives/createInfiniteItems.ts +66 -0
  28. package/src/primitives/createSpriteMap.ts +31 -0
  29. package/src/primitives/index.ts +5 -0
  30. package/src/primitives/jsx-runtime.d.ts +11 -0
  31. package/src/primitives/useFocusManager.ts +194 -0
  32. package/src/primitives/withPadding.ts +56 -0
  33. package/src/solidOpts.ts +2 -2
@@ -0,0 +1,174 @@
1
+ type CoreSpeechType = string | (() => SpeechType) | SpeechType[];
2
+ export type SpeechType = CoreSpeechType | Promise<CoreSpeechType>;
3
+
4
+ export interface SeriesResult {
5
+ series: Promise<void>;
6
+ readonly active: boolean;
7
+ append: (toSpeak: SpeechType) => void;
8
+ cancel: () => void;
9
+ }
10
+
11
+ /* global SpeechSynthesisErrorEvent */
12
+ function flattenStrings(series: SpeechType[] = []): SpeechType[] {
13
+ const flattenedSeries = [];
14
+
15
+ let i;
16
+ for (i = 0; i < series.length; i++) {
17
+ const s = series[i];
18
+ if (typeof s === 'string' && !s.includes('PAUSE-')) {
19
+ flattenedSeries.push(series[i]);
20
+ } else {
21
+ break;
22
+ }
23
+ }
24
+ // add a "word boundary" to ensure the Announcer doesn't automatically try to
25
+ // interpret strings that look like dates but are not actually dates
26
+ // for example, if "Rising Sun" and "1993" are meant to be two separate lines,
27
+ // when read together, "Sun 1993" is interpretted as "Sunday 1993"
28
+ return ([flattenedSeries.join(',\b ')] as SpeechType[]).concat(
29
+ series.slice(i),
30
+ );
31
+ }
32
+
33
+ function delay(pause: number) {
34
+ return new Promise((resolve) => {
35
+ setTimeout(resolve, pause);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Speak a string
41
+ *
42
+ * @param phrase Phrase to speak
43
+ * @param utterances An array which the new SpeechSynthesisUtterance instance representing this utterance will be appended
44
+ * @param lang Language to speak in
45
+ * @return {Promise<void>} Promise resolved when the utterance has finished speaking, and rejected if there's an error
46
+ */
47
+ function speak(
48
+ phrase: string,
49
+ utterances: SpeechSynthesisUtterance[],
50
+ lang = 'en-US',
51
+ ) {
52
+ const synth = window.speechSynthesis;
53
+ return new Promise<void>((resolve, reject) => {
54
+ const utterance = new SpeechSynthesisUtterance(phrase);
55
+ utterance.lang = lang;
56
+ utterance.onend = () => {
57
+ resolve();
58
+ };
59
+ utterance.onerror = (e) => {
60
+ reject(e);
61
+ };
62
+ utterances.push(utterance);
63
+ synth.speak(utterance);
64
+ });
65
+ }
66
+
67
+ function speakSeries(
68
+ series: SpeechType,
69
+ lang: string,
70
+ root = true,
71
+ ): SeriesResult {
72
+ const synth = window.speechSynthesis;
73
+ const remainingPhrases = flattenStrings(
74
+ Array.isArray(series) ? series : [series],
75
+ );
76
+ 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
+ const utterances: SpeechSynthesisUtterance[] = [];
83
+ let active: boolean = true;
84
+
85
+ const seriesChain = (async () => {
86
+ try {
87
+ while (active && remainingPhrases.length) {
88
+ const phrase = await Promise.resolve(remainingPhrases.shift());
89
+ 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;
98
+ }
99
+ await delay(pause);
100
+ } else if (typeof phrase === 'string' && phrase.length) {
101
+ // Speak it
102
+ const totalRetries = 3;
103
+ let retriesLeft = totalRetries;
104
+ while (active && retriesLeft > 0) {
105
+ try {
106
+ await speak(phrase, utterances, lang);
107
+ retriesLeft = 0;
108
+ } catch (e) {
109
+ // eslint-disable-next-line no-undef
110
+ if (e instanceof SpeechSynthesisErrorEvent) {
111
+ if (e.error === 'network') {
112
+ retriesLeft--;
113
+ console.warn(
114
+ `Speech synthesis network error. Retries left: ${retriesLeft}`,
115
+ );
116
+ await delay(500 * (totalRetries - retriesLeft));
117
+ } else if (
118
+ e.error === 'canceled' ||
119
+ e.error === 'interrupted'
120
+ ) {
121
+ // Cancel or interrupt error (ignore)
122
+ retriesLeft = 0;
123
+ } else {
124
+ throw new Error(`SpeechSynthesisErrorEvent: ${e.error}`);
125
+ }
126
+ } else {
127
+ throw e;
128
+ }
129
+ }
130
+ }
131
+ } else if (typeof phrase === 'function') {
132
+ const seriesResult = speakSeries(phrase(), lang, false);
133
+ nestedSeriesResults.push(seriesResult);
134
+ await seriesResult.series;
135
+ } else if (Array.isArray(phrase)) {
136
+ // Speak it (recursively)
137
+ const seriesResult = speakSeries(phrase, lang, false);
138
+ nestedSeriesResults.push(seriesResult);
139
+ await seriesResult.series;
140
+ }
141
+ }
142
+ } finally {
143
+ active = false;
144
+ }
145
+ })();
146
+ return {
147
+ series: seriesChain,
148
+ get active() {
149
+ return active;
150
+ },
151
+ append: (toSpeak: SpeechType) => {
152
+ remainingPhrases.push(toSpeak);
153
+ },
154
+ cancel: () => {
155
+ if (!active) {
156
+ return;
157
+ }
158
+ if (root) {
159
+ synth.cancel();
160
+ }
161
+ nestedSeriesResults.forEach((nestedSeriesResults) => {
162
+ nestedSeriesResults.cancel();
163
+ });
164
+ active = false;
165
+ },
166
+ };
167
+ }
168
+
169
+ let currentSeries: SeriesResult | undefined;
170
+ export default function (toSpeak: SpeechType, lang: string = 'en-US') {
171
+ currentSeries && currentSeries.cancel();
172
+ currentSeries = speakSeries(toSpeak, lang);
173
+ return currentSeries;
174
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ type Accessor,
3
+ batch,
4
+ type Setter,
5
+ createComputed,
6
+ createResource,
7
+ createSignal,
8
+ } from 'solid-js';
9
+
10
+ // Adopted from https://github.com/solidjs-community/solid-primitives/blob/main/packages/pagination/src/index.ts
11
+ // As we don't have intersection observer in Lightning, we can't use the original implementation
12
+
13
+ /**
14
+ * Provides an easy way to implement infinite items.
15
+ *
16
+ * ```ts
17
+ * const [items, loader, { item, setItem, setItems, end, setEnd }] = createInfiniteScroll(fetcher);
18
+ * ```
19
+ * @param fetcher `(item: number) => Promise<T[]>`
20
+ * @return `items()` is an accessor contains array of contents
21
+ * @property `items.loading` is a boolean indicator for the loading state
22
+ * @property `items.error` contains any error encountered
23
+ * @method `page` is an accessor that contains page number
24
+ * @method `setPage` allows to manually change the page number
25
+ * @method `setItems` allows to manually change the contents of the item
26
+ * @method `end` is a boolean indicator for end of the item
27
+ * @method `setEnd` allows to manually change the end
28
+ */
29
+ export function createInfiniteItems<T>(
30
+ fetcher: (item: number) => Promise<T[]>,
31
+ ): [
32
+ items: Accessor<T[]>,
33
+ options: {
34
+ page: Accessor<number>;
35
+ setPage: Setter<number>;
36
+ setItems: Setter<T[]>;
37
+ end: Accessor<boolean>;
38
+ setEnd: Setter<boolean>;
39
+ },
40
+ ] {
41
+ const [items, setItems] = createSignal<T[]>([]);
42
+ const [page, setPage] = createSignal(0);
43
+ const [end, setEnd] = createSignal(false);
44
+
45
+ const [contents] = createResource(page, fetcher);
46
+
47
+ createComputed(() => {
48
+ const content = contents();
49
+ if (!content) return;
50
+ batch(() => {
51
+ if (content.length === 0) setEnd(true);
52
+ setItems((p) => [...p, ...content]);
53
+ });
54
+ });
55
+
56
+ return [
57
+ items,
58
+ {
59
+ page,
60
+ setPage,
61
+ setItems,
62
+ end,
63
+ setEnd,
64
+ },
65
+ ];
66
+ }
@@ -0,0 +1,31 @@
1
+ import { renderer } from '@lightningtv/solid';
2
+ import type { SpecificTextureRef } from '@lightningjs/renderer';
3
+
4
+ export interface SpriteDef {
5
+ name: string;
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ export function createSpriteMap(src: string, subTextures: SpriteDef[]) {
13
+ const spriteMapTexture = renderer.createTexture('ImageTexture', {
14
+ src,
15
+ });
16
+
17
+ return subTextures.reduce<Record<string, SpecificTextureRef<'SubTexture'>>>(
18
+ (acc, t) => {
19
+ const { x, y, width, height } = t;
20
+ acc[t.name] = renderer.createTexture('SubTexture', {
21
+ texture: spriteMapTexture,
22
+ x,
23
+ y,
24
+ width,
25
+ height,
26
+ });
27
+ return acc;
28
+ },
29
+ {},
30
+ );
31
+ }
@@ -0,0 +1,5 @@
1
+ export * from './useFocusManager.js';
2
+ export * from './withPadding.js';
3
+ export * from './announcer/index.js';
4
+ export * from './createInfiniteItems.js';
5
+ export * from './createSpriteMap.js';
@@ -0,0 +1,11 @@
1
+ import 'solid-js';
2
+ import type { withPaddingInput } from './withPadding.ts';
3
+
4
+ declare module 'solid-js' {
5
+ namespace JSX {
6
+ interface Directives {
7
+ model: [() => any, (v: any) => any];
8
+ withPadding: withPaddingInput;
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,194 @@
1
+ import { createEffect, on, createSignal, untrack } from 'solid-js';
2
+ import { useKeyDownEvent } from '@solid-primitives/keyboard';
3
+ import {
4
+ activeElement,
5
+ ElementNode,
6
+ isFunc,
7
+ isArray,
8
+ } from '@lightningtv/solid';
9
+
10
+ export interface DefaultKeyMap {
11
+ Left: string | number | (string | number)[];
12
+ Right: string | number | (string | number)[];
13
+ Up: string | number | (string | number)[];
14
+ Down: string | number | (string | number)[];
15
+ Enter: string | number | (string | number)[];
16
+ Last: string | number | (string | number)[];
17
+ }
18
+
19
+ export interface KeyMap extends DefaultKeyMap {}
20
+
21
+ export type KeyHandlerReturn = boolean | void;
22
+
23
+ export type KeyHandler = (
24
+ this: ElementNode,
25
+ e: KeyboardEvent,
26
+ target: ElementNode,
27
+ handlerElm: ElementNode,
28
+ ) => KeyHandlerReturn;
29
+
30
+ /**
31
+ * Generates a map of event handlers for each key in the KeyMap
32
+ */
33
+ type KeyMapEventHandlers = {
34
+ [K in keyof KeyMap as `on${Capitalize<K>}`]?: KeyHandler;
35
+ };
36
+
37
+ declare module '@lightningtv/solid' {
38
+ /**
39
+ * Augment the existing IntrinsicCommonProps interface with our own
40
+ * FocusManager-specific properties.
41
+ */
42
+ interface IntrinsicCommonProps extends KeyMapEventHandlers {
43
+ onFocus?: (
44
+ currentFocusedElm: ElementNode | undefined,
45
+ prevFocusedElm: ElementNode | undefined,
46
+ ) => void;
47
+ onBlur?: (
48
+ currentFocusedElm: ElementNode | undefined,
49
+ prevFocusedElm: ElementNode | undefined,
50
+ ) => void;
51
+ onKeyPress?: (
52
+ this: ElementNode,
53
+ e: KeyboardEvent,
54
+ mappedKeyEvent: string | undefined,
55
+ handlerElm: ElementNode,
56
+ currentFocusedElm: ElementNode,
57
+ ) => KeyHandlerReturn;
58
+ onSelectedChanged?: (
59
+ container: ElementNode,
60
+ activeElm: ElementNode,
61
+ selectedIndex: number | undefined,
62
+ lastSelectedIndex: number | undefined,
63
+ ) => void;
64
+ skipFocus?: boolean;
65
+ wrap?: boolean;
66
+ plinko?: boolean;
67
+ }
68
+
69
+ interface IntrinsicNodeStyleProps {
70
+ // TODO: Refactor states to use a $ prefix
71
+ focus?: IntrinsicNodeStyleProps;
72
+ }
73
+
74
+ interface IntrinsicTextNodeStyleProps {
75
+ // TODO: Refactor states to use a $ prefix
76
+ focus?: IntrinsicTextNodeStyleProps;
77
+ }
78
+
79
+ interface TextNode {
80
+ skipFocus?: undefined;
81
+ }
82
+ }
83
+
84
+ const keyMapEntries: Record<string | number, string> = {
85
+ ArrowLeft: 'Left',
86
+ ArrowRight: 'Right',
87
+ ArrowUp: 'Up',
88
+ ArrowDown: 'Down',
89
+ Enter: 'Enter',
90
+ l: 'Last',
91
+ ' ': 'Space',
92
+ Backspace: 'Back',
93
+ Escape: 'Escape',
94
+ };
95
+
96
+ const [focusPath, setFocusPath] = createSignal<ElementNode[]>([]);
97
+ export { focusPath };
98
+ export const useFocusManager = (userKeyMap?: Partial<KeyMap>) => {
99
+ const keypressEvent = useKeyDownEvent();
100
+ if (userKeyMap) {
101
+ // Flatten the userKeyMap to a hash
102
+ for (const [key, value] of Object.entries(userKeyMap)) {
103
+ if (isArray(value)) {
104
+ value.forEach((v) => {
105
+ keyMapEntries[v] = key;
106
+ });
107
+ } else {
108
+ keyMapEntries[value] = key;
109
+ }
110
+ }
111
+ }
112
+ createEffect(
113
+ on(
114
+ activeElement,
115
+ (
116
+ currentFocusedElm: ElementNode,
117
+ prevFocusedElm: ElementNode | undefined,
118
+ prevFocusPath: ElementNode[] = [],
119
+ ) => {
120
+ const newFocusedElms = [];
121
+ let current = currentFocusedElm;
122
+
123
+ const fp: ElementNode[] = [];
124
+ while (current) {
125
+ if (!current.states.has('focus')) {
126
+ current.states.add('focus');
127
+ isFunc(current.onFocus) &&
128
+ current.onFocus.call(current, currentFocusedElm, prevFocusedElm);
129
+
130
+ newFocusedElms.push(current);
131
+ }
132
+ fp.push(current);
133
+ current = current.parent!;
134
+ }
135
+
136
+ prevFocusPath.forEach((elm) => {
137
+ if (!fp.includes(elm)) {
138
+ elm.states.remove('focus');
139
+ isFunc(elm.onBlur) &&
140
+ elm.onBlur.call(elm, currentFocusedElm, prevFocusedElm);
141
+ }
142
+ });
143
+
144
+ setFocusPath(fp);
145
+ return fp;
146
+ },
147
+ { defer: true },
148
+ ),
149
+ );
150
+
151
+ createEffect(() => {
152
+ const e = keypressEvent();
153
+
154
+ if (e) {
155
+ // Search keyMap for the value of the pressed key or keyCode if value undefined
156
+ const mappedKeyEvent = keyMapEntries[e.key] || keyMapEntries[e.keyCode];
157
+ untrack(() => {
158
+ const fp = focusPath();
159
+ let finalFocusElm: ElementNode | undefined = undefined;
160
+ for (const elm of fp) {
161
+ finalFocusElm = finalFocusElm || elm;
162
+ if (mappedKeyEvent) {
163
+ const onKeyHandler =
164
+ elm[`on${mappedKeyEvent}` as keyof KeyMapEventHandlers];
165
+ if (isFunc(onKeyHandler)) {
166
+ if (onKeyHandler.call(elm, e, elm, finalFocusElm) === true) {
167
+ break;
168
+ }
169
+ }
170
+ } else {
171
+ console.log(`Unhandled key event: ${e.key || e.keyCode}`);
172
+ }
173
+
174
+ if (isFunc(elm.onKeyPress)) {
175
+ if (
176
+ elm.onKeyPress.call(
177
+ elm,
178
+ e,
179
+ mappedKeyEvent,
180
+ elm,
181
+ finalFocusElm,
182
+ ) === true
183
+ ) {
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ return false;
189
+ });
190
+ }
191
+ });
192
+
193
+ return focusPath;
194
+ };
@@ -0,0 +1,56 @@
1
+ import type { ElementNode } from '@lightningtv/core';
2
+
3
+ export type withPaddingInput =
4
+ | number
5
+ | [number, number]
6
+ | [number, number, number]
7
+ | [number, number, number, number];
8
+
9
+ // To use with TS import withPadding and then put withPadding; on the next line to prevent tree shaking
10
+ export function withPadding(el: ElementNode, padding: () => withPaddingInput) {
11
+ const pad = padding();
12
+ let top: number, left: number, right: number, bottom: number;
13
+
14
+ if (Array.isArray(pad)) {
15
+ // top right bottom left
16
+ if (pad.length === 2) {
17
+ top = bottom = pad[0]!;
18
+ left = right = pad[1]!;
19
+ } else if (pad.length === 3) {
20
+ top = pad[0]!;
21
+ left = right = pad[1]!;
22
+ bottom = pad[2]!;
23
+ } else {
24
+ [top, right, bottom, left] = pad;
25
+ }
26
+ } else {
27
+ top = right = bottom = left = pad;
28
+ }
29
+
30
+ el.onBeforeLayout = (node, size) => {
31
+ if (size) {
32
+ el.width =
33
+ el.children.reduce((acc, c) => {
34
+ return acc + (c.width || 0);
35
+ }, 0) +
36
+ left +
37
+ right;
38
+ const firstChild = el.children[0];
39
+ if (firstChild) {
40
+ // set padding or marginLeft for flex
41
+ firstChild.x = left;
42
+ firstChild.marginLeft = left;
43
+ }
44
+
45
+ let maxHeight = 0;
46
+ el.children.forEach((c) => {
47
+ c.y = top;
48
+ c.marginTop = top;
49
+ maxHeight = Math.max(maxHeight, c.height || 0);
50
+ });
51
+ el.height = maxHeight + top + bottom;
52
+ // let flex know we need to re-layout
53
+ return true;
54
+ }
55
+ };
56
+ }
package/src/solidOpts.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { assertTruthy } from '@lightningjs/renderer/utils';
2
2
  import type { SolidNode, TextNode } from '@lightningtv/core';
3
- import { ElementNode, NodeTypes, log } from '@lightningtv/core';
3
+ import { ElementNode, NodeType, log } from '@lightningtv/core';
4
4
  import type { createRenderer } from 'solid-js/universal';
5
5
 
6
6
  export type SolidRendererOptions = Parameters<
@@ -13,7 +13,7 @@ export default {
13
13
  },
14
14
  createTextNode(text: string): TextNode {
15
15
  // A text node is just a string - not the <text> node
16
- return { type: NodeTypes.Text, text, parent: undefined };
16
+ return { type: NodeType.Text, text, parent: undefined };
17
17
  },
18
18
  replaceText(node: TextNode, value: string): void {
19
19
  log('Replace Text: ', node, value);