@vestra-player/sdk 1.5.44 → 1.5.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -247,18 +303,32 @@ export class ViewBase {
247
303
  this.flush();
248
304
  }
249
305
  bind(props) {
250
- this.#binders.set(props, this.scope.runtime.callSite());
306
+ // `token` names this binding of this view in the runtime's read
307
+ // tracking: the same function bound to two views is two parts.
308
+ this.#binders.set(props, {
309
+ site: this.scope.runtime.callSite(),
310
+ token: {},
311
+ });
251
312
  this.scope.update();
252
313
  return this.scope.onDispose(() => {
253
314
  this.#binders.delete(props);
254
315
  });
255
316
  }
256
- /** Current desired properties (bound values win over earlier `set`). */
317
+ /**
318
+ * Current desired properties (bound values win over earlier `set`). A
319
+ * precise pass (`Runtime.part`) skips a bind function that read none of
320
+ * the changed state: its last values stay.
321
+ */
257
322
  snapshot() {
258
- for (const [binder, site] of this.#binders) {
323
+ const runtime = this.scope.runtime;
324
+ for (const [binder, { site, token }] of this.#binders) {
259
325
  try {
260
- const props = this.scope.runtime.reader("a view's bind", site, binder);
261
- for (const [key, value] of Object.entries(encode(props)))
326
+ // Encoded inside the part: what encoding reads counts as the
327
+ // function's (a state object it returned).
328
+ const result = runtime.part(token, () => encode(runtime.reader("a view's bind", site, binder)));
329
+ if (!result)
330
+ continue;
331
+ for (const [key, value] of Object.entries(result.value))
262
332
  this.#values.set(key, value);
263
333
  }
264
334
  catch (error) {
@@ -275,6 +345,7 @@ export class ViewBase {
275
345
  }
276
346
  /** The host already shows these values (e.g. sent as initial properties). */
277
347
  shown(values) {
348
+ this.#generation++;
278
349
  const patches = this.#patches;
279
350
  this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
280
351
  this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
@@ -340,7 +411,10 @@ export class ViewBase {
340
411
  * Write one property: a row patch when the player takes them and one
341
412
  * applies (else the whole value); a `stale` patch is followed by the whole
342
413
  * value at once. The base is forgotten on any failure, so the next write
343
- * 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.
344
418
  */
345
419
  async #send(pending) {
346
420
  const { key, value } = pending;
@@ -348,34 +422,121 @@ export class ViewBase {
348
422
  await this.write(key, { value: wireValue(value) });
349
423
  return pending.sent();
350
424
  }
425
+ const generation = this.#generation;
351
426
  const base = this.#bases.get(key);
352
427
  this.#bases.delete(key);
353
428
  const sent = this.#sent.get(key);
354
- const plan = base !== undefined &&
429
+ let plan = base !== undefined &&
355
430
  sent?.kind === "rows" &&
356
431
  pending.rows &&
357
432
  (this.#stales.get(key) ?? 0) < 2
358
433
  ? rowPatch(sent.rows, pending.rows, value)
359
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
+ }
360
450
  if (plan) {
451
+ tooLarge(key, plan.ops);
452
+ const [first, ...rest] = chunkOps(plan.ops);
361
453
  const seq = ++this.#seq;
454
+ let applied = false;
362
455
  try {
363
- await this.write(key, { patch: { base: base, seq, ops: plan.ops } });
364
- this.#bases.set(key, seq);
365
- this.#stales.delete(key);
366
- return pending.sent(plan.counts);
456
+ await this.write(key, { patch: { base: base, seq, ops: first } });
457
+ applied = true;
367
458
  }
368
459
  catch (error) {
369
460
  if (!(error instanceof HostError && error.code === "stale"))
370
461
  throw error;
371
462
  this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
372
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
+ }
373
469
  }
374
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
+ }
375
506
  await this.write(key, { value: wireValue(value), seq });
376
507
  this.#bases.set(key, seq);
377
508
  return pending.sent();
378
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
+ }
379
540
  /**
380
541
  * Send what changed. A bound value is sent when it differs from the last one
381
542
  * sent; a `set()` value is always sent once. Nothing is resent merely
@@ -417,6 +578,13 @@ export class ViewBase {
417
578
  this.#broken = false;
418
579
  }
419
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
+ }
420
588
  failed = true;
421
589
  // Report the start of a failing streak only.
422
590
  if (!this.#broken)
@@ -516,7 +684,7 @@ class WindowHandle extends ViewBase {
516
684
  const ui = this.scope.runtime.host.ui;
517
685
  if (this.#opened)
518
686
  return ui.show(this.#id, true);
519
- const initial = this.wired(this.snapshot());
687
+ const initial = this.wired(this.snapshot(), true);
520
688
  await ui.open(this.#id, this.#file, initial, [...this.handlers.keys()]);
521
689
  this.#opened = true;
522
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