@px-petals/roku.motion-web 0.0.0-stage → 1.0.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.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * The flow of a playing Motion, as Roku's runs it (motion.flow.bs): which
3
+ * state is active, what the inputs hold, and when live values change.
4
+ *
5
+ * With a state machine, states are entered by name, run their entry
6
+ * actions and play their animation; the machine then settles, taking any
7
+ * transitions whose guards now pass. Without one, the bundle's initial
8
+ * animation plays. The flow owns the state and the values; the player it is
9
+ * given draws.
10
+ *
11
+ * A machine may have several layers, each in a state of its own. With the
12
+ * `layer` hook the flow reports every layer's states (and the cross-fade of
13
+ * the transition that entered one) and reads how long each has played, so
14
+ * exit times are exact; without it, the first layer plays by `play` alone,
15
+ * as on Roku.
16
+ */
17
+
18
+ import { defaults, exists, find, interactionActions, isEvent, layersOf, nextExit, perform, pointerTargetsOf, takeTransition } from './motion.machine.js'
19
+ import { inputValue } from './motion.values.js'
20
+
21
+ /** Most transitions one change can chain through before the machine is taken to be cycling */
22
+ const MAX_SETTLE = 16
23
+
24
+ /**
25
+ * @typedef {object} Hooks
26
+ * @property {(animation: string, loops: boolean) => void} play show an animation
27
+ * @property {(values: Record<string, any>) => void} valuesChanged
28
+ * @property {(state: string, layer: number) => void} stateChanged
29
+ * @property {(event: string) => void} event a custom event the machine fired, or `openUrl:<url>`
30
+ * @property {(message: string) => void} error
31
+ * @property {(action: any) => void} [focus] a focus action (FocusTarget, FocusMove, FocusClear) ran
32
+ * @property {(layer: number, state: any, fadeMs: number, ease?: number[]) => void} [layer] a layer entered a state, fading in along `ease`'s curve; replaces `play`
33
+ * @property {(layer: number) => number} [elapsed] ms the layer's current state has played, unwrapped
34
+ */
35
+
36
+ /**
37
+ * @param {any} manifest a bundle's motion.json
38
+ * @param {Hooks} hooks
39
+ */
40
+ export function createFlow(manifest, hooks) {
41
+ const graph = exists(manifest.machine) ? manifest.machine : null
42
+ const graphs = graph ? layersOf(graph) : []
43
+ const isMixed = typeof hooks.layer === 'function'
44
+ /** @type {Record<string, any>} */
45
+ let values = {}
46
+ /** @type {Record<string, boolean>} */
47
+ let events = {}
48
+ /** @type {string[]} each layer's current state */
49
+ let currents = []
50
+ /** @type {number[]} the latest exit time, in ms, a layer's current state has passed; -1 for none */
51
+ let crossed = []
52
+ let animation = ''
53
+ /** @type {Record<string, string>} */
54
+ let transitions = {}
55
+ /** Entry actions and listeners that run while a state is being entered must not settle the machine under it */
56
+ let busy = 0
57
+
58
+ /**
59
+ * The defaults, with the caller's inputs over them; then the first state.
60
+ *
61
+ * @param {Record<string, any>} inputs
62
+ * @param {string} [requested] a state to start in instead of the machine's own
63
+ * @param {Record<string, string>} [followers] without a machine, the animation each one's end plays next
64
+ */
65
+ function start(inputs, requested, followers) {
66
+ values = defaultValues()
67
+ events = {}
68
+ transitions = followers || {}
69
+ currents = []
70
+ crossed = []
71
+ mergeInputs(inputs)
72
+ hooks.valuesChanged(values)
73
+
74
+ if (!graph) {
75
+ showAnimation(firstAnimation(requested))
76
+ return
77
+ }
78
+ busy++
79
+ for (let n = 0; n < graphs.length; n++) {
80
+ currents[n] = ''
81
+ crossed[n] = -1
82
+ enter(n, requested && find(graphs[n], requested) ? requested : graphs[n].initial, 0)
83
+ }
84
+ busy--
85
+ settle()
86
+ }
87
+
88
+ /** The view model's default instance, then the machine's own input values for anything it leaves out. */
89
+ function defaultValues() {
90
+ /** @type {Record<string, any>} */
91
+ const result = {}
92
+ const data = manifest.data || {}
93
+ const instance = data.instances && typeof data.defaultInstance === 'string' ? data.instances[data.defaultInstance] : null
94
+ if (instance) for (const name in instance) result[name] = instance[name]
95
+ if (graph) {
96
+ const inputs = defaults(graph)
97
+ for (const name in inputs) if (!(name in result)) result[name] = inputs[name]
98
+ }
99
+ return result
100
+ }
101
+
102
+ /** @param {string} [requested] */
103
+ function firstAnimation(requested) {
104
+ const candidates = [ requested, manifest.initial ]
105
+ for (let n = 0; n < candidates.length; n++) if (candidates[n] && manifest.animations[candidates[n]]) return /** @type {string} */ (candidates[n])
106
+ for (const id in manifest.animations) return id
107
+ return ''
108
+ }
109
+
110
+ // States
111
+ // ■■■■■■
112
+
113
+ /**
114
+ * The caller asked for a state: enter it directly, in the layer that has it.
115
+ *
116
+ * @param {string} name a machine state, or an animation without a machine
117
+ */
118
+ function jumpTo(name) {
119
+ if (!graph) {
120
+ showAnimation(name)
121
+ return
122
+ }
123
+ let layer = -1
124
+ for (let n = 0; n < graphs.length && layer < 0; n++) if (find(graphs[n], name)) layer = n
125
+ if (layer < 0) {
126
+ hooks.error("unknown state '" + name + "'")
127
+ return
128
+ }
129
+ enter(layer, name, 0)
130
+ settle()
131
+ }
132
+
133
+ /**
134
+ * Without a machine, a state is an animation, played once.
135
+ *
136
+ * @param {string} name
137
+ */
138
+ function showAnimation(name) {
139
+ currents = [ name ]
140
+ hooks.stateChanged(name, 0)
141
+ play(name, false)
142
+ }
143
+
144
+ /**
145
+ * Leaving the previous state runs its exit actions; entering runs the new
146
+ * one's entry actions and plays its animation.
147
+ *
148
+ * @param {number} layer
149
+ * @param {string} name
150
+ * @param {number} fadeMs how long the outgoing state fades into this one
151
+ * @param {number[]} [ease] the fade's cubic curve, `[x1, y1, x2, y2]`; linear without
152
+ */
153
+ function enter(layer, name, fadeMs, ease) {
154
+ const layerGraph = graphs[layer]
155
+ busy++
156
+ const leaving = find(layerGraph, currents[layer])
157
+ if (leaving) run(leaving.exitActions)
158
+
159
+ const state = find(layerGraph, name)
160
+ if (!state) {
161
+ busy--
162
+ return
163
+ }
164
+ currents[layer] = name
165
+ crossed[layer] = -1
166
+ hooks.stateChanged(name, layer)
167
+ run(state.entryActions)
168
+ if (isMixed) {
169
+ if (typeof state.animation === 'string' || state.blend) /** @type {NonNullable<Hooks['layer']>} */ (hooks.layer)(layer, state, fadeMs, ease)
170
+ } else if (layer === 0 && typeof state.animation === 'string') {
171
+ play(state.animation, state.loop === true)
172
+ }
173
+ busy--
174
+ }
175
+
176
+ /**
177
+ * Takes transitions until none passes, then clears the events this round
178
+ * consumed. Every layer sees the same events in a round.
179
+ */
180
+ function settle() {
181
+ if (!graph) return
182
+ for (let n = 0; n < MAX_SETTLE; n++) {
183
+ /** @type {any[]} */
184
+ const taken = []
185
+ for (let layer = 0; layer < graphs.length; layer++) taken.push(takeTransition(graphs[layer], currents[layer], values, events, clockOf(layer)))
186
+ if (!taken.some(Boolean)) break
187
+ // an event that moved the machine is spent
188
+ events = {}
189
+ for (let layer = 0; layer < graphs.length; layer++) {
190
+ if (taken[layer]) enter(layer, taken[layer].toState, taken[layer].durationMs || 0, taken[layer].ease)
191
+ }
192
+ }
193
+ events = {}
194
+ hooks.valuesChanged(values)
195
+ }
196
+
197
+ /**
198
+ * How long a layer's state has played, for exit times; null when the
199
+ * player does not say, and exit times fall back to the whole timeline.
200
+ *
201
+ * @param {number} layer
202
+ * @returns {{ elapsed: number, length: number } | null}
203
+ */
204
+ function clockOf(layer) {
205
+ if (!hooks.elapsed) return null
206
+ return { elapsed: hooks.elapsed(layer), length: lengthOf(find(graphs[layer], currents[layer])) }
207
+ }
208
+
209
+ /**
210
+ * A state's timeline in ms: its animation's, or a blend's longest.
211
+ *
212
+ * @param {any} state
213
+ * @returns {number}
214
+ */
215
+ function lengthOf(state) {
216
+ if (!state) return 0
217
+ const ids = []
218
+ if (state.blend) for (let n = 0; n < state.blend.animations.length; n++) ids.push(state.blend.animations[n].animation)
219
+ else ids.push(state.animation)
220
+
221
+ let longest = 0
222
+ for (let n = 0; n < ids.length; n++) {
223
+ const entry = manifest.animations[ids[n]]
224
+ if (entry && entry.fps > 0) longest = Math.max(longest, (entry.frames / entry.fps) * 1000)
225
+ }
226
+ return longest
227
+ }
228
+
229
+ /**
230
+ * Time has passed: a transition waiting on an exit time may now go. Called
231
+ * as the player advances; settles only when a layer crosses an exit time.
232
+ */
233
+ function tick() {
234
+ if (!graph || !hooks.elapsed) return
235
+ for (let layer = 0; layer < graphs.length; layer++) {
236
+ const state = find(graphs[layer], currents[layer])
237
+ const next = nextExit(state, lengthOf(state), crossed[layer])
238
+ if (next < 0 || hooks.elapsed(layer) < next) continue
239
+
240
+ crossed[layer] = next
241
+ settle()
242
+ return
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {string} id
248
+ * @param {boolean} loops
249
+ */
250
+ function play(id, loops) {
251
+ if (!manifest.animations[id]) {
252
+ hooks.error("unknown animation '" + id + "'")
253
+ return
254
+ }
255
+ animation = id
256
+ hooks.play(id, loops)
257
+ }
258
+
259
+ // Inputs and interactions
260
+ // ■■■■■■■■■■■■■■■■■■■■■■■
261
+
262
+ /**
263
+ * Live values: changed ones are held, an Event input set to true fires.
264
+ *
265
+ * @param {Record<string, any>} inputs
266
+ */
267
+ function takeInputs(inputs) {
268
+ if (!mergeInputs(inputs)) return
269
+ hooks.valuesChanged(values)
270
+ settle()
271
+ }
272
+
273
+ /**
274
+ * @param {Record<string, any>} inputs
275
+ * @returns {boolean} whether anything changed or fired
276
+ */
277
+ function mergeInputs(inputs) {
278
+ let changed = false
279
+ for (const name in inputs || {}) {
280
+ // an enum may be given by name; the machine compares its index
281
+ const value = inputValue(manifest.data, name, inputs[name])
282
+ if (graph && isEvent(graph, name)) {
283
+ if (value === true) {
284
+ events[name] = true
285
+ changed = true
286
+ }
287
+ } else if (values[name] !== value) {
288
+ values[name] = value
289
+ changed = true
290
+ }
291
+ }
292
+ return changed
293
+ }
294
+
295
+ /**
296
+ * Runs the actions of a key binding.
297
+ *
298
+ * @param {any[]} actions
299
+ */
300
+ function act(actions) {
301
+ if (!graph) return
302
+ run(actions)
303
+ settle()
304
+ }
305
+
306
+ /**
307
+ * Focus moved onto or off a node: the Focus or Blur listeners on it run.
308
+ *
309
+ * @param {'Focus' | 'Blur'} type
310
+ * @param {string} target the node's `rid`, "" for the artboard
311
+ */
312
+ function focusEvent(type, target) {
313
+ if (!graph) return
314
+ run(interactionActions(graph, type, '', target))
315
+ if (!busy) settle()
316
+ }
317
+
318
+ /**
319
+ * A tap or click on the animation. Called by the host, it clicks every
320
+ * Click listener, as OK does; from a click on the canvas, only the listeners
321
+ * with no target of their own, which watch the whole artboard (those with
322
+ * a target are clicked by `pointer`, where the pointer hit it).
323
+ *
324
+ * @param {boolean} [isUntargeted] click only the listeners that have no target
325
+ */
326
+ function click(isUntargeted) {
327
+ if (!graph) return
328
+ run(interactionActions(graph, 'Click', '', isUntargeted ? '' : undefined))
329
+ settle()
330
+ }
331
+
332
+ /**
333
+ * A pointer event on a target: runs the actions of the interactions of that type on it.
334
+ *
335
+ * @param {string} type Click, PointerDown, PointerUp, PointerEnter, PointerExit or PointerMove
336
+ * @param {string} target the `rid` of the shape or group, or "" for the listeners without a target
337
+ */
338
+ function pointer(type, target) {
339
+ if (!graph) return
340
+ run(interactionActions(graph, type, '', target))
341
+ settle()
342
+ }
343
+
344
+ /**
345
+ * The pointer interactions' targets, for the player to watch.
346
+ *
347
+ * @returns {Record<string, string[]>} each target's `rid` ("" for none) → the interaction types on it
348
+ */
349
+ function pointerTargets() {
350
+ return graph ? pointerTargetsOf(graph) : {}
351
+ }
352
+
353
+ /**
354
+ * A layer's active state played to its end: its OnComplete interactions
355
+ * run, or without a machine, the animation `transitions` names for it plays.
356
+ *
357
+ * @param {number} [layer]
358
+ */
359
+ function finished(layer) {
360
+ const index = layer || 0
361
+ if (!graph) {
362
+ const next = transitions[currents[0]]
363
+ if (next) showAnimation(next)
364
+ return
365
+ }
366
+ run(interactionActions(graphs[index], 'OnComplete', currents[index]))
367
+ settle()
368
+ }
369
+
370
+ /** @param {any[]} actions */
371
+ function run(actions) {
372
+ if (!graph || !actions || !actions.length) return
373
+ const effects = perform(actions, graph, values, events)
374
+ for (let n = 0; n < effects.events.length; n++) hooks.event(effects.events[n])
375
+ if (effects.changed) hooks.valuesChanged(values)
376
+ if (hooks.focus) for (let n = 0; n < effects.focus.length; n++) hooks.focus(effects.focus[n])
377
+ }
378
+
379
+ return {
380
+ start,
381
+ jumpTo,
382
+ takeInputs,
383
+ act,
384
+ focusEvent,
385
+ click,
386
+ pointer,
387
+ pointerTargets,
388
+ finished,
389
+ tick,
390
+ get state() {
391
+ return currents[0] || ''
392
+ },
393
+ get states() {
394
+ return currents.slice()
395
+ },
396
+ get animation() {
397
+ return animation
398
+ },
399
+ get values() {
400
+ return values
401
+ },
402
+ }
403
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Keys and taps as a bundle's keyboard listeners name them. A bundle's
3
+ * `remoteKeys` use Rive's key names (`enter`, `space`, `up`); a DOM key
4
+ * event, or a Roku remote's name, becomes those here, so the same bundle
5
+ * answers a keyboard, a TV remote's arrows and a Roku remote alike.
6
+ */
7
+
8
+ /** Roku remote names → Rive key names, as Roku's Motion maps them (motion.flow.bs) */
9
+ const ROKU = {
10
+ OK: [ 'enter', 'kpEnter', 'space' ],
11
+ back: [ 'escape', 'backspace' ],
12
+ up: [ 'up' ],
13
+ down: [ 'down' ],
14
+ left: [ 'left' ],
15
+ right: [ 'right' ],
16
+ play: [ 'space' ],
17
+ }
18
+
19
+ /** `KeyboardEvent.key` values → Rive key names; older browsers send the short forms */
20
+ const DOM_KEYS = {
21
+ Enter: 'enter',
22
+ ' ': 'space',
23
+ Spacebar: 'space',
24
+ Escape: 'escape',
25
+ Esc: 'escape',
26
+ Backspace: 'backspace',
27
+ ArrowUp: 'up',
28
+ Up: 'up',
29
+ ArrowDown: 'down',
30
+ Down: 'down',
31
+ ArrowLeft: 'left',
32
+ Left: 'left',
33
+ ArrowRight: 'right',
34
+ Right: 'right',
35
+ Tab: 'tab',
36
+ Delete: 'deleteKey',
37
+ Del: 'deleteKey',
38
+ Insert: 'insert',
39
+ Home: 'home',
40
+ End: 'end',
41
+ PageUp: 'pageUp',
42
+ PageDown: 'pageDown',
43
+ }
44
+
45
+ /** `keyCode` → Rive key names, for browsers that predate `key` */
46
+ const KEY_CODES = { 8: 'backspace', 9: 'tab', 13: 'enter', 27: 'escape', 32: 'space', 33: 'pageUp', 34: 'pageDown', 35: 'end', 36: 'home', 37: 'left', 38: 'up', 39: 'right', 40: 'down', 45: 'insert', 46: 'deleteKey' }
47
+
48
+ /** `KeyboardEvent.code` values that are not a letter, digit or function key → Rive key names */
49
+ const DOM_CODES = {
50
+ NumpadEnter: 'kpEnter',
51
+ NumpadDecimal: 'kpDecimal',
52
+ NumpadDivide: 'kpDivide',
53
+ NumpadMultiply: 'kpMultiply',
54
+ NumpadSubtract: 'kpSubtract',
55
+ NumpadAdd: 'kpAdd',
56
+ Minus: 'minus',
57
+ Equal: 'equal',
58
+ Comma: 'comma',
59
+ Period: 'period',
60
+ Slash: 'slash',
61
+ Semicolon: 'semicolon',
62
+ Quote: 'apostrophe',
63
+ Backquote: 'graveAccent',
64
+ BracketLeft: 'leftBracket',
65
+ BracketRight: 'rightBracket',
66
+ Backslash: 'backslash',
67
+ }
68
+
69
+ /**
70
+ * The Rive key names a key stands for: a Roku remote name maps to its
71
+ * group, anything else is lowercased as given.
72
+ *
73
+ * @param {string} key
74
+ * @returns {string[]}
75
+ */
76
+ export function keyNames(key) {
77
+ const roku = ROKU[/** @type {keyof typeof ROKU} */ (key)]
78
+ return roku ? roku : [ key.toLowerCase() ]
79
+ }
80
+
81
+ /**
82
+ * The Rive key name for a DOM keyboard event, or "" for a key no bundle listens for.
83
+ * `code` (the physical key, as Rive reads it) comes first, then `key`, then
84
+ * `keyCode` for browsers that predate both.
85
+ *
86
+ * @param {{ key?: string, keyCode?: number, code?: string }} event
87
+ * @returns {string}
88
+ */
89
+ export function keyFromEvent(event) {
90
+ const code = event.code
91
+ if (code) {
92
+ const letter = /^Key([A-Z])$/.exec(code)
93
+ if (letter) return letter[1].toLowerCase()
94
+ const digit = /^(Digit|Numpad)(\d)$/.exec(code)
95
+ if (digit) return (digit[1] === 'Digit' ? 'key' : 'kp') + digit[2]
96
+ const numbered = /^F(\d{1,2})$/.exec(code)
97
+ if (numbered) return 'f' + numbered[1]
98
+ const named = DOM_CODES[/** @type {keyof typeof DOM_CODES} */ (code)]
99
+ if (named) return named
100
+ }
101
+ const byKey = event.key ? DOM_KEYS[/** @type {keyof typeof DOM_KEYS} */ (event.key)] : undefined
102
+ if (byKey) return byKey
103
+
104
+ const byCode = event.keyCode ? KEY_CODES[/** @type {keyof typeof KEY_CODES} */ (event.keyCode)] : undefined
105
+ if (byCode) return byCode
106
+
107
+ // a printable key is listened for by its own character, lowercased; a digit is `key0`
108
+ if (event.key && event.key.length === 1) return /\d/.test(event.key) ? 'key' + event.key : event.key.toLowerCase()
109
+ return ''
110
+ }
111
+
112
+ /**
113
+ * The modifiers held during a DOM keyboard event, as a KeyboardInput's mask: 1 shift, 2 ctrl, 4 alt, 8 meta.
114
+ *
115
+ * @param {{ shiftKey?: boolean, ctrlKey?: boolean, altKey?: boolean, metaKey?: boolean }} event
116
+ * @returns {number}
117
+ */
118
+ export function modifiersFromEvent(event) {
119
+ return (event.shiftKey ? 1 : 0) + (event.ctrlKey ? 2 : 0) + (event.altKey ? 4 : 0) + (event.metaKey ? 8 : 0)
120
+ }
121
+
122
+ /**
123
+ * A key the host names as a string: `tab`, `OK`, `a`, with modifiers in front
124
+ * (`shift+tab`, `ctrl+a`).
125
+ *
126
+ * @param {string} text
127
+ * @returns {{ names: string[], mods: number, key: string }}
128
+ */
129
+ export function parseKey(text) {
130
+ const parts = text.split('+')
131
+ let mods = 0
132
+ while (parts.length > 1) {
133
+ const part = parts[0].toLowerCase()
134
+ const bit = part === 'shift' ? 1 : part === 'ctrl' || part === 'control' ? 2 : part === 'alt' ? 4 : part === 'meta' || part === 'cmd' ? 8 : 0
135
+ if (!bit) break
136
+ mods += bit
137
+ parts.shift()
138
+ }
139
+ const key = parts.join('+')
140
+ return { names: keyNames(key), mods, key }
141
+ }
142
+
143
+ /**
144
+ * Whether a key clicks, as OK does on Roku: Enter, or Roku's OK.
145
+ *
146
+ * @param {string} key
147
+ */
148
+ export function isClick(key) {
149
+ return key === 'OK' || key === 'enter'
150
+ }