@lightningjs/blits 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0
4
+
5
+ _2 jul 2026_
6
+
7
+ - Added `keepAlive` option to `src`-object to prevent a texture from being cleaned up by the renderer
8
+ - Abstracted announcer (and speechSynthesis) to be platform provided (with speechSynthesis as the built-in default)
9
+ - Added dedicated findByData methods to testing harness to select specific nodes in the tree
10
+ - Improved test cases around frameTick cleanup
11
+
3
12
  ## 2.6.0
4
13
 
5
14
  _25 jun 2026_
package/index.d.ts CHANGED
@@ -66,6 +66,28 @@ declare module '@lightningjs/blits' {
66
66
  enableUtteranceKeepAlive?: boolean
67
67
  }
68
68
 
69
+ export interface AnnouncerDriverOptions extends AnnouncerUtteranceOptions {
70
+ /**
71
+ * Message to be spoken by the platform announcer driver.
72
+ */
73
+ message: string | number,
74
+ /**
75
+ * Internal announcement id used by the Blits announcer queue.
76
+ */
77
+ id: number,
78
+ }
79
+
80
+ export interface AnnouncerDriver {
81
+ /**
82
+ * Speak one queued announcement.
83
+ */
84
+ speak(options: AnnouncerDriverOptions): Promise<any>,
85
+ /**
86
+ * Stop the active platform announcement, when supported.
87
+ */
88
+ cancel(): void,
89
+ }
90
+
69
91
  export interface AnnouncerUtterance<T = any> extends Promise<T> {
70
92
  /**
71
93
  * Removes a specific message from the announcement queue,
@@ -1070,13 +1092,9 @@ declare module '@lightningjs/blits' {
1070
1092
  */
1071
1093
  createKeyboardEvent?: (type: string, init?: KeyboardEventInit | any) => any,
1072
1094
  /**
1073
- * SpeechSynthesisUtterance constructor used by the announcer.
1074
- */
1075
- SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance | any,
1076
- /**
1077
- * Speech synthesis API implementation used by the announcer.
1095
+ * Platform-specific announcer driver used instead of the default Web Speech driver.
1078
1096
  */
1079
- speechSynthesis?: SpeechSynthesis | any,
1097
+ announcer?: AnnouncerDriver,
1080
1098
  /**
1081
1099
  * Monotonic timestamp provider used for input throttling.
1082
1100
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -32,6 +32,10 @@
32
32
  "types": "./src/plugins/storage/storage.d.ts",
33
33
  "default": "./src/plugins/storage/storage.js"
34
34
  },
35
+ "./platforms/webOS/announcer": {
36
+ "types": "./src/platforms/webOS/announcer.d.ts",
37
+ "default": "./src/platforms/webOS/announcer.js"
38
+ },
35
39
  "./testing": {
36
40
  "types": "./src/testing/index.d.ts",
37
41
  "default": "./src/testing/index.js"
@@ -16,7 +16,6 @@
16
16
  */
17
17
 
18
18
  import { Log } from '../lib/log.js'
19
- import speechSynthesis from './speechSynthesis.js'
20
19
  import { platform } from '../platform.js'
21
20
 
22
21
  let active = false
@@ -31,6 +30,20 @@ let globalDefaultOptions = {
31
30
  enableUtteranceKeepAlive: !/android/i.test(platform.userAgent || ''),
32
31
  }
33
32
 
33
+ const getDriver = () => {
34
+ const driver = platform.announcer
35
+ if (driver && typeof driver.speak === 'function') {
36
+ return driver
37
+ }
38
+ }
39
+
40
+ const cancelDriver = () => {
41
+ const driver = getDriver()
42
+ if (driver && typeof driver.cancel === 'function') {
43
+ driver.cancel()
44
+ }
45
+ }
46
+
34
47
  const noopAnnouncement = {
35
48
  then() {},
36
49
  done() {},
@@ -84,9 +97,9 @@ const addToQueue = (message, politeness, delay = false, options = {}) => {
84
97
  // augment the promise with a stop function
85
98
  done.stop = () => {
86
99
  if (id === currentId) {
87
- speechSynthesis.cancel()
100
+ cancelDriver()
88
101
  isProcessing = false
89
- resolveFn('interupted')
102
+ resolveFn('interrupted')
90
103
  }
91
104
  }
92
105
 
@@ -133,7 +146,17 @@ const processQueue = async () => {
133
146
  debounce = setTimeout(() => {
134
147
  Log.debug(`Announcer - speaking: "${message}" (id: ${id})`)
135
148
 
136
- speechSynthesis
149
+ const driver = getDriver()
150
+ if (driver === undefined) {
151
+ currentId = null
152
+ currentResolveFn = null
153
+ isProcessing = false
154
+ resolveFn('unavailable')
155
+ processQueue()
156
+ return
157
+ }
158
+
159
+ driver
137
160
  .speak({
138
161
  message,
139
162
  id,
@@ -180,8 +203,8 @@ const stop = () => {
180
203
  // Clear debounce timer if speech hasn't started yet
181
204
  clearDebounceTimer()
182
205
 
183
- // Always cancel speech synthesis to ensure clean state
184
- speechSynthesis.cancel()
206
+ // Always cancel the platform announcer to ensure clean state
207
+ cancelDriver()
185
208
 
186
209
  // Store resolve function before resetting state
187
210
  const resolveFn = currentResolveFn
@@ -16,11 +16,28 @@
16
16
  */
17
17
 
18
18
  import { Log } from '../lib/log.js'
19
- import { platform } from '../platform.js'
20
19
 
21
20
  const utterances = new Map() // id -> { utterance, timer, ignoreResume }
22
21
 
23
22
  let initialized = false
23
+ const globalScope = globalThis
24
+
25
+ const getSpeechSynthesis = () => {
26
+ const windowRef = globalScope.window
27
+ const selfRef = globalScope.self || windowRef || globalScope
28
+
29
+ return (
30
+ globalScope.speechSynthesis ||
31
+ (selfRef && selfRef.speechSynthesis) ||
32
+ (windowRef && windowRef.speechSynthesis)
33
+ )
34
+ }
35
+
36
+ const getSpeechSynthesisUtterance = () => {
37
+ const windowRef = globalScope.window
38
+
39
+ return globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance)
40
+ }
24
41
 
25
42
  const clear = (id) => {
26
43
  const state = utterances.get(id)
@@ -35,7 +52,7 @@ const clear = (id) => {
35
52
  }
36
53
 
37
54
  const startKeepAlive = (id) => {
38
- const syn = platform.speechSynthesis
55
+ const syn = getSpeechSynthesis()
39
56
  const state = utterances.get(id)
40
57
 
41
58
  // utterance status: utterance was removed (cancelled or finished)
@@ -85,7 +102,7 @@ const defaultUtteranceProps = {
85
102
  }
86
103
 
87
104
  const initialize = () => {
88
- const syn = platform.speechSynthesis
105
+ const syn = getSpeechSynthesis()
89
106
  // syn api check: syn might not have getVoices method
90
107
  if (!syn || typeof syn.getVoices !== 'function') {
91
108
  initialized = false
@@ -99,7 +116,7 @@ const initialize = () => {
99
116
 
100
117
  const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
101
118
  return new Promise((resolve) => {
102
- const syn = platform.speechSynthesis
119
+ const syn = getSpeechSynthesis()
103
120
  if (!syn) {
104
121
  Log.debug('SpeechSynthesis - syn unavailable')
105
122
  resolve()
@@ -137,7 +154,7 @@ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
137
154
  }
138
155
 
139
156
  const speak = async (options) => {
140
- const syn = platform.speechSynthesis
157
+ const syn = getSpeechSynthesis()
141
158
  // options check: missing required options
142
159
  if (!options || !options.message) {
143
160
  return Promise.reject({ error: 'Missing message' })
@@ -157,7 +174,7 @@ const speak = async (options) => {
157
174
  // Wait for engine to be ready
158
175
  await waitForSynthReady()
159
176
 
160
- const SpeechSynthesisUtterance = platform.SpeechSynthesisUtterance
177
+ const SpeechSynthesisUtterance = getSpeechSynthesisUtterance()
161
178
  if (SpeechSynthesisUtterance === undefined) {
162
179
  return Promise.reject({ error: 'unavailable' })
163
180
  }
@@ -216,7 +233,7 @@ const speak = async (options) => {
216
233
 
217
234
  export default {
218
235
  speak(options) {
219
- const syn = platform.speechSynthesis
236
+ const syn = getSpeechSynthesis()
220
237
  if (syn !== undefined) {
221
238
  if (initialized === false) {
222
239
  initialize()
@@ -228,7 +245,7 @@ export default {
228
245
  }
229
246
  },
230
247
  cancel() {
231
- const syn = platform.speechSynthesis
248
+ const syn = getSpeechSynthesis()
232
249
  if (syn !== undefined) {
233
250
  // timers: clear all timers before cancelling
234
251
  for (const id of utterances.keys()) {
package/src/component.js CHANGED
@@ -38,6 +38,11 @@ import { plugins } from './plugin.js'
38
38
 
39
39
  // object to store global components
40
40
  let globalComponents
41
+ let devMode = false
42
+
43
+ export const setDevMode = (enabled) => {
44
+ devMode = enabled === true
45
+ }
41
46
 
42
47
  const required = (name) => {
43
48
  throw new Error(`Parameter ${name} is required`)
@@ -493,7 +498,7 @@ const Component = (name = required('name'), config = required('config')) => {
493
498
  // one time code generation (only if precompilation is turned off)
494
499
  if (config.code === undefined) {
495
500
  Log.debug(`Generating code for ${name} component`)
496
- config.code = codegenerator.call(config, parser(config.template, name))
501
+ config.code = codegenerator.call(config, parser(config.template, name), devMode)
497
502
  }
498
503
 
499
504
  // create an instance of the component, using base as the prototype (which contains Base)
@@ -202,6 +202,15 @@ const colorMap = {
202
202
  right: 'colorRight',
203
203
  }
204
204
 
205
+ const setTextureOption = (target, key, value) => {
206
+ const opts =
207
+ target.props['textureOptions'] ||
208
+ (target.element.node && target.element.node.textureOptions) ||
209
+ {}
210
+ opts[key] = value
211
+ target.props['textureOptions'] = opts
212
+ }
213
+
205
214
  /**
206
215
  * Default text settings for text nodes (initialized on first use).
207
216
  * @type {object|null}
@@ -275,6 +284,9 @@ const propsTransformer = {
275
284
  if (typeof v === 'object' || (isObjectString(v) === true && (v = parseToObject(v)))) {
276
285
  this.props['src'] = v.src
277
286
  this.props['imageType'] = v.type
287
+ if ('keepAlive' in v) {
288
+ setTextureOption(this, 'preventCleanup', v.keepAlive === true)
289
+ }
278
290
  } else {
279
291
  this.props['src'] = v
280
292
  }
@@ -318,7 +330,7 @@ const propsTransformer = {
318
330
  const resizeMode = {}
319
331
 
320
332
  if (v === 'cover' || v === 'contain') {
321
- this.props['textureOptions'] = { resizeMode: { type: v } }
333
+ setTextureOption(this, 'resizeMode', { type: v })
322
334
  return
323
335
  }
324
336
 
@@ -333,7 +345,7 @@ const propsTransformer = {
333
345
  resizeMode['clipX'] = 'x' in v.position === true ? v.position.x : null
334
346
  resizeMode['clipY'] = 'y' in v.position === true ? v.position.y : null
335
347
  }
336
- this.props['textureOptions'] = { resizeMode }
348
+ setTextureOption(this, 'resizeMode', resizeMode)
337
349
  }
338
350
  },
339
351
  set rtt(v) {
package/src/platform.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
+ import speechSynthesis from './announcer/speechSynthesis.js'
19
+
18
20
  const globalScope = globalThis
19
21
 
20
22
  const createKeyboardEventFactory = (KeyboardEventConstructor) => {
@@ -73,10 +75,7 @@ const browserPlatform = () => {
73
75
  ? (event) => event instanceof KeyboardEventConstructor
74
76
  : undefined,
75
77
  createKeyboardEvent,
76
- SpeechSynthesisUtterance:
77
- globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance),
78
- speechSynthesis:
79
- (selfRef && selfRef.speechSynthesis) || (windowRef && windowRef.speechSynthesis),
78
+ announcer: speechSynthesis,
80
79
  now:
81
80
  globalScope.performance && globalScope.performance.now !== undefined
82
81
  ? globalScope.performance.now.bind(globalScope.performance)
@@ -108,9 +107,7 @@ export const configurePlatform = (customPlatform) => {
108
107
  platform.getCookie = platform.getCookie || basePlatform.getCookie
109
108
  platform.setCookie = platform.setCookie || basePlatform.setCookie
110
109
  platform.historyBack = platform.historyBack || basePlatform.historyBack
111
- platform.SpeechSynthesisUtterance =
112
- platform.SpeechSynthesisUtterance || basePlatform.SpeechSynthesisUtterance
113
- platform.speechSynthesis = platform.speechSynthesis || basePlatform.speechSynthesis
110
+ platform.announcer = platform.announcer || basePlatform.announcer
114
111
  platform.now = platform.now || basePlatform.now
115
112
 
116
113
  return platform
@@ -0,0 +1,39 @@
1
+ import type { AnnouncerDriver } from '@lightningjs/blits'
2
+
3
+ export interface WebOSRequestOptions {
4
+ method: 'speak'
5
+ parameters: {
6
+ text: string
7
+ clear: boolean
8
+ }
9
+ onFailure(error: any): void
10
+ }
11
+
12
+ export type WebOSRequest = (uri: string, options: WebOSRequestOptions) => any
13
+
14
+ export interface WebOSAnnouncerOptions {
15
+ /**
16
+ * webOS service request function. Defaults to `webOS.service.request` when available.
17
+ */
18
+ request?: WebOSRequest
19
+ /**
20
+ * webOS TTS service URI.
21
+ *
22
+ * @default 'luna://com.webos.service.tts'
23
+ */
24
+ uri?: string
25
+ /**
26
+ * Whether the platform TTS queue should be cleared before speaking.
27
+ *
28
+ * @default true
29
+ */
30
+ clear?: boolean
31
+ /**
32
+ * Optional failure callback for the webOS TTS request.
33
+ */
34
+ onFailure?: (error: any) => void
35
+ }
36
+
37
+ declare function createAnnouncer(options?: WebOSAnnouncerOptions): AnnouncerDriver
38
+
39
+ export default createAnnouncer
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Copyright 2026 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ const globalScope = globalThis
19
+ const defaultUri = 'luna://com.webos.service.tts'
20
+
21
+ const getDefaultRequest = () => {
22
+ const windowRef = globalScope.window
23
+ const webOS = globalScope.webOS || (windowRef && windowRef.webOS)
24
+
25
+ if (webOS && webOS.service && typeof webOS.service.request === 'function') {
26
+ return webOS.service.request.bind(webOS.service)
27
+ }
28
+ }
29
+
30
+ const createAnnouncer = (options = {}) => {
31
+ const request = options.request || getDefaultRequest()
32
+ const uri = options.uri || defaultUri
33
+ const clear = options.clear !== false
34
+
35
+ let currentRequest
36
+
37
+ return {
38
+ speak(announcement) {
39
+ if (request === undefined) {
40
+ return Promise.reject({ error: 'unavailable' })
41
+ }
42
+
43
+ if (!announcement || announcement.message === undefined || announcement.message === null) {
44
+ return Promise.reject({ error: 'Missing message' })
45
+ }
46
+
47
+ try {
48
+ currentRequest = request(uri, {
49
+ method: 'speak',
50
+ parameters: {
51
+ text: String(announcement.message),
52
+ clear,
53
+ },
54
+ onFailure(error) {
55
+ currentRequest = undefined
56
+ if (options.onFailure) {
57
+ options.onFailure(error)
58
+ }
59
+ },
60
+ })
61
+ } catch (error) {
62
+ currentRequest = undefined
63
+ return Promise.reject(error)
64
+ }
65
+
66
+ return Promise.resolve()
67
+ },
68
+ cancel() {
69
+ if (currentRequest && typeof currentRequest.cancel === 'function') {
70
+ currentRequest.cancel()
71
+ }
72
+ currentRequest = undefined
73
+ },
74
+ }
75
+ }
76
+
77
+ export default createAnnouncer
@@ -43,6 +43,8 @@ export interface RenderComponentFixture {
43
43
  component: any
44
44
  root: any
45
45
  snapshot(): SnapshotNode | ComponentSnapshotNode
46
+ findByData(key: string, value: any): SnapshotNode | ComponentSnapshotNode | null
47
+ findAllByData(key: string, value: any): Array<SnapshotNode | ComponentSnapshotNode>
46
48
  setProps(props: Record<string, any>): void
47
49
  setState(state: Record<string, any>): void
48
50
  focus(event?: KeyboardEvent): Promise<any>
@@ -95,19 +95,11 @@ const nodePlatform = () => {
95
95
  KeyboardEvent: TestKeyboardEvent,
96
96
  isKeyboardEvent: (event) => event instanceof TestKeyboardEvent,
97
97
  createKeyboardEvent: (type, init = {}) => new TestKeyboardEvent(type, init),
98
- SpeechSynthesisUtterance: class TestSpeechSynthesisUtterance {
99
- constructor(text = '') {
100
- this.text = text
101
- }
102
- },
103
- speechSynthesis: {
104
- cancel() {},
105
- getVoices() {
106
- return []
98
+ announcer: {
99
+ speak() {
100
+ return Promise.resolve()
107
101
  },
108
- pause() {},
109
- resume() {},
110
- speak() {},
102
+ cancel() {},
111
103
  },
112
104
  now: Date.now,
113
105
  }
@@ -17,12 +17,14 @@
17
17
 
18
18
  import Settings from '../settings.js'
19
19
  import { renderer, stage } from '../launch.js'
20
+ import { setDevMode } from '../component.js'
20
21
  import symbols from '../lib/symbols.js'
21
22
  import { initLog } from '../lib/log.js'
22
23
  import { getRaw } from '../lib/reactivity/reactive.js'
23
24
  import Focus from '../focus/focus.js'
24
25
  import { initKeyMap, keyMap } from '../application.js'
25
26
  import { configurePlatform, platform } from '../platform.js'
27
+ import { isObjectString, parseToObject } from '../lib/utils.js'
26
28
  import nodePlatform from './nodePlatform.js'
27
29
 
28
30
  let nodeId = 0
@@ -32,6 +34,7 @@ const renderComponent = (Component, options = {}) => {
32
34
  const originalPlatform = platform
33
35
 
34
36
  Settings.set(options.settings || {})
37
+ setDevMode(true)
35
38
  configurePlatform(() => nodePlatform())
36
39
  initLog()
37
40
  initKeyMap()
@@ -58,6 +61,27 @@ const renderComponent = (Component, options = {}) => {
58
61
  return componentSnapshot(component, holderMap)
59
62
  }
60
63
 
64
+ const findAllByData = (key, value) => {
65
+ const matches = []
66
+ visitSnapshot(snapshot(), (node) => {
67
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
68
+ matches.push(node)
69
+ }
70
+ })
71
+ return matches
72
+ }
73
+
74
+ const findByData = (key, value) => {
75
+ let match = null
76
+ visitSnapshot(snapshot(), (node) => {
77
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
78
+ match = node
79
+ return true
80
+ }
81
+ })
82
+ return match
83
+ }
84
+
61
85
  const setProps = (props = {}) => {
62
86
  const keys = Object.keys(props)
63
87
  for (let i = 0; i < keys.length; i++) {
@@ -107,6 +131,8 @@ const renderComponent = (Component, options = {}) => {
107
131
  component,
108
132
  root,
109
133
  snapshot,
134
+ findByData,
135
+ findAllByData,
110
136
  setProps,
111
137
  setState,
112
138
  focus,
@@ -289,10 +315,21 @@ const createElement = ({ parent } = {}) => {
289
315
  }
290
316
  },
291
317
  set(key, value) {
318
+ if (key === 'inspector-data') {
319
+ this.setInspectorMetadata(value)
320
+ return
321
+ }
292
322
  this.attributes[key] = value
293
323
  this.node[key] = value
294
324
  },
295
- setInspectorMetadata() {},
325
+ setInspectorMetadata(data) {
326
+ if (isObjectString(data) === true) {
327
+ data = parseToObject(data)
328
+ }
329
+ if (data === null || typeof data !== 'object') return
330
+ this.attributes.data = { ...(this.attributes.data || {}), ...data }
331
+ this.node.data = { ...(this.node.data || {}), ...data }
332
+ },
296
333
  destroy() {
297
334
  this.eol = true
298
335
  this.children.length = 0
@@ -314,6 +351,19 @@ const appendToParent = (element, parent) => {
314
351
  }
315
352
  }
316
353
 
354
+ const visitSnapshot = (node, visit) => {
355
+ if (node === undefined || node === null) return false
356
+ if (visit(node) === true) return true
357
+ if (node.type === 'Component') {
358
+ return visitSnapshot(node.tree, visit)
359
+ }
360
+ const children = node.children || []
361
+ for (let i = 0; i < children.length; i++) {
362
+ if (visitSnapshot(children[i], visit) === true) return true
363
+ }
364
+ return false
365
+ }
366
+
317
367
  const createKeyboardEvent = (key, init = {}) => {
318
368
  const keyCode = init.keyCode || keyCodeFromKey(key)
319
369
  return platform.createKeyboardEvent('keydown', {