@vestra-player/sdk 1.5.42 → 1.5.44

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
@@ -6,6 +6,8 @@ import { basename, extname, resolve } from "node:path";
6
6
  import { failure, Latest, quiet } from "./runtime.js";
7
7
  import { dropsOf } from "./drops.js";
8
8
  import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
9
+ import { HostError } from "../wire.js";
10
+ import { countRow, diff, keyOf, PATCH_FEATURE, replay, withinLimits, } from "./patch.js";
9
11
  /** Slint names are kebab-case; plugin code may use camelCase. */
10
12
  export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
11
13
  const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
@@ -62,8 +64,9 @@ function wireValue(value) {
62
64
  throw failure("invalid", `image ${value.path} was released with the scope that created it`);
63
65
  return value.toJSON();
64
66
  }
67
+ // Array.from: a hole is `null`, as `JSON.stringify` writes it.
65
68
  if (Array.isArray(value))
66
- return value.map((item) => wireValue(item));
69
+ return Array.from(value, (item) => wireValue(item ?? null));
67
70
  if (value !== null && typeof value === "object") {
68
71
  const out = {};
69
72
  for (const [key, item] of Object.entries(value))
@@ -72,6 +75,89 @@ function wireValue(value) {
72
75
  }
73
76
  return value;
74
77
  }
78
+ /** Scalars compared by value. Non-finite numbers are JSON `null`: compared as JSON. */
79
+ const isPrimitive = (value) => value === null ||
80
+ typeof value === "string" ||
81
+ typeof value === "boolean" ||
82
+ (typeof value === "number" && Number.isFinite(value));
83
+ /** A row as `JSON.stringify` writes it inside an array. */
84
+ const encodeRow = (row) => JSON.stringify(row) ?? "null";
85
+ /** Each row of an array as `JSON.stringify` writes it; a hole is `null`. */
86
+ const encodeRows = (rows) => Array.from(rows, encodeRow);
87
+ function sameRows(a, b) {
88
+ if (a.length !== b.length)
89
+ return false;
90
+ for (let i = 0; i < a.length; i++)
91
+ if (a[i] !== b[i])
92
+ return false;
93
+ return true;
94
+ }
95
+ /** An initial property (wire form) as sent. */
96
+ function sentOf(value, patches) {
97
+ if (isPrimitive(value))
98
+ return { kind: "value", value };
99
+ if (Array.isArray(value) && patches) {
100
+ const keyed = keyOf(value);
101
+ return {
102
+ kind: "rows",
103
+ rows: {
104
+ enc: encodeRows(value),
105
+ field: keyed?.field ?? null,
106
+ keys: keyed?.keys ?? null,
107
+ counts: [],
108
+ },
109
+ };
110
+ }
111
+ return { kind: "json", enc: JSON.stringify(value) };
112
+ }
113
+ /**
114
+ * The row patch from `sent` to `next` (the bound `rows`), in wire form, and
115
+ * the new rows' limit counts; `null` when the whole value is to be sent:
116
+ * nothing to type the rows by (no rows sent), a Markdown document in a row
117
+ * (the player merges documents in place), too many operations, a value over
118
+ * the limits (the player refuses it whole, as before), or no change (an
119
+ * explicit `set()` of the same value).
120
+ */
121
+ function rowPatch(sent, next, rows) {
122
+ if (sent.enc.length === 0)
123
+ return null;
124
+ if (next.enc.some((row) => row.includes('"$vestraMarkdown"')))
125
+ return null;
126
+ const nextKeyed = sent.field === null ? null : next.keyed();
127
+ const keyed = nextKeyed !== null && nextKeyed.field === sent.field;
128
+ const plan = diff({ enc: sent.enc, keys: keyed ? sent.keys : null }, { enc: next.enc, keys: keyed ? nextKeyed.keys : null });
129
+ if (!plan || plan.ops.length === 0)
130
+ return null;
131
+ // A released image is refused wherever it is, as in a whole write: the
132
+ // rows the patch does not carry are checked too (those holding images).
133
+ for (let j = 0; j < rows.length; j++)
134
+ if (plan.from[j] >= 0 && next.enc[j].includes('"$vestraImage"'))
135
+ wireValue(rows[j] ?? null);
136
+ const counts = next.enc.map((_, j) => {
137
+ const from = plan.from[j];
138
+ return (from >= 0 ? sent.counts[from] : undefined) ?? countRow(rows[j]);
139
+ });
140
+ if (!withinLimits(counts))
141
+ return null;
142
+ // The ops must give exactly the new rows (a guard against this diff's bugs).
143
+ if (!sameRows(replay(sent.enc, plan.ops, next.enc), next.enc))
144
+ return null;
145
+ const ops = plan.ops.map((op) => {
146
+ switch (op.op) {
147
+ case "set":
148
+ return { op: "set", at: op.at, row: wireValue(rows[op.row] ?? null) };
149
+ case "insert":
150
+ return {
151
+ op: "insert",
152
+ at: op.at,
153
+ rows: op.rows.map((j) => wireValue(rows[j] ?? null)),
154
+ };
155
+ default:
156
+ return op;
157
+ }
158
+ });
159
+ return { ops, counts };
160
+ }
75
161
  export const ui = (scope) => ({
76
162
  image(path, options) {
77
163
  if (typeof path !== "string" || !path)
@@ -96,6 +182,18 @@ export class ViewBase {
96
182
  #binders = new Map();
97
183
  /** What the host currently shows, as far as this side knows. */
98
184
  #sent = new Map();
185
+ /**
186
+ * Row patches (player feature `ui.patch`): each property's last write the
187
+ * player confirmed, by `seq` (0: the initial properties). A patch applies
188
+ * on it; a property without one is sent whole.
189
+ */
190
+ #bases = new Map();
191
+ /**
192
+ * Patches of a property answered `stale` in a row: after two, the
193
+ * property is sent whole until the view shows its initial properties again.
194
+ */
195
+ #stales = new Map();
196
+ #seq = 0;
99
197
  /** Written by `set()`: sent even if equal to what was sent before. */
100
198
  #forced = new Set();
101
199
  /** The last value of each property whose write failed; not retried until it changes. */
@@ -111,6 +209,10 @@ export class ViewBase {
111
209
  this.scope = scope;
112
210
  this.#binding = scope.runtime.bind(() => this.evaluate());
113
211
  }
212
+ /** The player takes row patches (and `seq` on whole writes). */
213
+ get #patches() {
214
+ return this.scope.runtime.features.has(PATCH_FEATURE);
215
+ }
114
216
  /**
115
217
  * Initial properties in wire form. A released image is left out (the
116
218
  * property keeps its default) and reported, like a failed write.
@@ -173,11 +275,107 @@ export class ViewBase {
173
275
  }
174
276
  /** The host already shows these values (e.g. sent as initial properties). */
175
277
  shown(values) {
176
- this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, JSON.stringify(v)]));
278
+ const patches = this.#patches;
279
+ this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
280
+ this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
281
+ this.#stales.clear();
177
282
  this.#forced.clear();
178
283
  for (const key of Object.keys(values))
179
284
  this.#failed.delete(key);
180
285
  }
286
+ /** `value` encoded, and whether it differs from what was sent. */
287
+ #encode(key, value) {
288
+ const sent = this.#sent.get(key);
289
+ if (isPrimitive(value)) {
290
+ // Compared as it is: no `JSON.stringify` for the scalars of a pass.
291
+ const changed = sent?.kind === "value"
292
+ ? sent.value !== value
293
+ : sent?.kind !== "json" || sent.enc !== JSON.stringify(value);
294
+ return {
295
+ key,
296
+ value,
297
+ changed,
298
+ signature: () => JSON.stringify(value),
299
+ sent: () => ({ kind: "value", value }),
300
+ };
301
+ }
302
+ if (Array.isArray(value) && this.#patches) {
303
+ // Row by row: the same work as one `JSON.stringify`, and the rows to
304
+ // diff against those last sent.
305
+ const enc = encodeRows(value);
306
+ const signature = () => `[${enc.join(",")}]`;
307
+ const changed = sent?.kind === "rows"
308
+ ? !sameRows(sent.rows.enc, enc)
309
+ : sent?.kind !== "json" || sent.enc !== signature();
310
+ // Keys only for a write: an unchanged array costs its encoding alone.
311
+ let keys;
312
+ const keyed = () => (keys === undefined ? (keys = keyOf(value)) : keys);
313
+ return {
314
+ key,
315
+ value,
316
+ changed,
317
+ signature,
318
+ rows: { enc, keyed },
319
+ sent: (counts = []) => ({
320
+ kind: "rows",
321
+ rows: {
322
+ enc,
323
+ field: keyed()?.field ?? null,
324
+ keys: keyed()?.keys ?? null,
325
+ counts,
326
+ },
327
+ }),
328
+ };
329
+ }
330
+ const enc = JSON.stringify(value);
331
+ return {
332
+ key,
333
+ value,
334
+ changed: sent?.kind !== "json" || sent.enc !== enc,
335
+ signature: () => enc,
336
+ sent: () => ({ kind: "json", enc }),
337
+ };
338
+ }
339
+ /**
340
+ * Write one property: a row patch when the player takes them and one
341
+ * applies (else the whole value); a `stale` patch is followed by the whole
342
+ * value at once. The base is forgotten on any failure, so the next write
343
+ * of the property is whole.
344
+ */
345
+ async #send(pending) {
346
+ const { key, value } = pending;
347
+ if (!this.#patches) {
348
+ await this.write(key, { value: wireValue(value) });
349
+ return pending.sent();
350
+ }
351
+ const base = this.#bases.get(key);
352
+ this.#bases.delete(key);
353
+ const sent = this.#sent.get(key);
354
+ const plan = base !== undefined &&
355
+ sent?.kind === "rows" &&
356
+ pending.rows &&
357
+ (this.#stales.get(key) ?? 0) < 2
358
+ ? rowPatch(sent.rows, pending.rows, value)
359
+ : null;
360
+ if (plan) {
361
+ const seq = ++this.#seq;
362
+ 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);
367
+ }
368
+ catch (error) {
369
+ if (!(error instanceof HostError && error.code === "stale"))
370
+ throw error;
371
+ this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
372
+ }
373
+ }
374
+ const seq = ++this.#seq;
375
+ await this.write(key, { value: wireValue(value), seq });
376
+ this.#bases.set(key, seq);
377
+ return pending.sent();
378
+ }
181
379
  /**
182
380
  * Send what changed. A bound value is sent when it differs from the last one
183
381
  * sent; a `set()` value is always sent once. Nothing is resent merely
@@ -198,20 +396,22 @@ export class ViewBase {
198
396
  try {
199
397
  let pending;
200
398
  do {
201
- pending = [...this.#values].filter(([key, value]) => {
202
- const encoded = JSON.stringify(value);
203
- if (this.#failed.get(key) === encoded)
204
- return false;
205
- return this.#forced.has(key) || this.#sent.get(key) !== encoded;
206
- });
207
- for (const [key, value] of pending) {
399
+ pending = [];
400
+ for (const [key, value] of this.#values) {
401
+ const encoded = this.#encode(key, value);
402
+ const failedAs = this.#failed.get(key);
403
+ if (failedAs !== undefined && failedAs === encoded.signature())
404
+ continue;
405
+ if (this.#forced.has(key) || encoded.changed)
406
+ pending.push(encoded);
407
+ }
408
+ for (const encoded of pending) {
208
409
  if (!this.live())
209
410
  return;
210
- const encoded = JSON.stringify(value);
411
+ const key = encoded.key;
211
412
  this.#forced.delete(key);
212
413
  try {
213
- await this.write(key, wireValue(value));
214
- this.#sent.set(key, encoded);
414
+ this.#sent.set(key, await this.#send(encoded));
215
415
  this.#failed.delete(key);
216
416
  this.#backoff = 0;
217
417
  this.#broken = false;
@@ -222,7 +422,7 @@ export class ViewBase {
222
422
  if (!this.#broken)
223
423
  failure ??= error;
224
424
  this.#broken = true;
225
- this.#failed.set(key, encoded);
425
+ this.#failed.set(key, encoded.signature());
226
426
  }
227
427
  }
228
428
  } while (pending.length && !failed && this.live());
@@ -294,8 +494,8 @@ class WindowHandle extends ViewBase {
294
494
  live() {
295
495
  return this.#opened && !this.closed;
296
496
  }
297
- write(property, value) {
298
- return this.scope.runtime.host.ui.set(this.#id, property, value);
497
+ write(property, write) {
498
+ return this.scope.runtime.host.ui.set(this.#id, property, write);
299
499
  }
300
500
  on(callback, handler) {
301
501
  // The host forwards only callbacks named when the window opens.
@@ -400,8 +600,8 @@ class PanelHandle extends ViewBase {
400
600
  live() {
401
601
  return this.#mounted && !this.closed;
402
602
  }
403
- write(property, value) {
404
- return this.scope.runtime.host.ui.setMount(property, value);
603
+ write(property, write) {
604
+ return this.scope.runtime.host.ui.setMount(property, write);
405
605
  }
406
606
  get state() {
407
607
  const service = layoutOf(this.scope.runtime);
package/dist/wire.d.ts CHANGED
@@ -56,6 +56,45 @@ export interface UiCallback {
56
56
  callback: string;
57
57
  args: Json[];
58
58
  }
59
+ /**
60
+ * One row operation of a patch, by index, applied to the result of the
61
+ * ones before (player 0.6.0, feature `ui.patch`). `move` takes `count` rows
62
+ * out at `from` and puts them back at `to`, an index of the list without
63
+ * them.
64
+ */
65
+ export type RowOp = {
66
+ op: "set";
67
+ at: number;
68
+ row: Json;
69
+ } | {
70
+ op: "insert";
71
+ at: number;
72
+ rows: Json[];
73
+ } | {
74
+ op: "remove";
75
+ at: number;
76
+ count: number;
77
+ } | {
78
+ op: "move";
79
+ from: number;
80
+ count: number;
81
+ to: number;
82
+ };
83
+ /**
84
+ * A property write: a whole value (with its `seq` to players that take
85
+ * patches), or a row patch of an array applying on the write numbered
86
+ * `base` (0: the initial properties).
87
+ */
88
+ export type PropertyWrite = {
89
+ value: Json;
90
+ seq?: number;
91
+ } | {
92
+ patch: {
93
+ base: number;
94
+ seq: number;
95
+ ops: RowOp[];
96
+ };
97
+ };
59
98
  /**
60
99
  * A definite refusal from the player (or the plugin runner), with a stable
61
100
  * `code`. Plugin code sees it as a `PluginError` (an `Error` with `code`);
@@ -338,14 +377,41 @@ export interface WireRenderingState {
338
377
  reason: WireRenderingReason | null;
339
378
  };
340
379
  }
341
- /** `video.upscaling.state` and `video.upscaling.set` (player 0.5.16). */
380
+ /**
381
+ * An upscaling reason: `{ code, message }` as a rendering reason; with
382
+ * `decoding` a `cause`, with cause `copy-back` the `plugins` (player 0.5.25).
383
+ */
384
+ export interface WireUpscalingReason extends WireRenderingReason {
385
+ cause?: string;
386
+ plugins?: {
387
+ id: string;
388
+ name: string;
389
+ }[];
390
+ }
391
+ /**
392
+ * `video.upscaling.state` and `video.upscaling.set` (player 0.5.16); the
393
+ * monitoring fields from player 0.5.25, which also sends the state without
394
+ * `enabled` as the event `video.upscaling.changed`.
395
+ */
342
396
  export interface WireUpscalingState {
343
397
  available: boolean;
344
- /** The same `{ code, message }` shape as a rendering reason. */
345
- reason: WireRenderingReason | null;
398
+ reason: WireUpscalingReason | null;
346
399
  enabled: boolean;
347
400
  requested: boolean;
348
401
  factor: number | null;
402
+ video?: {
403
+ width: number;
404
+ height: number;
405
+ displayWidth: number;
406
+ displayHeight: number;
407
+ } | null;
408
+ area?: {
409
+ width: number;
410
+ height: number;
411
+ } | null;
412
+ gpu?: string | null;
413
+ hwdecCurrent?: string | null;
414
+ inChain?: boolean;
349
415
  }
350
416
  /**
351
417
  * Template arguments on the wire (`osd.show`, `osd.panel`, `dialogs.confirm`,
@@ -368,7 +434,7 @@ export interface Transport {
368
434
  locale?: string;
369
435
  }
370
436
  /** The events a plugin subscribes to (`events.subscribe`). */
371
- type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes";
437
+ type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes" | "video.upscaling.changed";
372
438
  export declare function createWire(transport: Transport): {
373
439
  processing: {
374
440
  state: () => Promise<import("./processing.ts").ProcessingState>;
@@ -454,7 +520,7 @@ export declare function createWire(transport: Transport): {
454
520
  global: string;
455
521
  properties: Record<string, Json>;
456
522
  }) => Promise<void>;
457
- set: (id: string, property: string, value: Json) => Promise<void>;
523
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
458
524
  unregister: (id: string) => Promise<void>;
459
525
  show: (id?: string) => Promise<void>;
460
526
  };
@@ -641,7 +707,7 @@ export declare function createWire(transport: Transport): {
641
707
  prepareMs: number;
642
708
  shellGeneration: number;
643
709
  }>;
644
- setMount: (property: string, value: Json) => Promise<void>;
710
+ setMount: (property: string, write: PropertyWrite) => Promise<void>;
645
711
  interaction: (locks: {
646
712
  focused: boolean;
647
713
  dragging: boolean;
@@ -650,7 +716,7 @@ export declare function createWire(transport: Transport): {
650
716
  unmount: () => Promise<void>;
651
717
  menu: (items: (string | null)[], x: number, y: number) => Promise<void>;
652
718
  open: (id: string, file: string, properties?: Record<string, Json>, callbacks?: string[]) => Promise<void>;
653
- set: (id: string, property: string, value: Json) => Promise<void>;
719
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
654
720
  show: (id: string, visible: boolean) => Promise<void>;
655
721
  close: (id: string) => Promise<void>;
656
722
  };
package/dist/wire.js CHANGED
@@ -125,7 +125,7 @@ export function createWire(transport) {
125
125
  /** Settings pages in the player's options window (`ui.settings`). */
126
126
  settings: {
127
127
  register: (options) => transport.request("settings.register", options),
128
- set: (id, property, value) => transport.request("settings.set", { id, property, value }),
128
+ set: (id, property, write) => transport.request("settings.set", { id, property, ...write }),
129
129
  unregister: (id) => transport.request("settings.unregister", { id }),
130
130
  show: (id) => transport.request("settings.show", id === undefined ? {} : { id }),
131
131
  },
@@ -321,12 +321,12 @@ export function createWire(transport) {
321
321
  base,
322
322
  }),
323
323
  mount: (options) => transport.request("ui.mount", options),
324
- setMount: (property, value) => transport.request("ui.setMount", { property, value }),
324
+ setMount: (property, write) => transport.request("ui.setMount", { property, ...write }),
325
325
  interaction: (locks) => transport.request("ui.interaction", locks),
326
326
  unmount: () => transport.request("ui.unmount"),
327
327
  menu: (items, x, y) => transport.request("ui.menu", { items, x, y }),
328
328
  open: (id, file, properties = {}, callbacks = []) => transport.request("ui.open", { id, file, properties, callbacks }),
329
- set: (id, property, value) => transport.request("ui.set", { id, property, value }),
329
+ set: (id, property, write) => transport.request("ui.set", { id, property, ...write }),
330
330
  show: (id, visible) => transport.request("ui.show", { id, visible }),
331
331
  close: (id) => transport.request("ui.close", { id }),
332
332
  },