@vestra-player/sdk 1.5.17 → 1.5.20
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 +26 -0
- package/dist/capabilities.js +1 -0
- package/dist/commands.js +0 -1
- package/dist/i18n-tools.d.ts +2 -2
- package/dist/i18n-tools.js +19 -1
- package/dist/player-runtime.d.ts +1 -1
- package/dist/player-runtime.js +1 -1
- package/dist/plugin/local.d.ts +7 -0
- package/dist/plugin/local.js +12 -0
- package/dist/plugin/markdown.d.ts +7 -0
- package/dist/plugin/markdown.js +62 -22
- package/dist/plugin/packages.js +5 -0
- package/dist/plugin/player.d.ts +2 -0
- package/dist/plugin/player.js +11 -6
- package/dist/plugin/services.js +10 -3
- package/dist/plugin/testing.d.ts +50 -10
- package/dist/plugin/testing.js +124 -23
- package/dist/plugin/types.d.ts +179 -24
- package/dist/plugin/ui.d.ts +4 -2
- package/dist/plugin/ui.js +31 -13
- package/dist/plugin/values.d.ts +15 -0
- package/dist/plugin/values.js +18 -0
- package/dist/wire.d.ts +3 -2
- package/dist/wire.js +3 -2
- package/llms-full.txt +171 -31
- package/llms.txt +23 -23
- package/package.json +1 -1
package/dist/plugin/ui.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import { basename, extname, resolve } from "node:path";
|
|
6
6
|
import { failure, Latest, quiet } from "./runtime.js";
|
|
7
7
|
import { dropsOf } from "./drops.js";
|
|
8
|
-
import { MarkdownHandle, markdown } from "./markdown.js";
|
|
8
|
+
import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
|
|
9
9
|
/** Slint names are kebab-case; plugin code may use camelCase. */
|
|
10
10
|
export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
11
11
|
const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
|
|
@@ -21,51 +21,69 @@ export class ImageHandle {
|
|
|
21
21
|
__vestraImage = true;
|
|
22
22
|
#scope;
|
|
23
23
|
#ref = ++images;
|
|
24
|
-
|
|
24
|
+
#maxSide;
|
|
25
|
+
constructor(scope, path, maxSide) {
|
|
25
26
|
this.#scope = scope;
|
|
26
27
|
this.path = path;
|
|
28
|
+
this.#maxSide = maxSide;
|
|
27
29
|
}
|
|
28
30
|
/** Released with its scope: writing it is `invalid`. */
|
|
29
31
|
get released() {
|
|
30
32
|
return this.#scope.disposed;
|
|
31
33
|
}
|
|
32
34
|
toJSON() {
|
|
33
|
-
|
|
35
|
+
// `maxSide` only when given: players before 0.5.1 refuse the field.
|
|
36
|
+
return this.#maxSide === undefined
|
|
37
|
+
? { $vestraImage: this.path, ref: this.#ref }
|
|
38
|
+
: { $vestraImage: this.path, ref: this.#ref, maxSide: this.#maxSide };
|
|
34
39
|
}
|
|
35
40
|
}
|
|
41
|
+
/** `ImageOptions.maxSide`, checked: an integer 1–4096 or absent. */
|
|
42
|
+
export function imageSide(options) {
|
|
43
|
+
const side = options?.maxSide;
|
|
44
|
+
if (side === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (!Number.isInteger(side) || side < 1 || side > 4096)
|
|
47
|
+
throw failure("invalid", "maxSide must be an integer 1–4096");
|
|
48
|
+
return side;
|
|
49
|
+
}
|
|
36
50
|
/**
|
|
37
|
-
* A property value as sent: images and documents in wire form,
|
|
38
|
-
*
|
|
39
|
-
* whole value, like the player.
|
|
51
|
+
* A property value as sent: images and documents in wire form, at any depth
|
|
52
|
+
* (released ones refused wherever they are), like the player.
|
|
40
53
|
*/
|
|
41
|
-
function wireValue(value
|
|
54
|
+
function wireValue(value) {
|
|
42
55
|
if (value instanceof MarkdownHandle) {
|
|
43
56
|
if (value.released)
|
|
44
57
|
throw failure("invalid", "Markdown document was released with the scope that created it");
|
|
45
58
|
return value.toJSON();
|
|
46
59
|
}
|
|
47
60
|
if (value instanceof ImageHandle) {
|
|
48
|
-
if (nested)
|
|
49
|
-
throw failure("invalid", `image ${value.path}: an image reference must be the whole value of an image property`);
|
|
50
61
|
if (value.released)
|
|
51
62
|
throw failure("invalid", `image ${value.path} was released with the scope that created it`);
|
|
52
63
|
return value.toJSON();
|
|
53
64
|
}
|
|
54
65
|
if (Array.isArray(value))
|
|
55
|
-
return value.map((item) => wireValue(item
|
|
66
|
+
return value.map((item) => wireValue(item));
|
|
56
67
|
if (value !== null && typeof value === "object") {
|
|
57
68
|
const out = {};
|
|
58
69
|
for (const [key, item] of Object.entries(value))
|
|
59
|
-
out[key] = wireValue(item
|
|
70
|
+
out[key] = wireValue(item);
|
|
60
71
|
return out;
|
|
61
72
|
}
|
|
62
73
|
return value;
|
|
63
74
|
}
|
|
64
75
|
export const ui = (scope) => ({
|
|
65
|
-
image(path) {
|
|
76
|
+
image(path, options) {
|
|
66
77
|
if (typeof path !== "string" || !path)
|
|
67
78
|
throw failure("invalid", "ui.image needs a file path");
|
|
68
|
-
return new ImageHandle(scope, path);
|
|
79
|
+
return new ImageHandle(scope, path, imageSide(options));
|
|
80
|
+
},
|
|
81
|
+
async remoteImage(url, options) {
|
|
82
|
+
if (typeof url !== "string" || !url.startsWith("https://"))
|
|
83
|
+
throw failure("invalid", "ui.remoteImage needs an https: address");
|
|
84
|
+
const side = imageSide(options);
|
|
85
|
+
const file = await remoteImageFile(scope.runtime, url);
|
|
86
|
+
return new ImageHandle(scope, file, side);
|
|
69
87
|
},
|
|
70
88
|
markdown: (text, options) => markdown(scope, text, options),
|
|
71
89
|
});
|
package/dist/plugin/values.d.ts
CHANGED
|
@@ -46,6 +46,21 @@ export declare const UI_VALUE_LIMITS: {
|
|
|
46
46
|
*/
|
|
47
47
|
readonly textBytes: number;
|
|
48
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* The player's runtime image limits (`host/src/ui_image.rs`): a file's
|
|
51
|
+
* bytes and sides, the decoded pixels each plugin caches, and (SDK 1.5.19,
|
|
52
|
+
* player 0.5.1) the decoded pixels the nested images of one view may show.
|
|
53
|
+
*/
|
|
54
|
+
export declare const IMAGE_LIMITS: Readonly<{
|
|
55
|
+
/** The largest image file the player reads, in bytes. */
|
|
56
|
+
fileBytes: number;
|
|
57
|
+
/** The longest side, in pixels, the player accepts for an image file. */
|
|
58
|
+
side: 4096;
|
|
59
|
+
/** The decoded pixel bytes the player caches for each plugin's images. */
|
|
60
|
+
pluginBytes: number;
|
|
61
|
+
/** The decoded pixel bytes the nested images of one view may show (player 0.5.1). */
|
|
62
|
+
viewBytes: number;
|
|
63
|
+
}>;
|
|
49
64
|
/**
|
|
50
65
|
* Why the player would refuse `value` (in wire form) as a property value,
|
|
51
66
|
* or `null`. The same checks, order and messages as the player's
|
package/dist/plugin/values.js
CHANGED
|
@@ -54,6 +54,21 @@ function documentText(value) {
|
|
|
54
54
|
const doc = value.$vestraMarkdown;
|
|
55
55
|
return isObject(doc) && typeof doc.text === "string" ? doc.text : null;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The player's runtime image limits (`host/src/ui_image.rs`): a file's
|
|
59
|
+
* bytes and sides, the decoded pixels each plugin caches, and (SDK 1.5.19,
|
|
60
|
+
* player 0.5.1) the decoded pixels the nested images of one view may show.
|
|
61
|
+
*/
|
|
62
|
+
export const IMAGE_LIMITS = Object.freeze({
|
|
63
|
+
/** The largest image file the player reads, in bytes. */
|
|
64
|
+
fileBytes: 16 * 1024 * 1024,
|
|
65
|
+
/** The longest side, in pixels, the player accepts for an image file. */
|
|
66
|
+
side: 4096,
|
|
67
|
+
/** The decoded pixel bytes the player caches for each plugin's images. */
|
|
68
|
+
pluginBytes: 64 * 1024 * 1024,
|
|
69
|
+
/** The decoded pixel bytes the nested images of one view may show (player 0.5.1). */
|
|
70
|
+
viewBytes: 32 * 1024 * 1024,
|
|
71
|
+
});
|
|
57
72
|
/**
|
|
58
73
|
* Why the player would refuse `value` (in wire form) as a property value,
|
|
59
74
|
* or `null`. The same checks, order and messages as the player's
|
|
@@ -79,6 +94,9 @@ export function valueLimitProblem(value) {
|
|
|
79
94
|
if (budget.values === L.values)
|
|
80
95
|
return `UI value exceeds ${L.values} total nodes`;
|
|
81
96
|
budget.values += 1;
|
|
97
|
+
// An image (nested from SDK 1.5.19) is one value, like a document.
|
|
98
|
+
if (isObject(value) && "$vestraImage" in value)
|
|
99
|
+
return null;
|
|
82
100
|
const doc = documentText(value);
|
|
83
101
|
if (doc !== null) {
|
|
84
102
|
const n = bytes(doc);
|
package/dist/wire.d.ts
CHANGED
|
@@ -465,11 +465,11 @@ export declare function createWire(transport: Transport): {
|
|
|
465
465
|
};
|
|
466
466
|
/** The core's own playback actions; resolves when the action finished. */
|
|
467
467
|
host: {
|
|
468
|
-
run: (action: string) => Promise<void>;
|
|
468
|
+
run: (action: string, seconds?: number) => Promise<void>;
|
|
469
469
|
};
|
|
470
470
|
step: {
|
|
471
471
|
step: (unit: string, direction: string, expected?: SourceStamp) => Promise<void>;
|
|
472
|
-
setStepSeek: (seconds: number) => Promise<void>;
|
|
472
|
+
setStepSeek: (seconds: number, keyframes?: boolean) => Promise<void>;
|
|
473
473
|
};
|
|
474
474
|
clipboard: {
|
|
475
475
|
readText: () => Promise<string | null>;
|
|
@@ -570,6 +570,7 @@ export declare function createWire(transport: Transport): {
|
|
|
570
570
|
cancel: (operationId: string) => Promise<PackageOperation>;
|
|
571
571
|
list: () => Promise<PackageInfo[]>;
|
|
572
572
|
setEnabled: (id: string, enabled: boolean) => Promise<void>;
|
|
573
|
+
reload: (id: string) => Promise<void>;
|
|
573
574
|
uninstall: (id: string, removeData: boolean, expectedRevision: number, requestId: string) => Promise<PackageOperation>;
|
|
574
575
|
};
|
|
575
576
|
snapshot: {
|
package/dist/wire.js
CHANGED
|
@@ -155,7 +155,7 @@ export function createWire(transport) {
|
|
|
155
155
|
},
|
|
156
156
|
/** The core's own playback actions; resolves when the action finished. */
|
|
157
157
|
host: {
|
|
158
|
-
run: (action) => transport.request("host.run", { action }),
|
|
158
|
+
run: (action, seconds) => transport.request("host.run", seconds === undefined ? { action } : { action, seconds }),
|
|
159
159
|
},
|
|
160
160
|
step: {
|
|
161
161
|
step: (unit, direction, expected) => transport.request("player.step", {
|
|
@@ -163,7 +163,7 @@ export function createWire(transport) {
|
|
|
163
163
|
direction,
|
|
164
164
|
...(expected ? { expected } : {}),
|
|
165
165
|
}),
|
|
166
|
-
setStepSeek: (seconds) => transport.request("player.setStepSeek", { seconds }),
|
|
166
|
+
setStepSeek: (seconds, keyframes) => transport.request("player.setStepSeek", keyframes === undefined ? { seconds } : { seconds, keyframes }),
|
|
167
167
|
},
|
|
168
168
|
clipboard: {
|
|
169
169
|
readText: () => transport.request("clipboard.readText"),
|
|
@@ -284,6 +284,7 @@ export function createWire(transport) {
|
|
|
284
284
|
cancel: (operationId) => transport.request("packages.cancel", { operationId }),
|
|
285
285
|
list: () => transport.request("packages.list"),
|
|
286
286
|
setEnabled: (id, enabled) => transport.request("packages.setEnabled", { id, enabled }),
|
|
287
|
+
reload: (id) => transport.request("packages.reload", { id }),
|
|
287
288
|
uninstall: (id, removeData, expectedRevision, requestId) => transport.request("packages.uninstall", {
|
|
288
289
|
id,
|
|
289
290
|
removeData,
|
package/llms-full.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @vestra-player/sdk 1.5.
|
|
1
|
+
# @vestra-player/sdk 1.5.20: 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
|
|
|
@@ -583,6 +583,23 @@ Play/pause, stop, previous/next, frame steps, relative seeks, speed, volume and
|
|
|
583
583
|
|
|
584
584
|
The difference from the `Player` methods: a `Player` control is a precise operation the plugin is responsible for (conditional, silent, its own error handling); a core action is the user's action, performed on the plugin's behalf. The action ids, menu places and default keys are in Reference: commands; how keys reach them is in Commands, menus and key bindings.
|
|
585
585
|
|
|
586
|
+
##### Step seeks: exact or keyframe
|
|
587
|
+
|
|
588
|
+
← / → (`seek-backward` / `seek-forward`) seek by the step that player.setStepSeek sets (5 s by default). By default they land on the exact time, which on a file with keyframes far apart means decoding from the previous keyframe first: a visible stall on 4K video. `setStepSeek({ seconds, keyframes: true })` (player 0.5.1, SDK 1.5.19) makes them land on a keyframe instead: forward on the first keyframe at or after position + step, backward on the last at or before position − step, and every press moves. Presses during a seek add up and continue from where it landed, and the on-screen text shows the landed time. Sources without moving video still seek exactly. The seek bar, `player.seek`, scrubs, chapters, loops and `player.step` are never affected.
|
|
589
|
+
|
|
590
|
+
For more steps of your own (Ctrl+→ and the like), call `plugin.host.run("seek-forward", { seconds: 30 })`: the same checks, mode, accumulation and text as →. In keyframe mode it resolves once the seek has landed, so `player.state.position` is current afterwards; do not show your own text for it. Set `"minPlayerVersion": "0.5.1"` when you rely on either option: older players ignore them.
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
const keyframes = true; // the plugin's own setting
|
|
594
|
+
await plugin.player.setStepSeek({ seconds: 5, keyframes });
|
|
595
|
+
const seek = (delta: number) =>
|
|
596
|
+
plugin.host.run(delta < 0 ? "seek-backward" : "seek-forward", {
|
|
597
|
+
seconds: Math.abs(delta),
|
|
598
|
+
});
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
In tests, `TestHost.keyframes` models the source's keyframes, and `host.coreSeeks` records each step seek with where it landed.
|
|
602
|
+
|
|
586
603
|
## Concept: Video and audio processing
|
|
587
604
|
|
|
588
605
|
A plugin can change what the user sees and hears in three different places. They look alike in the API (each is a handle with `set`, a `status` and `onStatus`), but they act at different stages and have different owners, so choosing the right one matters:
|
|
@@ -856,7 +873,8 @@ export component ListPanel inherits Rectangle {
|
|
|
856
873
|
##### Names and values
|
|
857
874
|
|
|
858
875
|
- Write names in camelCase or kebab-case in TypeScript: the SDK turns every capital letter into `-` plus the lower-case letter (`canMark` → `can-mark`, `strengthChanged` → `strength-changed`). Fields inside struct values are **not** converted, so spell struct fields exactly as the Slint struct does (and without `_`, which Slint treats as `-`).
|
|
859
|
-
- Values must be JSON: booleans, numbers, strings, arrays (Slint models) and objects (Slint structs). Two exceptions: an image from ui.image
|
|
876
|
+
- Values must be JSON: booleans, numbers, strings, arrays (Slint models) and objects (Slint structs). Two exceptions: an image from ui.image or ui.remoteImage wherever the Slint type is `image`: the whole value, or (SDK 1.5.19, player 0.5.1) a field at any depth, such as the logo of each row of a list; and a document from ui.markdown wherever the Slint type is `[MarkdownBlock]`: the whole value, or (SDK 1.5.4, the player after 0.4.0) a field at any depth, such as the `body` of each message in a chat list.
|
|
877
|
+
- Images in rows: give small pictures `maxSide` (about twice the drawn size, `64` for a 32 px logo), so that the player decodes them small. The images nested in one view's values may show at most 32 MB of decoded pixels together; a write past that is refused with `limit`. A nested image whose file fails the player's checks is drawn empty, and the rest of the row is shown. Rows with images update in place, so a `ListView` keeps its scroll position when the list is written again.
|
|
860
878
|
- Every name must exist in the Slint file. Writing a property the file does not declare makes a tool window's `show()` or a panel's `ready` fail, and in a settings page it keeps the Options window from opening; for a tool window, so does a callback bound with `on()` that the file does not declare. Once the view is open, such writes are reported to onError.
|
|
861
879
|
|
|
862
880
|
##### set, bind and the update pass
|
|
@@ -1850,7 +1868,7 @@ The player no longer keeps a previous version of a plugin. An upgrade replaces t
|
|
|
1850
1868
|
|
|
1851
1869
|
- There is no `packages.rollback`: the player answers the wire method with `unsupported` ("unknown package method"), and SDK 1.5.17 no longer has it.
|
|
1852
1870
|
- `packages.list()` and `packages.onChange` entries have no `previousVersion` (always `null` with SDK 1.5.16, removed in 1.5.17).
|
|
1853
|
-
- Built-in plugins (`builtin: true`) run in place from the player installation.
|
|
1871
|
+
- Built-in plugins (`builtin: true`) run in place from the player installation. Users manage only third-party plugins: a built-in can be neither uninstalled, replaced nor (from SDK 1.5.18 and the players released with it) disabled; `uninstall` and `setEnabled(id, false)` fail with `unsupported`, and a built-in an older player let the user disable runs again after the upgrade. A built-in's `directory` is inside the installation (`<install resources>/plugins/<id>`) and read-only.
|
|
1854
1872
|
- Third-party plugins can still be uninstalled.
|
|
1855
1873
|
|
|
1856
1874
|
Why: built-ins now run from the read-only installation instead of a copy, and an upgrade replaces the package instead of keeping the old one beside it. Going back to an older version becomes an ordinary uninstall and install that the user starts.
|
|
@@ -1862,14 +1880,15 @@ Why: built-ins now run from the read-only installation instead of a copy, and an
|
|
|
1862
1880
|
Before:
|
|
1863
1881
|
|
|
1864
1882
|
```ts nocheck
|
|
1865
|
-
if (info.previousVersion !== null)
|
|
1883
|
+
if (info.previousVersion !== null)
|
|
1884
|
+
showButton("Roll back", () => plugin.packages.rollback(info.id));
|
|
1866
1885
|
```
|
|
1867
1886
|
|
|
1868
1887
|
After: delete both. There is no replacement call; nothing on the player can restore an earlier package. Remove the button, menu item or command, and any state, text or test that depends on `previousVersion`. With SDK 1.5.17 the compiler finds every remaining use.
|
|
1869
1888
|
|
|
1870
1889
|
#### 2. Going back to an older version
|
|
1871
1890
|
|
|
1872
|
-
Managing plugins has exactly three operations: **install**, **uninstall** and **disable/enable**. There is no rollback, no "install older version" and no "Undo install".
|
|
1891
|
+
Managing third-party plugins has exactly three operations: **install**, **uninstall** and **disable/enable**. Built-ins have none of them. There is no rollback, no "install older version" and no "Undo install".
|
|
1873
1892
|
|
|
1874
1893
|
To go back to an older version, the user uninstalls the plugin and installs the older version's `.vxp` file themselves, as two ordinary operations. Do not offer an action that does both, an older-version install from a store, or an "Undo" after an install (the official store and the AI window drop theirs). Uninstalling without `removeData` keeps the plugin's data, and a reinstall from the same source reconnects to it; a `.vxp` file is a different source from a repository, so a plugin first installed from the store gets a new, empty data directory.
|
|
1875
1894
|
|
|
@@ -1884,13 +1903,13 @@ Check `builtin` before offering actions:
|
|
|
1884
1903
|
```ts
|
|
1885
1904
|
for (const info of await plugin.packages.list()) {
|
|
1886
1905
|
const actions = info.builtin
|
|
1887
|
-
? [
|
|
1888
|
-
: ["Disable", "Uninstall"];
|
|
1906
|
+
? []
|
|
1907
|
+
: [info.state === "disabled" ? "Enable" : "Disable", "Uninstall"];
|
|
1889
1908
|
console.log(info.id, actions);
|
|
1890
1909
|
}
|
|
1891
1910
|
```
|
|
1892
1911
|
|
|
1893
|
-
- Offer
|
|
1912
|
+
- Offer no action for a built-in: never disable, uninstall or install over it (the player answers `unsupported`). Built-ins that an older player listed as `disabled` come back as running.
|
|
1894
1913
|
- Never write into a built-in's `directory`, or into any plugin's `directory`: read `manifest.json` from it if you need to. A plugin writes only under its own plugin.dataDirectory.
|
|
1895
1914
|
|
|
1896
1915
|
#### 5. Tests
|
|
@@ -1899,17 +1918,18 @@ With SDK 1.5.17, `host.packages` entries have no `previousVersion` field: delete
|
|
|
1899
1918
|
|
|
1900
1919
|
#### Timeline
|
|
1901
1920
|
|
|
1902
|
-
| SDK
|
|
1903
|
-
|
|
|
1904
|
-
| 1.5.16 | ships with it | `Packages.rollback` and `PluginInfo.previousVersion` (and the wire field) are `@deprecated`. The player and `TestHost` answer rollback with `unsupported` and list `previousVersion: null`. Code still compiles.
|
|
1921
|
+
| SDK | Player | What happens |
|
|
1922
|
+
| ------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1923
|
+
| 1.5.16 | ships with it | `Packages.rollback` and `PluginInfo.previousVersion` (and the wire field) are `@deprecated`. The player and `TestHost` answer rollback with `unsupported` and list `previousVersion: null`. Code still compiles. |
|
|
1905
1924
|
| 1.5.17 | ships with it | **Removed:** `Packages.rollback`, `PluginInfo.previousVersion`, the wire method `packages.rollback` and the wire field `previousVersion`, `TestHost`'s `previousVersion` field and its `packages.rollback` handler, and `Packages.rollback` in the `plugins.manage` capability. Remaining uses are TypeScript compile errors. |
|
|
1925
|
+
| 1.5.18 | ships with it | Built-ins can no longer be disabled: the player and `TestHost` answer `setEnabled(builtin, false)` with `unsupported` (user ruling 2026-10-26). |
|
|
1906
1926
|
|
|
1907
1927
|
`apiVersion` stays `vestra/1` in both releases, as when 1.5.8 removed `PackageCandidate.trial()`.
|
|
1908
1928
|
|
|
1909
1929
|
#### Before you hand it in
|
|
1910
1930
|
|
|
1911
1931
|
- [ ] No call to `packages.rollback`; no UI or logic gated on `previousVersion`.
|
|
1912
|
-
- [ ] Built-ins offer
|
|
1932
|
+
- [ ] Built-ins offer no Disable, Enable or Uninstall.
|
|
1913
1933
|
- [ ] Nothing writes into a plugin's `directory`.
|
|
1914
1934
|
- [ ] No Undo after an install, no older-version install; older versions go through uninstall and a user-chosen `.vxp`.
|
|
1915
1935
|
- [ ] Tests compile and pass with SDK 1.5.17; no `previousVersion` is left in `host.packages` test data.
|
|
@@ -2155,7 +2175,7 @@ The table lists the API whose documentation names a player version, newest last:
|
|
|
2155
2175
|
| 0.4.1 | PluginError, FileResolverOptions, PackageCandidate.install, PluginWarning.code, Packages.validate |
|
|
2156
2176
|
| 0.4.2 | Resolvers.fileTypes, Resolvers.onFileTypesChange |
|
|
2157
2177
|
| 0.4.3 | CommandOptions.path, PropValue, View.set, Appearance.setLanguage, TemplateArgs, Text, ConfirmOptions.args, OsdPanel.set, OsdPanelOptions.maxColumns, Osd.show |
|
|
2158
|
-
| 0.5.1 | SlotState.reason, Panel.setInteraction, PlaybackStats.presentedFps |
|
|
2178
|
+
| 0.5.1 | ImageRef, ImageOptions.maxSide, PropValue, Ui.image, Ui.remoteImage, SlotState.reason, Panel.setInteraction, PlaybackStats.presentedFps, Player.setStepSeek, Host.run, Packages.reload |
|
|
2159
2179
|
|
|
2160
2180
|
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).
|
|
2161
2181
|
|
|
@@ -2176,7 +2196,7 @@ A plugin lists what it may do in the manifest's `capabilities` (Manifest). A cal
|
|
|
2176
2196
|
| `ui.window` | Windows.create | — | Open tool windows | |
|
|
2177
2197
|
| `ui.mount` | Panels.mount, Panels.bottom, Panels.sidebar, Panels.backdrop, PanelSlot.onChange, Panel.onChange, BackdropSlot.onChange | — | Show panels in the bottom bar, the sidebar or behind the video | |
|
|
2178
2198
|
| `ui.dialog` | Dialogs.confirm, Dialogs.openFile, Dialogs.openFiles, Dialogs.openDirectory, Dialogs.saveFile | — | Show file pickers and confirmations | |
|
|
2179
|
-
| `plugins.manage` | Packages.validate, Packages.validateArchive, Packages.setEnabled, Packages.uninstall | — | Install, uninstall and disable other plugins | yes |
|
|
2199
|
+
| `plugins.manage` | Packages.validate, Packages.validateArchive, Packages.setEnabled, Packages.reload, Packages.uninstall | — | Install, uninstall and disable other plugins | yes |
|
|
2180
2200
|
| `processing.video` | Plugin.video.filter, Plugin.video.view, Plugin.video.render | `playback.read` | Process the picture (filters, shaders) | |
|
|
2181
2201
|
| `processing.audio` | Plugin.audio.filter | `playback.read`, `events` | Process the sound | |
|
|
2182
2202
|
| `subtitles.read` | Subtitles.state, Subtitles.onChange, Subtitles.refresh | — | Read subtitles | |
|
|
@@ -2649,7 +2669,6 @@ Every command the player (`host`) and its built-in plugins register when the pla
|
|
|
2649
2669
|
| Settings ▸ Keyboard Shortcuts… | | `vestra.hotkeys/keys` |
|
|
2650
2670
|
| Settings ▸ Mouse… | | `vestra.hotkeys/mouse` |
|
|
2651
2671
|
| Settings ▸ Ignore Default Shortcuts | | `vestra.hotkeys/ignore-defaults` |
|
|
2652
|
-
| Settings ▸ Plugin Manager… | | `vestra.plugins/manage` |
|
|
2653
2672
|
| Settings ▸ Appearance ▸ Vestra Dark | | `vestra.preferences/theme.vestra` |
|
|
2654
2673
|
| Settings ▸ Appearance ▸ Vestra Light | | `vestra.preferences/theme.vestra-light` |
|
|
2655
2674
|
| Settings ▸ Appearance ▸ Show Logo When Idle | | `vestra.visualizer/idle-logo` |
|
|
@@ -3535,6 +3554,20 @@ interface ImageRef {
|
|
|
3535
3554
|
}
|
|
3536
3555
|
```
|
|
3537
3556
|
|
|
3557
|
+
### ImageOptions
|
|
3558
|
+
|
|
3559
|
+
Options of Ui.image and Ui.remoteImage.
|
|
3560
|
+
|
|
3561
|
+
```ts
|
|
3562
|
+
interface ImageOptions {
|
|
3563
|
+
/**
|
|
3564
|
+
* Decode the image scaled down, aspect kept, so that neither side is over this many pixels (an integer 1–4096); smaller images stay as they are. Use about twice the drawn size (64 for a 32 px logo) so that it stays sharp on high-density screens. It keeps the decoded memory small: a 1024×1024 icon is 4 MB decoded, 16 KB at `maxSide: 64`.
|
|
3565
|
+
* @player 0.5.1; older players refuse the reference (`invalid`).
|
|
3566
|
+
*/
|
|
3567
|
+
maxSide?: number;
|
|
3568
|
+
}
|
|
3569
|
+
```
|
|
3570
|
+
|
|
3538
3571
|
### MarkdownRef
|
|
3539
3572
|
|
|
3540
3573
|
A Markdown document for `MarkdownView` or `MarkdownBlocks`, made by
|
|
@@ -3569,9 +3602,8 @@ interface MarkdownOptions {
|
|
|
3569
3602
|
|
|
3570
3603
|
### PropValue
|
|
3571
3604
|
|
|
3572
|
-
The value of one Slint property: JSON with Markdown documents
|
|
3573
|
-
depth (PropData)
|
|
3574
|
-
property.
|
|
3605
|
+
The value of one Slint property: JSON with Markdown documents and images
|
|
3606
|
+
at any depth (PropData).
|
|
3575
3607
|
|
|
3576
3608
|
```ts
|
|
3577
3609
|
type PropValue = PropData | ImageRef;
|
|
@@ -3579,11 +3611,11 @@ type PropValue = PropData | ImageRef;
|
|
|
3579
3611
|
|
|
3580
3612
|
### PropData
|
|
3581
3613
|
|
|
3582
|
-
JSON whose values may also be Markdown documents
|
|
3583
|
-
of PropValue that nests.
|
|
3614
|
+
JSON whose values may also be Markdown documents and (SDK 1.5.19) images,
|
|
3615
|
+
at any depth: the part of PropValue that nests.
|
|
3584
3616
|
|
|
3585
3617
|
```ts
|
|
3586
|
-
type PropData = null | boolean | number | string | MarkdownRef | Text | TextList | PropData[] | {
|
|
3618
|
+
type PropData = null | boolean | number | string | MarkdownRef | ImageRef | Text | TextList | PropData[] | {
|
|
3587
3619
|
[key: string]: PropData;
|
|
3588
3620
|
};
|
|
3589
3621
|
```
|
|
@@ -3609,10 +3641,22 @@ interface Ui {
|
|
|
3609
3641
|
* Reference a PNG or JPEG file for an `image` property.
|
|
3610
3642
|
* @capability None.
|
|
3611
3643
|
* @param path - Absolute path of the image file, inside the package directory or the data directory.
|
|
3644
|
+
* @param options - `maxSide` (SDK 1.5.19, player 0.5.1): decode the image scaled down, aspect kept, so that neither side is over it.
|
|
3612
3645
|
* @returns A new ImageRef, owned by this scope.
|
|
3613
|
-
* @throws invalid (throws) `path` is not a non-empty string.
|
|
3646
|
+
* @throws invalid (throws) `path` is not a non-empty string, or `maxSide` is not an integer 1–4096.
|
|
3614
3647
|
*/
|
|
3615
|
-
image(path: string): ImageRef;
|
|
3648
|
+
image(path: string, options?: ImageOptions): ImageRef;
|
|
3649
|
+
/**
|
|
3650
|
+
* Download an `https:` PNG or JPEG image (a thumbnail, a repository's plugin icon) and reference the local copy, as Ui.image does.
|
|
3651
|
+
* @capability None (the plugin process has network access).
|
|
3652
|
+
* @player Any (the reference is an ordinary ImageRef); nested in a value, player 0.5.1.
|
|
3653
|
+
* @param url - The image's `https:` address.
|
|
3654
|
+
* @param options - As for Ui.image.
|
|
3655
|
+
* @returns The reference, owned by this scope, once the file is in the cache.
|
|
3656
|
+
* @throws invalid (rejects) `url` is not an `https:` address, or `maxSide` is not an integer 1–4096.
|
|
3657
|
+
* @throws unavailable (rejects) the download failed, timed out, was too large, or is not a PNG or JPEG image; or the plugin has no data directory.
|
|
3658
|
+
*/
|
|
3659
|
+
remoteImage(url: string, options?: ImageOptions): Promise<ImageRef>;
|
|
3616
3660
|
/**
|
|
3617
3661
|
* Prepare a Markdown document for `MarkdownView` (`@vestra/markdown.slint`).
|
|
3618
3662
|
* @capability None.
|
|
@@ -5179,10 +5223,28 @@ interface Player {
|
|
|
5179
5223
|
*/
|
|
5180
5224
|
step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
|
|
5181
5225
|
/**
|
|
5182
|
-
* The core's first-tier relative seek (`seek-backward` / `seek-forward`,
|
|
5226
|
+
* The core's first-tier relative seek (`seek-backward` / `seek-forward`,
|
|
5227
|
+
* ← / →): 0.1–3600 seconds in tenths, default 5. The newest call of any
|
|
5228
|
+
* plugin wins; it reverts when that plugin stops.
|
|
5229
|
+
*
|
|
5230
|
+
* `keyframes: true` (player 0.5.1, SDK 1.5.19): the step seeks
|
|
5231
|
+
* (`seek-backward` / `seek-forward`, including Host.run with
|
|
5232
|
+
* `seconds` and the picture-in-picture skips) land on a keyframe instead
|
|
5233
|
+
* of the exact time: forward on the first keyframe at or after position +
|
|
5234
|
+
* step, backward on the last at or before position − step, and they
|
|
5235
|
+
* always move. Presses during a seek add up and continue from where it
|
|
5236
|
+
* landed; the on-screen text shows the landed time. Sources without moving
|
|
5237
|
+
* video seek exactly. The seek bar, Player.seek, scrubs, chapters,
|
|
5238
|
+
* loops and Player.step are unaffected. Omitted or `false`: exact
|
|
5239
|
+
* seeks, as before. `seconds` and `keyframes` are written as a pair: the
|
|
5240
|
+
* newest call sets both.
|
|
5241
|
+
*
|
|
5242
|
+
* Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
|
|
5243
|
+
* the manifest when you rely on it.
|
|
5183
5244
|
*/
|
|
5184
5245
|
setStepSeek(options: {
|
|
5185
5246
|
seconds: number;
|
|
5247
|
+
keyframes?: boolean;
|
|
5186
5248
|
}): Promise<void>;
|
|
5187
5249
|
/** The chapters of the current media (empty without media or chapters), read on demand; `sourceId` tells which source they belong to. `playback.read`. */
|
|
5188
5250
|
chapters(): Promise<{
|
|
@@ -5224,7 +5286,7 @@ The core's basic playback actions (menu commands owned by the player).
|
|
|
5224
5286
|
| `play-pause` | Space | pause / resume, replay after the end |
|
|
5225
5287
|
| `previous` / `next` | PageUp, Mouse:X1 / PageDown, Mouse:X2 | ask the sequence provider |
|
|
5226
5288
|
| `frame-back` / `frame-forward` | D / F | `player.step("frame", …)` |
|
|
5227
|
-
| `seek-backward` / `seek-forward` | ← / → | relative seek by `setStepSeek` seconds |
|
|
5289
|
+
| `seek-backward` / `seek-forward` | ← / → | relative seek by `setStepSeek` seconds (or `host.run`'s `seconds`), exact or to keyframes as `setStepSeek` says |
|
|
5228
5290
|
| `rate-normal`, `rate-down`, `rate-up` | Z, X, C | 1×, ±0.1 within 0.25–4 |
|
|
5229
5291
|
| `volume-up` / `volume-down` | ↑ / ↓, wheel | ±5 (`volume-up` stops at 100) |
|
|
5230
5292
|
| `mute` | M | toggle mute |
|
|
@@ -5250,6 +5312,23 @@ interface Host {
|
|
|
5250
5312
|
* Prefer this over reimplementing playback buttons.
|
|
5251
5313
|
*/
|
|
5252
5314
|
run(action: HostAction): Promise<void>;
|
|
5315
|
+
/**
|
|
5316
|
+
* One step seek of `seconds` (0.1–3600 in tenths) instead of the
|
|
5317
|
+
* Player.setStepSeek step: the same checks, mode (exact or
|
|
5318
|
+
* keyframes), accumulation and on-screen text as ← / →. Use it for more
|
|
5319
|
+
* seek steps of your own (Ctrl+→ and the like) so that they add up with
|
|
5320
|
+
* the core's and land the same way.
|
|
5321
|
+
*
|
|
5322
|
+
* In keyframe mode it resolves once the seek has landed (so
|
|
5323
|
+
* `player.state.position` is current after the `await`); presses that add
|
|
5324
|
+
* up resolve together, the last with the text. In exact mode it resolves
|
|
5325
|
+
* once the seek is submitted, as before. `invalid` for `seconds` out of
|
|
5326
|
+
* range.
|
|
5327
|
+
* @player 0.5.1 (set `"minPlayerVersion": "0.5.1"`: older players ignore `seconds` and seek by the `setStepSeek` step)
|
|
5328
|
+
*/
|
|
5329
|
+
run(action: "seek-backward" | "seek-forward", options: {
|
|
5330
|
+
seconds: number;
|
|
5331
|
+
}): Promise<void>;
|
|
5253
5332
|
}
|
|
5254
5333
|
```
|
|
5255
5334
|
|
|
@@ -6007,7 +6086,9 @@ interface PluginInfo {
|
|
|
6007
6086
|
* The package directory (read its `manifest.json` for display fields). A built-in's directory is inside the player installation (`<install resources>/plugins/<id>`) and read-only: a plugin writes only under its own Plugin.dataDirectory.
|
|
6008
6087
|
*/
|
|
6009
6088
|
directory: string;
|
|
6010
|
-
/**
|
|
6089
|
+
/**
|
|
6090
|
+
* Shipped with the player: it always runs; it cannot be disabled, uninstalled or replaced (older players, before SDK 1.5.18, let it be disabled). `false` from players before 0.2.0.
|
|
6091
|
+
*/
|
|
6011
6092
|
builtin: boolean;
|
|
6012
6093
|
/** Where an installed plugin came from; `null` for development loads. */
|
|
6013
6094
|
source: PackageSource | null;
|
|
@@ -6085,9 +6166,26 @@ interface Packages {
|
|
|
6085
6166
|
/** The new list after any start, stop, failure, install or uninstall. No capability. */
|
|
6086
6167
|
onChange(listener: (plugins: PluginInfo[]) => void): Disposable;
|
|
6087
6168
|
/**
|
|
6088
|
-
* Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed.
|
|
6169
|
+
* Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed. `unsupported` to disable a built-in (PluginInfo.builtin): users manage third-party plugins only (players released with SDK 1.5.18 on; older ones disabled built-ins too).
|
|
6089
6170
|
*/
|
|
6090
6171
|
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
6172
|
+
/**
|
|
6173
|
+
* Restart a plugin (`plugins.manage`): stop it and start it again from the
|
|
6174
|
+
* same package with the same data directory, for example to recover one
|
|
6175
|
+
* that misbehaves. Built-ins included. A stopped or failed plugin is just
|
|
6176
|
+
* started. The disabled flag is never written, so a reload never leaves
|
|
6177
|
+
* a plugin disabled.
|
|
6178
|
+
*
|
|
6179
|
+
* Resolves once the plugin is stopped and its start is scheduled; the
|
|
6180
|
+
* player starts it as soon as the old process is gone (at most 20 s).
|
|
6181
|
+
* Follow it with Packages.onChange: `starting`, then `running`,
|
|
6182
|
+
* or `failed` with the reason.
|
|
6183
|
+
*
|
|
6184
|
+
* `invalid` for oneself or a disabled plugin (enable it instead),
|
|
6185
|
+
* `missing` unknown, `busy` while it is being installed or reloaded.
|
|
6186
|
+
* @player 0.5.1; older players reject it as an unknown request (`unsupported`).
|
|
6187
|
+
*/
|
|
6188
|
+
reload(id: string): Promise<void>;
|
|
6091
6189
|
/**
|
|
6092
6190
|
* Remove an installed plugin (`plugins.manage`); `removeData` also deletes its data directory, otherwise reinstalling it from the same source reconnects to its data. Its user bindings stay (as orphans). `unsupported` for built-ins and development loads, `missing` unknown, `invalid` for oneself.
|
|
6093
6191
|
*/
|
|
@@ -6987,12 +7085,16 @@ class TestHost implements Transport {
|
|
|
6987
7085
|
kind: string;
|
|
6988
7086
|
title?: string | null;
|
|
6989
7087
|
}[]): void;
|
|
6990
|
-
/**
|
|
7088
|
+
/**
|
|
7089
|
+
* Runtime images written to views (`plugin.ui.image`, `plugin.ui.remoteImage`), whole values and (SDK 1.5.19) images nested in rows, newest last. The SDK refuses images of released scopes before anything is sent. The test host does not read the files, so it does not check the player's per-view budget (`IMAGE_LIMITS.viewBytes`).
|
|
7090
|
+
*/
|
|
6991
7091
|
readonly images: {
|
|
6992
7092
|
/** The image file's path, as given to `ui.image`. */
|
|
6993
7093
|
path: string;
|
|
6994
7094
|
/** The SDK's reference number for that `ImageRef`. */
|
|
6995
7095
|
ref: number;
|
|
7096
|
+
/** Its `maxSide` option, when given. */
|
|
7097
|
+
maxSide?: number;
|
|
6996
7098
|
}[];
|
|
6997
7099
|
/**
|
|
6998
7100
|
* The file an image property shows, or `null` when the value is not a runtime image. View properties hold images in their wire form; this reads the path back.
|
|
@@ -7181,7 +7283,7 @@ class TestHost implements Transport {
|
|
|
7181
7283
|
/** Export jobs by job id. Each state query advances a job one step (`queued` → `running` → `completed`; `cancelling` → `cancelled`). Wire-level. */
|
|
7182
7284
|
readonly jobs: Map<number, MediaJob>;
|
|
7183
7285
|
/**
|
|
7184
|
-
* `openStream`'s transport: a Unix socket
|
|
7286
|
+
* `openStream`'s transport: a local stream (a Unix socket on macOS and Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the plugin must send the token first, as the player on Windows answers. Set it before the plugin calls `openStream`.
|
|
7185
7287
|
*/
|
|
7186
7288
|
producerTransport: "unix" | "tcp";
|
|
7187
7289
|
/** Generated streams (`player.openStream`) by producer id: the fake player end of each frame connection. Wire-level beyond the FakeProducer fields. */
|
|
@@ -7313,6 +7415,25 @@ class TestHost implements Transport {
|
|
|
7313
7415
|
constructor();
|
|
7314
7416
|
/** The `player.setStepSeek` value in effect (default 5). */
|
|
7315
7417
|
stepSeek: number;
|
|
7418
|
+
/** The `player.setStepSeek` `keyframes` in effect (default `false`, SDK 1.5.19). */
|
|
7419
|
+
stepSeekKeyframes: boolean;
|
|
7420
|
+
/**
|
|
7421
|
+
* Test data: the keyframe times (seconds, ascending) of the current source, for keyframe step seeks. `null` (the default): none modelled, and step seeks are exact even with `stepSeekKeyframes`.
|
|
7422
|
+
*/
|
|
7423
|
+
keyframes: number[] | null;
|
|
7424
|
+
/**
|
|
7425
|
+
* Every step seek the core ran (`seek-backward` / `seek-forward`, from a key, the menu or `host.run`), newest last: the step, whether it went to a keyframe, and where it landed.
|
|
7426
|
+
*/
|
|
7427
|
+
readonly coreSeeks: {
|
|
7428
|
+
/** `seek-backward` or `seek-forward`. */
|
|
7429
|
+
action: string;
|
|
7430
|
+
/** The step: `host.run`'s `seconds`, else `setStepSeek`'s. */
|
|
7431
|
+
seconds: number;
|
|
7432
|
+
/** It landed on a keyframe (`stepSeekKeyframes` and keyframes modelled). */
|
|
7433
|
+
keyframes: boolean;
|
|
7434
|
+
/** The new position. */
|
|
7435
|
+
landed: number;
|
|
7436
|
+
}[];
|
|
7316
7437
|
/** Every `player.step`, newest last. */
|
|
7317
7438
|
readonly steps: {
|
|
7318
7439
|
/** `"frame"` or `"keyframe"`. */
|
|
@@ -7426,7 +7547,7 @@ class TestHost implements Transport {
|
|
|
7426
7547
|
/** Every `packages.setEnabled` / `uninstall` / `install`, newest last. */
|
|
7427
7548
|
readonly packageCalls: {
|
|
7428
7549
|
/** Which call. */
|
|
7429
|
-
method: "setEnabled" | "uninstall" | "install";
|
|
7550
|
+
method: "setEnabled" | "reload" | "uninstall" | "install";
|
|
7430
7551
|
/** The target plugin id. */
|
|
7431
7552
|
id: string;
|
|
7432
7553
|
/** `setEnabled`'s value. */
|
|
@@ -7443,7 +7564,7 @@ class TestHost implements Transport {
|
|
|
7443
7564
|
*/
|
|
7444
7565
|
readonly candidates: Map<string, PackageManifest>;
|
|
7445
7566
|
/**
|
|
7446
|
-
* Remote
|
|
7567
|
+
* Remote images (`plugin.ui.markdown`, `plugin.ui.remoteImage`): address → a local PNG or JPEG file served for it, or `null` for a failing download. Nothing is fetched from the network in tests; unknown addresses fail.
|
|
7447
7568
|
*/
|
|
7448
7569
|
readonly remoteImages: Map<string, string | null>;
|
|
7449
7570
|
/**
|
|
@@ -7872,6 +7993,25 @@ const UI_VALUE_LIMITS: {
|
|
|
7872
7993
|
};
|
|
7873
7994
|
```
|
|
7874
7995
|
|
|
7996
|
+
### IMAGE_LIMITS
|
|
7997
|
+
|
|
7998
|
+
The player's runtime image limits (`host/src/ui_image.rs`): a file's
|
|
7999
|
+
bytes and sides, the decoded pixels each plugin caches, and (SDK 1.5.19,
|
|
8000
|
+
player 0.5.1) the decoded pixels the nested images of one view may show.
|
|
8001
|
+
|
|
8002
|
+
```ts
|
|
8003
|
+
const IMAGE_LIMITS: Readonly<{
|
|
8004
|
+
/** The largest image file the player reads, in bytes. */
|
|
8005
|
+
fileBytes: number;
|
|
8006
|
+
/** The longest side, in pixels, the player accepts for an image file. */
|
|
8007
|
+
side: 4096;
|
|
8008
|
+
/** The decoded pixel bytes the player caches for each plugin's images. */
|
|
8009
|
+
pluginBytes: number;
|
|
8010
|
+
/** The decoded pixel bytes the nested images of one view may show (player 0.5.1). */
|
|
8011
|
+
viewBytes: number;
|
|
8012
|
+
}>;
|
|
8013
|
+
```
|
|
8014
|
+
|
|
7875
8015
|
### HostError
|
|
7876
8016
|
|
|
7877
8017
|
A definite refusal from the player (or the plugin runner), with a stable
|