@vestra-player/sdk 1.5.43 → 1.5.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
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
+
14
+ ## 1.5.44
15
+
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).
17
+
18
+ - **Row patches.** When a bound or set array changes, the SDK sends the rows that changed instead of the whole array: `set`, `insert`, `remove` and `move` operations by index (wire: `ui.setMount`, `ui.set` and `settings.set` with `patch: { base, seq, ops }` instead of `value`; whole writes carry `seq`). Rows are matched by a `key`, `id`, `rowId` or `row-id` field when every row has one, unique, as a string or number (moves are kept to a minimum); otherwise by index, with the unchanged rows at both ends left out. The player writes the rows into its live model in place, without comparing the whole array: selecting one row of a 200-row list costs one row. A player that cannot apply a patch answers `stale` and the SDK sends the whole value at once. Whole values are sent as before when the player does not advertise the feature (`features: ["ui.patch"]` in its start-up data), after a failed write, for rows written into an empty array, for rows holding a Markdown document, and for a value over the limits (refused with `limit`, as before).
19
+ After two `stale` answers in a row for a property, it is sent whole until the view shows its initial properties again. The limit counts see what is sent: an image or document handle is one value, any other object with `toJSON` (a `Date`) counts as what it returns, and a hole in a sparse array is `null`. A released image is refused in any row of the array, also in rows a patch keeps, as in a whole write. Your Slint code must not write into the rows of an array property: a patch does not overwrite rows whose value did not change ([Arrays: only the changed rows are sent](docs/explanation/ui.md#arrays-only-the-changed-rows-are-sent)). `set()` with an unchanged array sends it whole.
20
+ - **Cheaper update passes.** A scalar property is compared with what was sent without `JSON.stringify`; an array is encoded row by row, the same work as before.
21
+ - **`TestHost`** takes patches as the player does (`base`, `stale`, the checks on operations and the value limits on the result) and keeps whole values in `props`, so tests see no difference. It advertises the feature; `host.app = { ...host.app, features: [] }` before `startPlugin` gives an older player's behaviour.
22
+ - **`@vestra/widgets.slint`** (the read-only copy of the player's widget library, player 0.6.0): the widget library copy gains `Check`, `Radio`, `RadioMark`, `RadioGroup`, `Tab`, `Tabs` and `Choice` (a drop-down), drawn in the player's theme with a keyboard focus ring; `Slider` takes the keyboard (arrow keys by `step`, a twentieth of the range by default) and reports `has-focus`. A Slint file that imports them needs player 0.6.0. `Check` takes `self-toggle: false` for a state the plugin decides (a confirmation first); `Tabs` takes `emphasis` per tab; `Slider` takes `focusable: false` to stay out of the Tab order; `Choice` takes `window-height` so its list fits a short window. `@vestra/markdown.slint`: `MarkdownView` builds only the blocks on screen (a `ListView`), so long documents cost what is visible.
23
+
3
24
  ## 1.5.43
4
25
 
5
26
  **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`, no new capability. The new API needs player 0.5.25 (`"minPlayerVersion": "0.5.25"`); older players leave the new fields out and refuse the new event (the listener never fires). The `decoding` reason code stays as it is: the cause is a new optional field beside it.
@@ -10,7 +31,6 @@
10
31
  - **`TestHost`**: `upscalingDecoding(cause, plugins?)` fakes each cause with the player's message (and a matching `upscaling.hwdecCurrent`); `upscaling.video`, `area`, `gpu`, `hwdecCurrent` (defaults: 1080p on a 4K area, an RTX 4070, `d3d11va`; `inChain` follows `requested`); `upscalingChanged()` sends the event to a listening plugin.
11
32
  - The player's capability table copy (`video.upscaling.changed` under `video.upscaling`) and `CAPABILITY_INFO` (`VideoUpscaling.onChange`) follow.
12
33
 
13
-
14
34
  ## 1.5.42
15
35
 
16
36
  **Who must change: nobody.** Documentation only: no API changes, `apiVersion` stays `vestra/1`, no player version needed. The package's code is the same as 1.5.41; the doc comments' `@see` links (27 in `src/plugin/types.ts`) now point at `docs/explanation/` instead of `docs/concepts/`. The site's page addresses stay the same, and the old _Concepts_ group address redirects to _Explanation_; `llms.txt` and `llms-full.txt` follow the new pages.
@@ -24,3 +24,4 @@ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale
24
24
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
25
25
  export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
26
26
  export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.ts";
27
+ export { PATCH_FEATURE, PATCH_OPS } from "./plugin/patch.ts";
@@ -22,3 +22,4 @@ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale
22
22
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
23
23
  export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
24
24
  export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.js";
25
+ export { PATCH_FEATURE, PATCH_OPS } from "./plugin/patch.js";
@@ -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
  }
@@ -0,0 +1,101 @@
1
+ /** The player feature that takes row patches. */
2
+ export declare const PATCH_FEATURE = "ui.patch";
3
+ /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
4
+ export declare const PATCH_OPS = 1024;
5
+ /**
6
+ * Fields that identify a row, in the order they are tried. A field is used
7
+ * when every row has it, as a string or a number, and no two rows share it.
8
+ */
9
+ export declare const KEY_FIELDS: readonly string[];
10
+ export type RowKey = string | number;
11
+ /** One planned operation; `set` and `insert` name rows of the new array. */
12
+ export type PlannedOp = {
13
+ op: "set";
14
+ at: number;
15
+ row: number;
16
+ } | {
17
+ op: "insert";
18
+ at: number;
19
+ rows: number[];
20
+ } | {
21
+ op: "remove";
22
+ at: number;
23
+ count: number;
24
+ } | {
25
+ op: "move";
26
+ from: number;
27
+ count: number;
28
+ to: number;
29
+ };
30
+ /** What one row counts toward the value limits (as a row of an array). */
31
+ export interface RowCount {
32
+ values: number;
33
+ text: number;
34
+ /** Over a per-row limit (a string, the depth): the whole value is sent. */
35
+ over: boolean;
36
+ }
37
+ /** An array as sent: each row's JSON, its key field and keys, its counts. */
38
+ export interface Rows {
39
+ enc: string[];
40
+ field: string | null;
41
+ keys: RowKey[] | null;
42
+ /** Filled in as patches need them. */
43
+ counts: (RowCount | undefined)[];
44
+ }
45
+ /**
46
+ * The field that identifies `rows`, and each row's key; `null` when no
47
+ * field of {@link KEY_FIELDS} does (rows are then matched by index).
48
+ */
49
+ export declare function keyOf(rows: readonly unknown[]): {
50
+ field: string;
51
+ keys: RowKey[];
52
+ } | null;
53
+ /**
54
+ * What `row` counts toward the value limits as a row of an array (depth
55
+ * 1), like `valueLimitProblem` on the row as sent: every value one, an
56
+ * image one, a document one and its text, strings and field names their
57
+ * UTF-8 bytes. Any other object with `toJSON` counts as what it returns.
58
+ */
59
+ export declare function countRow(row: unknown): RowCount;
60
+ /** The new array is within the value limits (counted per row). */
61
+ export declare function withinLimits(counts: readonly RowCount[]): boolean;
62
+ /**
63
+ * 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
65
+ * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
66
+ * have keys from the same field; otherwise by index.
67
+ */
68
+ export declare function diff(old: {
69
+ enc: readonly string[];
70
+ keys: readonly RowKey[] | null;
71
+ }, next: {
72
+ enc: readonly string[];
73
+ keys: readonly RowKey[] | null;
74
+ }): {
75
+ ops: PlannedOp[];
76
+ from: Int32Array;
77
+ } | null;
78
+ /**
79
+ * Apply planned operations to a copy of `old` (the rows' JSON), taking new
80
+ * rows from `next`: the check that a diff produces exactly the new rows.
81
+ */
82
+ export declare function replay(old: readonly string[], ops: readonly PlannedOp[], next: readonly string[]): string[];
83
+ /** Apply wire operations to an array (the TestHost's copy of a value). */
84
+ export declare function applyOps<T>(rows: T[], ops: readonly ({
85
+ op: "set";
86
+ at: number;
87
+ row: T;
88
+ } | {
89
+ op: "insert";
90
+ at: number;
91
+ rows: T[];
92
+ } | {
93
+ op: "remove";
94
+ at: number;
95
+ count: number;
96
+ } | {
97
+ op: "move";
98
+ from: number;
99
+ count: number;
100
+ to: number;
101
+ })[]): T[];
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Row patches of array properties (player 0.6.0, feature `ui.patch`): the
3
+ * diff between the rows last sent and the rows bound now, as `set`,
4
+ * `insert`, `remove` and `move` operations by index. Internal; plugins see
5
+ * none of it (the player's doc/architecture/large-lists.md §2).
6
+ */
7
+ import { UI_VALUE_LIMITS } from "./values.js";
8
+ /** The player feature that takes row patches. */
9
+ export const PATCH_FEATURE = "ui.patch";
10
+ /** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
11
+ export const PATCH_OPS = 1024;
12
+ /**
13
+ * Fields that identify a row, in the order they are tried. A field is used
14
+ * when every row has it, as a string or a number, and no two rows share it.
15
+ */
16
+ export const KEY_FIELDS = ["key", "id", "rowId", "row-id"];
17
+ const plain = (value) => {
18
+ if (value === null || typeof value !== "object" || Array.isArray(value))
19
+ return false;
20
+ const proto = Object.getPrototypeOf(value);
21
+ return proto === Object.prototype || proto === null;
22
+ };
23
+ /**
24
+ * The field that identifies `rows`, and each row's key; `null` when no
25
+ * field of {@link KEY_FIELDS} does (rows are then matched by index).
26
+ */
27
+ export function keyOf(rows) {
28
+ const first = rows[0];
29
+ if (!plain(first))
30
+ return null;
31
+ for (const field of KEY_FIELDS) {
32
+ if (!(field in first))
33
+ continue;
34
+ const keys = [];
35
+ const seen = new Set();
36
+ let ok = true;
37
+ for (let i = 0; i < rows.length; i++) {
38
+ const row = rows[i];
39
+ const key = plain(row) ? row[field] : undefined;
40
+ if ((typeof key !== "string" && typeof key !== "number") ||
41
+ seen.has(key)) {
42
+ ok = false;
43
+ break;
44
+ }
45
+ seen.add(key);
46
+ keys.push(key);
47
+ }
48
+ if (ok)
49
+ return { field, keys };
50
+ }
51
+ return null;
52
+ }
53
+ const bytes = (text) => Buffer.byteLength(text, "utf8");
54
+ /**
55
+ * `value` as `JSON.stringify` writes it: an object's `toJSON` result
56
+ * (once), `undefined` for what it leaves out (a function, a symbol).
57
+ */
58
+ function jsonOf(value) {
59
+ if (value !== null &&
60
+ typeof value === "object" &&
61
+ !("__vestraImage" in value) &&
62
+ !("__vestraMarkdown" in value) &&
63
+ typeof value.toJSON === "function")
64
+ return value.toJSON();
65
+ return typeof value === "function" || typeof value === "symbol"
66
+ ? undefined
67
+ : value;
68
+ }
69
+ /**
70
+ * What `row` counts toward the value limits as a row of an array (depth
71
+ * 1), like `valueLimitProblem` on the row as sent: every value one, an
72
+ * image one, a document one and its text, strings and field names their
73
+ * UTF-8 bytes. Any other object with `toJSON` counts as what it returns.
74
+ */
75
+ export function countRow(row) {
76
+ const count = { values: 0, text: 0, over: false };
77
+ const L = UI_VALUE_LIMITS;
78
+ const text = (s) => {
79
+ const n = bytes(s);
80
+ if (n > L.stringBytes)
81
+ count.over = true;
82
+ count.text += n;
83
+ };
84
+ const walk = (value, depth) => {
85
+ if (count.over)
86
+ return;
87
+ if (depth > L.depth) {
88
+ count.over = true;
89
+ return;
90
+ }
91
+ count.values += 1;
92
+ if (value !== null && typeof value === "object") {
93
+ if ("__vestraImage" in value)
94
+ return;
95
+ if ("__vestraMarkdown" in value) {
96
+ count.text += bytes(value.text);
97
+ return;
98
+ }
99
+ }
100
+ if (typeof value === "string")
101
+ return text(value);
102
+ if (Array.isArray(value)) {
103
+ if (value.length > L.arrayRows)
104
+ count.over = true;
105
+ // Array.from: a hole is `null`, as `JSON.stringify` writes it.
106
+ for (const item of Array.from(value))
107
+ walk(jsonOf(item) ?? null, depth + 1);
108
+ return;
109
+ }
110
+ if (value !== null && typeof value === "object")
111
+ for (const [name, raw] of Object.entries(value)) {
112
+ const item = jsonOf(raw);
113
+ if (item === undefined)
114
+ continue;
115
+ text(name);
116
+ walk(item, depth + 1);
117
+ }
118
+ };
119
+ walk(jsonOf(row) ?? null, 1);
120
+ return count;
121
+ }
122
+ /** The new array is within the value limits (counted per row). */
123
+ export function withinLimits(counts) {
124
+ const L = UI_VALUE_LIMITS;
125
+ if (counts.length > L.arrayRows)
126
+ return false;
127
+ let values = 1;
128
+ let text = 0;
129
+ for (const count of counts) {
130
+ if (count.over)
131
+ return false;
132
+ values += count.values;
133
+ text += count.text;
134
+ }
135
+ return values <= L.values && text <= L.textBytes;
136
+ }
137
+ /** The longest increasing subsequence of `values`, as a set of indices. */
138
+ function increasing(values) {
139
+ const tails = [];
140
+ const before = new Int32Array(values.length).fill(-1);
141
+ for (let i = 0; i < values.length; i++) {
142
+ let lo = 0;
143
+ let hi = tails.length;
144
+ while (lo < hi) {
145
+ const mid = (lo + hi) >> 1;
146
+ if (values[tails[mid]] < values[i])
147
+ lo = mid + 1;
148
+ else
149
+ hi = mid;
150
+ }
151
+ if (lo > 0)
152
+ before[i] = tails[lo - 1];
153
+ tails[lo] = i;
154
+ }
155
+ const out = new Set();
156
+ for (let i = tails.at(-1) ?? -1; i >= 0; i = before[i])
157
+ out.add(i);
158
+ return out;
159
+ }
160
+ /**
161
+ * 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
163
+ * whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
164
+ * have keys from the same field; otherwise by index.
165
+ */
166
+ export function diff(old, next) {
167
+ const from = new Int32Array(next.enc.length).fill(-1);
168
+ const ops = old.keys && next.keys
169
+ ? keyed(old.enc, old.keys, next.enc, next.keys, from)
170
+ : byIndex(old.enc, next.enc, from);
171
+ return ops && ops.length <= PATCH_OPS ? { ops, from } : null;
172
+ }
173
+ function byIndex(old, next, from) {
174
+ const min = Math.min(old.length, next.length);
175
+ let p = 0;
176
+ while (p < min && old[p] === next[p])
177
+ from[p] = p++;
178
+ let s = 0;
179
+ while (s < min - p && old[old.length - 1 - s] === next[next.length - 1 - s]) {
180
+ from[next.length - 1 - s] = old.length - 1 - s;
181
+ s++;
182
+ }
183
+ const ops = [];
184
+ const middle = Math.min(old.length, next.length) - p - s;
185
+ for (let i = p; i < p + middle; i++)
186
+ if (old[i] === next[i])
187
+ from[i] = i;
188
+ else
189
+ ops.push({ op: "set", at: i, row: i });
190
+ const at = p + middle;
191
+ if (next.length > old.length)
192
+ ops.push({
193
+ op: "insert",
194
+ at,
195
+ rows: Array.from({ length: next.length - old.length }, (_, k) => at + k),
196
+ });
197
+ else if (old.length > next.length)
198
+ ops.push({ op: "remove", at, count: old.length - next.length });
199
+ return ops;
200
+ }
201
+ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
202
+ const sets = [];
203
+ const keep = (j, i) => {
204
+ if (oldEnc[i] === newEnc[j])
205
+ from[j] = i;
206
+ else
207
+ sets.push({ op: "set", at: j, row: j });
208
+ };
209
+ const min = Math.min(oldKeys.length, newKeys.length);
210
+ let p = 0;
211
+ while (p < min && oldKeys[p] === newKeys[p])
212
+ keep(p, p++);
213
+ let s = 0;
214
+ while (s < min - p &&
215
+ oldKeys[oldKeys.length - 1 - s] === newKeys[newKeys.length - 1 - s]) {
216
+ keep(newKeys.length - 1 - s, oldKeys.length - 1 - s);
217
+ s++;
218
+ }
219
+ const oldEnd = oldKeys.length - s;
220
+ const newEnd = newKeys.length - s;
221
+ // The new middle's keys, by position (relative to p).
222
+ const target = new Map();
223
+ for (let j = p; j < newEnd; j++)
224
+ target.set(newKeys[j], j - p);
225
+ const ops = [];
226
+ // Removes, from the end so that indices stay valid; runs merged.
227
+ const kept = [];
228
+ const oldAt = new Map();
229
+ for (let i = oldEnd - 1; i >= p; i--) {
230
+ if (target.has(oldKeys[i]))
231
+ continue;
232
+ const last = ops.at(-1);
233
+ if (last?.op === "remove" && last.at === i + 1) {
234
+ last.at = i;
235
+ last.count++;
236
+ }
237
+ else
238
+ ops.push({ op: "remove", at: i, count: 1 });
239
+ if (ops.length > PATCH_OPS)
240
+ return null;
241
+ }
242
+ for (let i = p; i < oldEnd; i++)
243
+ if (target.has(oldKeys[i])) {
244
+ oldAt.set(oldKeys[i], i);
245
+ kept.push(oldKeys[i]);
246
+ }
247
+ // Moves: every kept row outside a longest increasing run of target
248
+ // positions goes right after the kept row before it in the new order.
249
+ const positions = kept.map((key) => target.get(key));
250
+ const stay = increasing(positions);
251
+ if (kept.length - stay.size + ops.length > PATCH_OPS)
252
+ return null;
253
+ const stays = new Set();
254
+ for (const index of stay)
255
+ stays.add(kept[index]);
256
+ const current = kept.slice();
257
+ let previous;
258
+ for (let j = p; j < newEnd; j++) {
259
+ const key = newKeys[j];
260
+ if (!oldAt.has(key))
261
+ continue;
262
+ if (!stays.has(key)) {
263
+ const at = current.indexOf(key);
264
+ current.splice(at, 1);
265
+ const to = previous === undefined ? 0 : current.indexOf(previous) + 1;
266
+ current.splice(to, 0, key);
267
+ if (at !== to)
268
+ ops.push({ op: "move", from: p + at, count: 1, to: p + to });
269
+ }
270
+ previous = key;
271
+ }
272
+ // Inserts, from the top; runs merged.
273
+ for (let j = p; j < newEnd; j++) {
274
+ const key = newKeys[j];
275
+ if (oldAt.has(key)) {
276
+ keep(j, oldAt.get(key));
277
+ continue;
278
+ }
279
+ const last = ops.at(-1);
280
+ if (last?.op === "insert" && last.at + last.rows.length === j)
281
+ last.rows.push(j);
282
+ else
283
+ ops.push({ op: "insert", at: j, rows: [j] });
284
+ if (ops.length > PATCH_OPS)
285
+ return null;
286
+ }
287
+ sets.sort((a, b) => a.at - b.at);
288
+ for (const set of sets) {
289
+ ops.push(set);
290
+ if (ops.length > PATCH_OPS)
291
+ return null;
292
+ }
293
+ return ops;
294
+ }
295
+ /**
296
+ * Apply planned operations to a copy of `old` (the rows' JSON), taking new
297
+ * rows from `next`: the check that a diff produces exactly the new rows.
298
+ */
299
+ export function replay(old, ops, next) {
300
+ const rows = old.slice();
301
+ for (const op of ops)
302
+ switch (op.op) {
303
+ case "set":
304
+ rows[op.at] = next[op.row];
305
+ break;
306
+ case "insert":
307
+ rows.splice(op.at, 0, ...op.rows.map((j) => next[j]));
308
+ break;
309
+ case "remove":
310
+ rows.splice(op.at, op.count);
311
+ break;
312
+ case "move":
313
+ rows.splice(op.to, 0, ...rows.splice(op.from, op.count));
314
+ break;
315
+ }
316
+ return rows;
317
+ }
318
+ /** Apply wire operations to an array (the TestHost's copy of a value). */
319
+ export function applyOps(rows, ops) {
320
+ const out = rows.slice();
321
+ for (const op of ops)
322
+ switch (op.op) {
323
+ case "set":
324
+ out[op.at] = op.row;
325
+ break;
326
+ case "insert":
327
+ out.splice(op.at, 0, ...op.rows);
328
+ break;
329
+ case "remove":
330
+ out.splice(op.at, op.count);
331
+ break;
332
+ case "move":
333
+ out.splice(op.to, 0, ...out.splice(op.from, op.count));
334
+ break;
335
+ }
336
+ return out;
337
+ }
@@ -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 = {}) {