@vestra-player/sdk 1.5.44 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.45
4
+
5
+ **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed. Only when the SDK re-evaluates bindings changes; what a plugin's views and commands show stays the same. One case can show in tests: a plugin that changes state outside a handler (a timer, a promise continuation after the handler returned, a child process) without calling `plugin.update()`, and a test that relied on `host.setSource(...)` to refresh it. Have the plugin call `plugin.update()` after such a change (as the documentation always said), or wait `await host.settle(1000)` in the test; `settle()` does not wait for the once-a-second full pass.
6
+
7
+ - **Precise update passes.** While media plays, the player sends its state about ten times a second, and each change used to re-run every `view.bind` function and every command `enabled`, `checked`, `busy` and `reason` function of the plugin. Now the SDK records which fields of `plugin.player.state` each of them read (`position`, `paused`, `phase`, `source`, …) and, after a player state change, re-runs only those that read a field that changed: a list bound in a panel no longer re-runs on every position tick. `isCurrent` and `Media.current` count as reading the source's identity.
8
+ - **Full passes as before** after any code of the plugin the SDK runs (a command's `run`, a window, panel or settings page callback, a listener, `onEnd`, and so on), when the promise it returned settles, after `plugin.update()`, and after every other state change (window, subtitles, panels, timeline, appearance). One exception keeps a playing list cheap: a `player.onChange` listener that returned synchronously and read only fields of the state that did not change is not followed by a full pass (a listener that read a changed field, no field at all, or returned a promise still is).
9
+ - **A full pass at least once a second while precise passes skip something.** State a plugin changes outside code the SDK runs (its own `setInterval`, a socket callback) without calling `plugin.update()` used to show at the next player state change (within about 0.1 s while playing); now it shows at the latest about 1 s later. Call `plugin.update()` after such a change, as the documentation always said.
10
+ - **`plugin.player.state`** reads as before (the same keys in the same order, frozen, JSON, spread, `structuredClone`, `util.inspect` and `assert.deepEqual` give the same values); its fields are getters now, so `Object.getOwnPropertyDescriptor` shows `get` instead of `value`, and `Reflect.ownKeys` and `Object.getOwnPropertySymbols` also list `Symbol(nodejs.util.inspect.custom)` (not enumerable).
11
+ - **`Scrub.active`** turning false (a new source, a failed seek) now causes a full pass, so a binding that reads it follows.
12
+ - **`TestHost`** runs the same code: the plugin's tests see the same passes as the player. `settle()` does not wait for the once-a-second full pass; `settle(1000)` does (`TestHost.settle`, _Test a plugin_ say so).
13
+
3
14
  ## 1.5.44
4
15
 
5
16
  **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. How the SDK sends array properties changes, on players that take it (player 0.6.0); with older players it sends whole values as before. The read-only copy of the widget library gains components that need player 0.6.0 (`"minPlayerVersion": "0.6.0"` for a plugin whose Slint uses them; an older player fails to compile such a file).
@@ -25,6 +25,9 @@ app) {
25
25
  return {
26
26
  plugin: root,
27
27
  activated: () => runtime.activated(),
28
- dispose: () => root.dispose(),
28
+ dispose: async () => {
29
+ await root.dispose();
30
+ runtime.stop();
31
+ },
29
32
  };
30
33
  }
@@ -3,6 +3,12 @@ import { Latest } from "./runtime.ts";
3
3
  import type { Runtime } from "./runtime.ts";
4
4
  import type { ScopeImpl } from "./scope.ts";
5
5
  import type { Conditional, Disposable, Player, PlayerState, PluginError } from "./types.ts";
6
+ /**
7
+ * The key of `plugin.player.state` in the runtime's field tracking
8
+ * (`Runtime.depends`). Its fields are the state's own, plus `stamp` for
9
+ * what `isCurrent` and `Media.current` compare.
10
+ */
11
+ export declare const PLAYER = "player";
6
12
  export declare const rawOf: (state: PlayerState) => SourceState | undefined;
7
13
  /** A step-seek length the player accepts: 0.1–3600 seconds in tenths. */
8
14
  export declare const validStep: (seconds: unknown) => seconds is number;
@@ -14,6 +20,11 @@ export declare class PlayerService {
14
20
  readonly runtime: Runtime;
15
21
  raw: SourceState;
16
22
  state: PlayerState;
23
+ /**
24
+ * The fields the last accepted state changed (`onChange` listeners are
25
+ * told so a listener that read none of them needs no full pass).
26
+ */
27
+ change: ReadonlyMap<string, ReadonlySet<string>>;
17
28
  readonly output: Latest<{
18
29
  volume?: number;
19
30
  muted?: boolean;
@@ -40,9 +40,63 @@ const audioTrack = (track) => Object.freeze({
40
40
  title: track.title,
41
41
  language: track.language,
42
42
  });
43
- function view(raw) {
43
+ /**
44
+ * The key of `plugin.player.state` in the runtime's field tracking
45
+ * (`Runtime.depends`). Its fields are the state's own, plus `stamp` for
46
+ * what `isCurrent` and `Media.current` compare.
47
+ */
48
+ export const PLAYER = "player";
49
+ /** `util.inspect` shows a state's values, not its getters. */
50
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom");
51
+ const values = new WeakMap();
52
+ /**
53
+ * The state a plugin sees: each field a getter over the same values, so the
54
+ * SDK learns which fields a binding or listener read (`Runtime.depends`).
55
+ * It behaves like the plain frozen object it was: same keys in the same
56
+ * order, enumerable, JSON, spread, `structuredClone` and `util.inspect`
57
+ * give the values.
58
+ */
59
+ function tracked(runtime, data) {
60
+ const state = {};
61
+ for (const key of Object.keys(data)) {
62
+ const value = data[key];
63
+ Object.defineProperty(state, key, {
64
+ enumerable: true,
65
+ get: () => {
66
+ runtime?.depends(PLAYER, key);
67
+ return value;
68
+ },
69
+ });
70
+ }
71
+ Object.defineProperty(state, INSPECT, { value: () => data });
72
+ const frozen = Object.freeze(state);
73
+ values.set(frozen, data);
74
+ return frozen;
75
+ }
76
+ /** The fields of `next` that differ from `last` (`stamp` included). */
77
+ function changes(last, lastRaw, next, nextRaw) {
78
+ const a = values.get(last);
79
+ const b = values.get(next);
80
+ const changed = [];
81
+ for (const key of Object.keys(b)) {
82
+ const x = a[key];
83
+ const y = b[key];
84
+ if (x === y)
85
+ continue;
86
+ if (x === null ||
87
+ y === null ||
88
+ typeof x !== "object" ||
89
+ typeof y !== "object" ||
90
+ JSON.stringify(x) !== JSON.stringify(y))
91
+ changed.push(key);
92
+ }
93
+ if (!sameStamp(lastRaw.stamp, nextRaw.stamp))
94
+ changed.push("stamp");
95
+ return changed;
96
+ }
97
+ function view(raw, runtime) {
44
98
  const reason = raw.terminalReason;
45
- const state = Object.freeze({
99
+ const data = Object.freeze({
46
100
  sourceId: raw.stamp.generation,
47
101
  kind: raw.kind === "producer" ? "stream" : "media",
48
102
  phase: raw.phase,
@@ -78,6 +132,7 @@ function view(raw) {
78
132
  active: raw.audioOutput?.active ?? null,
79
133
  }),
80
134
  });
135
+ const state = tracked(runtime, data);
81
136
  raws.set(state, raw);
82
137
  return state;
83
138
  }
@@ -110,12 +165,18 @@ export function abortError() {
110
165
  export class PlayerService {
111
166
  runtime;
112
167
  raw = empty;
113
- state = view(empty);
168
+ state;
169
+ /**
170
+ * The fields the last accepted state changed (`onChange` listeners are
171
+ * told so a listener that read none of them needs no full pass).
172
+ */
173
+ change = new Map();
114
174
  #watchers = new Set();
115
175
  #operation = 0;
116
176
  output;
117
177
  constructor(runtime) {
118
178
  this.runtime = runtime;
179
+ this.state = view(empty, runtime);
119
180
  // The SDK's copy of the state (a mirror): a plugin listens through
120
181
  // `watch` and the bindings that read `state`.
121
182
  runtime.on("source.state", (data) => this.accept(data), "events", true);
@@ -137,12 +198,16 @@ export class PlayerService {
137
198
  return;
138
199
  if (JSON.stringify(raw) === JSON.stringify(this.raw))
139
200
  return;
201
+ const last = this.state;
202
+ const lastRaw = this.raw;
140
203
  this.raw = raw;
141
- this.state = view(raw);
204
+ this.state = view(raw, this.runtime);
205
+ const changed = changes(last, lastRaw, this.state, raw);
206
+ this.change = new Map([[PLAYER, new Set(changed)]]);
142
207
  for (const watcher of [...this.#watchers])
143
208
  watcher(raw);
144
- // Bindings may read `player.state`; refresh them like after a handler.
145
- this.runtime.invalidate();
209
+ // A precise pass: only the bindings that read a changed field.
210
+ this.runtime.changed(PLAYER, changed);
146
211
  }
147
212
  watch(watcher) {
148
213
  this.#watchers.add(watcher);
@@ -244,6 +309,7 @@ class MediaHandle {
244
309
  this.#ticket = ticket;
245
310
  }
246
311
  get current() {
312
+ this.#service.runtime.depends(PLAYER, "stamp");
247
313
  return this.#service.raw.stamp.generation === this.#ticket.stamp.generation;
248
314
  }
249
315
  ready(options = {}) {
@@ -313,7 +379,9 @@ class ScrubHandle {
313
379
  #pending = null;
314
380
  #watch;
315
381
  #queue;
382
+ #service;
316
383
  constructor(service) {
384
+ this.#service = service;
317
385
  this.#allowed = { ...service.raw.stamp };
318
386
  this.#watch = service.watch((raw) => {
319
387
  if (!sameStamp(raw.stamp, this.#allowed) &&
@@ -377,6 +445,9 @@ class ScrubHandle {
377
445
  this.#active = false;
378
446
  this.#queue.stop();
379
447
  void this.#watch.dispose();
448
+ // `active` changed, maybe outside plugin code (a new source, a failed
449
+ // seek): bindings that read it see it in a full pass.
450
+ this.#service.runtime.invalidate();
380
451
  }
381
452
  }
382
453
  const superseded = new HostError("superseded", "gesture ended");
@@ -434,10 +505,11 @@ export function player(scope) {
434
505
  return {
435
506
  get state() {
436
507
  scope.runtime.reading("source.state");
508
+ scope.runtime.depends(PLAYER);
437
509
  return service().state;
438
510
  },
439
511
  onChange(listener) {
440
- const watch = service().watch(() => scope.run(listener, service().state));
512
+ const watch = service().watch(() => scope.runOn(service().change, listener, service().state));
441
513
  return scope.add({
442
514
  dispose: async () => {
443
515
  scope.forget(watch);
@@ -448,6 +520,7 @@ export function player(scope) {
448
520
  refresh: () => service().refresh(),
449
521
  isCurrent(state) {
450
522
  const raw = raws.get(state);
523
+ scope.runtime.depends(PLAYER, "stamp");
451
524
  return !!raw && sameStamp(raw.stamp, service().raw.stamp);
452
525
  },
453
526
  async open(source, options = {}) {
@@ -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> {
@@ -76,6 +90,36 @@ export declare class Runtime {
76
90
  * on it, so the event counts as listened to while the binding reads it.
77
91
  */
78
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;
79
123
  /**
80
124
  * Evaluate `read` as `label`, added at `where`: the state it reads is
81
125
  * reported as read by it. `field`: a command's `enabled` or `checked`,
@@ -88,8 +132,21 @@ export declare class Runtime {
88
132
  * line (`main.ts:353`). Captured now, formatted when asked.
89
133
  */
90
134
  callSite(): () => string | undefined;
91
- /** 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
+ */
92
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;
93
150
  /**
94
151
  * Host events with one `events.subscribe` per name, however many listeners.
95
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();
@@ -196,11 +245,12 @@ export class Runtime {
196
245
  }
197
246
  bind(evaluate) {
198
247
  const held = new Map();
199
- this.#bindings.set(evaluate, held);
248
+ const binding = { held, deps: new Map() };
249
+ this.#bindings.set(evaluate, binding);
200
250
  this.invalidate();
201
251
  return {
202
252
  dispose: async () => {
203
- if (this.#bindings.get(evaluate) !== held)
253
+ if (this.#bindings.get(evaluate) !== binding)
204
254
  return;
205
255
  this.#bindings.delete(evaluate);
206
256
  for (const hold of held.values())
@@ -231,6 +281,92 @@ export class Runtime {
231
281
  return `${reader.label} is a function that reads ${read}${at}${hint}`;
232
282
  });
233
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
+ }
234
370
  /**
235
371
  * Evaluate `read` as `label`, added at `where`: the state it reads is
236
372
  * reported as read by it. `field`: a command's `enabled` or `checked`,
@@ -286,18 +422,24 @@ export class Runtime {
286
422
  return undefined;
287
423
  }
288
424
  /** Evaluate one binding, holding the events whose state it read. */
289
- #evaluate(evaluate, held) {
425
+ #evaluate(evaluate, binding) {
426
+ const held = binding.held;
290
427
  const reads = new Map();
428
+ const deps = new Map();
291
429
  const outer = this.#reads;
430
+ const outerDeps = this.#deps;
292
431
  const reader = this.#reader;
293
432
  this.#reads = reads;
433
+ this.#deps = deps;
294
434
  this.#reader = undefined;
295
435
  try {
296
436
  evaluate();
297
437
  }
298
438
  finally {
299
439
  this.#reads = outer;
440
+ this.#deps = outerDeps;
300
441
  this.#reader = reader;
442
+ binding.deps = deps;
301
443
  for (const [event, hold] of held)
302
444
  if (!reads.has(event)) {
303
445
  held.delete(event);
@@ -308,24 +450,82 @@ export class Runtime {
308
450
  held.set(event, this.hold(event, why));
309
451
  }
310
452
  }
311
- /** 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
+ */
312
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() {
313
477
  if (this.#scheduled)
314
478
  return;
315
479
  this.#scheduled = true;
316
- queueMicrotask(() => {
317
- this.#scheduled = false;
318
- for (const [evaluate, held] of [...this.#bindings]) {
319
- if (this.#bindings.get(evaluate) !== held)
320
- continue;
321
- try {
322
- this.#evaluate(evaluate, held);
323
- }
324
- catch (error) {
325
- console.error(`[${this.manifest.id}] binding failed:`, error);
326
- }
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;
327
504
  }
328
- });
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;
329
529
  }
330
530
  /**
331
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
  }
@@ -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
@@ -3213,6 +3213,13 @@ export class TestHost {
3213
3213
  * for: on a loaded machine it can finish after `settle()` returns. Poll
3214
3214
  * the result you assert on instead (second example).
3215
3215
  *
3216
+ * `settle()` does not wait for the SDK's once-a-second full update pass
3217
+ * (SDK 1.5.45). After `setSource` (or another player state change) only
3218
+ * the bindings that read a changed field of `plugin.player.state` run
3219
+ * again; state the plugin changed outside a handler without
3220
+ * `plugin.update()` shows only in the next full pass. Have the plugin
3221
+ * call `plugin.update()` after such a change, or wait `settle(1000)`.
3222
+ *
3216
3223
  * @param ms - Extra time to wait, for debounces and timers (default 0).
3217
3224
  *
3218
3225
  * @example Wait past a debounced save
@@ -565,14 +565,16 @@ export interface CommandState {
565
565
  reason: TextValue | null;
566
566
  }
567
567
  /**
568
- * A value, or a function the SDK re-evaluates on every update pass (see
569
- * {@link Scope.update}); only changes are sent to the player.
568
+ * A value, or a function the SDK re-evaluates on every full update pass,
569
+ * and after a player state change only if it read a field of
570
+ * `plugin.player.state` that changed (see {@link Scope.update}); only
571
+ * changes are sent to the player.
570
572
  *
571
573
  * @example Mix a computed state with a fixed one
572
574
  * The Loop item carries a check mark while a loop is set and is always
573
- * enabled. A function is evaluated again in every update pass (after each
574
- * handler and player state change) and only changes are sent; a plain
575
- * value never changes.
575
+ * enabled. A function is evaluated again in every full update pass (after
576
+ * each handler), and after a player state change only if it read a changed
577
+ * field; only changes are sent, and a plain value never changes.
576
578
  * ```ts
577
579
  * plugin.commands.add({
578
580
  * id: "loop",
@@ -1060,8 +1062,9 @@ export interface CommandOptions {
1060
1062
  * @remarks
1061
1063
  * The handle of one menu item and its keys. Its state (`enabled`, `checked`,
1062
1064
  * `busy`, `reason`) is bound: functions given to {@link Commands.add} or
1063
- * {@link Command.set} are evaluated again in every update pass and only
1064
- * changes reach the player. Its title, path, bindings and button cannot
1065
+ * {@link Command.set} are evaluated again in every full update pass (after
1066
+ * a player state change only if they read a changed field of
1067
+ * `plugin.player.state`) and only changes reach the player. Its title, path, bindings and button cannot
1065
1068
  * change in place; dispose the command (or a child {@link Scope}) and add it
1066
1069
  * again. Disposing it, its scope or stopping the plugin removes the menu
1067
1070
  * item, its keys and its bar button.
@@ -1081,7 +1084,7 @@ export interface CommandOptions {
1081
1084
  * path: ["@video", "Export…"],
1082
1085
  * run: runExport,
1083
1086
  * });
1084
- * // A function: re-evaluated in every update pass.
1087
+ * // A function: re-evaluated in every full pass and when `phase` changes.
1085
1088
  * exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
1086
1089
  * ```
1087
1090
  *
@@ -1706,8 +1709,9 @@ export interface Ui {
1706
1709
  * callback.
1707
1710
  *
1708
1711
  * `bind` is the usual way: the SDK re-evaluates bound functions in every
1709
- * update pass (after each handler it calls, after player state changes, on
1710
- * {@link Scope.update}) and sends only what changed, at most one write in
1712
+ * full update pass (after each handler it calls, on {@link Scope.update}),
1713
+ * and after a player state change those that read a changed field of
1714
+ * `plugin.player.state`, and sends only what changed, at most one write in
1711
1715
  * flight per view. `set` sends now, even an unchanged value.
1712
1716
  *
1713
1717
  * Writes that fail (a name the Slint file does not declare, a value of the
@@ -1843,7 +1847,11 @@ export interface View {
1843
1847
  *
1844
1848
  * @remarks
1845
1849
  * The SDK calls `props` in every update pass (and right after `bind`) and
1846
- * sends the values that differ from the ones it sent last. Because an
1850
+ * sends the values that differ from the ones it sent last. After a change
1851
+ * of the player state alone it calls `props` only if it read a field of
1852
+ * `plugin.player.state` that changed (SDK 1.5.45); a full pass follows
1853
+ * after each handler, on {@link Scope.update} and at least once a second
1854
+ * while the player state keeps changing. Because an
1847
1855
  * unchanged value is not sent again, text the user typed into an `in-out`
1848
1856
  * property stays until the plugin's value for it changes.
1849
1857
  *
@@ -1864,8 +1872,9 @@ export interface View {
1864
1872
  * @example Show what is playing and whether it can seek
1865
1873
  * The status window always shows the current file and enables its seek
1866
1874
  * controls only for seekable sources. The SDK calls the function right
1867
- * away and again in every update pass (after player state changes and
1868
- * after each handler) and sends only the values that changed.
1875
+ * away, again in every full update pass (after each handler) and after
1876
+ * each change of `source` or `can`, and sends only the values that
1877
+ * changed.
1869
1878
  * ```ts
1870
1879
  * const status = plugin.windows.create("ui/status.slint");
1871
1880
  * status.bind(() => ({
@@ -5697,8 +5706,8 @@ export interface Player {
5697
5706
  * @example Drive the player's Next and Previous from a playlist
5698
5707
  * The player's Next and Previous items and keys play the plugin's list.
5699
5708
  * The player calls `next` and `previous` when the user asks; `canNext`
5700
- * and `canPrevious` are evaluated again after every handler the SDK runs,
5701
- * and while they return false the player disables its items ("No next
5709
+ * and `canPrevious` are evaluated again after every handler the SDK runs
5710
+ * (and after a player state change if they read a changed field), and while they return false the player disables its items ("No next
5702
5711
  * item").
5703
5712
  * ```ts
5704
5713
  * declare const items: string[];
@@ -38,7 +38,11 @@ export declare abstract class ViewBase implements View {
38
38
  on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
39
39
  set(props: Props): void;
40
40
  bind(props: () => Props): Disposable;
41
- /** Current desired properties (bound values win over earlier `set`). */
41
+ /**
42
+ * Current desired properties (bound values win over earlier `set`). A
43
+ * precise pass (`Runtime.part`) skips a bind function that read none of
44
+ * the changed state: its last values stay.
45
+ */
42
46
  protected snapshot(): Record<string, PropValue>;
43
47
  protected evaluate(): void;
44
48
  /** The host already shows these values (e.g. sent as initial properties). */
package/dist/plugin/ui.js CHANGED
@@ -247,18 +247,32 @@ export class ViewBase {
247
247
  this.flush();
248
248
  }
249
249
  bind(props) {
250
- this.#binders.set(props, this.scope.runtime.callSite());
250
+ // `token` names this binding of this view in the runtime's read
251
+ // tracking: the same function bound to two views is two parts.
252
+ this.#binders.set(props, {
253
+ site: this.scope.runtime.callSite(),
254
+ token: {},
255
+ });
251
256
  this.scope.update();
252
257
  return this.scope.onDispose(() => {
253
258
  this.#binders.delete(props);
254
259
  });
255
260
  }
256
- /** Current desired properties (bound values win over earlier `set`). */
261
+ /**
262
+ * Current desired properties (bound values win over earlier `set`). A
263
+ * precise pass (`Runtime.part`) skips a bind function that read none of
264
+ * the changed state: its last values stay.
265
+ */
257
266
  snapshot() {
258
- for (const [binder, site] of this.#binders) {
267
+ const runtime = this.scope.runtime;
268
+ for (const [binder, { site, token }] of this.#binders) {
259
269
  try {
260
- const props = this.scope.runtime.reader("a view's bind", site, binder);
261
- for (const [key, value] of Object.entries(encode(props)))
270
+ // Encoded inside the part: what encoding reads counts as the
271
+ // function's (a state object it returned).
272
+ const result = runtime.part(token, () => encode(runtime.reader("a view's bind", site, binder)));
273
+ if (!result)
274
+ continue;
275
+ for (const [key, value] of Object.entries(result.value))
262
276
  this.#values.set(key, value);
263
277
  }
264
278
  catch (error) {
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.44: full reference
1
+ # @vestra-player/sdk 1.5.45: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
@@ -1903,6 +1903,8 @@ host.handle("processing.replace", () => {
1903
1903
 
1904
1904
  `await host.settle()` after every action that makes the plugin talk to the player; `host.settle(400)` also lets debounces and timers due by then fire. Then assert on what the host recorded: `host.window(id).props` (kebab-case names), `host.command(id).state`, `host.osd`, `host.source`.
1905
1905
 
1906
+ `settle` does not wait for the once-a-second full update pass (SDK 1.5.45): after `host.setSource(...)` only the bindings that read a changed field of `plugin.player.state` run again. A value your plugin changed outside a handler (a timer, a promise continuation, a child process) without `plugin.update()` shows in the next full pass, at most about a second later; call `plugin.update()` in the plugin after such a change, or wait `await host.settle(1000)` in the test.
1907
+
1906
1908
  `settle` waits only for calls to the player. Work the plugin does on its own (reading or parsing a file, a child process, a network request) can finish later on a loaded machine; poll the value you assert on instead:
1907
1909
 
1908
1910
  ```ts nocheck
@@ -3866,14 +3868,18 @@ There is no cleanup function to return from `activate`. Because every resource i
3866
3868
  A plugin's state is plain variables. The SDK keeps the player in step with it through **bindings** and the **update pass**:
3867
3869
 
3868
3870
  - A binding is a function the SDK calls to get a current value: view.bind(() => props) for a window, panel or settings page, and the function form of a command's `enabled`, `checked`, `busy` and `reason` (Bindable).
3869
- - In an update pass the SDK calls every binding of the plugin, compares each result with the value it last sent, and sends only what changed.
3871
+ - In an update pass the SDK calls the bindings of the plugin, compares each result with the value it last sent, and sends only what changed.
3870
3872
 
3871
- Update passes run:
3873
+ A **full pass** calls every binding. It runs:
3872
3874
 
3873
3875
  - after every handler the SDK calls returns (a command's `run`, a view callback, an `onChange`, `onStatus`, `onEnd` or `onStop` listener), and again when the promise it returned settles;
3874
- - after the player, subtitle, panel position, filter or export state changes;
3876
+ - after the subtitle, window, panel position, timeline, appearance, filter or export state changes;
3875
3877
  - when the plugin calls plugin.update().
3876
3878
 
3879
+ A **precise pass** follows a change of the player state (SDK 1.5.45). While media plays the player sends its state about ten times a second, mostly with only `position` changed. The SDK notes which fields of `plugin.player.state` each binding read the last time it ran (each `view.bind` function on its own; a command's four functions together) and calls only the bindings that read a field that changed. A panel whose list reads nothing of the player state is not rebuilt on every position tick; a label that shows the position is. `player.isCurrent` and `Media.current` count as reading the source. A `player.onChange` listener is followed by a full pass, as above, unless it returned synchronously and read only fields that did not change.
3880
+
3881
+ A precise pass cannot see state the plugin changed by itself. So while precise passes leave bindings out, a full pass follows at least once a second: a variable your own timer changed without `plugin.update()` appears within about a second (before SDK 1.5.45 it usually appeared at the next position tick). Call `plugin.update()` after such a change to show it at once.
3882
+
3877
3883
  Several requests within one microtask are merged into one pass, and every target has at most one write in flight: values that change meanwhile are merged into the next write. A binding that changes 60 times a second therefore costs one write per round trip, never a growing queue.
3878
3884
 
3879
3885
  One click, end to end (a bookmark window; the video is at 42 s):
@@ -3893,7 +3899,7 @@ sequenceDiagram
3893
3899
  S->>U: the label changes, Jump to bookmark is enabled
3894
3900
  ```
3895
3901
 
3896
- The rule that follows: **state you change outside a handler, or after an `await` inside one, appears only at the next pass.** Changing a variable from your own `setInterval`, a socket callback or the middle of a long task shows nothing until something triggers a pass; call `plugin.update()` after such a change. Keep a slider responsive while dragging shows the pattern; How Slint and TypeScript talk explains `set` versus `bind` for views.
3902
+ The rule that follows: **state you change outside a handler, or after an `await` inside one, appears only at the next full pass.** Changing a variable from your own `setInterval`, a socket callback or the middle of a long task shows nothing until something triggers one (at most about a second while media plays); call `plugin.update()` after such a change. Keep a slider responsive while dragging shows the pattern; How Slint and TypeScript talk explains `set` versus `bind` for views.
3897
3903
 
3898
3904
  #### Stopping
3899
3905
 
@@ -4009,7 +4015,7 @@ const toggle = plugin.commands.add({
4009
4015
 
4010
4016
  - **The id is the command's identity.** It is unique within the plugin; the player knows the command as `<manifest id>/<id>`. The user's own key bindings are stored under that pair, so changing a title or moving the command to another menu keeps them.
4011
4017
  - **`path` puts it in the main menu.** Leave it out for a command that only has bindings or lives in a panel's context menu (Panel.showMenu); it then needs a `title`.
4012
- - **State is declared, not pushed.** `enabled`, `checked`, `busy` and `reason` are values or functions (Bindable); `enabled` and `checked` may also be conditions from `plugin.when` (below). Functions are evaluated in every update pass and sent only when they change, so the menu's check mark follows the player without a listener you write. Command.set replaces them later.
4018
+ - **State is declared, not pushed.** `enabled`, `checked`, `busy` and `reason` are values or functions (Bindable); `enabled` and `checked` may also be conditions from `plugin.when` (below). Functions are evaluated in every full update pass, and after a player state change only if they read a field that changed; they are sent only when they change, so the menu's check mark follows the player without a listener you write. Command.set replaces them later.
4013
4019
  - **The player calls `run` only when it can run.** While a command is disabled or busy, neither its menu item nor its binding calls `run`; `reason` tells the user why (it appears in the menu and, for a key press, on the video).
4014
4020
  - **Titles and paths are fixed.** They cannot change in place. A menu that follows changing content (tracks, presets, a radio group) lives in a child scope that is disposed and rebuilt when the content changes; `order` keeps its place (Handles and scopes).
4015
4021
  - Disposing the command removes it from the menu and the binding table; the same id can be added again at once. The number of commands per plugin and per player is limited (Reference: limits); the limit stops runaway menus, so cap menus built from media content (tracks, chapters) yourself.
@@ -4266,7 +4272,7 @@ player.openStream (capability `producer.av`) makes a source whose picture and so
4266
4272
 
4267
4273
  #### Next and previous
4268
4274
 
4269
- The player's **Previous** and **Next** (menu, keys, the bottom bar) are not tied to a playlist. One plugin at a time can **provide the sequence** with provideSequence, giving `next()`, `previous()` and two flags, `canNext` and `canPrevious`, which are bindings re-evaluated in every update pass.
4275
+ The player's **Previous** and **Next** (menu, keys, the bottom bar) are not tied to a playlist. One plugin at a time can **provide the sequence** with provideSequence, giving `next()`, `previous()` and two flags, `canNext` and `canPrevious`, which are bindings re-evaluated in every full update pass, and after a player state change only if they read a changed field.
4270
4276
 
4271
4277
  - Every plugin sees the flags as `player.state.can.next` and `can.previous`, and player.next() asks the provider to move; the provider's `next` runs in its own scope.
4272
4278
  - A second provider fails with `conflict` and does not fail activation; it is tried again when its flags change, so it takes over once the first provider goes away.
@@ -4627,12 +4633,12 @@ export component ListPanel inherits Rectangle {
4627
4633
 
4628
4634
  | | `set({ x })` | `bind(() => ({ x }))` |
4629
4635
  | ---------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
4630
- | When it is sent | Immediately (in the next write). | In every update pass, if `x` differs from the last value sent. |
4636
+ | When it is sent | Immediately (in the next write). | In every full update pass, and after a player state change if the function read a changed field; when `x` differs from the last value sent. |
4631
4637
  | Same value again | Sent again. Use this to pull a control back to the model value after the user moved it. | Not sent. A field the user edited stays as the user left it until your value changes. |
4632
4638
  | Write fails | Retried with back-off (0.25 s doubling up to 30 s). | Not repeated until the value changes. |
4633
4639
  | Typical use | One-off values: a document, a reset. | Everything derived from plugin state: labels, enabled flags, lists, status lines. |
4634
4640
 
4635
- Update passes run after every handler the SDK calls, after player and other state changes, and when you call plugin.update(); several requests are merged into one pass, and each view has at most one write in flight (The update pass has the details).
4641
+ Update passes run after every handler the SDK calls, after player and other state changes, and when you call plugin.update(); several requests are merged into one pass, and each view has at most one write in flight. After a player state change only the bind functions that read a changed field of `plugin.player.state` run again (The update pass has the details).
4636
4642
 
4637
4643
  The rule to remember: **state you change after an `await` inside a handler appears only when the handler finishes**, unless something else triggers a pass. Call `plugin.update()` right after changing it (see Keep a slider responsive while dragging).
4638
4644
 
@@ -5810,6 +5816,7 @@ Every SDK release's CHANGELOG entry opens with who must change and what happens
5810
5816
 
5811
5817
  | Release | Who must change | Guide |
5812
5818
  | --- | --- | --- |
5819
+ | [1.5.45](https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md#1545) | **Nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed. | — |
5813
5820
  | [1.5.44](https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md#1544) | **Nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. | — |
5814
5821
  | [1.5.43](https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md#1543) | **Nobody.** Additions only, `apiVersion` stays `vestra/1`, no new capability. | — |
5815
5822
  | [1.5.42](https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md#1542) | **Nobody.** Documentation only: no API changes, `apiVersion` stays `vestra/1`, no player version needed. | — |
@@ -6367,8 +6374,10 @@ interface CommandState {
6367
6374
 
6368
6375
  ### Bindable
6369
6376
 
6370
- A value, or a function the SDK re-evaluates on every update pass (see
6371
- Scope.update); only changes are sent to the player.
6377
+ A value, or a function the SDK re-evaluates on every full update pass,
6378
+ and after a player state change only if it read a field of
6379
+ `plugin.player.state` that changed (see Scope.update); only
6380
+ changes are sent to the player.
6372
6381
 
6373
6382
  ```ts
6374
6383
  type Bindable<T> = T | (() => T);
@@ -6377,9 +6386,9 @@ type Bindable<T> = T | (() => T);
6377
6386
  Example: Mix a computed state with a fixed one
6378
6387
 
6379
6388
  The Loop item carries a check mark while a loop is set and is always
6380
- enabled. A function is evaluated again in every update pass (after each
6381
- handler and player state change) and only changes are sent; a plain
6382
- value never changes.
6389
+ enabled. A function is evaluated again in every full update pass (after
6390
+ each handler), and after a player state change only if it read a changed
6391
+ field; only changes are sent, and a plain value never changes.
6383
6392
  ```ts
6384
6393
  plugin.commands.add({
6385
6394
  id: "loop",
@@ -6800,8 +6809,9 @@ from the menu and the binding table; the id can be added again right away.
6800
6809
 
6801
6810
  The handle of one menu item and its keys. Its state (`enabled`, `checked`,
6802
6811
  `busy`, `reason`) is bound: functions given to Commands.add or
6803
- Command.set are evaluated again in every update pass and only
6804
- changes reach the player. Its title, path, bindings and button cannot
6812
+ Command.set are evaluated again in every full update pass (after
6813
+ a player state change only if they read a changed field of
6814
+ `plugin.player.state`) and only changes reach the player. Its title, path, bindings and button cannot
6805
6815
  change in place; dispose the command (or a child Scope) and add it
6806
6816
  again. Disposing it, its scope or stopping the plugin removes the menu
6807
6817
  item, its keys and its bar button.
@@ -6864,7 +6874,7 @@ const exportCommand = plugin.commands.add({
6864
6874
  path: ["@video", "Export…"],
6865
6875
  run: runExport,
6866
6876
  });
6867
- // A function: re-evaluated in every update pass.
6877
+ // A function: re-evaluated in every full pass and when `phase` changes.
6868
6878
  exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
6869
6879
  ```
6870
6880
 
@@ -7415,8 +7425,9 @@ a property back: a value the user changes reaches it only through a
7415
7425
  callback.
7416
7426
 
7417
7427
  `bind` is the usual way: the SDK re-evaluates bound functions in every
7418
- update pass (after each handler it calls, after player state changes, on
7419
- Scope.update) and sends only what changed, at most one write in
7428
+ full update pass (after each handler it calls, on Scope.update),
7429
+ and after a player state change those that read a changed field of
7430
+ `plugin.player.state`, and sends only what changed, at most one write in
7420
7431
  flight per view. `set` sends now, even an unchanged value.
7421
7432
 
7422
7433
  Writes that fail (a name the Slint file does not declare, a value of the
@@ -7519,7 +7530,11 @@ interface View {
7519
7530
  * Keep properties equal to a function of the plugin's state.
7520
7531
  *
7521
7532
  * The SDK calls `props` in every update pass (and right after `bind`) and
7522
- * sends the values that differ from the ones it sent last. Because an
7533
+ * sends the values that differ from the ones it sent last. After a change
7534
+ * of the player state alone it calls `props` only if it read a field of
7535
+ * `plugin.player.state` that changed (SDK 1.5.45); a full pass follows
7536
+ * after each handler, on Scope.update and at least once a second
7537
+ * while the player state keeps changing. Because an
7523
7538
  * unchanged value is not sent again, text the user typed into an `in-out`
7524
7539
  * property stays until the plugin's value for it changes.
7525
7540
  *
@@ -7537,8 +7552,9 @@ interface View {
7537
7552
  * @example Show what is playing and whether it can seek
7538
7553
  * The status window always shows the current file and enables its seek
7539
7554
  * controls only for seekable sources. The SDK calls the function right
7540
- * away and again in every update pass (after player state changes and
7541
- * after each handler) and sends only the values that changed.
7555
+ * away, again in every full update pass (after each handler) and after
7556
+ * each change of `source` or `can`, and sends only the values that
7557
+ * changed.
7542
7558
  * ```ts
7543
7559
  * const status = plugin.windows.create("ui/status.slint");
7544
7560
  * status.bind(() => ({
@@ -10947,8 +10963,8 @@ interface Player {
10947
10963
  * @example Drive the player's Next and Previous from a playlist
10948
10964
  * The player's Next and Previous items and keys play the plugin's list.
10949
10965
  * The player calls `next` and `previous` when the user asks; `canNext`
10950
- * and `canPrevious` are evaluated again after every handler the SDK runs,
10951
- * and while they return false the player disables its items ("No next
10966
+ * and `canPrevious` are evaluated again after every handler the SDK runs
10967
+ * (and after a player state change if they read a changed field), and while they return false the player disables its items ("No next
10952
10968
  * item").
10953
10969
  * ```ts
10954
10970
  * const playAt = async (at: number) => {
@@ -17001,6 +17017,13 @@ class TestHost implements Transport {
17001
17017
  * or parsing a file, a child process, a network request) is not waited
17002
17018
  * for: on a loaded machine it can finish after `settle()` returns. Poll
17003
17019
  * the result you assert on instead (second example).
17020
+ *
17021
+ * `settle()` does not wait for the SDK's once-a-second full update pass
17022
+ * (SDK 1.5.45). After `setSource` (or another player state change) only
17023
+ * the bindings that read a changed field of `plugin.player.state` run
17024
+ * again; state the plugin changed outside a handler without
17025
+ * `plugin.update()` shows only in the next full pass. Have the plugin
17026
+ * call `plugin.update()` after such a change, or wait `settle(1000)`.
17004
17027
  * @param ms - Extra time to wait, for debounces and timers (default 0).
17005
17028
  * @capability None.
17006
17029
  * @throws Never.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.5.44: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process (the player's built-in plugins share one). Everything a plugin creates (commands, windows, panels, filters, listeners…) is a handle that is released automatically when the plugin stops. L<n>: a line of llms-full.txt (next to this file), which has every page, declaration and example.
3
+ > @vestra-player/sdk 1.5.45: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process (the player's built-in plugins share one). Everything a plugin creates (commands, windows, panels, filters, listeners…) is a handle that is released automatically when the plugin stops. L<n>: a line of llms-full.txt (next to this file), which has every page, declaration and example.
4
4
 
5
5
  **Rules**
6
6
 
@@ -33,7 +33,7 @@ my-plugin/
33
33
  }
34
34
  ```
35
35
 
36
- Fields: `id`, `version`, `apiVersion`, `entry`, `capabilities`, `name`, `author`, `punchline`, `description`, `tags`, `icon`, `readme`, `changelog`, `sourceUrl`, `license`, `minPlayerVersion`; unknown fields and capabilities are rejected. Rules and limits: L2216–2289.
36
+ Fields: `id`, `version`, `apiVersion`, `entry`, `capabilities`, `name`, `author`, `punchline`, `description`, `tags`, `icon`, `readme`, `changelog`, `sourceUrl`, `license`, `minPlayerVersion`; unknown fields and capabilities are rejected. Rules and limits: L2218–2291.
37
37
 
38
38
  **main.ts**
39
39
 
@@ -78,9 +78,9 @@ export default async function activate(plugin: Plugin) {
78
78
  - `secrets`: Secrets.get/set/delete
79
79
  - `rendering.manage`: Rendering.setPreference/restartPlayer
80
80
  - `video.upscaling`: VideoUpscaling.state/set/onChange
81
- - No capability: `tools`, `storage`, `system`, `app`, `ui` (images and Markdown), `i18n`, reading window.state, appearance.theme, packages.list and rendering.state. Details: L2290–2350.
81
+ - No capability: `tools`, `storage`, `system`, `app`, `ui` (images and Markdown), `i18n`, reading window.state, appearance.theme, packages.list and rendering.state. Details: L2292–2352.
82
82
 
83
- **Test**: `tests/*.test.ts` with `node:test`, run by `vestra-plugin test`. `startPlugin(activate, { directory: new URL("..", import.meta.url) })` from `@vestra-player/sdk/testing` returns `{ host, stop }`, `host` an in-memory player (TestHost): act (`host.command(id).run()`, `host.press(key)`), `await host.settle()`, assert on `host.osd`, `host.window(id).props`… Guide: L1861–2019; TestHost: L15463.
83
+ **Test**: `tests/*.test.ts` with `node:test`, run by `vestra-plugin test`. `startPlugin(activate, { directory: new URL("..", import.meta.url) })` from `@vestra-player/sdk/testing` returns `{ host, stop }`, `host` an in-memory player (TestHost): act (`host.command(id).run()`, `host.press(key)`), `await host.settle()`, assert on `host.osd`, `host.window(id).props`… Guide: L1861–2021; TestHost: L15479.
84
84
 
85
85
  **Command line**
86
86
 
@@ -108,40 +108,40 @@ export default async function activate(plugin: Plugin) {
108
108
  - `limit`: A player-wide limit was reached, a property value is over the limits of PropValue
109
109
  - `reserved`: The id is `vestra` or starts with `vestra.`
110
110
  - `versionMismatch`: The package needs a newer player
111
- - Others: PluginError, llms-full.txt L5945.
111
+ - Others: PluginError, llms-full.txt L5952.
112
112
 
113
113
  ## Reference
114
114
 
115
115
  - [llms-full.txt](https://vestrasys.github.io/vestra-player-sdk/llms-full.txt), also node_modules/@vestra-player/sdk/llms-full.txt; pages: [site](https://vestrasys.github.io/vestra-player-sdk/).
116
116
  - Tutorials: Getting started L9–134; Add a window L135–213; Add settings and translations L214–367; Test, check, pack and publish L368–424; Write your own blur kernel L425–685
117
- - How-to: Control playback L686–854; Store a plugin's data L855–961; Load subtitles from a plugin L962–1074; Keep a slider responsive while dragging L1075–1262; Remember a window's state between runs L1263–1414; Offer a GPU rendering switch L1415–1540; Keep a command's state without keeping the plugin running L1541–1697; Translate a plugin L1698–1860; Test a plugin L1861–2019; Publish a plugin L2020–2121; Troubleshooting L2122–2215
118
- - Reference: Manifest L2216–2289; Capabilities L2290–2350; Limits L2351–2496; Commands, menus and bindings L2497–3022; Platforms L3023–3053; Theme L3054–3403; Command line L3404–3461; Error codes L3462–3509; Translations L3510–3602; Test host L3603–3789
119
- - Explanation: How a plugin runs L3790–3988; Commands, menus and key bindings L3989–4143; Playback state, controls and sources L4144–4303; Video and audio processing L4304–4424; Windows, panels, settings pages, dialogs and the OSD L4425–4687; GPU and software rendering L4688–4704; The player's Rust core L4705–5046; How the player runs and talks to plugins L5047–5386
120
- - Guidelines: Plugin rules L5387–5398; Interface guidelines L5399–5515; Menu guidelines L5516–5574
121
- - Upgrading: Migrate a plugin to English source text L5575–5724; Migrating off package rollback (1.5.16, removed in 1.5.17) L5725–5802; Who must change, release by release L5803–5867
122
- - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L5868–15136
123
- - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html): L15137–18004
117
+ - How-to: Control playback L686–854; Store a plugin's data L855–961; Load subtitles from a plugin L962–1074; Keep a slider responsive while dragging L1075–1262; Remember a window's state between runs L1263–1414; Offer a GPU rendering switch L1415–1540; Keep a command's state without keeping the plugin running L1541–1697; Translate a plugin L1698–1860; Test a plugin L1861–2021; Publish a plugin L2022–2123; Troubleshooting L2124–2217
118
+ - Reference: Manifest L2218–2291; Capabilities L2292–2352; Limits L2353–2498; Commands, menus and bindings L2499–3024; Platforms L3025–3055; Theme L3056–3405; Command line L3406–3463; Error codes L3464–3511; Translations L3512–3604; Test host L3605–3791
119
+ - Explanation: How a plugin runs L3792–3994; Commands, menus and key bindings L3995–4149; Playback state, controls and sources L4150–4309; Video and audio processing L4310–4430; Windows, panels, settings pages, dialogs and the OSD L4431–4693; GPU and software rendering L4694–4710; The player's Rust core L4711–5052; How the player runs and talks to plugins L5053–5392
120
+ - Guidelines: Plugin rules L5393–5404; Interface guidelines L5405–5521; Menu guidelines L5522–5580
121
+ - Upgrading: Migrate a plugin to English source text L5581–5730; Migrating off package rollback (1.5.16, removed in 1.5.17) L5731–5808; Who must change, release by release L5809–5874
122
+ - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L5875–15152
123
+ - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html): L15153–18027
124
124
 
125
125
  ## API
126
126
 
127
127
  Where each `plugin.<module>` is declared:
128
128
 
129
- - Plugin L6284, Scope L6020, PluginError L5945, Disposable L5883
130
- - Commands L6871; When L6469; Windows L7808; Panels L9254; Dialogs L9924; Player L10665; video: VideoFilter L11545, VideoView L11645, RenderOptions L11732, VideoShaders L11810, VideoUpscaling L12168; audio: AudioFilter L11590; Subtitles L12404; media: MediaCapture L12808; Resolvers L13038; Packages L13440; Osd L11410; Host L11252; Clipboard L13769; Power L13856; Tools L13915; Storage L14067; System L14266; Secrets L14346; window: MainWindow L9571; App L9751; Rendering L14609; Bindings L13668; Ui L7160; Timeline L9483; Drops L8501; Appearance L8582; I18n L8815; Settings L9098.
129
+ - Plugin L6291, Scope L6027, PluginError L5952, Disposable L5890
130
+ - Commands L6881; When L6478; Windows L7824; Panels L9270; Dialogs L9940; Player L10681; video: VideoFilter L11561, VideoView L11661, RenderOptions L11748, VideoShaders L11826, VideoUpscaling L12184; audio: AudioFilter L11606; Subtitles L12420; media: MediaCapture L12824; Resolvers L13054; Packages L13456; Osd L11426; Host L11268; Clipboard L13785; Power L13872; Tools L13931; Storage L14083; System L14282; Secrets L14362; window: MainWindow L9587; App L9767; Rendering L14625; Bindings L13684; Ui L7170; Timeline L9499; Drops L8517; Appearance L8598; I18n L8831; Settings L9114.
131
131
 
132
132
  ## Examples
133
133
 
134
134
  Tested plugins in [examples/](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples):
135
135
 
136
- - hello: The README Quick start: one menu command and an on-screen message. L18439–18534
137
- - blur: A tool window with one slider that blurs the playing video. L18535–18802
138
- - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L18803–19141
139
- - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L19142–19334
140
- - panel-menu: A sidebar panel with its own right-click menu. L19335–19549
141
- - menus: Radio menu items, separator groups and a menu item whose title changes. L19550–19715
142
- - theme-widgets: A tool window drawn with the player's theme and widgets. L19716–19885
143
- - markdown: A window that shows a Markdown document. L19886–20088
144
- - i18n: A plugin in the player's language: English source text, a Chinese translation. L20089–20262
136
+ - hello: The README Quick start: one menu command and an on-screen message. L18462–18557
137
+ - blur: A tool window with one slider that blurs the playing video. L18558–18825
138
+ - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L18826–19164
139
+ - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L19165–19357
140
+ - panel-menu: A sidebar panel with its own right-click menu. L19358–19572
141
+ - menus: Radio menu items, separator groups and a menu item whose title changes. L19573–19738
142
+ - theme-widgets: A tool window drawn with the player's theme and widgets. L19739–19908
143
+ - markdown: A window that shows a Markdown document. L19909–20111
144
+ - i18n: A plugin in the player's language: English source text, a Chinese translation. L20112–20285
145
145
 
146
146
  ## Optional
147
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.44",
3
+ "version": "1.5.45",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {