@lightningtv/solid
Advanced tools
Comparing version 0.0.3 to 0.0.4
@@ -1,6 +0,8 @@ | ||
import { createSignal, mergeProps as mergeProps$1, createRoot, createRenderEffect, createMemo, createComponent as createComponent$1, untrack, splitProps } from 'solid-js'; | ||
import { createSignal, mergeProps as mergeProps$1, createRoot, createRenderEffect, createMemo, createComponent as createComponent$1, untrack, splitProps, createEffect, on, createResource, createComputed, batch } from 'solid-js'; | ||
export { ErrorBoundary, For, Index, Match, Show, Suspense, SuspenseList, Switch } from 'solid-js'; | ||
import { Config, isInteger, ElementNode, NodeTypes, log, startLightningRenderer } from '@lightningtv/core'; | ||
import { Config, isInteger, ElementNode, NodeType, log, startLightningRenderer } from '@lightningtv/core'; | ||
export * from '@lightningtv/core'; | ||
import { createElement as createElement$1, spread as spread$1 } from '@lightningtv/solid'; | ||
import { createElement as createElement$1, spread as spread$1, isArray, activeElement as activeElement$1, isFunc, renderer as renderer$1 } from '@lightningtv/solid'; | ||
import { useKeyDownEvent } from '@solid-primitives/keyboard'; | ||
import { debounce } from '@solid-primitives/scheduled'; | ||
@@ -338,3 +340,3 @@ const [activeElement, setActiveElement] = createSignal(undefined); | ||
return { | ||
type: NodeTypes.Text, | ||
type: NodeType.Text, | ||
text, | ||
@@ -462,3 +464,462 @@ parent: undefined | ||
export { Dynamic, Text, View, activeElement, createComponent, createElement, createTextNode, deg2rad, effect, hexColor, insert, insertNode, memo, mergeProps, render, renderSync, setActiveElement, setProp, spread, startLightning, use }; | ||
/** | ||
* Generates a map of event handlers for each key in the KeyMap | ||
*/ | ||
const keyMapEntries = { | ||
ArrowLeft: 'Left', | ||
ArrowRight: 'Right', | ||
ArrowUp: 'Up', | ||
ArrowDown: 'Down', | ||
Enter: 'Enter', | ||
l: 'Last', | ||
' ': 'Space', | ||
Backspace: 'Back', | ||
Escape: 'Escape' | ||
}; | ||
const [focusPath, setFocusPath] = createSignal([]); | ||
const useFocusManager = userKeyMap => { | ||
const keypressEvent = useKeyDownEvent(); | ||
if (userKeyMap) { | ||
// Flatten the userKeyMap to a hash | ||
for (const [key, value] of Object.entries(userKeyMap)) { | ||
if (isArray(value)) { | ||
value.forEach(v => { | ||
keyMapEntries[v] = key; | ||
}); | ||
} else { | ||
keyMapEntries[value] = key; | ||
} | ||
} | ||
} | ||
createEffect(on(activeElement$1, (currentFocusedElm, prevFocusedElm, prevFocusPath = []) => { | ||
let current = currentFocusedElm; | ||
const fp = []; | ||
while (current) { | ||
if (!current.states.has('focus')) { | ||
current.states.add('focus'); | ||
isFunc(current.onFocus) && current.onFocus.call(current, currentFocusedElm, prevFocusedElm); | ||
} | ||
fp.push(current); | ||
current = current.parent; | ||
} | ||
prevFocusPath.forEach(elm => { | ||
if (!fp.includes(elm)) { | ||
elm.states.remove('focus'); | ||
isFunc(elm.onBlur) && elm.onBlur.call(elm, currentFocusedElm, prevFocusedElm); | ||
} | ||
}); | ||
setFocusPath(fp); | ||
return fp; | ||
}, { | ||
defer: true | ||
})); | ||
createEffect(() => { | ||
const e = keypressEvent(); | ||
if (e) { | ||
// Search keyMap for the value of the pressed key or keyCode if value undefined | ||
const mappedKeyEvent = keyMapEntries[e.key] || keyMapEntries[e.keyCode]; | ||
untrack(() => { | ||
const fp = focusPath(); | ||
let finalFocusElm = undefined; | ||
for (const elm of fp) { | ||
finalFocusElm = finalFocusElm || elm; | ||
if (mappedKeyEvent) { | ||
const onKeyHandler = elm[`on${mappedKeyEvent}`]; | ||
if (isFunc(onKeyHandler)) { | ||
if (onKeyHandler.call(elm, e, elm, finalFocusElm) === true) { | ||
break; | ||
} | ||
} | ||
} else { | ||
console.log(`Unhandled key event: ${e.key || e.keyCode}`); | ||
} | ||
if (isFunc(elm.onKeyPress)) { | ||
if (elm.onKeyPress.call(elm, e, mappedKeyEvent, elm, finalFocusElm) === true) { | ||
break; | ||
} | ||
} | ||
} | ||
return false; | ||
}); | ||
} | ||
}); | ||
return focusPath; | ||
}; | ||
// To use with TS import withPadding and then put withPadding; on the next line to prevent tree shaking | ||
function withPadding(el, padding) { | ||
const pad = padding(); | ||
let top, left, right, bottom; | ||
if (Array.isArray(pad)) { | ||
// top right bottom left | ||
if (pad.length === 2) { | ||
top = bottom = pad[0]; | ||
left = right = pad[1]; | ||
} else if (pad.length === 3) { | ||
top = pad[0]; | ||
left = right = pad[1]; | ||
bottom = pad[2]; | ||
} else { | ||
[top, right, bottom, left] = pad; | ||
} | ||
} else { | ||
top = right = bottom = left = pad; | ||
} | ||
el.onBeforeLayout = (node, size) => { | ||
if (size) { | ||
el.width = el.children.reduce((acc, c) => { | ||
return acc + (c.width || 0); | ||
}, 0) + left + right; | ||
const firstChild = el.children[0]; | ||
if (firstChild) { | ||
// set padding or marginLeft for flex | ||
firstChild.x = left; | ||
firstChild.marginLeft = left; | ||
} | ||
let maxHeight = 0; | ||
el.children.forEach(c => { | ||
c.y = top; | ||
c.marginTop = top; | ||
maxHeight = Math.max(maxHeight, c.height || 0); | ||
}); | ||
el.height = maxHeight + top + bottom; | ||
// let flex know we need to re-layout | ||
return true; | ||
} | ||
}; | ||
} | ||
/* global SpeechSynthesisErrorEvent */ | ||
function flattenStrings(series = []) { | ||
const flattenedSeries = []; | ||
let i; | ||
for (i = 0; i < series.length; i++) { | ||
const s = series[i]; | ||
if (typeof s === 'string' && !s.includes('PAUSE-')) { | ||
flattenedSeries.push(series[i]); | ||
} else { | ||
break; | ||
} | ||
} | ||
// add a "word boundary" to ensure the Announcer doesn't automatically try to | ||
// interpret strings that look like dates but are not actually dates | ||
// for example, if "Rising Sun" and "1993" are meant to be two separate lines, | ||
// when read together, "Sun 1993" is interpretted as "Sunday 1993" | ||
return [flattenedSeries.join(',\b ')].concat(series.slice(i)); | ||
} | ||
function delay(pause) { | ||
return new Promise(resolve => { | ||
setTimeout(resolve, pause); | ||
}); | ||
} | ||
/** | ||
* Speak a string | ||
* | ||
* @param phrase Phrase to speak | ||
* @param utterances An array which the new SpeechSynthesisUtterance instance representing this utterance will be appended | ||
* @param lang Language to speak in | ||
* @return {Promise<void>} Promise resolved when the utterance has finished speaking, and rejected if there's an error | ||
*/ | ||
function speak(phrase, utterances, lang = 'en-US') { | ||
const synth = window.speechSynthesis; | ||
return new Promise((resolve, reject) => { | ||
const utterance = new SpeechSynthesisUtterance(phrase); | ||
utterance.lang = lang; | ||
utterance.onend = () => { | ||
resolve(); | ||
}; | ||
utterance.onerror = e => { | ||
reject(e); | ||
}; | ||
utterances.push(utterance); | ||
synth.speak(utterance); | ||
}); | ||
} | ||
function speakSeries(series, lang, root = true) { | ||
const synth = window.speechSynthesis; | ||
const remainingPhrases = flattenStrings(Array.isArray(series) ? series : [series]); | ||
const nestedSeriesResults = []; | ||
/* | ||
We hold this array of SpeechSynthesisUtterances in order to prevent them from being | ||
garbage collected prematurely on STB hardware which can cause the 'onend' events of | ||
utterances to not fire consistently. | ||
*/ | ||
const utterances = []; | ||
let active = true; | ||
const seriesChain = (async () => { | ||
try { | ||
while (active && remainingPhrases.length) { | ||
const phrase = await Promise.resolve(remainingPhrases.shift()); | ||
if (!active) { | ||
// Exit | ||
// Need to check this after the await in case it was cancelled in between | ||
break; | ||
} else if (typeof phrase === 'string' && phrase.includes('PAUSE-')) { | ||
// Pause it | ||
let pause = Number(phrase.split('PAUSE-')[1]) * 1000; | ||
if (isNaN(pause)) { | ||
pause = 0; | ||
} | ||
await delay(pause); | ||
} else if (typeof phrase === 'string' && phrase.length) { | ||
// Speak it | ||
const totalRetries = 3; | ||
let retriesLeft = totalRetries; | ||
while (active && retriesLeft > 0) { | ||
try { | ||
await speak(phrase, utterances, lang); | ||
retriesLeft = 0; | ||
} catch (e) { | ||
// eslint-disable-next-line no-undef | ||
if (e instanceof SpeechSynthesisErrorEvent) { | ||
if (e.error === 'network') { | ||
retriesLeft--; | ||
console.warn(`Speech synthesis network error. Retries left: ${retriesLeft}`); | ||
await delay(500 * (totalRetries - retriesLeft)); | ||
} else if (e.error === 'canceled' || e.error === 'interrupted') { | ||
// Cancel or interrupt error (ignore) | ||
retriesLeft = 0; | ||
} else { | ||
throw new Error(`SpeechSynthesisErrorEvent: ${e.error}`); | ||
} | ||
} else { | ||
throw e; | ||
} | ||
} | ||
} | ||
} else if (typeof phrase === 'function') { | ||
const seriesResult = speakSeries(phrase(), lang, false); | ||
nestedSeriesResults.push(seriesResult); | ||
await seriesResult.series; | ||
} else if (Array.isArray(phrase)) { | ||
// Speak it (recursively) | ||
const seriesResult = speakSeries(phrase, lang, false); | ||
nestedSeriesResults.push(seriesResult); | ||
await seriesResult.series; | ||
} | ||
} | ||
} finally { | ||
active = false; | ||
} | ||
})(); | ||
return { | ||
series: seriesChain, | ||
get active() { | ||
return active; | ||
}, | ||
append: toSpeak => { | ||
remainingPhrases.push(toSpeak); | ||
}, | ||
cancel: () => { | ||
if (!active) { | ||
return; | ||
} | ||
if (root) { | ||
synth.cancel(); | ||
} | ||
nestedSeriesResults.forEach(nestedSeriesResults => { | ||
nestedSeriesResults.cancel(); | ||
}); | ||
active = false; | ||
} | ||
}; | ||
} | ||
let currentSeries; | ||
function SpeechEngine (toSpeak, lang = 'en-US') { | ||
currentSeries && currentSeries.cancel(); | ||
currentSeries = speakSeries(toSpeak, lang); | ||
return currentSeries; | ||
} | ||
let resetFocusPathTimer; | ||
let prevFocusPath = []; | ||
let currentlySpeaking; | ||
let voiceOutDisabled = false; | ||
const fiveMinutes = 300000; | ||
function debounceWithFlush(callback, time) { | ||
const trigger = debounce(callback, time); | ||
let scopedValue; | ||
const debounced = newValue => { | ||
scopedValue = newValue; | ||
trigger(newValue); | ||
}; | ||
debounced.flush = () => { | ||
trigger.clear(); | ||
callback(scopedValue); | ||
}; | ||
debounced.clear = trigger.clear; | ||
return debounced; | ||
} | ||
function getElmName(elm) { | ||
return elm.id || elm.name; | ||
} | ||
function onFocusChangeCore(focusPath = []) { | ||
if (!Announcer.onFocusChange || !Announcer.enabled) { | ||
return; | ||
} | ||
const loaded = focusPath.every(elm => !elm.loading); | ||
const focusDiff = focusPath.filter(elm => !prevFocusPath.includes(elm)); | ||
resetFocusPathTimer(); | ||
if (!loaded && Announcer.onFocusChange) { | ||
Announcer.onFocusChange([]); | ||
return; | ||
} | ||
prevFocusPath = focusPath.slice(0); | ||
const toAnnounceText = []; | ||
const toAnnounce = focusDiff.reduce((acc, elm) => { | ||
if (elm.announce) { | ||
acc.push([getElmName(elm), 'Announce', elm.announce]); | ||
toAnnounceText.push(elm.announce); | ||
} else if (elm.title) { | ||
acc.push([getElmName(elm), 'Title', elm.title]); | ||
toAnnounceText.push(elm.title); | ||
} else { | ||
acc.push([getElmName(elm), 'No Announce', '']); | ||
} | ||
return acc; | ||
}, []); | ||
focusDiff.reverse().reduce((acc, elm) => { | ||
if (elm.announceContext) { | ||
acc.push([getElmName(elm), 'Context', elm.announceContext]); | ||
toAnnounceText.push(elm.announceContext); | ||
} else { | ||
acc.push([getElmName(elm), 'No Context', '']); | ||
} | ||
return acc; | ||
}, toAnnounce); | ||
if (Announcer.debug) { | ||
console.table(toAnnounce); | ||
} | ||
if (toAnnounceText.length) { | ||
return Announcer.speak(toAnnounceText.reduce((acc, val) => acc.concat(val), [])); | ||
} | ||
} | ||
function textToSpeech(toSpeak) { | ||
if (voiceOutDisabled) { | ||
return; | ||
} | ||
return currentlySpeaking = SpeechEngine(toSpeak); | ||
} | ||
const Announcer = { | ||
debug: false, | ||
enabled: true, | ||
cancel: function () { | ||
currentlySpeaking && currentlySpeaking.cancel(); | ||
}, | ||
clearPrevFocus: function (depth = 0) { | ||
prevFocusPath = prevFocusPath.slice(0, depth); | ||
resetFocusPathTimer(); | ||
}, | ||
speak: function (text, { | ||
append = false, | ||
notification = false | ||
} = {}) { | ||
if (Announcer.onFocusChange && Announcer.enabled) { | ||
Announcer.onFocusChange.flush(); | ||
if (append && currentlySpeaking && currentlySpeaking.active) { | ||
currentlySpeaking.append(text); | ||
} else { | ||
Announcer.cancel(); | ||
textToSpeech(text); | ||
} | ||
if (notification) { | ||
voiceOutDisabled = true; | ||
currentlySpeaking?.series.finally(() => { | ||
voiceOutDisabled = false; | ||
Announcer.refresh(); | ||
}).catch(console.error); | ||
} | ||
} | ||
return currentlySpeaking; | ||
}, | ||
refresh: function (depth = 0) { | ||
Announcer.clearPrevFocus(depth); | ||
Announcer.onFocusChange && Announcer.onFocusChange(untrack(() => focusPath())); | ||
}, | ||
setupTimers: function ({ | ||
focusDebounce = 400, | ||
focusChangeTimeout = fiveMinutes | ||
} = {}) { | ||
Announcer.onFocusChange = debounceWithFlush(onFocusChangeCore, focusDebounce); | ||
resetFocusPathTimer = debounceWithFlush(() => { | ||
// Reset focus path for full announce | ||
prevFocusPath = []; | ||
}, focusChangeTimeout); | ||
} | ||
}; | ||
const useAnnouncer = () => { | ||
Announcer.setupTimers(); | ||
createEffect(on(focusPath, Announcer.onFocusChange, { | ||
defer: true | ||
})); | ||
return Announcer; | ||
}; | ||
// Adopted from https://github.com/solidjs-community/solid-primitives/blob/main/packages/pagination/src/index.ts | ||
// As we don't have intersection observer in Lightning, we can't use the original implementation | ||
/** | ||
* Provides an easy way to implement infinite items. | ||
* | ||
* ```ts | ||
* const [items, loader, { item, setItem, setItems, end, setEnd }] = createInfiniteScroll(fetcher); | ||
* ``` | ||
* @param fetcher `(item: number) => Promise<T[]>` | ||
* @return `items()` is an accessor contains array of contents | ||
* @property `items.loading` is a boolean indicator for the loading state | ||
* @property `items.error` contains any error encountered | ||
* @method `page` is an accessor that contains page number | ||
* @method `setPage` allows to manually change the page number | ||
* @method `setItems` allows to manually change the contents of the item | ||
* @method `end` is a boolean indicator for end of the item | ||
* @method `setEnd` allows to manually change the end | ||
*/ | ||
function createInfiniteItems(fetcher) { | ||
const [items, setItems] = createSignal([]); | ||
const [page, setPage] = createSignal(0); | ||
const [end, setEnd] = createSignal(false); | ||
const [contents] = createResource(page, fetcher); | ||
createComputed(() => { | ||
const content = contents(); | ||
if (!content) return; | ||
batch(() => { | ||
if (content.length === 0) setEnd(true); | ||
setItems(p => [...p, ...content]); | ||
}); | ||
}); | ||
return [items, { | ||
page, | ||
setPage, | ||
setItems, | ||
end, | ||
setEnd | ||
}]; | ||
} | ||
function createSpriteMap(src, subTextures) { | ||
const spriteMapTexture = renderer$1.createTexture('ImageTexture', { | ||
src | ||
}); | ||
return subTextures.reduce((acc, t) => { | ||
const { | ||
x, | ||
y, | ||
width, | ||
height | ||
} = t; | ||
acc[t.name] = renderer$1.createTexture('SubTexture', { | ||
texture: spriteMapTexture, | ||
x, | ||
y, | ||
width, | ||
height | ||
}); | ||
return acc; | ||
}, {}); | ||
} | ||
export { Dynamic, Text, View, activeElement, createComponent, createElement, createInfiniteItems, createSpriteMap, createTextNode, deg2rad, effect, focusPath, hexColor, insert, insertNode, memo, mergeProps, render, renderSync, setActiveElement, setProp, spread, startLightning, use, useAnnouncer, useFocusManager, withPadding }; | ||
//# sourceMappingURL=index.js.map |
@@ -9,1 +9,2 @@ import './jsx-runtime.js'; | ||
export * from './render.js'; | ||
export * from './primitives/index.js'; |
import { assertTruthy } from '@lightningjs/renderer/utils'; | ||
import { ElementNode, log } from '@lightningtv/core'; | ||
import { ElementNode, NodeType, log } from '@lightningtv/core'; | ||
export default { | ||
@@ -9,3 +9,3 @@ createElement(name) { | ||
// A text node is just a string - not the <text> node | ||
return { type: 2 /* NodeTypes.Text */, text, parent: undefined }; | ||
return { type: NodeType.Text, text, parent: undefined }; | ||
}, | ||
@@ -12,0 +12,0 @@ replaceText(node, value) { |
@@ -9,1 +9,2 @@ import './jsx-runtime.js'; | ||
export * from './render.js'; | ||
export * from './primitives/index.js'; |
{ | ||
"name": "@lightningtv/solid", | ||
"version": "0.0.3", | ||
"version": "0.0.4", | ||
"description": "Lightning Renderer for Solid Universal", | ||
@@ -36,3 +36,6 @@ "type": "module", | ||
"dependencies": { | ||
"@lightningtv/core": "^0.0.3" | ||
"@lightningtv/core": "^0.0.5", | ||
"@lightningtv/solid": "file:", | ||
"@solid-primitives/keyboard": "^1.2.8", | ||
"@solid-primitives/scheduled": "^1.4.3" | ||
}, | ||
@@ -39,0 +42,0 @@ "devDependencies": { |
@@ -18,1 +18,2 @@ import './jsx-runtime.js'; | ||
export * from './render.js'; | ||
export * from './primitives/index.js'; |
import { assertTruthy } from '@lightningjs/renderer/utils'; | ||
import type { SolidNode, TextNode } from '@lightningtv/core'; | ||
import { ElementNode, NodeTypes, log } from '@lightningtv/core'; | ||
import { ElementNode, NodeType, log } from '@lightningtv/core'; | ||
import type { createRenderer } from 'solid-js/universal'; | ||
@@ -16,3 +16,3 @@ | ||
// A text node is just a string - not the <text> node | ||
return { type: NodeTypes.Text, text, parent: undefined }; | ||
return { type: NodeType.Text, text, parent: undefined }; | ||
}, | ||
@@ -19,0 +19,0 @@ replaceText(node: TextNode, value: string): void { |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
183612
55
2659
6
+ Added@lightningtv/solid@file:
+ Added@lightningtv/core@0.0.5(transitive)
+ Added@solid-primitives/event-listener@2.3.3(transitive)
+ Added@solid-primitives/keyboard@1.2.8(transitive)
+ Added@solid-primitives/rootless@1.4.5(transitive)
+ Added@solid-primitives/scheduled@1.4.4(transitive)
+ Added@solid-primitives/utils@6.2.3(transitive)
- Removed@lightningtv/core@0.0.3(transitive)
Updated@lightningtv/core@^0.0.5