@vestra-player/sdk 1.5.45 → 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,20 @@
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
+
3
18
  ## 1.5.45
4
19
 
5
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.
@@ -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);
@@ -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;
@@ -935,10 +935,15 @@ export class TestHost {
935
935
  * view and property (none: 0, the initial properties).
936
936
  */
937
937
  #writes = new Map();
938
+ /** The parts of a whole value held until its last part, by view and property. */
939
+ #parts = new Map();
938
940
  #resetWrites(view) {
939
941
  for (const slot of [...this.#writes.keys()])
940
942
  if (slot.startsWith(`${view}\u0000`))
941
943
  this.#writes.delete(slot);
944
+ for (const slot of [...this.#parts.keys()])
945
+ if (slot.startsWith(`${view}\u0000`))
946
+ this.#parts.delete(slot);
942
947
  }
943
948
  /**
944
949
  * A property write as the player takes it, returning the value the
@@ -950,6 +955,44 @@ export class TestHost {
950
955
  #write(view, property, p, current) {
951
956
  const slot = `${view}\u0000${property}`;
952
957
  const patch = p.patch;
958
+ // A whole array sent in parts (player 0.6.0, SDK 1.5.46): every part but
959
+ // the last carries `more`; the player holds them and writes the whole
960
+ // value at the last, as here (`undefined`: held, nothing shown yet).
961
+ const more = p.more === true;
962
+ const held = this.#parts.get(slot);
963
+ this.#parts.delete(slot);
964
+ if (more && patch === undefined) {
965
+ const rows = this.#shownValues(p.value);
966
+ if (!Array.isArray(rows))
967
+ throw new HostError("invalid", "a value sent in parts is an array");
968
+ noMarkdownParts(rows);
969
+ this.#checkValues({ [property]: rows });
970
+ this.#parts.set(slot, {
971
+ seq: typeof p.seq === "number" ? p.seq : 0,
972
+ rows,
973
+ });
974
+ return undefined;
975
+ }
976
+ if (patch !== undefined && held && patch.base === held.seq) {
977
+ const ops = patchOps(patch.ops, held.rows.length);
978
+ const resolved = ops.map((op) => op.op === "set"
979
+ ? { ...op, row: this.#shownValues(op.row) }
980
+ : op.op === "insert"
981
+ ? { ...op, rows: op.rows.map((row) => this.#shownValues(row)) }
982
+ : op);
983
+ noMarkdownParts(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
984
+ const rows = applyOps(held.rows, resolved);
985
+ this.#checkValues({ [property]: rows });
986
+ if (more) {
987
+ this.#parts.set(slot, { seq: patch.seq, rows });
988
+ return undefined;
989
+ }
990
+ this.#seeImages([rows]);
991
+ this.#writes.set(slot, patch.seq);
992
+ return rows;
993
+ }
994
+ if (patch !== undefined && more)
995
+ throw new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
953
996
  if (patch === undefined) {
954
997
  const value = this.#shownValues(p.value);
955
998
  this.#checkValues({ [property]: value });
@@ -3899,7 +3942,10 @@ export class TestHost {
3899
3942
  if (!window)
3900
3943
  throw new HostError("missing", "view not found");
3901
3944
  const property = String(p.property);
3902
- window.props[property] = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3945
+ const next = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3946
+ // A part of a value sent in parts is held until the last (player 0.6.0).
3947
+ if (next !== undefined)
3948
+ window.props[property] = next;
3903
3949
  },
3904
3950
  "ui.show": (p) => {
3905
3951
  const window = this.windows.get(String(p.id));
@@ -4260,7 +4306,10 @@ export class TestHost {
4260
4306
  "ui.setMount": (p) => {
4261
4307
  const panel = this.mounted();
4262
4308
  const property = String(p.property);
4263
- panel.props[property] = this.#write("$mount", property, p, panel.props[property]);
4309
+ const next = this.#write("$mount", property, p, panel.props[property]);
4310
+ // A part of a value sent in parts is held until the last (player 0.6.0).
4311
+ if (next !== undefined)
4312
+ panel.props[property] = next;
4264
4313
  },
4265
4314
  "timeline.marks": () => ({ marks: this.timeline.marks }),
4266
4315
  "drops.provide": (p) => {
@@ -4323,7 +4372,10 @@ export class TestHost {
4323
4372
  if (!page)
4324
4373
  throw new HostError("missing", "page not registered");
4325
4374
  const property = String(p.property);
4326
- page.props[property] = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4375
+ const next = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4376
+ // A part of a value sent in parts is held until the last (player 0.6.0).
4377
+ if (next !== undefined)
4378
+ page.props[property] = next;
4327
4379
  },
4328
4380
  "settings.unregister": (p) => {
4329
4381
  this.settings.pages.delete(String(p.id));
@@ -5352,6 +5404,11 @@ export async function startPlugin(activate, options = {}) {
5352
5404
  * before; at most {@link PATCH_OPS}; the result at most
5353
5405
  * `UI_VALUE_LIMITS.arrayRows` rows.
5354
5406
  */
5407
+ /** The player takes no Markdown documents in parts of a value (`invalid`). */
5408
+ function noMarkdownParts(rows) {
5409
+ if (JSON.stringify(rows).includes('"$vestraMarkdown"'))
5410
+ throw new HostError("invalid", "a value sent in parts holds no Markdown documents");
5411
+ }
5355
5412
  function patchOps(ops, length) {
5356
5413
  if (!Array.isArray(ops))
5357
5414
  throw new HostError("invalid", "patch.ops requires an array");
@@ -1384,20 +1384,23 @@ export interface MarkdownOptions {
1384
1384
  * thumbnails.
1385
1385
  *
1386
1386
  * Limits per value (`UI_VALUE_LIMITS` in `@vestra-player/sdk/testing`,
1387
- * player 0.4.3; older players: 4096 values and 1024 rows): nesting depth 8,
1388
- * 16384 values in total (every array, object, string, number, boolean and
1389
- * null counts, the value itself included; a document or an image is one), 4096 rows
1390
- * per array, 64 KiB per string or field name and 1 MiB of text in total,
1391
- * field names and the text of its Markdown documents included (256 KB per
1392
- * document). A list of rows with 8 fields is 9 values per row, so about
1393
- * 1800 rows fit. The whole request must also fit the player's 1 MiB
1394
- * message. A value over a limit is refused with code `limit` and the
1395
- * property keeps what it showed (see {@link View.set}); `TestHost` refuses
1396
- * it the same way.
1397
- *
1398
- * The limits bound the work of one write, not the size of your data: for
1399
- * a list that can grow without bound, send the rows near the view and the
1400
- * row count, and move that window as the user scrolls.
1387
+ * player 0.6.0; players 0.4.3 to 0.5: 16384 values, 4096 rows, 1 MiB of
1388
+ * text): nesting depth 8, 1,048,576 values in total (every array, object,
1389
+ * string, number, boolean and null counts, the value itself included; a
1390
+ * document or an image is one), 100,000 rows per array, 64 KiB per string
1391
+ * or field name and 32 MiB of text in total, field names and the text of
1392
+ * its Markdown documents included (256 KB per document). A list of rows
1393
+ * with 13 fields is 14 values per row, so 50,000 such rows fit. An array
1394
+ * property larger than one 1 MiB request is sent in parts (SDK 1.5.46, a
1395
+ * player that takes row patches); only the top-level array is split, so
1396
+ * any other value, and each row, must fit in one request (a row of more
1397
+ * than 512 KiB is refused with `limit`). A value over a limit is refused
1398
+ * with code
1399
+ * `limit` and the property keeps what it showed (see {@link View.set});
1400
+ * `TestHost` refuses it the same way.
1401
+ *
1402
+ * Bind a whole list and draw it with a Slint `ListView`, which builds only
1403
+ * the rows on screen; the SDK sends what changed (row patches).
1401
1404
  *
1402
1405
  * @since 1.0.0
1403
1406
  * @category UI
@@ -32,9 +32,15 @@ export declare abstract class ViewBase implements View {
32
32
  protected abstract write(property: string, write: PropertyWrite): Promise<void>;
33
33
  /**
34
34
  * Initial properties in wire form. A released image is left out (the
35
- * property keeps its default) and reported, like a failed write.
35
+ * property keeps its default) and reported, like a failed write. With a
36
+ * player that takes row patches, the arrays of one request carry at most
37
+ * {@link CHUNK_BYTES} of rows together (SDK 1.5.46): an array over that
38
+ * goes with its first rows, and the first flush sends the rest as patches.
39
+ * An array over the value limits is refused here, as the player would
40
+ * refuse it whole: left out and reported (a panel, a settings page), or,
41
+ * with `refuse` (a window's `show`), thrown.
36
42
  */
37
- protected wired(values: Record<string, PropValue>): Record<string, Json>;
43
+ protected wired(values: Record<string, PropValue>, refuse?: boolean): Record<string, Json>;
38
44
  on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
39
45
  set(props: Props): void;
40
46
  bind(props: () => Props): Disposable;
package/dist/plugin/ui.js CHANGED
@@ -7,7 +7,8 @@ import { failure, Latest, quiet } from "./runtime.js";
7
7
  import { dropsOf } from "./drops.js";
8
8
  import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
9
9
  import { HostError } from "../wire.js";
10
- import { countRow, diff, keyOf, PATCH_FEATURE, replay, withinLimits, } from "./patch.js";
10
+ import { countRow, diff, keyOf, CHUNK_BYTES, chunkOps, PATCH_FEATURE, prefixWithin, jsonBytes, rowBytes, replay, withinLimits, } from "./patch.js";
11
+ import { UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
11
12
  /** Slint names are kebab-case; plugin code may use camelCase. */
12
13
  export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
13
14
  const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
@@ -110,6 +111,26 @@ function sentOf(value, patches) {
110
111
  }
111
112
  return { kind: "json", enc: JSON.stringify(value) };
112
113
  }
114
+ /**
115
+ * A write sent in parts stopped because its view closed or showed its
116
+ * initial properties again: not a failure.
117
+ */
118
+ class Abandoned extends Error {
119
+ }
120
+ /** The wire key of a Markdown document: rows holding one are never sent in parts. */ const MARKDOWN_KEY = '"$vestraMarkdown"';
121
+ /** A row larger than one request can carry (`limit`). */
122
+ const rowTooLarge = (key, bytes) => new HostError("limit", `${key}: a row of ${bytes} bytes is larger than one request takes (${CHUNK_BYTES} bytes)`);
123
+ /** Refuse a patch carrying a row larger than one request, before sending it. */
124
+ function tooLarge(key, ops) {
125
+ for (const op of ops) {
126
+ const rows = op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : [];
127
+ for (const row of rows) {
128
+ const bytes = rowBytes(row);
129
+ if (bytes > CHUNK_BYTES)
130
+ throw rowTooLarge(key, bytes);
131
+ }
132
+ }
133
+ }
113
134
  /**
114
135
  * The row patch from `sent` to `next` (the bound `rows`), in wire form, and
115
136
  * the new rows' limit counts; `null` when the whole value is to be sent:
@@ -194,6 +215,8 @@ export class ViewBase {
194
215
  */
195
216
  #stales = new Map();
196
217
  #seq = 0;
218
+ /** Counts the times the view showed its initial properties (`shown`). */
219
+ #generation = 0;
197
220
  /** Written by `set()`: sent even if equal to what was sent before. */
198
221
  #forced = new Set();
199
222
  /** The last value of each property whose write failed; not retried until it changes. */
@@ -215,18 +238,51 @@ export class ViewBase {
215
238
  }
216
239
  /**
217
240
  * Initial properties in wire form. A released image is left out (the
218
- * property keeps its default) and reported, like a failed write.
241
+ * property keeps its default) and reported, like a failed write. With a
242
+ * player that takes row patches, the arrays of one request carry at most
243
+ * {@link CHUNK_BYTES} of rows together (SDK 1.5.46): an array over that
244
+ * goes with its first rows, and the first flush sends the rest as patches.
245
+ * An array over the value limits is refused here, as the player would
246
+ * refuse it whole: left out and reported (a panel, a settings page), or,
247
+ * with `refuse` (a window's `show`), thrown.
219
248
  */
220
- wired(values) {
249
+ wired(values, refuse = false) {
221
250
  const out = {};
251
+ let room = CHUNK_BYTES;
222
252
  for (const [key, value] of Object.entries(values)) {
253
+ let wire;
223
254
  try {
224
- out[key] = wireValue(value);
255
+ wire = wireValue(value);
225
256
  }
226
257
  catch (error) {
227
258
  this.#failed.set(key, JSON.stringify(value));
228
259
  this.scope.report(error);
260
+ continue;
261
+ }
262
+ if (this.#patches && Array.isArray(wire)) {
263
+ const sizes = wire.map(rowBytes);
264
+ const total = sizes.reduce((a, b) => a + b, 0);
265
+ if (total > room && !JSON.stringify(wire).includes(MARKDOWN_KEY)) {
266
+ const largest = sizes.reduce((a, b) => Math.max(a, b), 0);
267
+ const problem = valueLimitProblem(wire);
268
+ if (problem || largest > CHUNK_BYTES) {
269
+ const error = problem
270
+ ? new HostError("limit", `${key}: ${problem}`)
271
+ : rowTooLarge(key, largest);
272
+ if (refuse)
273
+ throw error;
274
+ this.#failed.set(key, JSON.stringify(value));
275
+ this.scope.report(error);
276
+ continue;
277
+ }
278
+ const count = room > 0 ? prefixWithin(sizes, room) : 0;
279
+ wire = wire.slice(0, count);
280
+ room -= sizes.slice(0, count).reduce((a, b) => a + b, 0);
281
+ }
282
+ else
283
+ room -= total;
229
284
  }
285
+ out[key] = wire;
230
286
  }
231
287
  return out;
232
288
  }
@@ -289,6 +345,7 @@ export class ViewBase {
289
345
  }
290
346
  /** The host already shows these values (e.g. sent as initial properties). */
291
347
  shown(values) {
348
+ this.#generation++;
292
349
  const patches = this.#patches;
293
350
  this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
294
351
  this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
@@ -354,7 +411,10 @@ export class ViewBase {
354
411
  * Write one property: a row patch when the player takes them and one
355
412
  * applies (else the whole value); a `stale` patch is followed by the whole
356
413
  * value at once. The base is forgotten on any failure, so the next write
357
- * of the property is whole.
414
+ * of the property is whole. A write larger than one request
415
+ * ({@link CHUNK_BYTES} of rows, SDK 1.5.46) is sent in parts: a whole
416
+ * array as its first rows and `insert` patches of the rest, a patch as
417
+ * several, each applying on the one before.
358
418
  */
359
419
  async #send(pending) {
360
420
  const { key, value } = pending;
@@ -362,34 +422,121 @@ export class ViewBase {
362
422
  await this.write(key, { value: wireValue(value) });
363
423
  return pending.sent();
364
424
  }
425
+ const generation = this.#generation;
365
426
  const base = this.#bases.get(key);
366
427
  this.#bases.delete(key);
367
428
  const sent = this.#sent.get(key);
368
- const plan = base !== undefined &&
429
+ let plan = base !== undefined &&
369
430
  sent?.kind === "rows" &&
370
431
  pending.rows &&
371
432
  (this.#stales.get(key) ?? 0) < 2
372
433
  ? rowPatch(sent.rows, pending.rows, value)
373
434
  : null;
435
+ // A patch sent in several never passes the row limit midway (the diff
436
+ // removes before it inserts; checked anyway); otherwise whole.
437
+ if (plan && sent?.kind === "rows") {
438
+ let length = sent.rows.enc.length;
439
+ for (const op of plan.ops) {
440
+ if (op.op === "insert")
441
+ length += op.rows.length;
442
+ else if (op.op === "remove")
443
+ length -= op.count;
444
+ if (length > UI_VALUE_LIMITS.arrayRows) {
445
+ plan = null;
446
+ break;
447
+ }
448
+ }
449
+ }
374
450
  if (plan) {
451
+ tooLarge(key, plan.ops);
452
+ const [first, ...rest] = chunkOps(plan.ops);
375
453
  const seq = ++this.#seq;
454
+ let applied = false;
376
455
  try {
377
- await this.write(key, { patch: { base: base, seq, ops: plan.ops } });
378
- this.#bases.set(key, seq);
379
- this.#stales.delete(key);
380
- return pending.sent(plan.counts);
456
+ await this.write(key, { patch: { base: base, seq, ops: first } });
457
+ applied = true;
381
458
  }
382
459
  catch (error) {
383
460
  if (!(error instanceof HostError && error.code === "stale"))
384
461
  throw error;
385
462
  this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
386
463
  }
464
+ if (applied) {
465
+ this.#stales.delete(key);
466
+ await this.#parts(key, seq, rest, false, generation);
467
+ return pending.sent(plan.counts);
468
+ }
387
469
  }
388
470
  const seq = ++this.#seq;
471
+ // Rows holding Markdown documents go whole, as before: the player
472
+ // merges documents in place and takes no parts of them.
473
+ if (Array.isArray(value) &&
474
+ pending.rows &&
475
+ !pending.rows.enc.some((row) => row.includes(MARKDOWN_KEY))) {
476
+ const sizes = pending.rows.enc.map(jsonBytes);
477
+ const count = prefixWithin(sizes, CHUNK_BYTES);
478
+ if (count < value.length) {
479
+ const rows = value;
480
+ // Over the limits, or a row larger than one request: refused here,
481
+ // as the player would refuse the whole value, before any part is
482
+ // sent (a part refused midway would leave the list half sent).
483
+ const problem = valueLimitProblem(wireValue(value));
484
+ if (problem)
485
+ throw new HostError("limit", `${key}: ${problem}`);
486
+ const largest = sizes.reduce((a, b) => Math.max(a, b), 0);
487
+ if (largest > CHUNK_BYTES)
488
+ throw rowTooLarge(key, largest);
489
+ // Every part but the last carries `more`: the player holds them
490
+ // and writes the whole value at the last, so a list it shows is
491
+ // written once (player 0.6.0).
492
+ await this.write(key, {
493
+ value: wireValue(rows.slice(0, count)),
494
+ seq,
495
+ more: true,
496
+ });
497
+ const insert = {
498
+ op: "insert",
499
+ at: count,
500
+ rows: rows.slice(count).map((row) => wireValue(row ?? null)),
501
+ };
502
+ await this.#parts(key, seq, chunkOps([insert]), true, generation);
503
+ return pending.sent();
504
+ }
505
+ }
389
506
  await this.write(key, { value: wireValue(value), seq });
390
507
  this.#bases.set(key, seq);
391
508
  return pending.sent();
392
509
  }
510
+ /**
511
+ * The rest of a write sent in parts: each patch applies on the one
512
+ * before (`base`), and the property's base is the last one's; with
513
+ * `more`, every part but the last says so. A failure leaves no base: the
514
+ * next write of the property is whole. When the view closed or showed its
515
+ * initial properties again meanwhile (`generation`), the rest is dropped
516
+ * quietly ({@link Abandoned}): the view's next flush sends what it shows.
517
+ */
518
+ async #parts(key, base, groups, more, generation) {
519
+ let last = base;
520
+ for (const [i, ops] of groups.entries()) {
521
+ if (!this.live() || this.#generation !== generation)
522
+ throw new Abandoned();
523
+ const seq = ++this.#seq;
524
+ try {
525
+ await this.write(key, {
526
+ patch: { base: last, seq, ops },
527
+ ...(more && i < groups.length - 1 ? { more: true } : {}),
528
+ });
529
+ }
530
+ catch (error) {
531
+ // Refused because the view went meanwhile: not a failure.
532
+ if (!this.live() || this.#generation !== generation)
533
+ throw new Abandoned();
534
+ throw error;
535
+ }
536
+ last = seq;
537
+ }
538
+ this.#bases.set(key, last);
539
+ }
393
540
  /**
394
541
  * Send what changed. A bound value is sent when it differs from the last one
395
542
  * sent; a `set()` value is always sent once. Nothing is resent merely
@@ -431,6 +578,13 @@ export class ViewBase {
431
578
  this.#broken = false;
432
579
  }
433
580
  catch (error) {
581
+ // The view closed or showed its initial properties again while
582
+ // a write was sent in parts: nothing failed (the next flush
583
+ // sends what the view shows).
584
+ if (error instanceof Abandoned) {
585
+ queueMicrotask(() => this.flush());
586
+ return;
587
+ }
434
588
  failed = true;
435
589
  // Report the start of a failing streak only.
436
590
  if (!this.#broken)
@@ -530,7 +684,7 @@ class WindowHandle extends ViewBase {
530
684
  const ui = this.scope.runtime.host.ui;
531
685
  if (this.#opened)
532
686
  return ui.show(this.#id, true);
533
- const initial = this.wired(this.snapshot());
687
+ const initial = this.wired(this.snapshot(), true);
534
688
  await ui.open(this.#id, this.#file, initial, [...this.handlers.keys()]);
535
689
  this.#opened = true;
536
690
  this.shown(initial);
@@ -12,41 +12,26 @@ import type { Json } from "../wire.ts";
12
12
  * makes `Window.show` reject; a panel or settings page opens without it and
13
13
  * the failure goes to the plugin's `onError`.
14
14
  *
15
- * They bound the work one write costs the player's interface thread; they
16
- * are not meant as list sizes to aim for. A list that can grow without
17
- * bound (a playlist, a history) should send the rows near the view and say
18
- * how many there are, not the whole list.
15
+ * Since player 0.6.0 (SDK 1.5.46) they allow a whole list: a plugin binds
16
+ * all of its rows (a playlist of 50,000 entries fits) and the view draws
17
+ * them with a Slint `ListView`, which builds only the rows on screen. The
18
+ * SDK sends a changed array as row patches, and an array larger than one
19
+ * request (about 512 KiB of rows) as its first rows followed by `insert`
20
+ * patches of the rest; the player converts large writes off its interface
21
+ * thread. Before player 0.6.0 the limits were 4,096 rows, 16,384 values and
22
+ * 1 MiB of text, so a long list had to be sent as a window of rows.
23
+ *
24
+ * In a row, `null` where the view declares an `image` shows no picture
25
+ * (player 0.6.0): rows without a thumbnail need no placeholder image.
26
+ *
27
+ * @example Bind a whole list
28
+ * The view draws every row with a `ListView`; the SDK sends what changed.
19
29
  *
20
- * @example Send a long playlist as a window of rows
21
- * A list view shows any number of entries while each write stays small:
22
- * the plugin sends only the rows around the scroll position plus the total,
23
- * and the view reports where it scrolled to. After the `scrolled` handler
24
- * returns, the SDK evaluates the `bind` function again and sends the new
25
- * window. Each row object and each of its fields count as one value toward
26
- * `values`, and so does the array holding them.
27
30
  * ```ts
28
- * declare const entries: { title: string; duration: number }[];
29
- * import type { View } from "@vestra-player/sdk";
31
+ * declare const entries: { id: string; title: string; duration: string }[];
30
32
  * declare const list: View;
31
33
  * // ---cut---
32
- * import { UI_VALUE_LIMITS } from "@vestra-player/sdk/testing";
33
- *
34
- * // 200 rows, or fewer if the limits were smaller: 2 fields per row make
35
- * // 3 values (the row and its fields), plus 1 for the array.
36
- * const size = Math.min(
37
- * 200,
38
- * UI_VALUE_LIMITS.arrayRows,
39
- * Math.floor((UI_VALUE_LIMITS.values - 1) / 3),
40
- * );
41
- * let first = 0;
42
- * list.bind(() => ({
43
- * total: entries.length,
44
- * rows: entries.slice(first, first + size),
45
- * }));
46
- * // The Slint view reports its first visible row; the window follows it.
47
- * list.on("scrolled", (row: number) => {
48
- * first = Math.max(0, row - Math.floor(size / 2));
49
- * });
34
+ * list.bind(() => ({ rows: entries.map((e) => ({ ...e, selected: false })) }));
50
35
  * ```
51
36
  *
52
37
  * @since 1.5.10
@@ -59,16 +44,16 @@ export declare const UI_VALUE_LIMITS: {
59
44
  /**
60
45
  * Values in all, counting every array, object, string, number, boolean
61
46
  * and null, the value itself included; a Markdown document is one
62
- * (16384).
47
+ * (1,048,576; 16,384 before player 0.6.0).
63
48
  */
64
- readonly values: 16384;
65
- /** Rows of one array (4096). */
66
- readonly arrayRows: 4096;
49
+ readonly values: number;
50
+ /** Rows of one array (100,000; 4,096 before player 0.6.0). */
51
+ readonly arrayRows: 100000;
67
52
  /** UTF-8 bytes of one string or object field name (64 KiB). */
68
53
  readonly stringBytes: number;
69
54
  /**
70
55
  * UTF-8 bytes of all strings and field names together, the text of the
71
- * value's Markdown documents included (1 MiB).
56
+ * value's Markdown documents included (32 MiB; 1 MiB before player 0.6.0).
72
57
  */
73
58
  readonly textBytes: number;
74
59
  };
@@ -10,41 +10,26 @@
10
10
  * makes `Window.show` reject; a panel or settings page opens without it and
11
11
  * the failure goes to the plugin's `onError`.
12
12
  *
13
- * They bound the work one write costs the player's interface thread; they
14
- * are not meant as list sizes to aim for. A list that can grow without
15
- * bound (a playlist, a history) should send the rows near the view and say
16
- * how many there are, not the whole list.
13
+ * Since player 0.6.0 (SDK 1.5.46) they allow a whole list: a plugin binds
14
+ * all of its rows (a playlist of 50,000 entries fits) and the view draws
15
+ * them with a Slint `ListView`, which builds only the rows on screen. The
16
+ * SDK sends a changed array as row patches, and an array larger than one
17
+ * request (about 512 KiB of rows) as its first rows followed by `insert`
18
+ * patches of the rest; the player converts large writes off its interface
19
+ * thread. Before player 0.6.0 the limits were 4,096 rows, 16,384 values and
20
+ * 1 MiB of text, so a long list had to be sent as a window of rows.
21
+ *
22
+ * In a row, `null` where the view declares an `image` shows no picture
23
+ * (player 0.6.0): rows without a thumbnail need no placeholder image.
24
+ *
25
+ * @example Bind a whole list
26
+ * The view draws every row with a `ListView`; the SDK sends what changed.
17
27
  *
18
- * @example Send a long playlist as a window of rows
19
- * A list view shows any number of entries while each write stays small:
20
- * the plugin sends only the rows around the scroll position plus the total,
21
- * and the view reports where it scrolled to. After the `scrolled` handler
22
- * returns, the SDK evaluates the `bind` function again and sends the new
23
- * window. Each row object and each of its fields count as one value toward
24
- * `values`, and so does the array holding them.
25
28
  * ```ts
26
- * declare const entries: { title: string; duration: number }[];
27
- * import type { View } from "@vestra-player/sdk";
29
+ * declare const entries: { id: string; title: string; duration: string }[];
28
30
  * declare const list: View;
29
31
  * // ---cut---
30
- * import { UI_VALUE_LIMITS } from "@vestra-player/sdk/testing";
31
- *
32
- * // 200 rows, or fewer if the limits were smaller: 2 fields per row make
33
- * // 3 values (the row and its fields), plus 1 for the array.
34
- * const size = Math.min(
35
- * 200,
36
- * UI_VALUE_LIMITS.arrayRows,
37
- * Math.floor((UI_VALUE_LIMITS.values - 1) / 3),
38
- * );
39
- * let first = 0;
40
- * list.bind(() => ({
41
- * total: entries.length,
42
- * rows: entries.slice(first, first + size),
43
- * }));
44
- * // The Slint view reports its first visible row; the window follows it.
45
- * list.on("scrolled", (row: number) => {
46
- * first = Math.max(0, row - Math.floor(size / 2));
47
- * });
32
+ * list.bind(() => ({ rows: entries.map((e) => ({ ...e, selected: false })) }));
48
33
  * ```
49
34
  *
50
35
  * @since 1.5.10
@@ -57,18 +42,18 @@ export const UI_VALUE_LIMITS = {
57
42
  /**
58
43
  * Values in all, counting every array, object, string, number, boolean
59
44
  * and null, the value itself included; a Markdown document is one
60
- * (16384).
45
+ * (1,048,576; 16,384 before player 0.6.0).
61
46
  */
62
- values: 16384,
63
- /** Rows of one array (4096). */
64
- arrayRows: 4096,
47
+ values: 1024 * 1024,
48
+ /** Rows of one array (100,000; 4,096 before player 0.6.0). */
49
+ arrayRows: 100_000,
65
50
  /** UTF-8 bytes of one string or object field name (64 KiB). */
66
51
  stringBytes: 64 * 1024,
67
52
  /**
68
53
  * UTF-8 bytes of all strings and field names together, the text of the
69
- * value's Markdown documents included (1 MiB).
54
+ * value's Markdown documents included (32 MiB; 1 MiB before player 0.6.0).
70
55
  */
71
- textBytes: 1024 * 1024,
56
+ textBytes: 32 * 1024 * 1024,
72
57
  };
73
58
  const L = UI_VALUE_LIMITS;
74
59
  const bytes = (text) => Buffer.byteLength(text, "utf8");
package/dist/wire.d.ts CHANGED
@@ -88,12 +88,15 @@ export type RowOp = {
88
88
  export type PropertyWrite = {
89
89
  value: Json;
90
90
  seq?: number;
91
+ more?: boolean;
91
92
  } | {
92
93
  patch: {
93
94
  base: number;
94
95
  seq: number;
95
96
  ops: RowOp[];
96
97
  };
98
+ /** A part of a whole value sent in parts, not the last (player 0.6.0). */
99
+ more?: boolean;
97
100
  };
98
101
  /**
99
102
  * A definite refusal from the player (or the plugin runner), with a stable
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.45: full reference
1
+ # @vestra-player/sdk 1.5.46: 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
 
@@ -2284,6 +2284,7 @@ The table lists the API whose documentation names a player version, newest last:
2284
2284
  | 0.5.20 | VideoShaders |
2285
2285
  | 0.5.21 | Scope.when, When, Bindings.onChange |
2286
2286
  | 0.5.25 | UpscalingReason.cause, UpscalingReason.plugins, UpscalingDecodingCause, UpscalingPlugin, UpscalingVideoSize, UpscalingState.video, UpscalingState.area, UpscalingState.gpu, UpscalingState.hwdecCurrent, UpscalingState.inChain, VideoUpscaling.onChange |
2287
+ | 0.6.0 | PropValue |
2287
2288
 
2288
2289
  The Slint library carries no version tags: `Theme.danger` needs player 0.3.0; `@vestra/icons.slint` and the palette `vestra-light` need 0.4.0 (Theme).
2289
2290
 
@@ -2352,7 +2353,7 @@ The check follows types, not names: `plugin.player.seek`, `const { player } = pl
2352
2353
 
2353
2354
  ## Reference: Limits
2354
2355
 
2355
- The player bounds what one request may cost it. The limits are guards against runaway plugins, not sizes to aim for: a plugin that needs to come close to one usually needs a different design (send the rows near the view, not the whole list). The SDK checks the same limits before it sends a request, so a plugin's own tests hit them too (TestHost enforces them). A name in code style is a constant you can import from `@vestra-player/sdk/testing`.
2356
+ The player bounds what one request may cost it. The limits are guards against runaway plugins, not sizes to aim for. Since player 0.6.0 a whole list fits (50,000 playlist rows): bind it and draw it with a Slint `ListView` (a long list should use one: a `for` builds every row). The SDK sends row patches, and an array property larger than one request in parts (SDK 1.5.46); only a property's top-level array without Markdown documents is split, so any other value, and each row, must fit in one request (a row of more than 512 KiB is refused with `limit`). The SDK checks the same limits before it sends a request, so a plugin's own tests hit them too (TestHost enforces them). A name in code style is a constant you can import from `@vestra-player/sdk/testing`.
2356
2357
 
2357
2358
  #### Commands
2358
2359
 
@@ -2380,10 +2381,10 @@ One value written to a window, panel or settings page property (View.set, View.b
2380
2381
  | Limit | Value | Meaning |
2381
2382
  | --- | --- | --- |
2382
2383
  | `UI_VALUE_LIMITS.depth` | 8 | Nesting depth; the value itself is depth 0. |
2383
- | `UI_VALUE_LIMITS.values` | 16384 | Values in all, counting every array, object, string, number, boolean and null, the value itself included; a Markdown document is one. |
2384
- | `UI_VALUE_LIMITS.arrayRows` | 4096 | Rows of one array. |
2384
+ | `UI_VALUE_LIMITS.values` | 1048576 | Values in all, counting every array, object, string, number, boolean and null, the value itself included; a Markdown document is one. |
2385
+ | `UI_VALUE_LIMITS.arrayRows` | 100000 | Rows of one array. |
2385
2386
  | `UI_VALUE_LIMITS.stringBytes` | 65536 (64 KiB) | UTF-8 bytes of one string or object field name. |
2386
- | `UI_VALUE_LIMITS.textBytes` | 1048576 (1 MiB) | UTF-8 bytes of all strings and field names together, the text of the value's Markdown documents included. |
2387
+ | `UI_VALUE_LIMITS.textBytes` | 33554432 (32 MiB) | UTF-8 bytes of all strings and field names together, the text of the value's Markdown documents included. |
2387
2388
 
2388
2389
  #### Text values
2389
2390
 
@@ -5816,6 +5817,7 @@ Every SDK release's CHANGELOG entry opens with who must change and what happens
5816
5817
 
5817
5818
  | Release | Who must change | Guide |
5818
5819
  | --- | --- | --- |
5820
+ | [1.5.46](https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md#1546) | **Nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. | — |
5819
5821
  | [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. | — |
5820
5822
  | [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. | — |
5821
5823
  | [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. | — |
@@ -7102,20 +7104,23 @@ ImageOptions.maxSide for small pictures such as logos and
7102
7104
  thumbnails.
7103
7105
 
7104
7106
  Limits per value (`UI_VALUE_LIMITS` in `@vestra-player/sdk/testing`,
7105
- player 0.4.3; older players: 4096 values and 1024 rows): nesting depth 8,
7106
- 16384 values in total (every array, object, string, number, boolean and
7107
- null counts, the value itself included; a document or an image is one), 4096 rows
7108
- per array, 64 KiB per string or field name and 1 MiB of text in total,
7109
- field names and the text of its Markdown documents included (256 KB per
7110
- document). A list of rows with 8 fields is 9 values per row, so about
7111
- 1800 rows fit. The whole request must also fit the player's 1 MiB
7112
- message. A value over a limit is refused with code `limit` and the
7113
- property keeps what it showed (see View.set); `TestHost` refuses
7114
- it the same way.
7115
-
7116
- The limits bound the work of one write, not the size of your data: for
7117
- a list that can grow without bound, send the rows near the view and the
7118
- row count, and move that window as the user scrolls.
7107
+ player 0.6.0; players 0.4.3 to 0.5: 16384 values, 4096 rows, 1 MiB of
7108
+ text): nesting depth 8, 1,048,576 values in total (every array, object,
7109
+ string, number, boolean and null counts, the value itself included; a
7110
+ document or an image is one), 100,000 rows per array, 64 KiB per string
7111
+ or field name and 32 MiB of text in total, field names and the text of
7112
+ its Markdown documents included (256 KB per document). A list of rows
7113
+ with 13 fields is 14 values per row, so 50,000 such rows fit. An array
7114
+ property larger than one 1 MiB request is sent in parts (SDK 1.5.46, a
7115
+ player that takes row patches); only the top-level array is split, so
7116
+ any other value, and each row, must fit in one request (a row of more
7117
+ than 512 KiB is refused with `limit`). A value over a limit is refused
7118
+ with code
7119
+ `limit` and the property keeps what it showed (see View.set);
7120
+ `TestHost` refuses it the same way.
7121
+
7122
+ Bind a whole list and draw it with a Slint `ListView`, which builds only
7123
+ the rows on screen; the SDK sends what changed (row patches).
7119
7124
 
7120
7125
  ```ts
7121
7126
  type PropValue = PropData | ImageRef;
@@ -17854,53 +17859,41 @@ keeps what it showed) and reported to `Scope.onError`: a write by
17854
17859
  makes `Window.show` reject; a panel or settings page opens without it and
17855
17860
  the failure goes to the plugin's `onError`.
17856
17861
 
17857
- They bound the work one write costs the player's interface thread; they
17858
- are not meant as list sizes to aim for. A list that can grow without
17859
- bound (a playlist, a history) should send the rows near the view and say
17860
- how many there are, not the whole list.
17862
+ Since player 0.6.0 (SDK 1.5.46) they allow a whole list: a plugin binds
17863
+ all of its rows (a playlist of 50,000 entries fits) and the view draws
17864
+ them with a Slint `ListView`, which builds only the rows on screen. The
17865
+ SDK sends a changed array as row patches, and an array larger than one
17866
+ request (about 512 KiB of rows) as its first rows followed by `insert`
17867
+ patches of the rest; the player converts large writes off its interface
17868
+ thread. Before player 0.6.0 the limits were 4,096 rows, 16,384 values and
17869
+ 1 MiB of text, so a long list had to be sent as a window of rows.
17870
+
17871
+ In a row, `null` where the view declares an `image` shows no picture
17872
+ (player 0.6.0): rows without a thumbnail need no placeholder image.
17861
17873
 
17862
17874
  ```ts
17863
17875
  const UI_VALUE_LIMITS: {
17864
17876
  /** Nesting depth; the value itself is depth 0 (8). */
17865
17877
  readonly depth: 8;
17866
- /** Values in all, counting every array, object, string, number, boolean and null, the value itself included; a Markdown document is one (16384). */
17867
- readonly values: 16384;
17868
- /** Rows of one array (4096). */
17869
- readonly arrayRows: 4096;
17878
+ /**
17879
+ * Values in all, counting every array, object, string, number, boolean and null, the value itself included; a Markdown document is one (1,048,576; 16,384 before player 0.6.0).
17880
+ */
17881
+ readonly values: number;
17882
+ /** Rows of one array (100,000; 4,096 before player 0.6.0). */
17883
+ readonly arrayRows: 100000;
17870
17884
  /** UTF-8 bytes of one string or object field name (64 KiB). */
17871
17885
  readonly stringBytes: number;
17872
- /** UTF-8 bytes of all strings and field names together, the text of the value's Markdown documents included (1 MiB). */
17886
+ /** UTF-8 bytes of all strings and field names together, the text of the value's Markdown documents included (32 MiB; 1 MiB before player 0.6.0). */
17873
17887
  readonly textBytes: number;
17874
17888
  };
17875
17889
  ```
17876
17890
 
17877
- Example: Send a long playlist as a window of rows
17891
+ Example: Bind a whole list
17878
17892
 
17879
- A list view shows any number of entries while each write stays small:
17880
- the plugin sends only the rows around the scroll position plus the total,
17881
- and the view reports where it scrolled to. After the `scrolled` handler
17882
- returns, the SDK evaluates the `bind` function again and sends the new
17883
- window. Each row object and each of its fields count as one value toward
17884
- `values`, and so does the array holding them.
17885
- ```ts
17886
- import { UI_VALUE_LIMITS } from "@vestra-player/sdk/testing";
17893
+ The view draws every row with a `ListView`; the SDK sends what changed.
17887
17894
 
17888
- // 200 rows, or fewer if the limits were smaller: 2 fields per row make
17889
- // 3 values (the row and its fields), plus 1 for the array.
17890
- const size = Math.min(
17891
- 200,
17892
- UI_VALUE_LIMITS.arrayRows,
17893
- Math.floor((UI_VALUE_LIMITS.values - 1) / 3),
17894
- );
17895
- let first = 0;
17896
- list.bind(() => ({
17897
- total: entries.length,
17898
- rows: entries.slice(first, first + size),
17899
- }));
17900
- // The Slint view reports its first visible row; the window follows it.
17901
- list.on("scrolled", (row: number) => {
17902
- first = Math.max(0, row - Math.floor(size / 2));
17903
- });
17895
+ ```ts
17896
+ list.bind(() => ({ rows: entries.map((e) => ({ ...e, selected: false })) }));
17904
17897
  ```
17905
17898
 
17906
17899
  ### IMAGE_LIMITS
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
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.
3
+ > @vestra-player/sdk 1.5.46: 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: L2218–2291.
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–2292.
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: L2292–2352.
81
+ - No capability: `tools`, `storage`, `system`, `app`, `ui` (images and Markdown), `i18n`, reading window.state, appearance.theme, packages.list and rendering.state. Details: L2293–2353.
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–2021; TestHost: L15479.
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: L15484.
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 L5952.
111
+ - Others: PluginError, llms-full.txt L5954.
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
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
118
+ - Reference: Manifest L2218–2292; Capabilities L2293–2353; Limits L2354–2499; Commands, menus and bindings L2500–3025; Platforms L3026–3056; Theme L3057–3406; Command line L3407–3464; Error codes L3465–3512; Translations L3513–3605; Test host L3606–3792
119
+ - Explanation: How a plugin runs L3793–3995; Commands, menus and key bindings L3996–4150; Playback state, controls and sources L4151–4310; Video and audio processing L4311–4431; Windows, panels, settings pages, dialogs and the OSD L4432–4694; GPU and software rendering L4695–4711; The player's Rust core L4712–5053; How the player runs and talks to plugins L5054–5393
120
+ - Guidelines: Plugin rules L5394–5405; Interface guidelines L5406–5522; Menu guidelines L5523–5581
121
+ - Upgrading: Migrate a plugin to English source text L5582–5731; Migrating off package rollback (1.5.16, removed in 1.5.17) L5732–5809; Who must change, release by release L5810–5876
122
+ - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L5877–15157
123
+ - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html): L15158–18020
124
124
 
125
125
  ## API
126
126
 
127
127
  Where each `plugin.<module>` is declared:
128
128
 
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.
129
+ - Plugin L6293, Scope L6029, PluginError L5954, Disposable L5892
130
+ - Commands L6883; When L6480; Windows L7829; Panels L9275; Dialogs L9945; Player L10686; video: VideoFilter L11566, VideoView L11666, RenderOptions L11753, VideoShaders L11831, VideoUpscaling L12189; audio: AudioFilter L11611; Subtitles L12425; media: MediaCapture L12829; Resolvers L13059; Packages L13461; Osd L11431; Host L11273; Clipboard L13790; Power L13877; Tools L13936; Storage L14088; System L14287; Secrets L14367; window: MainWindow L9592; App L9772; Rendering L14630; Bindings L13689; Ui L7175; Timeline L9504; Drops L8522; Appearance L8603; I18n L8836; Settings L9119.
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. 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
136
+ - hello: The README Quick start: one menu command and an on-screen message. L18455–18550
137
+ - blur: A tool window with one slider that blurs the playing video. L18551–18818
138
+ - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L18819–19157
139
+ - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L19158–19350
140
+ - panel-menu: A sidebar panel with its own right-click menu. L19351–19565
141
+ - menus: Radio menu items, separator groups and a menu item whose title changes. L19566–19731
142
+ - theme-widgets: A tool window drawn with the player's theme and widgets. L19732–19901
143
+ - markdown: A window that shows a Markdown document. L19902–20104
144
+ - i18n: A plugin in the player's language: English source text, a Chinese translation. L20105–20278
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.45",
3
+ "version": "1.5.46",
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": {