@vestra-player/sdk 1.5.43 → 1.5.45

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.
@@ -23,6 +23,20 @@ export declare const failure: (code: string, message?: string) => PluginError;
23
23
  export declare const uncertain: (error: unknown) => boolean;
24
24
  /** Mark a promise as observed so an unused `ready` never crashes the runner. */
25
25
  export declare function quiet<T>(promise: Promise<T>): Promise<T>;
26
+ /**
27
+ * The fields of the SDK's state copies that a binding or listener read, by
28
+ * state (`player`: `plugin.player.state`). An empty set: the state was
29
+ * taken whole (no field read was seen), so any change of it counts.
30
+ */
31
+ export type Deps = Map<string, Set<string>>;
32
+ /** A change of the SDK's state copies: the fields that changed, by state. */
33
+ export type Change = ReadonlyMap<string, ReadonlySet<string>>;
34
+ /**
35
+ * The longest a binding waits for a full pass while precise passes skip
36
+ * it: state a plugin changes outside code the SDK runs (its own timer,
37
+ * without `update()`) still shows within about this long.
38
+ */
39
+ export declare const SAFETY_PASS_MS = 1000;
26
40
  export declare const same: (a: Json | undefined, b: Json | undefined) => boolean;
27
41
  /** One value in flight, only the newest waiting. Each push learns its fate. */
28
42
  export declare class Latest<T> {
@@ -54,6 +68,12 @@ export declare class Runtime {
54
68
  readonly app: App;
55
69
  /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
56
70
  readonly locale: Locale;
71
+ /**
72
+ * Protocol additions the player advertises at start-up (`features` in the
73
+ * handshake's app data, player 0.6.0): `ui.patch` (row patches of
74
+ * property writes). Not part of the plugin API; empty for older players.
75
+ */
76
+ readonly features: ReadonlySet<string>;
57
77
  constructor(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string, app?: unknown);
58
78
  can(capability: string): boolean;
59
79
  /** Lazily created singletons shared by every scope of this plugin. */
@@ -70,6 +90,36 @@ export declare class Runtime {
70
90
  * on it, so the event counts as listened to while the binding reads it.
71
91
  */
72
92
  reading(event: string): void;
93
+ /**
94
+ * A field of the SDK's copy of a state (`key`) is read; without `field`,
95
+ * the state is taken (`plugin.player.state`). What is being evaluated
96
+ * then depends on it: a precise pass after a change of that field runs
97
+ * it again.
98
+ */
99
+ depends(key: string, field?: string): void;
100
+ /**
101
+ * Evaluate one part of the binding being evaluated (one `view.bind`
102
+ * function), identified by `token`. A precise pass skips it when nothing
103
+ * it read last time changed, and returns `undefined`; what it read then
104
+ * still counts for the binding.
105
+ */
106
+ part<T>(token: object, read: () => T): {
107
+ value: T;
108
+ } | undefined;
109
+ /**
110
+ * Run plugin code (a state listener) and return the state copy fields it
111
+ * read; they are not counted as a binding's.
112
+ */
113
+ observe<T>(run: () => T): {
114
+ value: T;
115
+ deps: Deps;
116
+ };
117
+ /**
118
+ * Whether code that read `deps` while `change` was delivered needs a full
119
+ * pass after it: it read a field that changed, or no field of that state
120
+ * at all (so the SDK cannot tell what it depends on).
121
+ */
122
+ needsFull(deps: Deps, change: Change): boolean;
73
123
  /**
74
124
  * Evaluate `read` as `label`, added at `where`: the state it reads is
75
125
  * reported as read by it. `field`: a command's `enabled` or `checked`,
@@ -82,8 +132,21 @@ export declare class Runtime {
82
132
  * line (`main.ts:353`). Captured now, formatted when asked.
83
133
  */
84
134
  callSite(): () => string | undefined;
85
- /** Coalesce: one evaluation pass per microtask turn, however many changes. */
135
+ /**
136
+ * A full pass: every binding is evaluated. Requested after plugin code
137
+ * the SDK ran, by `plugin.update()`, and on any change other than a state
138
+ * copy's fields. Coalesced: one pass per microtask turn, however many
139
+ * requests.
140
+ */
86
141
  invalidate(): void;
142
+ /**
143
+ * Fields of the SDK's copy of a state (`key`) changed: a precise pass
144
+ * evaluates only the bindings (and view bind functions) that read one of
145
+ * them, unless a full pass is due anyway.
146
+ */
147
+ changed(key: string, fields: Iterable<string>): void;
148
+ /** The plugin was disposed: cancel the pending safety pass, arm no more. */
149
+ stop(): void;
87
150
  /**
88
151
  * Host events with one `events.subscribe` per name, however many listeners.
89
152
  * Without the `events` capability the listener simply never fires.
@@ -53,6 +53,39 @@ export function quiet(promise) {
53
53
  promise.catch(() => { });
54
54
  return promise;
55
55
  }
56
+ /** Whether something that read `deps` may see a different result after `change`. */
57
+ function affected(deps, change) {
58
+ for (const [key, fields] of change) {
59
+ const read = deps.get(key);
60
+ if (!read)
61
+ continue;
62
+ if (read.size === 0)
63
+ return true;
64
+ for (const field of fields)
65
+ if (read.has(field))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** Add `from` to `into` (an empty set, the whole state, wins). */
71
+ function merge(into, from) {
72
+ for (const [key, fields] of from) {
73
+ const set = into.get(key);
74
+ if (!set)
75
+ into.set(key, new Set(fields));
76
+ else if (set.size && fields.size)
77
+ for (const field of fields)
78
+ set.add(field);
79
+ else
80
+ set.clear();
81
+ }
82
+ }
83
+ /**
84
+ * The longest a binding waits for a full pass while precise passes skip
85
+ * it: state a plugin changes outside code the SDK runs (its own timer,
86
+ * without `update()`) still shows within about this long.
87
+ */
88
+ export const SAFETY_PASS_MS = 1000;
56
89
  export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
57
90
  /** One value in flight, only the newest waiting. Each push learns its fate. */
58
91
  export class Latest {
@@ -124,6 +157,22 @@ export class Runtime {
124
157
  /** Who reads now (a command's field and where it was added). */
125
158
  #reader;
126
159
  #scheduled = false;
160
+ /** The next pass evaluates every binding (not only a state copy changed). */
161
+ #full = false;
162
+ /** State copy changes since the last pass (a precise pass, unless `#full`). */
163
+ #pending = new Map();
164
+ /** The change the running precise pass is for; `undefined` in a full pass. */
165
+ #pass;
166
+ /** The state copy fields read by what is being evaluated now. */
167
+ #deps;
168
+ /** Each part's reads and fields last time (`part`). */
169
+ #parts = new WeakMap();
170
+ /** A precise pass skipped something: a full pass follows (`SAFETY_PASS_MS`). */
171
+ #safety;
172
+ /** The running pass skipped a binding or a part. */
173
+ #skipped = false;
174
+ /** The plugin was disposed: no safety pass is armed any more. */
175
+ #stopped = false;
127
176
  #events = new Map();
128
177
  views = new Map();
129
178
  commands = new Map();
@@ -132,6 +181,12 @@ export class Runtime {
132
181
  app;
133
182
  /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
134
183
  locale;
184
+ /**
185
+ * Protocol additions the player advertises at start-up (`features` in the
186
+ * handshake's app data, player 0.6.0): `ui.patch` (row patches of
187
+ * property writes). Not part of the plugin API; empty for older players.
188
+ */
189
+ features;
135
190
  constructor(transport, manifest, directory, dataDirectory, app) {
136
191
  this.#transport = transport;
137
192
  this.dataDirectory = dataDirectory;
@@ -139,6 +194,11 @@ export class Runtime {
139
194
  this.host = createWire(transport);
140
195
  const host = this.host;
141
196
  this.app = appInfo(app, () => host.app.system());
197
+ const features = app
198
+ ?.features;
199
+ this.features = new Set(Array.isArray(features)
200
+ ? features.filter((name) => typeof name === "string")
201
+ : []);
142
202
  this.locale = LOCALES.includes(transport.locale ?? "")
143
203
  ? transport.locale
144
204
  : "en";
@@ -185,11 +245,12 @@ export class Runtime {
185
245
  }
186
246
  bind(evaluate) {
187
247
  const held = new Map();
188
- this.#bindings.set(evaluate, held);
248
+ const binding = { held, deps: new Map() };
249
+ this.#bindings.set(evaluate, binding);
189
250
  this.invalidate();
190
251
  return {
191
252
  dispose: async () => {
192
- if (this.#bindings.get(evaluate) !== held)
253
+ if (this.#bindings.get(evaluate) !== binding)
193
254
  return;
194
255
  this.#bindings.delete(evaluate);
195
256
  for (const hold of held.values())
@@ -220,6 +281,92 @@ export class Runtime {
220
281
  return `${reader.label} is a function that reads ${read}${at}${hint}`;
221
282
  });
222
283
  }
284
+ /**
285
+ * A field of the SDK's copy of a state (`key`) is read; without `field`,
286
+ * the state is taken (`plugin.player.state`). What is being evaluated
287
+ * then depends on it: a precise pass after a change of that field runs
288
+ * it again.
289
+ */
290
+ depends(key, field) {
291
+ const deps = this.#deps;
292
+ if (!deps)
293
+ return;
294
+ let fields = deps.get(key);
295
+ if (!fields)
296
+ deps.set(key, (fields = new Set()));
297
+ if (field !== undefined)
298
+ fields.add(field);
299
+ }
300
+ /**
301
+ * Evaluate one part of the binding being evaluated (one `view.bind`
302
+ * function), identified by `token`. A precise pass skips it when nothing
303
+ * it read last time changed, and returns `undefined`; what it read then
304
+ * still counts for the binding.
305
+ */
306
+ part(token, read) {
307
+ const last = this.#parts.get(token);
308
+ const outerReads = this.#reads;
309
+ const outerDeps = this.#deps;
310
+ if (last && this.#pass && !affected(last.deps, this.#pass)) {
311
+ this.#skipped = true;
312
+ if (outerReads)
313
+ for (const [event, why] of last.reads)
314
+ if (!outerReads.has(event))
315
+ outerReads.set(event, why);
316
+ if (outerDeps)
317
+ merge(outerDeps, last.deps);
318
+ return undefined;
319
+ }
320
+ const reads = new Map();
321
+ const deps = new Map();
322
+ this.#reads = outerReads && reads;
323
+ this.#deps = deps;
324
+ try {
325
+ return { value: read() };
326
+ }
327
+ finally {
328
+ this.#reads = outerReads;
329
+ this.#deps = outerDeps;
330
+ this.#parts.set(token, { reads, deps });
331
+ if (outerReads)
332
+ for (const [event, why] of reads)
333
+ if (!outerReads.has(event))
334
+ outerReads.set(event, why);
335
+ if (outerDeps)
336
+ merge(outerDeps, deps);
337
+ }
338
+ }
339
+ /**
340
+ * Run plugin code (a state listener) and return the state copy fields it
341
+ * read; they are not counted as a binding's.
342
+ */
343
+ observe(run) {
344
+ const outerReads = this.#reads;
345
+ const outerDeps = this.#deps;
346
+ const deps = new Map();
347
+ this.#reads = undefined;
348
+ this.#deps = deps;
349
+ try {
350
+ return { value: run(), deps };
351
+ }
352
+ finally {
353
+ this.#reads = outerReads;
354
+ this.#deps = outerDeps;
355
+ }
356
+ }
357
+ /**
358
+ * Whether code that read `deps` while `change` was delivered needs a full
359
+ * pass after it: it read a field that changed, or no field of that state
360
+ * at all (so the SDK cannot tell what it depends on).
361
+ */
362
+ needsFull(deps, change) {
363
+ for (const key of change.keys()) {
364
+ const read = deps.get(key);
365
+ if (!read || read.size === 0)
366
+ return true;
367
+ }
368
+ return affected(deps, change);
369
+ }
223
370
  /**
224
371
  * Evaluate `read` as `label`, added at `where`: the state it reads is
225
372
  * reported as read by it. `field`: a command's `enabled` or `checked`,
@@ -275,18 +422,24 @@ export class Runtime {
275
422
  return undefined;
276
423
  }
277
424
  /** Evaluate one binding, holding the events whose state it read. */
278
- #evaluate(evaluate, held) {
425
+ #evaluate(evaluate, binding) {
426
+ const held = binding.held;
279
427
  const reads = new Map();
428
+ const deps = new Map();
280
429
  const outer = this.#reads;
430
+ const outerDeps = this.#deps;
281
431
  const reader = this.#reader;
282
432
  this.#reads = reads;
433
+ this.#deps = deps;
283
434
  this.#reader = undefined;
284
435
  try {
285
436
  evaluate();
286
437
  }
287
438
  finally {
288
439
  this.#reads = outer;
440
+ this.#deps = outerDeps;
289
441
  this.#reader = reader;
442
+ binding.deps = deps;
290
443
  for (const [event, hold] of held)
291
444
  if (!reads.has(event)) {
292
445
  held.delete(event);
@@ -297,24 +450,82 @@ export class Runtime {
297
450
  held.set(event, this.hold(event, why));
298
451
  }
299
452
  }
300
- /** Coalesce: one evaluation pass per microtask turn, however many changes. */
453
+ /**
454
+ * A full pass: every binding is evaluated. Requested after plugin code
455
+ * the SDK ran, by `plugin.update()`, and on any change other than a state
456
+ * copy's fields. Coalesced: one pass per microtask turn, however many
457
+ * requests.
458
+ */
301
459
  invalidate() {
460
+ this.#full = true;
461
+ this.#schedule();
462
+ }
463
+ /**
464
+ * Fields of the SDK's copy of a state (`key`) changed: a precise pass
465
+ * evaluates only the bindings (and view bind functions) that read one of
466
+ * them, unless a full pass is due anyway.
467
+ */
468
+ changed(key, fields) {
469
+ let set = this.#pending.get(key);
470
+ if (!set)
471
+ this.#pending.set(key, (set = new Set()));
472
+ for (const field of fields)
473
+ set.add(field);
474
+ this.#schedule();
475
+ }
476
+ #schedule() {
302
477
  if (this.#scheduled)
303
478
  return;
304
479
  this.#scheduled = true;
305
- queueMicrotask(() => {
306
- this.#scheduled = false;
307
- for (const [evaluate, held] of [...this.#bindings]) {
308
- if (this.#bindings.get(evaluate) !== held)
309
- continue;
310
- try {
311
- this.#evaluate(evaluate, held);
312
- }
313
- catch (error) {
314
- console.error(`[${this.manifest.id}] binding failed:`, error);
315
- }
480
+ queueMicrotask(() => this.#run());
481
+ }
482
+ /**
483
+ * One pass. A precise pass that skips anything arms a full pass
484
+ * `SAFETY_PASS_MS` later (unless a full pass comes first), for plugin
485
+ * state changed outside code the SDK ran.
486
+ */
487
+ #run() {
488
+ this.#scheduled = false;
489
+ const full = this.#full;
490
+ const change = this.#pending;
491
+ this.#full = false;
492
+ this.#pending = new Map();
493
+ if (full) {
494
+ clearTimeout(this.#safety);
495
+ this.#safety = undefined;
496
+ }
497
+ this.#skipped = false;
498
+ for (const [evaluate, binding] of [...this.#bindings]) {
499
+ if (this.#bindings.get(evaluate) !== binding)
500
+ continue;
501
+ if (!full && !affected(binding.deps, change)) {
502
+ this.#skipped = true;
503
+ continue;
316
504
  }
317
- });
505
+ this.#pass = full ? undefined : change;
506
+ try {
507
+ this.#evaluate(evaluate, binding);
508
+ }
509
+ catch (error) {
510
+ console.error(`[${this.manifest.id}] binding failed:`, error);
511
+ }
512
+ finally {
513
+ this.#pass = undefined;
514
+ }
515
+ }
516
+ if (this.#skipped && !this.#safety && !this.#stopped) {
517
+ this.#safety = setTimeout(() => {
518
+ this.#safety = undefined;
519
+ this.invalidate();
520
+ }, SAFETY_PASS_MS);
521
+ this.#safety.unref?.();
522
+ }
523
+ }
524
+ /** The plugin was disposed: cancel the pending safety pass, arm no more. */
525
+ stop() {
526
+ this.#stopped = true;
527
+ clearTimeout(this.#safety);
528
+ this.#safety = undefined;
318
529
  }
319
530
  /**
320
531
  * Host events with one `events.subscribe` per name, however many listeners.
@@ -1,4 +1,4 @@
1
- import type { Runtime } from "./runtime.ts";
1
+ import type { Change, Runtime } from "./runtime.ts";
2
2
  import type { Disposable, Plugin, PluginError, Scope } from "./types.ts";
3
3
  export declare class ScopeImpl implements Plugin {
4
4
  #private;
@@ -54,6 +54,14 @@ export declare class ScopeImpl implements Plugin {
54
54
  report(error: unknown): void;
55
55
  /** Invoke plugin code on its behalf: report failures, then refresh bindings. */
56
56
  run(handler: (...args: never[]) => unknown, ...args: unknown[]): void;
57
+ /**
58
+ * Run a listener of a state change (`player.onChange`) like `run`, except
59
+ * that the full pass after it is left out when it returned synchronously
60
+ * and read only fields of the state that `change` left alone: its result
61
+ * then depends on nothing new. The precise pass for the change runs
62
+ * anyway, and a full pass follows within `SAFETY_PASS_MS`.
63
+ */
64
+ runOn(change: Change, handler: (...args: never[]) => unknown, ...args: unknown[]): void;
57
65
  update(): void;
58
66
  dispose(): Promise<void>;
59
67
  }
@@ -145,6 +145,30 @@ export class ScopeImpl {
145
145
  }
146
146
  this.update();
147
147
  }
148
+ /**
149
+ * Run a listener of a state change (`player.onChange`) like `run`, except
150
+ * that the full pass after it is left out when it returned synchronously
151
+ * and read only fields of the state that `change` left alone: its result
152
+ * then depends on nothing new. The precise pass for the change runs
153
+ * anyway, and a full pass follows within `SAFETY_PASS_MS`.
154
+ */
155
+ runOn(change, handler, ...args) {
156
+ if (this.disposed)
157
+ return;
158
+ let full = true;
159
+ try {
160
+ const { value, deps } = this.runtime.observe(() => handler(...args));
161
+ if (value instanceof Promise)
162
+ value.catch((error) => this.report(error)).finally(() => this.update());
163
+ else
164
+ full = this.runtime.needsFull(deps, change);
165
+ }
166
+ catch (error) {
167
+ this.report(error);
168
+ }
169
+ if (full)
170
+ this.update();
171
+ }
148
172
  update() {
149
173
  this.runtime.invalidate();
150
174
  }
@@ -61,8 +61,8 @@ class Page extends ViewBase {
61
61
  live() {
62
62
  return this.#registered && !this.closed;
63
63
  }
64
- write(property, value) {
65
- return this.scope.runtime.host.settings.set(this.#id, property, value);
64
+ write(property, write) {
65
+ return this.scope.runtime.host.settings.set(this.#id, property, write);
66
66
  }
67
67
  async dispose() {
68
68
  if (this.closed)
@@ -2621,6 +2621,13 @@ export declare class TestHost implements Transport {
2621
2621
  * for: on a loaded machine it can finish after `settle()` returns. Poll
2622
2622
  * the result you assert on instead (second example).
2623
2623
  *
2624
+ * `settle()` does not wait for the SDK's once-a-second full update pass
2625
+ * (SDK 1.5.45). After `setSource` (or another player state change) only
2626
+ * the bindings that read a changed field of `plugin.player.state` run
2627
+ * again; state the plugin changed outside a handler without
2628
+ * `plugin.update()` shows only in the next full pass. Have the plugin
2629
+ * call `plugin.update()` after such a change, or wait `settle(1000)`.
2630
+ *
2624
2631
  * @param ms - Extra time to wait, for debounces and timers (default 0).
2625
2632
  *
2626
2633
  * @example Wait past a debounced save