@vestra-player/sdk 1.5.44 → 1.5.46

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.46
4
+
5
+ **Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. The new limits and the split writes take effect with player 0.6.0 (large lists P3); with older players arrays are sent whole as before and the old limits apply there. **A plugin that binds a whole long list depends on them: set `"minPlayerVersion": "0.6.0"`.**
6
+
7
+ - **Whole lists.** `UI_VALUE_LIMITS` follow player 0.6.0: 100,000 rows per array (was 4,096), 1,048,576 values (was 16,384) and 32 MiB of text (was 1 MiB) in one property value. A plugin binds its whole list; a long list should be drawn with a Slint `ListView` (a `for` builds every row). The windowing example is replaced by a whole-list one.
8
+ - **Writes larger than one request are split.** The protocol line stays 1 MiB (`MESSAGE_LIMIT`). With a player that takes row patches:
9
+ - an array whose rows exceed 512 KiB (`CHUNK_BYTES`, internal) is sent as a whole write of its first rows and `insert` patches of the rest, each applying on the one before; every part but the last carries `more: true`, and the player holds them and writes the whole value at the last, so a list it already shows changes once (its scroll position and selection stay);
10
+ - a patch carrying more is sent as several patches, each applying on the one before; a diff may now plan up to 16,384 operations (at most 1,024 moves), sent 1,024 at most per patch, before the value goes whole;
11
+ - the initial properties of `panels.mount`, `ui.open` and `settings.page` carry at most 512 KiB of rows together: the first rows show at once and the rest follows as patches on the first update;
12
+ - only a property's top-level array is split, and not one whose rows hold Markdown documents (sent whole as before: the player merges documents in place); any other value, and each row, must fit in one request. A row of more than 512 KiB, or a value over the limits, is refused with `limit` before any part is sent;
13
+ - when the view closes or shows its initial properties again while a write goes in parts, the rest is dropped without an error and the view's next update sends what it shows; any other failure part-way leaves no base, so the next write of the property is whole (in parts again).
14
+ - **`vestra-plugin check` warns** (a reminder, never a failure) where a plugin's .slint file puts a `for` over an array it binds (an `in` or `in-out` array property) outside a `ListView`: such a `for` builds every row up front.
15
+ - **`null` for an image in a row** shows no picture (player 0.6.0), so rows without a thumbnail need no placeholder image.
16
+ - **`TestHost`** enforces the new limits and takes the split writes like the player (each line within `MESSAGE_LIMIT`, parts with `more` held until the last); `props` hold whole values as before.
17
+
18
+ ## 1.5.45
19
+
20
+ **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.
21
+
22
+ - **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.
23
+ - **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).
24
+ - **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.
25
+ - **`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).
26
+ - **`Scrub.active`** turning false (a new source, a failed seek) now causes a full pass, so a binding that reads it follows.
27
+ - **`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).
28
+
3
29
  ## 1.5.44
4
30
 
5
31
  **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).
@@ -357,7 +357,10 @@ async function check(target, report = null) {
357
357
  }
358
358
  say(`${target.name}: capabilities declared for every API use`);
359
359
  if (report) return;
360
- for (const line of await bottomBarWarnings(target))
360
+ for (const line of [
361
+ ...(await bottomBarWarnings(target)),
362
+ ...(await forWarnings(target)),
363
+ ])
361
364
  console.log(`${target.name}: warning: ${line}`);
362
365
  // Translation warnings never fail the check.
363
366
  if (present(join(target.directory, "locales"))) {
@@ -502,6 +505,61 @@ async function bottomBarWarnings(target) {
502
505
  return warnings;
503
506
  }
504
507
 
508
+ /**
509
+ * A `for` over an array the plugin binds (an `in` or `in-out` property of
510
+ * array type of a component or global in the same file), outside a
511
+ * `ListView`, in the plugin's .slint files: a reminder, not a failure
512
+ * (SDK 1.5.46). Such a `for` builds every row of the array up front; since
513
+ * player 0.6.0 a plugin may bind a whole long list, which belongs in a
514
+ * `ListView` (it builds only the rows on screen). A text scan: imported
515
+ * components and `@children` are not followed.
516
+ */
517
+ async function forWarnings(target) {
518
+ const { slint } = await sourceFiles(target);
519
+ const warnings = [];
520
+ for (const file of slint) {
521
+ const text = slintCode(
522
+ await readFile(join(target.directory, file), "utf8"),
523
+ );
524
+ const arrays = new Set(
525
+ [
526
+ ...text.matchAll(
527
+ /\b(?:in|in-out)\s+property\s*<\s*\[[^\]]*\]\s*>\s*([A-Za-z_][\w-]*)/g,
528
+ ),
529
+ ].map((match) => match[1]),
530
+ );
531
+ if (!arrays.size) continue;
532
+ // The elements open at each point: the name before each `{`.
533
+ const open = [];
534
+ let line = 1;
535
+ for (let i = 0; i < text.length; i++) {
536
+ const c = text[i];
537
+ if (c === "\n") line++;
538
+ else if (c === "{") {
539
+ const name = /([A-Za-z_][\w-]*)\s*$/.exec(
540
+ text.slice(Math.max(0, i - 80), i),
541
+ );
542
+ open.push(name ? name[1] : "");
543
+ } else if (c === "}") open.pop();
544
+ else if (
545
+ text.startsWith("for", i) &&
546
+ !/[\w-]/.test(text[i - 1] ?? " ") &&
547
+ /\s/.test(text[i + 3] ?? "")
548
+ ) {
549
+ const loop =
550
+ /^for\s+[\w-]+(?:\s*\[\s*[\w-]+\s*\])?\s+in\s+([\w.-]+)/.exec(
551
+ text.slice(i, i + 200),
552
+ );
553
+ const array = loop?.[1].split(".").at(-1);
554
+ if (array && arrays.has(array) && !open.includes("ListView"))
555
+ warnings.push(
556
+ `${file.replaceAll("\\", "/")}:${line}: \`for\` over the bound array \`${array}\` outside a ListView builds every row; a long list should use a ListView (docs/reference/limits.md)`,
557
+ );
558
+ }
559
+ }
560
+ }
561
+ return warnings;
562
+ }
505
563
  /** The plugin's own production .ts / .mts and .slint files. */
506
564
  async function sourceFiles(target) {
507
565
  const all = await files(target.directory);
@@ -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
  }
@@ -2,6 +2,12 @@
2
2
  export declare const PATCH_FEATURE = "ui.patch";
3
3
  /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
4
4
  export declare const PATCH_OPS = 1024;
5
+ /**
6
+ * The most operations one diff plans (SDK 1.5.46): its patch is sent as
7
+ * several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
8
+ * (in parts, which the player writes at once).
9
+ */
10
+ export declare const DIFF_OPS: number;
5
11
  /**
6
12
  * Fields that identify a row, in the order they are tried. A field is used
7
13
  * when every row has it, as a string or a number, and no two rows share it.
@@ -61,7 +67,8 @@ export declare function countRow(row: unknown): RowCount;
61
67
  export declare function withinLimits(counts: readonly RowCount[]): boolean;
62
68
  /**
63
69
  * The operations that turn the old rows into the new ones (`null`: more
64
- * than {@link PATCH_OPS} would be needed), and for each new row the old row
70
+ * than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
71
+ * moves), and for each new row the old row
65
72
  * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
66
73
  * have keys from the same field; otherwise by index.
67
74
  */
@@ -99,3 +106,47 @@ export declare function applyOps<T>(rows: T[], ops: readonly ({
99
106
  count: number;
100
107
  to: number;
101
108
  })[]): T[];
109
+ /**
110
+ * The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
111
+ * half the 1 MiB protocol line, which leaves room for the request around
112
+ * them. A larger whole write is sent as its first rows and `insert`
113
+ * patches of the rest; a larger patch as several patches, each applying on
114
+ * the one before (the player's doc/architecture/large-lists.md §4).
115
+ */
116
+ export declare const CHUNK_BYTES: number;
117
+ /** The bytes a row adds to a write (its JSON and a separator). */
118
+ export declare const rowBytes: (row: unknown) => number;
119
+ /**
120
+ * How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
121
+ * bytes; at least one when there are rows (a row over the budget alone is
122
+ * left to the player's limits).
123
+ */
124
+ export declare function prefixWithin(sizes: readonly number[], budget: number): number;
125
+ /** A wire operation (`set` and `insert` carry rows). */
126
+ export type WireOp = {
127
+ op: "set";
128
+ at: number;
129
+ row: unknown;
130
+ } | {
131
+ op: "insert";
132
+ at: number;
133
+ rows: unknown[];
134
+ } | {
135
+ op: "remove";
136
+ at: number;
137
+ count: number;
138
+ } | {
139
+ op: "move";
140
+ from: number;
141
+ count: number;
142
+ to: number;
143
+ };
144
+ /**
145
+ * `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
146
+ * {@link PATCH_OPS} operations each, in order: applying them one after the
147
+ * other gives what `ops` give. An `insert` of more rows than fit is split
148
+ * into inserts of consecutive runs.
149
+ */
150
+ export declare function chunkOps<T extends WireOp>(ops: readonly T[]): T[][];
151
+ /** The bytes a row already in JSON adds to a write (and a separator). */
152
+ export declare const jsonBytes: (json: string) => number;
@@ -9,6 +9,12 @@ import { UI_VALUE_LIMITS } from "./values.js";
9
9
  export const PATCH_FEATURE = "ui.patch";
10
10
  /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
11
11
  export const PATCH_OPS = 1024;
12
+ /**
13
+ * The most operations one diff plans (SDK 1.5.46): its patch is sent as
14
+ * several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
15
+ * (in parts, which the player writes at once).
16
+ */
17
+ export const DIFF_OPS = 16 * PATCH_OPS;
12
18
  /**
13
19
  * Fields that identify a row, in the order they are tried. A field is used
14
20
  * when every row has it, as a string or a number, and no two rows share it.
@@ -159,7 +165,8 @@ function increasing(values) {
159
165
  }
160
166
  /**
161
167
  * The operations that turn the old rows into the new ones (`null`: more
162
- * than {@link PATCH_OPS} would be needed), and for each new row the old row
168
+ * than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
169
+ * moves), and for each new row the old row
163
170
  * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
164
171
  * have keys from the same field; otherwise by index.
165
172
  */
@@ -168,7 +175,7 @@ export function diff(old, next) {
168
175
  const ops = old.keys && next.keys
169
176
  ? keyed(old.enc, old.keys, next.enc, next.keys, from)
170
177
  : byIndex(old.enc, next.enc, from);
171
- return ops && ops.length <= PATCH_OPS ? { ops, from } : null;
178
+ return ops && ops.length <= DIFF_OPS ? { ops, from } : null;
172
179
  }
173
180
  function byIndex(old, next, from) {
174
181
  const min = Math.min(old.length, next.length);
@@ -236,7 +243,7 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
236
243
  }
237
244
  else
238
245
  ops.push({ op: "remove", at: i, count: 1 });
239
- if (ops.length > PATCH_OPS)
246
+ if (ops.length > DIFF_OPS)
240
247
  return null;
241
248
  }
242
249
  for (let i = p; i < oldEnd; i++)
@@ -248,7 +255,10 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
248
255
  // positions goes right after the kept row before it in the new order.
249
256
  const positions = kept.map((key) => target.get(key));
250
257
  const stay = increasing(positions);
251
- if (kept.length - stay.size + ops.length > PATCH_OPS)
258
+ // Moves cost a search each: at most PATCH_OPS of them.
259
+ if (kept.length - stay.size > PATCH_OPS)
260
+ return null;
261
+ if (kept.length - stay.size + ops.length > DIFF_OPS)
252
262
  return null;
253
263
  const stays = new Set();
254
264
  for (const index of stay)
@@ -281,13 +291,13 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
281
291
  last.rows.push(j);
282
292
  else
283
293
  ops.push({ op: "insert", at: j, rows: [j] });
284
- if (ops.length > PATCH_OPS)
294
+ if (ops.length > DIFF_OPS)
285
295
  return null;
286
296
  }
287
297
  sets.sort((a, b) => a.at - b.at);
288
298
  for (const set of sets) {
289
299
  ops.push(set);
290
- if (ops.length > PATCH_OPS)
300
+ if (ops.length > DIFF_OPS)
291
301
  return null;
292
302
  }
293
303
  return ops;
@@ -335,3 +345,91 @@ export function applyOps(rows, ops) {
335
345
  }
336
346
  return out;
337
347
  }
348
+ /**
349
+ * The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
350
+ * half the 1 MiB protocol line, which leaves room for the request around
351
+ * them. A larger whole write is sent as its first rows and `insert`
352
+ * patches of the rest; a larger patch as several patches, each applying on
353
+ * the one before (the player's doc/architecture/large-lists.md §4).
354
+ */
355
+ export const CHUNK_BYTES = 512 * 1024;
356
+ /** About what an operation adds to a request besides its rows. */
357
+ const OP_BYTES = 64;
358
+ /** UTF-8 bytes of `text` (JSON of a row), without encoding it. */
359
+ function utf8Bytes(text) {
360
+ let bytes = text.length;
361
+ for (let i = 0; i < text.length; i++) {
362
+ const code = text.charCodeAt(i);
363
+ if (code >= 0x80)
364
+ bytes += code >= 0x800 && (code < 0xd800 || code > 0xdfff) ? 2 : 1;
365
+ }
366
+ return bytes;
367
+ }
368
+ /** The bytes a row adds to a write (its JSON and a separator). */
369
+ export const rowBytes = (row) => utf8Bytes(JSON.stringify(row) ?? "null") + 1;
370
+ /**
371
+ * How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
372
+ * bytes; at least one when there are rows (a row over the budget alone is
373
+ * left to the player's limits).
374
+ */
375
+ export function prefixWithin(sizes, budget) {
376
+ let used = 0;
377
+ for (let i = 0; i < sizes.length; i++) {
378
+ used += sizes[i];
379
+ if (used > budget)
380
+ return Math.max(i, 1);
381
+ }
382
+ return sizes.length;
383
+ }
384
+ /**
385
+ * `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
386
+ * {@link PATCH_OPS} operations each, in order: applying them one after the
387
+ * other gives what `ops` give. An `insert` of more rows than fit is split
388
+ * into inserts of consecutive runs.
389
+ */
390
+ export function chunkOps(ops) {
391
+ const groups = [];
392
+ let group = [];
393
+ let bytes = 0;
394
+ const push = (op, size) => {
395
+ if (group.length &&
396
+ (bytes + size > CHUNK_BYTES || group.length >= PATCH_OPS)) {
397
+ groups.push(group);
398
+ group = [];
399
+ bytes = 0;
400
+ }
401
+ group.push(op);
402
+ bytes += size;
403
+ };
404
+ for (const op of ops) {
405
+ if (op.op !== "insert") {
406
+ push(op, OP_BYTES + (op.op === "set" ? rowBytes(op.row) : 0));
407
+ continue;
408
+ }
409
+ const sizes = op.rows.map(rowBytes);
410
+ let start = 0;
411
+ while (start < sizes.length) {
412
+ const room = Math.max(CHUNK_BYTES - bytes - OP_BYTES, 0);
413
+ let count = prefixWithin(sizes.slice(start), room);
414
+ // Nothing fits beside this group's rows: the run starts a new patch.
415
+ if (room === 0 ||
416
+ sizes.slice(start, start + count).reduce((a, b) => a + b, 0) > room) {
417
+ if (group.length) {
418
+ groups.push(group);
419
+ group = [];
420
+ bytes = 0;
421
+ }
422
+ count = prefixWithin(sizes.slice(start), CHUNK_BYTES - OP_BYTES);
423
+ }
424
+ const rows = op.rows.slice(start, start + count);
425
+ const size = OP_BYTES + sizes.slice(start, start + count).reduce((a, b) => a + b, 0);
426
+ push({ op: "insert", at: op.at + start, rows }, size);
427
+ start += count;
428
+ }
429
+ }
430
+ if (group.length)
431
+ groups.push(group);
432
+ return groups;
433
+ }
434
+ /** The bytes a row already in JSON adds to a write (and a separator). */
435
+ export const jsonBytes = (json) => utf8Bytes(json) + 1;
@@ -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.