@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.
@@ -86,6 +86,7 @@ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
86
86
  import { shaderIdProblem, shaderListProblem } from "./shaders.js";
87
87
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
88
88
  import { createPlugin } from "./index.js";
89
+ import { applyOps, PATCH_FEATURE, PATCH_OPS } from "./patch.js";
89
90
  import { evaluate } from "./when.js";
90
91
  import { WAKE_EVENTS } from "./runtime.js";
91
92
  import { THEMES } from "./appearance.js";
@@ -929,6 +930,54 @@ export class TestHost {
929
930
  if (reason !== null && reason !== undefined)
930
931
  this.#declared(reason, "reason");
931
932
  }
933
+ /**
934
+ * Row patches (player 0.6.0): the `seq` of each property's last write, by
935
+ * view and property (none: 0, the initial properties).
936
+ */
937
+ #writes = new Map();
938
+ #resetWrites(view) {
939
+ for (const slot of [...this.#writes.keys()])
940
+ if (slot.startsWith(`${view}\u0000`))
941
+ this.#writes.delete(slot);
942
+ }
943
+ /**
944
+ * A property write as the player takes it, returning the value the
945
+ * property shows after it: a whole `value`, or a `patch` of the array
946
+ * `current` applying on the write numbered `base` (code `stale`
947
+ * otherwise). The result is checked against the value limits whole: the
948
+ * player counts the rows a patch keeps too.
949
+ */
950
+ #write(view, property, p, current) {
951
+ const slot = `${view}\u0000${property}`;
952
+ const patch = p.patch;
953
+ if (patch === undefined) {
954
+ const value = this.#shownValues(p.value);
955
+ this.#checkValues({ [property]: value });
956
+ this.#seeImages([value]);
957
+ this.#writes.set(slot, typeof p.seq === "number" ? p.seq : 0);
958
+ return value;
959
+ }
960
+ const { base, seq } = patch;
961
+ if (!Number.isInteger(base) ||
962
+ !Number.isInteger(seq) ||
963
+ base < 0 ||
964
+ seq < 1)
965
+ throw new HostError("invalid", "patch.base and patch.seq require non-negative integers (seq ≥ 1)");
966
+ const stale = () => new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
967
+ if ((this.#writes.get(slot) ?? 0) !== base || !Array.isArray(current))
968
+ throw stale();
969
+ const ops = patchOps(patch.ops, current.length);
970
+ const resolved = ops.map((op) => op.op === "set"
971
+ ? { ...op, row: this.#shownValues(op.row) }
972
+ : op.op === "insert"
973
+ ? { ...op, rows: op.rows.map((row) => this.#shownValues(row)) }
974
+ : op);
975
+ const value = applyOps(current, resolved);
976
+ this.#checkValues({ [property]: value });
977
+ this.#seeImages(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
978
+ this.#writes.set(slot, seq);
979
+ return value;
980
+ }
932
981
  /**
933
982
  * Refuse property values over the player's limits ({@link UI_VALUE_LIMITS})
934
983
  * with `limit`, naming the property, as the player does.
@@ -3164,6 +3213,13 @@ export class TestHost {
3164
3213
  * for: on a loaded machine it can finish after `settle()` returns. Poll
3165
3214
  * the result you assert on instead (second example).
3166
3215
  *
3216
+ * `settle()` does not wait for the SDK's once-a-second full update pass
3217
+ * (SDK 1.5.45). After `setSource` (or another player state change) only
3218
+ * the bindings that read a changed field of `plugin.player.state` run
3219
+ * again; state the plugin changed outside a handler without
3220
+ * `plugin.update()` shows only in the next full pass. Have the plugin
3221
+ * call `plugin.update()` after such a change, or wait `settle(1000)`.
3222
+ *
3167
3223
  * @param ms - Extra time to wait, for debounces and timers (default 0).
3168
3224
  *
3169
3225
  * @example Wait past a debounced save
@@ -3816,6 +3872,7 @@ export class TestHost {
3816
3872
  const id = String(p.id);
3817
3873
  if (this.windows.has(id))
3818
3874
  throw new HostError("conflict", "view already exists");
3875
+ this.#resetWrites(`w:${id}`);
3819
3876
  const properties = this.#shownValues(p.properties);
3820
3877
  this.#checkValues(properties);
3821
3878
  this.#seeImages(properties);
@@ -3841,10 +3898,8 @@ export class TestHost {
3841
3898
  const window = this.windows.get(String(p.id));
3842
3899
  if (!window)
3843
3900
  throw new HostError("missing", "view not found");
3844
- const value = this.#shownValues(p.value);
3845
- this.#checkValues({ [String(p.property)]: value });
3846
- this.#seeImages([value]);
3847
- window.props[String(p.property)] = value;
3901
+ const property = String(p.property);
3902
+ window.props[property] = this.#write(`w:${String(p.id)}`, property, p, window.props[property]);
3848
3903
  },
3849
3904
  "ui.show": (p) => {
3850
3905
  const window = this.windows.get(String(p.id));
@@ -3878,6 +3933,7 @@ export class TestHost {
3878
3933
  ]));
3879
3934
  const properties = this.#initialValues(this.#shownValues(p.properties));
3880
3935
  this.#seeImages(properties);
3936
+ this.#resetWrites("$mount");
3881
3937
  const host = this;
3882
3938
  this.panel = {
3883
3939
  slot: String(p.slot),
@@ -4202,11 +4258,9 @@ export class TestHost {
4202
4258
  this.emit("sequence.step", { direction });
4203
4259
  },
4204
4260
  "ui.setMount": (p) => {
4205
- this.mounted();
4206
- const value = this.#shownValues(p.value);
4207
- this.#checkValues({ [String(p.property)]: value });
4208
- this.#seeImages([value]);
4209
- this.mounted().props[String(p.property)] = value;
4261
+ const panel = this.mounted();
4262
+ const property = String(p.property);
4263
+ panel.props[property] = this.#write("$mount", property, p, panel.props[property]);
4210
4264
  },
4211
4265
  "timeline.marks": () => ({ marks: this.timeline.marks }),
4212
4266
  "drops.provide": (p) => {
@@ -4245,6 +4299,7 @@ export class TestHost {
4245
4299
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
4246
4300
  const properties = this.#initialValues(this.#shownValues(p.properties));
4247
4301
  this.#seeImages(properties);
4302
+ this.#resetWrites(`$settings:${id}`);
4248
4303
  const host = this;
4249
4304
  this.settings.pages.set(id, {
4250
4305
  category: String(p.category),
@@ -4267,10 +4322,8 @@ export class TestHost {
4267
4322
  const page = this.settings.pages.get(String(p.id));
4268
4323
  if (!page)
4269
4324
  throw new HostError("missing", "page not registered");
4270
- const value = this.#shownValues(p.value);
4271
- this.#checkValues({ [String(p.property)]: value });
4272
- this.#seeImages([value]);
4273
- page.props[String(p.property)] = value;
4325
+ const property = String(p.property);
4326
+ page.props[property] = this.#write(`$settings:${String(p.id)}`, property, p, page.props[property]);
4274
4327
  },
4275
4328
  "settings.unregister": (p) => {
4276
4329
  this.settings.pages.delete(String(p.id));
@@ -5261,7 +5314,10 @@ export async function startPlugin(activate, options = {}) {
5261
5314
  mkdirSync(data, { recursive: true });
5262
5315
  if (host.fileRoots.length === 0)
5263
5316
  host.fileRoots = [directory, data];
5264
- const instance = await createPlugin(host, manifest, directory, data, host.app);
5317
+ const instance = await createPlugin(host, manifest, directory, data,
5318
+ // Like the player: protocol additions it takes (`app.features` set on
5319
+ // the host replaces them, e.g. `[]` for an older player's behaviour).
5320
+ { features: [PATCH_FEATURE], ...host.app });
5265
5321
  await activate(instance.plugin);
5266
5322
  // Like the player: a position or resolver another plugin holds is not an
5267
5323
  // activation failure.
@@ -5290,3 +5346,72 @@ export async function startPlugin(activate, options = {}) {
5290
5346
  },
5291
5347
  };
5292
5348
  }
5349
+ /**
5350
+ * A patch's ops checked against an array of `length` rows, as the player
5351
+ * checks them (`ui_patch.rs`): in order, each on the result of the ones
5352
+ * before; at most {@link PATCH_OPS}; the result at most
5353
+ * `UI_VALUE_LIMITS.arrayRows` rows.
5354
+ */
5355
+ function patchOps(ops, length) {
5356
+ if (!Array.isArray(ops))
5357
+ throw new HostError("invalid", "patch.ops requires an array");
5358
+ if (ops.length > PATCH_OPS)
5359
+ throw new HostError("limit", `a patch carries at most ${PATCH_OPS} operations`);
5360
+ const index = (op, key) => {
5361
+ const value = op[key];
5362
+ if (!Number.isInteger(value) || value < 0)
5363
+ throw new HostError("invalid", `patch op ${key} requires a non-negative integer`);
5364
+ return value;
5365
+ };
5366
+ const count = (op) => {
5367
+ const value = index(op, "count");
5368
+ if (value === 0)
5369
+ throw new HostError("invalid", "patch op count must be at least 1");
5370
+ return value;
5371
+ };
5372
+ const past = (what) => new HostError("invalid", `${what} is past the end of the list`);
5373
+ let len = length;
5374
+ return ops.map((raw) => {
5375
+ const op = (raw ?? {});
5376
+ switch (op.op) {
5377
+ case "set": {
5378
+ const at = index(op, "at");
5379
+ if (at >= len)
5380
+ throw past(`set at ${at}`);
5381
+ if (!("row" in op))
5382
+ throw new HostError("invalid", "patch op set requires row");
5383
+ return { op: "set", at, row: op.row };
5384
+ }
5385
+ case "insert": {
5386
+ const at = index(op, "at");
5387
+ const rows = op.rows;
5388
+ if (!Array.isArray(rows) || rows.length === 0)
5389
+ throw new HostError("invalid", "patch op insert requires rows");
5390
+ if (at > len)
5391
+ throw past(`insert at ${at}`);
5392
+ len += rows.length;
5393
+ if (len > UI_VALUE_LIMITS.arrayRows)
5394
+ throw new HostError("limit", `UI array exceeds ${UI_VALUE_LIMITS.arrayRows} rows`);
5395
+ return { op: "insert", at, rows: rows };
5396
+ }
5397
+ case "remove": {
5398
+ const at = index(op, "at");
5399
+ const n = count(op);
5400
+ if (at + n > len)
5401
+ throw past(`remove ${n} at ${at}`);
5402
+ len -= n;
5403
+ return { op: "remove", at, count: n };
5404
+ }
5405
+ case "move": {
5406
+ const from = index(op, "from");
5407
+ const n = count(op);
5408
+ const to = index(op, "to");
5409
+ if (from + n > len || to + n > len)
5410
+ throw past(`move ${n} from ${from} to ${to}`);
5411
+ return { op: "move", from, count: n, to };
5412
+ }
5413
+ default:
5414
+ throw new HostError("invalid", "patch op must be set, insert, remove or move");
5415
+ }
5416
+ });
5417
+ }
@@ -565,14 +565,16 @@ export interface CommandState {
565
565
  reason: TextValue | null;
566
566
  }
567
567
  /**
568
- * A value, or a function the SDK re-evaluates on every update pass (see
569
- * {@link Scope.update}); only changes are sent to the player.
568
+ * A value, or a function the SDK re-evaluates on every full update pass,
569
+ * and after a player state change only if it read a field of
570
+ * `plugin.player.state` that changed (see {@link Scope.update}); only
571
+ * changes are sent to the player.
570
572
  *
571
573
  * @example Mix a computed state with a fixed one
572
574
  * The Loop item carries a check mark while a loop is set and is always
573
- * enabled. A function is evaluated again in every update pass (after each
574
- * handler and player state change) and only changes are sent; a plain
575
- * value never changes.
575
+ * enabled. A function is evaluated again in every full update pass (after
576
+ * each handler), and after a player state change only if it read a changed
577
+ * field; only changes are sent, and a plain value never changes.
576
578
  * ```ts
577
579
  * plugin.commands.add({
578
580
  * id: "loop",
@@ -1060,8 +1062,9 @@ export interface CommandOptions {
1060
1062
  * @remarks
1061
1063
  * The handle of one menu item and its keys. Its state (`enabled`, `checked`,
1062
1064
  * `busy`, `reason`) is bound: functions given to {@link Commands.add} or
1063
- * {@link Command.set} are evaluated again in every update pass and only
1064
- * changes reach the player. Its title, path, bindings and button cannot
1065
+ * {@link Command.set} are evaluated again in every full update pass (after
1066
+ * a player state change only if they read a changed field of
1067
+ * `plugin.player.state`) and only changes reach the player. Its title, path, bindings and button cannot
1065
1068
  * change in place; dispose the command (or a child {@link Scope}) and add it
1066
1069
  * again. Disposing it, its scope or stopping the plugin removes the menu
1067
1070
  * item, its keys and its bar button.
@@ -1081,7 +1084,7 @@ export interface CommandOptions {
1081
1084
  * path: ["@video", "Export…"],
1082
1085
  * run: runExport,
1083
1086
  * });
1084
- * // A function: re-evaluated in every update pass.
1087
+ * // A function: re-evaluated in every full pass and when `phase` changes.
1085
1088
  * exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
1086
1089
  * ```
1087
1090
  *
@@ -1706,8 +1709,9 @@ export interface Ui {
1706
1709
  * callback.
1707
1710
  *
1708
1711
  * `bind` is the usual way: the SDK re-evaluates bound functions in every
1709
- * update pass (after each handler it calls, after player state changes, on
1710
- * {@link Scope.update}) and sends only what changed, at most one write in
1712
+ * full update pass (after each handler it calls, on {@link Scope.update}),
1713
+ * and after a player state change those that read a changed field of
1714
+ * `plugin.player.state`, and sends only what changed, at most one write in
1711
1715
  * flight per view. `set` sends now, even an unchanged value.
1712
1716
  *
1713
1717
  * Writes that fail (a name the Slint file does not declare, a value of the
@@ -1843,7 +1847,11 @@ export interface View {
1843
1847
  *
1844
1848
  * @remarks
1845
1849
  * The SDK calls `props` in every update pass (and right after `bind`) and
1846
- * sends the values that differ from the ones it sent last. Because an
1850
+ * sends the values that differ from the ones it sent last. After a change
1851
+ * of the player state alone it calls `props` only if it read a field of
1852
+ * `plugin.player.state` that changed (SDK 1.5.45); a full pass follows
1853
+ * after each handler, on {@link Scope.update} and at least once a second
1854
+ * while the player state keeps changing. Because an
1847
1855
  * unchanged value is not sent again, text the user typed into an `in-out`
1848
1856
  * property stays until the plugin's value for it changes.
1849
1857
  *
@@ -1864,8 +1872,9 @@ export interface View {
1864
1872
  * @example Show what is playing and whether it can seek
1865
1873
  * The status window always shows the current file and enables its seek
1866
1874
  * controls only for seekable sources. The SDK calls the function right
1867
- * away and again in every update pass (after player state changes and
1868
- * after each handler) and sends only the values that changed.
1875
+ * away, again in every full update pass (after each handler) and after
1876
+ * each change of `source` or `can`, and sends only the values that
1877
+ * changed.
1869
1878
  * ```ts
1870
1879
  * const status = plugin.windows.create("ui/status.slint");
1871
1880
  * status.bind(() => ({
@@ -5697,8 +5706,8 @@ export interface Player {
5697
5706
  * @example Drive the player's Next and Previous from a playlist
5698
5707
  * The player's Next and Previous items and keys play the plugin's list.
5699
5708
  * The player calls `next` and `previous` when the user asks; `canNext`
5700
- * and `canPrevious` are evaluated again after every handler the SDK runs,
5701
- * and while they return false the player disables its items ("No next
5709
+ * and `canPrevious` are evaluated again after every handler the SDK runs
5710
+ * (and after a player state change if they read a changed field), and while they return false the player disables its items ("No next
5702
5711
  * item").
5703
5712
  * ```ts
5704
5713
  * declare const items: string[];
@@ -1,4 +1,4 @@
1
- import type { Json } from "../wire.ts";
1
+ import type { Json, PropertyWrite } from "../wire.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
3
  import type { Dialogs, Disposable, ImageOptions, ImageRef, Panels, PropValue, Props, Ui, View, Windows } from "./types.ts";
4
4
  /** Slint names are kebab-case; plugin code may use camelCase. */
@@ -29,7 +29,7 @@ export declare abstract class ViewBase implements View {
29
29
  protected closed: boolean;
30
30
  constructor(scope: ScopeImpl);
31
31
  protected abstract live(): boolean;
32
- protected abstract write(property: string, value: Json): Promise<void>;
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
35
  * property keeps its default) and reported, like a failed write.
@@ -38,7 +38,11 @@ export declare abstract class ViewBase implements View {
38
38
  on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
39
39
  set(props: Props): void;
40
40
  bind(props: () => Props): Disposable;
41
- /** Current desired properties (bound values win over earlier `set`). */
41
+ /**
42
+ * Current desired properties (bound values win over earlier `set`). A
43
+ * precise pass (`Runtime.part`) skips a bind function that read none of
44
+ * the changed state: its last values stay.
45
+ */
42
46
  protected snapshot(): Record<string, PropValue>;
43
47
  protected evaluate(): void;
44
48
  /** The host already shows these values (e.g. sent as initial properties). */