@vestra-player/sdk 1.5.28 → 1.5.30

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.
@@ -11,12 +11,36 @@ import type { Bindings } from "./types.ts";
11
11
  * `"Shift+1"`). Anything else fails with code `invalid`. The player's tests
12
12
  * check that these agree with the player.
13
13
  *
14
- * @example
14
+ * @example Check in a test that the plugin's default keys are well formed
15
+ * The test fails on a binding the player would refuse with `invalid`, such
16
+ * as modifiers out of order or a misspelt key name. `isBinding` applies the
17
+ * same grammar as the player's parser: the part after the last `+` is the
18
+ * input, everything before it modifiers in the order of `modifiers`; an
19
+ * input is a listed name or one other printable character, and `Shift`
20
+ * never goes with a digit or such a character.
15
21
  * ```ts
22
+ * import assert from "node:assert/strict";
16
23
  * import { BINDING_INPUTS } from "@vestra-player/sdk/testing";
17
24
  *
18
- * assert.ok(BINDING_INPUTS.named.includes("PageUp"));
19
- * assert.ok(BINDING_INPUTS.mouse.includes("Mouse:WheelUp"));
25
+ * const { modifiers, letters, digits, functionKeys, named, mouse } = BINDING_INPUTS;
26
+ * const names = new Set([...letters, ...functionKeys, ...named, ...mouse]);
27
+ * function isBinding(binding: string): boolean {
28
+ * // "Ctrl++" is not a binding: the + key is written "Plus".
29
+ * const parts = binding.split("+");
30
+ * const input = parts.pop()!;
31
+ * const order = parts.map((m) => (modifiers as readonly string[]).indexOf(m));
32
+ * // Each modifier known, at most once, in the listed order.
33
+ * if (order.some((at, i) => at < 0 || (i > 0 && at <= order[i - 1]!)))
34
+ * return false;
35
+ * if (names.has(input)) return true; // letters are upper case: "A"
36
+ * const typed = digits.includes(input) || /^[^\p{L}\p{N}\p{C}\s+]$/u.test(input);
37
+ * // ">" rather than "Shift+.": the character already says Shift.
38
+ * return typed && !parts.includes("Shift");
39
+ * }
40
+ * for (const binding of ["Ctrl+Shift+PageUp", "Alt+Mouse:WheelUp", ">"])
41
+ * assert.ok(isBinding(binding), binding);
42
+ * for (const binding of ["Shift+Ctrl+A", "Ctrl+a", "Shift+."])
43
+ * assert.ok(!isBinding(binding), binding);
20
44
  * ```
21
45
  *
22
46
  * @since 1.5.14
@@ -18,12 +18,36 @@ import { failure } from "./runtime.js";
18
18
  * `"Shift+1"`). Anything else fails with code `invalid`. The player's tests
19
19
  * check that these agree with the player.
20
20
  *
21
- * @example
21
+ * @example Check in a test that the plugin's default keys are well formed
22
+ * The test fails on a binding the player would refuse with `invalid`, such
23
+ * as modifiers out of order or a misspelt key name. `isBinding` applies the
24
+ * same grammar as the player's parser: the part after the last `+` is the
25
+ * input, everything before it modifiers in the order of `modifiers`; an
26
+ * input is a listed name or one other printable character, and `Shift`
27
+ * never goes with a digit or such a character.
22
28
  * ```ts
29
+ * import assert from "node:assert/strict";
23
30
  * import { BINDING_INPUTS } from "@vestra-player/sdk/testing";
24
31
  *
25
- * assert.ok(BINDING_INPUTS.named.includes("PageUp"));
26
- * assert.ok(BINDING_INPUTS.mouse.includes("Mouse:WheelUp"));
32
+ * const { modifiers, letters, digits, functionKeys, named, mouse } = BINDING_INPUTS;
33
+ * const names = new Set([...letters, ...functionKeys, ...named, ...mouse]);
34
+ * function isBinding(binding: string): boolean {
35
+ * // "Ctrl++" is not a binding: the + key is written "Plus".
36
+ * const parts = binding.split("+");
37
+ * const input = parts.pop()!;
38
+ * const order = parts.map((m) => (modifiers as readonly string[]).indexOf(m));
39
+ * // Each modifier known, at most once, in the listed order.
40
+ * if (order.some((at, i) => at < 0 || (i > 0 && at <= order[i - 1]!)))
41
+ * return false;
42
+ * if (names.has(input)) return true; // letters are upper case: "A"
43
+ * const typed = digits.includes(input) || /^[^\p{L}\p{N}\p{C}\s+]$/u.test(input);
44
+ * // ">" rather than "Shift+.": the character already says Shift.
45
+ * return typed && !parts.includes("Shift");
46
+ * }
47
+ * for (const binding of ["Ctrl+Shift+PageUp", "Alt+Mouse:WheelUp", ">"])
48
+ * assert.ok(isBinding(binding), binding);
49
+ * for (const binding of ["Shift+Ctrl+A", "Ctrl+a", "Shift+."])
50
+ * assert.ok(!isBinding(binding), binding);
27
51
  * ```
28
52
  *
29
53
  * @since 1.5.14
@@ -1,7 +1,8 @@
1
1
  /** Menu commands: declaration, state sync (revisions hidden) and dispatch. */
2
2
  import { failure, Latest, quiet } from "./runtime.js";
3
3
  import { checkBindings } from "./bindings.js";
4
- import { MENU_IDS } from "../commands.js";
4
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
5
+ import { ImageHandle } from "./ui.js";
5
6
  import { isTextValue, textProblem } from "../i18n.js";
6
7
  const value = (item) => typeof item === "function" ? item() : item;
7
8
  /** Validate options and build the wire declaration. */
@@ -32,7 +33,7 @@ function declare(options) {
32
33
  if (!Number.isInteger(order) || Math.abs(order) > 1_000_000)
33
34
  throw failure("invalid", `command ${options.id}: order must be an integer within ±1000000`);
34
35
  const bindings = [...(options.bindings ?? [])];
35
- return {
36
+ const declaration = {
36
37
  title: title,
37
38
  path: path,
38
39
  order,
@@ -42,6 +43,32 @@ function declare(options) {
42
43
  focus: options.focus ?? null,
43
44
  repeat: options.repeat ?? false,
44
45
  };
46
+ // Only when set: players before 0.5.10 refuse the field.
47
+ if (options.button !== undefined)
48
+ declaration.button = button(options.id, options.button, path.length > 0);
49
+ return declaration;
50
+ }
51
+ /** `CommandOptions.button`, checked as the player checks it, in wire form. */
52
+ function button(id, value, inMenu) {
53
+ const fail = (problem) => failure("invalid", `command ${id}: ${problem}`);
54
+ if (value === null || typeof value !== "object")
55
+ throw fail("button is { icon, order }");
56
+ if (!inMenu)
57
+ throw fail("button needs a menu path: a bar button is also a menu item");
58
+ const { icon, order } = value;
59
+ if (!Number.isInteger(order) ||
60
+ order < 0 ||
61
+ order > BAR_BUTTON_LIMITS.maxOrder)
62
+ throw fail(`button order must be an integer 0–${BAR_BUTTON_LIMITS.maxOrder}`);
63
+ if (icon instanceof ImageHandle) {
64
+ if (icon.released)
65
+ throw fail(`button image ${icon.path} was released with the scope that created it`);
66
+ return { icon: icon.toJSON(), order };
67
+ }
68
+ if (typeof icon !== "string" ||
69
+ !BAR_BUTTON_ICONS.includes(icon))
70
+ throw fail(`unknown button icon ${JSON.stringify(icon)}: a name of @vestra/icons.slint (BAR_BUTTON_ICONS) or an image from plugin.ui.image`);
71
+ return { icon, order };
45
72
  }
46
73
  /** Unregistrations in flight per plugin: re-adding an id waits for its removal. */
47
74
  const removals = new WeakMap();
@@ -8,11 +8,23 @@ import type { TemplateArgsJson, TextValueJson } from "../wire.ts";
8
8
  * rejects, `OsdPanel.set` reports to `Scope.onError`. The player's tests
9
9
  * check that these agree with the player.
10
10
  *
11
- * @example
11
+ * @example Keep a long list inside the information block's limits
12
+ * The block shows the first 32 rows of a list, each cut at 60 columns. The
13
+ * player counts the text as shown (after it is translated and formatted); a
14
+ * block over `panelLines` lines or `panelBytes` bytes is refused whole with
15
+ * `invalid`, reported to `Scope.onError` because `set` never throws, so
16
+ * the plugin trims the list itself (for very long non-ASCII rows, check the
17
+ * bytes too).
12
18
  * ```ts
19
+ * import type { Plugin } from "@vestra-player/sdk";
20
+ * declare const plugin: Plugin;
21
+ * declare const rows: string[];
22
+ * // ---cut---
13
23
  * import { OSD_LIMITS } from "@vestra-player/sdk/testing";
14
24
  *
25
+ * const panel = plugin.osd.panel();
15
26
  * const lines = rows.slice(0, OSD_LIMITS.panelLines);
27
+ * // maxColumns cuts each longer line and ends it with "…".
16
28
  * panel.set(lines.join("\n"), null, { maxColumns: 60 });
17
29
  * ```
18
30
  *
@@ -9,11 +9,23 @@ import { isTextValue, textProblem } from "../i18n.js";
9
9
  * rejects, `OsdPanel.set` reports to `Scope.onError`. The player's tests
10
10
  * check that these agree with the player.
11
11
  *
12
- * @example
12
+ * @example Keep a long list inside the information block's limits
13
+ * The block shows the first 32 rows of a list, each cut at 60 columns. The
14
+ * player counts the text as shown (after it is translated and formatted); a
15
+ * block over `panelLines` lines or `panelBytes` bytes is refused whole with
16
+ * `invalid`, reported to `Scope.onError` because `set` never throws, so
17
+ * the plugin trims the list itself (for very long non-ASCII rows, check the
18
+ * bytes too).
13
19
  * ```ts
20
+ * import type { Plugin } from "@vestra-player/sdk";
21
+ * declare const plugin: Plugin;
22
+ * declare const rows: string[];
23
+ * // ---cut---
14
24
  * import { OSD_LIMITS } from "@vestra-player/sdk/testing";
15
25
  *
26
+ * const panel = plugin.osd.panel();
16
27
  * const lines = rows.slice(0, OSD_LIMITS.panelLines);
28
+ * // maxColumns cuts each longer line and ends it with "…".
17
29
  * panel.set(lines.join("\n"), null, { maxColumns: 60 });
18
30
  * ```
19
31
  *
@@ -13,13 +13,31 @@ export declare function tools(scope: ScopeImpl): Tools;
13
13
  * with the key and the request around the value). The player's tests check
14
14
  * that these agree with the player.
15
15
  *
16
- * @example
16
+ * @example Remember a position per URL without breaking the limits
17
+ * The plugin keeps the last position of each URL for this player session
18
+ * and skips a URL too long to be a key. Keys are counted in UTF-8 bytes, so
19
+ * the length is measured with `Buffer.byteLength`, not `length`; a `set`
20
+ * that would take the plugin's entries over 1 MiB rejects with `limit` and
21
+ * changes nothing, so the plugin clears the old entries and goes on.
17
22
  * ```ts
18
- * import assert from "node:assert/strict";
23
+ * import type { Plugin, PluginError } from "@vestra-player/sdk";
24
+ * declare const plugin: Plugin;
25
+ * declare const url: string;
26
+ * declare const position: number;
27
+ * // ---cut---
19
28
  * import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
20
29
  *
21
- * const key = "https://example.com/a.mkv";
22
- * assert.ok(Buffer.byteLength(key) <= SESSION_STORAGE_LIMITS.keyBytes);
30
+ * const session = plugin.storage.session;
31
+ * if (Buffer.byteLength(url) <= SESSION_STORAGE_LIMITS.keyBytes) {
32
+ * try {
33
+ * await session.set(url, position);
34
+ * } catch (error) {
35
+ * if ((error as PluginError).code !== "limit") throw error;
36
+ * // Full: start over rather than lose the newest position.
37
+ * for (const key of await session.keys()) await session.delete(key);
38
+ * await session.set(url, position);
39
+ * }
40
+ * }
23
41
  * ```
24
42
  *
25
43
  * @since 1.5.25
@@ -46,11 +64,33 @@ export declare function system(scope: ScopeImpl): System;
46
64
  * fails with code `invalid`; a new name beyond `names` fails with `limit`.
47
65
  * The player's tests check that these agree with the player.
48
66
  *
49
- * @example
67
+ * @example Store an API token only when it fits
68
+ * The plugin keeps a token the user pasted, or tells them it is empty or
69
+ * too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
70
+ * `Buffer.byteLength`; `secrets.set` would refuse other values with
71
+ * `invalid`. The store itself can still refuse (`unsupported` without a
72
+ * system store, `unavailable` or `failed` when it denies access): only
73
+ * those codes get the "could not be saved" message, anything else (a
74
+ * missing `secrets` capability) is a bug and is thrown on.
50
75
  * ```ts
76
+ * import type { Plugin, PluginError } from "@vestra-player/sdk";
77
+ * declare const plugin: Plugin;
78
+ * declare const token: string;
79
+ * // ---cut---
51
80
  * import { SECRET_LIMITS } from "@vestra-player/sdk/testing";
52
81
  *
53
- * assert.ok(Buffer.byteLength(token) <= SECRET_LIMITS.valueBytes);
82
+ * if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
83
+ * await plugin.osd.show("Paste a token of at most 1024 bytes.");
84
+ * } else {
85
+ * try {
86
+ * await plugin.secrets.set("api-token", token);
87
+ * } catch (error) {
88
+ * const { code } = error as PluginError;
89
+ * if (!["unsupported", "unavailable", "failed"].includes(code)) throw error;
90
+ * // No keychain or access denied: ask again next time.
91
+ * await plugin.osd.show("The token could not be saved.");
92
+ * }
93
+ * }
54
94
  * ```
55
95
  *
56
96
  * @since 1.5.14
@@ -247,13 +247,31 @@ class Store {
247
247
  * with the key and the request around the value). The player's tests check
248
248
  * that these agree with the player.
249
249
  *
250
- * @example
250
+ * @example Remember a position per URL without breaking the limits
251
+ * The plugin keeps the last position of each URL for this player session
252
+ * and skips a URL too long to be a key. Keys are counted in UTF-8 bytes, so
253
+ * the length is measured with `Buffer.byteLength`, not `length`; a `set`
254
+ * that would take the plugin's entries over 1 MiB rejects with `limit` and
255
+ * changes nothing, so the plugin clears the old entries and goes on.
251
256
  * ```ts
252
- * import assert from "node:assert/strict";
257
+ * import type { Plugin, PluginError } from "@vestra-player/sdk";
258
+ * declare const plugin: Plugin;
259
+ * declare const url: string;
260
+ * declare const position: number;
261
+ * // ---cut---
253
262
  * import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
254
263
  *
255
- * const key = "https://example.com/a.mkv";
256
- * assert.ok(Buffer.byteLength(key) <= SESSION_STORAGE_LIMITS.keyBytes);
264
+ * const session = plugin.storage.session;
265
+ * if (Buffer.byteLength(url) <= SESSION_STORAGE_LIMITS.keyBytes) {
266
+ * try {
267
+ * await session.set(url, position);
268
+ * } catch (error) {
269
+ * if ((error as PluginError).code !== "limit") throw error;
270
+ * // Full: start over rather than lose the newest position.
271
+ * for (const key of await session.keys()) await session.delete(key);
272
+ * await session.set(url, position);
273
+ * }
274
+ * }
257
275
  * ```
258
276
  *
259
277
  * @since 1.5.25
@@ -390,11 +408,33 @@ export function system(scope) {
390
408
  * fails with code `invalid`; a new name beyond `names` fails with `limit`.
391
409
  * The player's tests check that these agree with the player.
392
410
  *
393
- * @example
411
+ * @example Store an API token only when it fits
412
+ * The plugin keeps a token the user pasted, or tells them it is empty or
413
+ * too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
414
+ * `Buffer.byteLength`; `secrets.set` would refuse other values with
415
+ * `invalid`. The store itself can still refuse (`unsupported` without a
416
+ * system store, `unavailable` or `failed` when it denies access): only
417
+ * those codes get the "could not be saved" message, anything else (a
418
+ * missing `secrets` capability) is a bug and is thrown on.
394
419
  * ```ts
420
+ * import type { Plugin, PluginError } from "@vestra-player/sdk";
421
+ * declare const plugin: Plugin;
422
+ * declare const token: string;
423
+ * // ---cut---
395
424
  * import { SECRET_LIMITS } from "@vestra-player/sdk/testing";
396
425
  *
397
- * assert.ok(Buffer.byteLength(token) <= SECRET_LIMITS.valueBytes);
426
+ * if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
427
+ * await plugin.osd.show("Paste a token of at most 1024 bytes.");
428
+ * } else {
429
+ * try {
430
+ * await plugin.secrets.set("api-token", token);
431
+ * } catch (error) {
432
+ * const { code } = error as PluginError;
433
+ * if (!["unsupported", "unavailable", "failed"].includes(code)) throw error;
434
+ * // No keychain or access denied: ask again next time.
435
+ * await plugin.osd.show("The token could not be saved.");
436
+ * }
437
+ * }
398
438
  * ```
399
439
  *
400
440
  * @since 1.5.14