@vestra-player/sdk 1.6.5 → 1.6.8
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 +24 -0
- package/bin/vestra-plugin.mjs +1 -84
- package/dist/api-requests.js +2 -0
- package/dist/commands.d.ts +22 -7
- package/dist/commands.js +12 -7
- package/dist/docs-tree.json.gz +0 -0
- package/dist/host-capabilities.js +3 -0
- package/dist/plugin/bindings.d.ts +9 -0
- package/dist/plugin/bindings.js +41 -0
- package/dist/plugin/commands.js +99 -0
- package/dist/plugin/runtime.js +3 -0
- package/dist/plugin/testing.d.ts +30 -2
- package/dist/plugin/testing.js +96 -4
- package/dist/plugin/types.d.ts +92 -18
- package/dist/wire.d.ts +2 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/slint/vestra/bar-buttons.slint +72 -16
- package/slint/vestra/widgets.slint +95 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.8
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody; authors of a bottom bar should move to `content-min-width`.** Documentation, a read-only Slint property and a removed CLI warning: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability. A plugin's `button` declarations stay as they are. A bottom bar that still places `BarButtons` keeps working on player 0.7.6 (it shows that strip, and the player draws no second one); it is deprecated and goes in a later patch.
|
|
6
|
+
|
|
7
|
+
- **The player places the bottom bar's button strip** (player 0.7.6, menu redesign step 3a). It draws the strip at the right end of the bottom slot, also when no plugin mounts a bottom bar (in a band of its own): the plugins' buttons, a » menu, the player's **Command Palette** (`host/palette`, a magnifier) and **Toggle Sidebar**. [Bottom-bar buttons](docs/explanation/commands.md#bottom-bar-buttons) now reads: buttons are ordered by `order`, then by plugin id; lower `order` comes first and stays visible longest; when the strip runs out of room, or more than 8 plugin buttons are declared, the remaining buttons move to the » menu, in the same order; a button is never dropped. Before player 0.7.6 a narrow strip lost its lowest orders first and the player left out all but the eight highest; `BAR_BUTTON_LIMITS.total` is now what the strip holds. [Reference: commands](docs/reference/commands.md#bottom-bar-buttons), [Reference: limits](docs/reference/limits.md#bottom-bar-buttons), `CommandButton`, `CommandOptions.button` and `BAR_BUTTON_LIMITS` say so.
|
|
8
|
+
- **Writing a bottom bar** ([Interface guidelines](docs/guidelines/interface.md#writing-a-bottom-bar)): a bar places nothing. It reports the least width of its own controls in a `content-min-width` length property on the mounted component (the player gives the strip the rest, so the time label is never covered) and ends its row at the strip, reading `BarButtonsHost.width` (new, read only; `fixed` for an optional control kept ahead of plugin buttons) from `@vestra/bar-buttons.slint`. The file stays in the Slint library copy for that global; its `BarButtons` component is deprecated.
|
|
9
|
+
- **`@vestra/bar-buttons.slint`** (the read-only copy of the player's widget library, synced): `BarButtonsHost` gains `width`, `fixed`, `shown` and `more` (out: the strip's width, the cells a bar must leave free (the player's own and the » cell), the plugin buttons in the strip, whether » shows) and the player's `palette`, `overflow` and `room`; the comments describe the player's strip and mark `BarButtons` deprecated.
|
|
10
|
+
- **`vestra-plugin check` no longer warns** that a bottom bar does not place `BarButtons`: since player 0.7.6 that is the right thing to do.
|
|
11
|
+
|
|
12
|
+
## 1.6.7
|
|
13
|
+
|
|
14
|
+
**Who must change: nobody.** The 1.6.6 release below, published again under a new number: its npm publish failed (the provenance transparency log returned 500, then 409 for the same tarball), so 1.6.6 never reached npm. Nothing else changed.
|
|
15
|
+
|
|
16
|
+
## 1.6.6
|
|
17
|
+
|
|
18
|
+
**Who must change: nobody.** Additions only: two members of `plugin.commands`, a wire method and event, two Slint widget additions and a `TestHost` method. `apiVersion` stays `vestra/1`, no new capability (`commands` covers them). A plugin that does not use them sends exactly what it sent before. One that uses them needs player 0.7.6 (`"minPlayerVersion": "0.7.6"`): the player builds `plugin` from its own SDK, so on older players `plugin.commands.keys` does not exist (a `TypeError`), and their `@vestra/widgets.slint` has no `KeyHint` or `ToolTipKey`, so a Slint file that uses them does not compile.
|
|
19
|
+
|
|
20
|
+
- **`plugin.commands.keys(id)`** (player 0.7.6): the keys of one of the plugin's **own** commands, as the player's menus and command palette write them (`["W"]`, `["Ctrl+B"]`, `["⌘B"]` on macOS, arrows as `←`), always the current binding: the user's keys after a change, `[]` when the user cleared them or turned on Ignore Default Shortcuts, `[]` for a key another command holds; mouse bindings and (outside macOS) `Cmd` bindings left out. Read-only and synchronous; read in a `bind` function, the view follows every change. Another plugin's commands, or an unknown id, read `[]`; it needs `commands` only, not `bindings.manage`. The first read asks the player (`commands.keys`) and subscribes to the `commands.keys` event, which the player sends only to the plugin whose own labels changed, at most every 200 ms; until the first answer every command reads `[]`. See [Key hints beside controls](docs/explanation/commands.md#key-hints-beside-controls).
|
|
21
|
+
- **`plugin.commands.onKeysChange(listener)`** (player 0.7.6): runs after any of the plugin's keys changed. `commands.keys` is a wake event, like `bindings.changed`: a listener (or a binding that reads `keys`) does not keep a plugin running while idle, and a plugin in standby is started for a change of its keys.
|
|
22
|
+
- **`KeyHint`** in `@vestra/widgets.slint`: a key cap (monospace `font-xs`, `text-dim`, a 1px border, both palettes); an empty `text` draws nothing and takes no space.
|
|
23
|
+
- **`ToolTipArea.keys`** (a list of the new struct `ToolTipKey`, `{ key, text }`) and **`ToolTipArea.detail`**: the tip reads "Brightness [W] − [E] + · double-click: Default" with key caps. Without them the tip is the plain text tip as before.
|
|
24
|
+
- **`TestHost.rebind(command, inputs | null)`** changes or clears a key of the plugin's command as the user does; `TestHost` answers `commands.keys` and pushes the event like the player, writing labels for `host.systemInfo.os` (macOS by default: `⇧W`, `⌘B`). `bindings.set` and `bindings.reset` push it too.
|
|
25
|
+
- The [theme-widgets example](examples/theme-widgets) shows a slider's two commands (F7, F8) as key caps and in its tooltip.
|
|
26
|
+
|
|
3
27
|
## 1.6.5
|
|
4
28
|
|
|
5
29
|
**Who must change: nobody, but run `vestra-plugin i18n extract` again if a command sets a `category`.** A fix in the plugin CLI only: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability, no player version needed. A plugin whose commands set `category` as a string literal that is not already a message now has those texts in `locales/messages.pot`, so its next `vestra-plugin i18n check` fails with "messages.pot is out of date" until `i18n extract` runs. A category written with `plugin.i18n.text(…)` was already extracted, and a plugin that sets no category sees no change.
|
package/bin/vestra-plugin.mjs
CHANGED
|
@@ -81,7 +81,6 @@ import {
|
|
|
81
81
|
basename,
|
|
82
82
|
dirname,
|
|
83
83
|
extname,
|
|
84
|
-
isAbsolute,
|
|
85
84
|
join,
|
|
86
85
|
relative,
|
|
87
86
|
resolve,
|
|
@@ -363,10 +362,7 @@ async function check(target, report = null) {
|
|
|
363
362
|
}
|
|
364
363
|
say(`${target.name}: capabilities declared for every API use`);
|
|
365
364
|
if (report) return;
|
|
366
|
-
for (const line of
|
|
367
|
-
...(await bottomBarWarnings(target)),
|
|
368
|
-
...(await forWarnings(target)),
|
|
369
|
-
])
|
|
365
|
+
for (const line of await forWarnings(target))
|
|
370
366
|
console.log(`${target.name}: warning: ${line}`);
|
|
371
367
|
// Translation warnings never fail the check.
|
|
372
368
|
if (present(join(target.directory, "locales"))) {
|
|
@@ -432,85 +428,6 @@ function slintCode(text) {
|
|
|
432
428
|
return out;
|
|
433
429
|
}
|
|
434
430
|
|
|
435
|
-
/** Whether `path` lies inside `directory` (not a sibling sharing its prefix). */
|
|
436
|
-
function inside(directory, path) {
|
|
437
|
-
const rel = relative(resolve(directory), path);
|
|
438
|
-
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Whether the Slint file at `file` (relative to the plugin directory)
|
|
443
|
-
* imports `BarButtons` from `@vestra/bar-buttons.slint`, itself or
|
|
444
|
-
* through the plugin's own Slint files (relative paths, any depth, inside
|
|
445
|
-
* the plugin directory). Local re-exports count as such files too:
|
|
446
|
-
* `export { Row } from "row.slint"` and `export * from "row.slint"` are
|
|
447
|
-
* followed, and `export { BarButtons } from "@vestra/bar-buttons.slint"` (or
|
|
448
|
-
* `export * from` it) in a file the bar imports counts as placing it.
|
|
449
|
-
* Comments do not count.
|
|
450
|
-
*/
|
|
451
|
-
async function importsBarButtons(target, file, seen = new Set()) {
|
|
452
|
-
const path = resolve(target.directory, file);
|
|
453
|
-
if (seen.has(path) || !inside(target.directory, path)) return false;
|
|
454
|
-
seen.add(path);
|
|
455
|
-
const text = await readFile(path, "utf8").catch(() => null);
|
|
456
|
-
if (text === null) return false;
|
|
457
|
-
const code = slintCode(text);
|
|
458
|
-
// { names (or "*"), from }: imports, and re-exports with a source file.
|
|
459
|
-
const imports = [
|
|
460
|
-
...code.matchAll(
|
|
461
|
-
/\b(?:import|export)\s*(?:(\*)\s*from\s*|\{([^}]*)\}\s*from\s*)?"([^"]+)"/g,
|
|
462
|
-
),
|
|
463
|
-
].map(([, star, names, from]) => ({ names: star ?? names ?? "", from }));
|
|
464
|
-
for (const { names, from } of imports)
|
|
465
|
-
if (
|
|
466
|
-
from === "@vestra/bar-buttons.slint" &&
|
|
467
|
-
(names === "*" ||
|
|
468
|
-
names
|
|
469
|
-
.split(",")
|
|
470
|
-
.some((name) => /^BarButtons(\s+as\s+[\w-]+)?$/.test(name.trim())))
|
|
471
|
-
)
|
|
472
|
-
return true;
|
|
473
|
-
for (const { from } of imports) {
|
|
474
|
-
if (
|
|
475
|
-
from.startsWith("@") ||
|
|
476
|
-
!from.endsWith(".slint") ||
|
|
477
|
-
from === "std-widgets.slint"
|
|
478
|
-
)
|
|
479
|
-
continue;
|
|
480
|
-
if (await importsBarButtons(target, join(dirname(file), from), seen))
|
|
481
|
-
return true;
|
|
482
|
-
}
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* A plugin that mounts a bottom bar (`panels.mount("bottom", "<file>")`)
|
|
488
|
-
* whose Slint file does not import `BarButtons` (`@vestra/bar-buttons.slint`),
|
|
489
|
-
* itself or through its local imports: while it is the bottom bar, no
|
|
490
|
-
* plugin's command button shows (SDK 1.5.29). The mount is read from the
|
|
491
|
-
* source text: a literal file name in the call.
|
|
492
|
-
*/
|
|
493
|
-
async function bottomBarWarnings(target) {
|
|
494
|
-
const { typescript: sources } = await sourceFiles(target);
|
|
495
|
-
const files = new Set();
|
|
496
|
-
for (const file of sources) {
|
|
497
|
-
const text = await readFile(join(target.directory, file), "utf8");
|
|
498
|
-
for (const match of text.matchAll(
|
|
499
|
-
/\.mount\(\s*["'`]bottom["'`]\s*,\s*["'`]([^"'`$]+)["'`]/g,
|
|
500
|
-
))
|
|
501
|
-
files.add(match[1]);
|
|
502
|
-
}
|
|
503
|
-
const warnings = [];
|
|
504
|
-
for (const file of files) {
|
|
505
|
-
if (!present(join(target.directory, file))) continue;
|
|
506
|
-
if (await importsBarButtons(target, file)) continue;
|
|
507
|
-
warnings.push(
|
|
508
|
-
`${file} is a bottom bar without BarButtons (import { BarButtons } from "@vestra/bar-buttons.slint"): while it is mounted, the command buttons of every plugin (and the player's sidebar toggle) show nowhere; see docs/guidelines/interface.md#writing-a-bottom-bar`,
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
return warnings;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
431
|
/**
|
|
515
432
|
* A `for` over an array the plugin binds (an `in` or `in-out` property of
|
|
516
433
|
* array type of a component or global in the same file), outside a
|
package/dist/api-requests.js
CHANGED
|
@@ -98,6 +98,8 @@ export const API_REQUESTS = Object.freeze({
|
|
|
98
98
|
"Resolvers.add": [{ method: "sourceResolvers.register" }],
|
|
99
99
|
"Resolvers.onFileTypesChange": [listen("resolvers.fileTypes")],
|
|
100
100
|
"Commands.add": [{ method: "commands.register" }],
|
|
101
|
+
"Commands.keys": [{ method: "commands.keys" }, listen("commands.keys")],
|
|
102
|
+
"Commands.onKeysChange": [listen("commands.keys")],
|
|
101
103
|
"Host.run": [{ method: "host.run" }],
|
|
102
104
|
"Windows.create": [{ method: "ui.open" }],
|
|
103
105
|
"Dialogs.confirm": [{ method: "dialogs.confirm" }],
|
package/dist/commands.d.ts
CHANGED
|
@@ -158,12 +158,11 @@ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back"
|
|
|
158
158
|
/**
|
|
159
159
|
* The player's limits on bottom-bar buttons (`CommandOptions.button`), the
|
|
160
160
|
* same values as the player's (its tests check that they agree). A button
|
|
161
|
-
* past `perPlugin`
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* then the earlier declaration, goes first).
|
|
161
|
+
* past `perPlugin` has none (its command still registers and its menu entry
|
|
162
|
+
* stays). `total` is what the strip holds: the `total` lowest by order,
|
|
163
|
+
* plugin id and declaration order; the rest are in the strip's » menu, in
|
|
164
|
+
* the same order (older players keep the `total` highest and leave the
|
|
165
|
+
* others out).
|
|
167
166
|
*
|
|
168
167
|
* @since 1.5.29
|
|
169
168
|
*
|
|
@@ -172,7 +171,7 @@ export declare const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back"
|
|
|
172
171
|
export declare const BAR_BUTTON_LIMITS: {
|
|
173
172
|
/** Buttons one plugin shows (1). */
|
|
174
173
|
readonly perPlugin: 1;
|
|
175
|
-
/** Buttons the
|
|
174
|
+
/** Buttons in the strip, every plugin together, besides the player's own (8); the rest are in its » menu. */
|
|
176
175
|
readonly total: 8;
|
|
177
176
|
/** The highest `order` (999; the lowest is 0). */
|
|
178
177
|
readonly maxOrder: 999;
|
|
@@ -345,12 +344,28 @@ export type CommandDeclaration = {
|
|
|
345
344
|
*/
|
|
346
345
|
home?: string;
|
|
347
346
|
};
|
|
347
|
+
/**
|
|
348
|
+
* `commands.keys` (player 0.7.6): each of the plugin's own commands, by
|
|
349
|
+
* local id, with the labels of its current keys; the answer to the request
|
|
350
|
+
* and the data of the event of that name.
|
|
351
|
+
*/
|
|
352
|
+
export type CommandKeys = {
|
|
353
|
+
keys: Record<string, string[]>;
|
|
354
|
+
/** The player's binding table revision: a larger one is newer. */
|
|
355
|
+
revision: number;
|
|
356
|
+
};
|
|
348
357
|
export declare function commandClient(transport: Transport): {
|
|
349
358
|
register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState, when?: CommandWhen) => Promise<{
|
|
350
359
|
revision: number;
|
|
351
360
|
}>;
|
|
352
361
|
/** Remove the command from the menu and the binding table; the id is free again. */
|
|
353
362
|
unregister: (id: string) => Promise<void>;
|
|
363
|
+
/**
|
|
364
|
+
* The key labels of the plugin's own commands, as menus show them
|
|
365
|
+
* (player 0.7.6); later changes come as the `commands.keys` event, in
|
|
366
|
+
* the same shape. `revision` orders answers and events.
|
|
367
|
+
*/
|
|
368
|
+
keys: () => Promise<CommandKeys>;
|
|
354
369
|
update: (id: string, revision: number, state: CommandState, when?: CommandWhen) => Promise<{
|
|
355
370
|
revision: number;
|
|
356
371
|
}>;
|
package/dist/commands.js
CHANGED
|
@@ -202,12 +202,11 @@ export const BAR_BUTTON_ICONS = [
|
|
|
202
202
|
/**
|
|
203
203
|
* The player's limits on bottom-bar buttons (`CommandOptions.button`), the
|
|
204
204
|
* same values as the player's (its tests check that they agree). A button
|
|
205
|
-
* past `perPlugin`
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* then the earlier declaration, goes first).
|
|
205
|
+
* past `perPlugin` has none (its command still registers and its menu entry
|
|
206
|
+
* stays). `total` is what the strip holds: the `total` lowest by order,
|
|
207
|
+
* plugin id and declaration order; the rest are in the strip's » menu, in
|
|
208
|
+
* the same order (older players keep the `total` highest and leave the
|
|
209
|
+
* others out).
|
|
211
210
|
*
|
|
212
211
|
* @since 1.5.29
|
|
213
212
|
*
|
|
@@ -216,7 +215,7 @@ export const BAR_BUTTON_ICONS = [
|
|
|
216
215
|
export const BAR_BUTTON_LIMITS = {
|
|
217
216
|
/** Buttons one plugin shows (1). */
|
|
218
217
|
perPlugin: 1,
|
|
219
|
-
/** Buttons the
|
|
218
|
+
/** Buttons in the strip, every plugin together, besides the player's own (8); the rest are in its » menu. */
|
|
220
219
|
total: 8,
|
|
221
220
|
/** The highest `order` (999; the lowest is 0). */
|
|
222
221
|
maxOrder: 999,
|
|
@@ -692,6 +691,12 @@ export function commandClient(transport) {
|
|
|
692
691
|
}),
|
|
693
692
|
/** Remove the command from the menu and the binding table; the id is free again. */
|
|
694
693
|
unregister: (id) => transport.request("commands.unregister", { id }),
|
|
694
|
+
/**
|
|
695
|
+
* The key labels of the plugin's own commands, as menus show them
|
|
696
|
+
* (player 0.7.6); later changes come as the `commands.keys` event, in
|
|
697
|
+
* the same shape. `revision` orders answers and events.
|
|
698
|
+
*/
|
|
699
|
+
keys: () => transport.request("commands.keys", {}),
|
|
695
700
|
update: (id, revision, state, when) => transport.request("commands.update", {
|
|
696
701
|
id,
|
|
697
702
|
revision,
|
package/dist/docs-tree.json.gz
CHANGED
|
Binary file
|
|
@@ -84,6 +84,7 @@ export const HOST_CAPABILITIES = {
|
|
|
84
84
|
needs: {
|
|
85
85
|
cases: {
|
|
86
86
|
"bindings.changed": "bindings.manage",
|
|
87
|
+
"commands.keys": "commands",
|
|
87
88
|
"packages.changed": null,
|
|
88
89
|
"video.upscaling.changed": "video.upscaling",
|
|
89
90
|
},
|
|
@@ -96,6 +97,7 @@ export const HOST_CAPABILITIES = {
|
|
|
96
97
|
needs: {
|
|
97
98
|
cases: {
|
|
98
99
|
"bindings.changed": "bindings.manage",
|
|
100
|
+
"commands.keys": "commands",
|
|
99
101
|
"packages.changed": null,
|
|
100
102
|
"video.upscaling.changed": "video.upscaling",
|
|
101
103
|
},
|
|
@@ -116,6 +118,7 @@ export const HOST_CAPABILITIES = {
|
|
|
116
118
|
{ name: "commands.update", needs: "commands" },
|
|
117
119
|
{ name: "commands.unregister", needs: "commands" },
|
|
118
120
|
{ name: "host.run", needs: "commands" },
|
|
121
|
+
{ name: "commands.keys", needs: "commands" },
|
|
119
122
|
],
|
|
120
123
|
name: "commands",
|
|
121
124
|
requires: [],
|
|
@@ -79,3 +79,12 @@ export declare function canonical(text: string): string;
|
|
|
79
79
|
export declare function checkBindings(bindings: readonly string[]): string[];
|
|
80
80
|
/** `plugin.bindings` (`bindings.manage`): the table and the user overlay. */
|
|
81
81
|
export declare const bindingsApi: (scope: ScopeImpl) => Bindings;
|
|
82
|
+
/**
|
|
83
|
+
* How the player's menus write a canonical binding (`bindings.rs`
|
|
84
|
+
* `label_in`), for `TestHost`'s `commands.keys`: `Ctrl+O` and arrows as
|
|
85
|
+
* ← → ↑ ↓ (`Esc`, `PgUp`, `Num5`); on macOS (`mac`) symbols in Apple's
|
|
86
|
+
* order ⌃⌥⇧⌘, a `Ctrl` binding as ⌘ (it answers to ⌘ there) and ⌃ only
|
|
87
|
+
* next to ⌘. `null` for a binding menus do not show: a mouse binding, and
|
|
88
|
+
* a `Cmd` one outside macOS.
|
|
89
|
+
*/
|
|
90
|
+
export declare function keyLabel(binding: string, mac: boolean): string | null;
|
package/dist/plugin/bindings.js
CHANGED
|
@@ -219,3 +219,44 @@ export const bindingsApi = (scope) => ({
|
|
|
219
219
|
});
|
|
220
220
|
},
|
|
221
221
|
});
|
|
222
|
+
/** Named keys whose label is not their name (the player's `label_in`). */
|
|
223
|
+
const NAMED_LABELS = {
|
|
224
|
+
ArrowLeft: "←",
|
|
225
|
+
ArrowRight: "→",
|
|
226
|
+
ArrowUp: "↑",
|
|
227
|
+
ArrowDown: "↓",
|
|
228
|
+
Escape: "Esc",
|
|
229
|
+
PageUp: "PgUp",
|
|
230
|
+
PageDown: "PgDn",
|
|
231
|
+
Delete: "Del",
|
|
232
|
+
Plus: "+",
|
|
233
|
+
NumpadPlus: "Num+",
|
|
234
|
+
NumpadMinus: "Num-",
|
|
235
|
+
NumpadMultiply: "Num*",
|
|
236
|
+
NumpadDivide: "Num/",
|
|
237
|
+
NumpadEnter: "NumEnter",
|
|
238
|
+
NumpadDecimal: "Num.",
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* How the player's menus write a canonical binding (`bindings.rs`
|
|
242
|
+
* `label_in`), for `TestHost`'s `commands.keys`: `Ctrl+O` and arrows as
|
|
243
|
+
* ← → ↑ ↓ (`Esc`, `PgUp`, `Num5`); on macOS (`mac`) symbols in Apple's
|
|
244
|
+
* order ⌃⌥⇧⌘, a `Ctrl` binding as ⌘ (it answers to ⌘ there) and ⌃ only
|
|
245
|
+
* next to ⌘. `null` for a binding menus do not show: a mouse binding, and
|
|
246
|
+
* a `Cmd` one outside macOS.
|
|
247
|
+
*/
|
|
248
|
+
export function keyLabel(binding, mac) {
|
|
249
|
+
if (binding.startsWith("Mouse:"))
|
|
250
|
+
return null;
|
|
251
|
+
const parts = binding.split("+");
|
|
252
|
+
const input = parts.pop();
|
|
253
|
+
const has = (mod) => parts.includes(mod);
|
|
254
|
+
const [ctrl, alt, shift, cmd] = ["Ctrl", "Alt", "Shift", "Cmd"].map(has);
|
|
255
|
+
if (cmd && !mac)
|
|
256
|
+
return null;
|
|
257
|
+
const mods = mac
|
|
258
|
+
? `${ctrl && cmd ? "⌃" : ""}${alt ? "⌥" : ""}${shift ? "⇧" : ""}${ctrl || cmd ? "⌘" : ""}`
|
|
259
|
+
: parts.map((mod) => `${mod}+`).join("");
|
|
260
|
+
const digit = /^Numpad(\d)$/.exec(input)?.[1];
|
|
261
|
+
return mods + (NAMED_LABELS[input] ?? (digit ? `Num${digit}` : input));
|
|
262
|
+
}
|
package/dist/plugin/commands.js
CHANGED
|
@@ -242,6 +242,105 @@ class CommandHandle {
|
|
|
242
242
|
pending.delete(this.id);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
+
const NONE = Object.freeze([]);
|
|
246
|
+
const KEYS = "commands.keys";
|
|
247
|
+
/**
|
|
248
|
+
* One per plugin: the SDK's copy of the key labels of the plugin's own
|
|
249
|
+
* commands (player 0.7.6). Started by the first `keys` read or
|
|
250
|
+
* `onKeysChange`: it subscribes to `commands.keys` (a mirror, unless a
|
|
251
|
+
* listener or a binding holds it), then asks once; the player pushes every
|
|
252
|
+
* later change. Answers and events carry the binding table's revision, and
|
|
253
|
+
* an older one than what the copy has is dropped.
|
|
254
|
+
*/
|
|
255
|
+
class KeysService {
|
|
256
|
+
#runtime;
|
|
257
|
+
#keys = new Map();
|
|
258
|
+
#revision = -1;
|
|
259
|
+
#started = false;
|
|
260
|
+
#listeners = new Set();
|
|
261
|
+
constructor(runtime) {
|
|
262
|
+
this.#runtime = runtime;
|
|
263
|
+
}
|
|
264
|
+
start() {
|
|
265
|
+
if (this.#started)
|
|
266
|
+
return;
|
|
267
|
+
this.#started = true;
|
|
268
|
+
const runtime = this.#runtime;
|
|
269
|
+
// Without `commands` the plugin has no commands: every read is [].
|
|
270
|
+
if (!runtime.can("commands"))
|
|
271
|
+
return;
|
|
272
|
+
runtime.on(KEYS, (data) => this.#accept(data), "commands", true);
|
|
273
|
+
// Subscribed first: a change after this answer comes as an event.
|
|
274
|
+
runtime.host.commands.keys().then((answer) => this.#accept(answer),
|
|
275
|
+
// An older player (`unsupported`): no keys, as without a binding.
|
|
276
|
+
() => { });
|
|
277
|
+
}
|
|
278
|
+
#accept(data) {
|
|
279
|
+
const answer = data;
|
|
280
|
+
const revision = answer?.revision;
|
|
281
|
+
const keys = answer?.keys;
|
|
282
|
+
if (typeof revision !== "number" || !keys || typeof keys !== "object")
|
|
283
|
+
return;
|
|
284
|
+
if (revision < this.#revision)
|
|
285
|
+
return;
|
|
286
|
+
this.#revision = revision;
|
|
287
|
+
const next = new Map();
|
|
288
|
+
for (const [id, labels] of Object.entries(keys))
|
|
289
|
+
if (Array.isArray(labels) && labels.length)
|
|
290
|
+
next.set(id, Object.freeze(labels.map(String)));
|
|
291
|
+
const changed = [];
|
|
292
|
+
for (const id of new Set([...this.#keys.keys(), ...next.keys()])) {
|
|
293
|
+
const before = this.#keys.get(id) ?? NONE;
|
|
294
|
+
const after = next.get(id) ?? NONE;
|
|
295
|
+
if (before.length !== after.length ||
|
|
296
|
+
before.some((label, at) => label !== after[at]))
|
|
297
|
+
changed.push(id);
|
|
298
|
+
// The same labels keep the same array.
|
|
299
|
+
else if (after !== NONE)
|
|
300
|
+
next.set(id, before);
|
|
301
|
+
}
|
|
302
|
+
this.#keys = next;
|
|
303
|
+
if (!changed.length)
|
|
304
|
+
return;
|
|
305
|
+
this.#runtime.changed(KEYS, changed);
|
|
306
|
+
for (const listener of [...this.#listeners])
|
|
307
|
+
listener();
|
|
308
|
+
}
|
|
309
|
+
get(id) {
|
|
310
|
+
return this.#keys.get(id) ?? NONE;
|
|
311
|
+
}
|
|
312
|
+
watch(listener) {
|
|
313
|
+
this.#listeners.add(listener);
|
|
314
|
+
const held = this.#runtime.hold(KEYS);
|
|
315
|
+
return {
|
|
316
|
+
dispose: async () => {
|
|
317
|
+
this.#listeners.delete(listener);
|
|
318
|
+
await held.dispose();
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const keysOf = (runtime) => runtime.service("commands.keys", () => new KeysService(runtime));
|
|
245
324
|
export const commands = (scope) => ({
|
|
246
325
|
add: (options) => new CommandHandle(scope, options),
|
|
326
|
+
keys(id) {
|
|
327
|
+
if (typeof id !== "string")
|
|
328
|
+
throw failure("invalid", "keys takes a command id of this plugin");
|
|
329
|
+
const service = keysOf(scope.runtime);
|
|
330
|
+
service.start();
|
|
331
|
+
scope.runtime.reading(KEYS);
|
|
332
|
+
scope.runtime.depends(KEYS, id);
|
|
333
|
+
return service.get(id);
|
|
334
|
+
},
|
|
335
|
+
onKeysChange(listener) {
|
|
336
|
+
const service = keysOf(scope.runtime);
|
|
337
|
+
service.start();
|
|
338
|
+
const watch = service.watch(() => scope.run(listener));
|
|
339
|
+
return scope.add({
|
|
340
|
+
dispose: async () => {
|
|
341
|
+
scope.forget(watch);
|
|
342
|
+
await watch.dispose();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
},
|
|
247
346
|
});
|
package/dist/plugin/runtime.js
CHANGED
|
@@ -13,6 +13,8 @@ const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
|
|
|
13
13
|
export const WAKE_EVENTS = [
|
|
14
14
|
"packages.changed",
|
|
15
15
|
"bindings.changed",
|
|
16
|
+
// Player 0.7.6: sent only to the plugin whose own keys changed.
|
|
17
|
+
"commands.keys",
|
|
16
18
|
"appearance.changed",
|
|
17
19
|
"resolvers.fileTypes",
|
|
18
20
|
];
|
|
@@ -25,6 +27,7 @@ const STATE = {
|
|
|
25
27
|
"window.state": { name: "plugin.window.state", when: true },
|
|
26
28
|
"appearance.changed": { name: "plugin.appearance", when: true },
|
|
27
29
|
"timeline.changed": { name: "plugin.timeline.marks", when: false },
|
|
30
|
+
"commands.keys": { name: "plugin.commands.keys", when: false },
|
|
28
31
|
"playback.backend": { name: "plugin.subtitles.state", when: false },
|
|
29
32
|
"resolvers.fileTypes": {
|
|
30
33
|
name: "plugin.resolvers.fileTypes",
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -2403,6 +2403,34 @@ export declare class TestHost implements Transport {
|
|
|
2403
2403
|
* ```
|
|
2404
2404
|
*/
|
|
2405
2405
|
get conflicts(): string[][];
|
|
2406
|
+
/**
|
|
2407
|
+
* Change the keys of one of the plugin's commands as the user does in
|
|
2408
|
+
* Keyboard Shortcuts (SDK 1.6.6): `inputs` replaces its keys (`[]`
|
|
2409
|
+
* clears them, as Ignore Default Shortcuts does for every default),
|
|
2410
|
+
* `null` gives it its own defaults back. It writes
|
|
2411
|
+
* {@link TestHost.bindings | bindings.overlay}, and the plugin hears it
|
|
2412
|
+
* as from the player: `bindings.changed` and, when what
|
|
2413
|
+
* `plugin.commands.keys` reads changed, `commands.keys`.
|
|
2414
|
+
*
|
|
2415
|
+
* @example Check that a key hint follows a rebinding
|
|
2416
|
+
* The view showed W for Brightness −; after the user moves it to
|
|
2417
|
+
* Shift+W, it shows the new key. `rebind` changes the overlay and
|
|
2418
|
+
* pushes the new labels, and `settle` lets the binding run again. The
|
|
2419
|
+
* test host is a Mac unless `systemInfo.os` says otherwise, hence ⇧W.
|
|
2420
|
+
* ```ts
|
|
2421
|
+
* import assert from "node:assert/strict";
|
|
2422
|
+
* declare const host: TestHost;
|
|
2423
|
+
* // ---cut---
|
|
2424
|
+
* host.rebind("brightness.down", ["Shift+W"]);
|
|
2425
|
+
* await host.settle();
|
|
2426
|
+
* assert.equal(host.window("picture").props["brightness-down"], "⇧W");
|
|
2427
|
+
* ```
|
|
2428
|
+
*
|
|
2429
|
+
* @capability None.
|
|
2430
|
+
* @throws `Error` `command` is not a command of the plugin.
|
|
2431
|
+
* @since 1.6.6
|
|
2432
|
+
*/
|
|
2433
|
+
rebind(command: string, inputs: readonly string[] | null): void;
|
|
2406
2434
|
/**
|
|
2407
2435
|
* The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"`
|
|
2408
2436
|
* to inputs; preset it to simulate saved overrides). `press` honours it.
|
|
@@ -2617,8 +2645,8 @@ export declare class TestHost implements Transport {
|
|
|
2617
2645
|
* The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
|
|
2618
2646
|
* shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
|
|
2619
2647
|
* later one stays a menu item only). Empty once the command is disposed.
|
|
2620
|
-
* Whether the
|
|
2621
|
-
* the
|
|
2648
|
+
* Whether one is in the strip or its » menu is the player's business
|
|
2649
|
+
* (the room and the other plugins' buttons).
|
|
2622
2650
|
*
|
|
2623
2651
|
* @example Click the plugin's bottom-bar button in a test
|
|
2624
2652
|
* The test finds the plugin's Store button, clicks it and checks that the
|
package/dist/plugin/testing.js
CHANGED
|
@@ -78,7 +78,7 @@ import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
|
|
|
78
78
|
import { OSD_LIMITS } from "./osd.js";
|
|
79
79
|
import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
|
|
80
80
|
import { SUBTITLE_LIMITS } from "./subtitles.js";
|
|
81
|
-
import { BINDING_INPUTS, canonical } from "./bindings.js";
|
|
81
|
+
import { BINDING_INPUTS, canonical, keyLabel } from "./bindings.js";
|
|
82
82
|
import { THEME_PALETTES } from "../palettes.js";
|
|
83
83
|
import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
|
|
84
84
|
import { validStep } from "./player.js";
|
|
@@ -3080,7 +3080,7 @@ export class TestHost {
|
|
|
3080
3080
|
const scope = declaration.focus ? "panel" : "global";
|
|
3081
3081
|
// The user overlay (`bindings.set`) replaces a command's own bindings.
|
|
3082
3082
|
const overridden = this.bindings.overlay.get(this.#bindingKey(id));
|
|
3083
|
-
for (const binding of overridden ?? declaration.bindings) {
|
|
3083
|
+
for (const binding of overridden ?? declaration.bindings ?? []) {
|
|
3084
3084
|
const key = `${scope} ${binding}`;
|
|
3085
3085
|
claims.set(key, [...(claims.get(key) ?? []), id]);
|
|
3086
3086
|
}
|
|
@@ -3091,6 +3091,87 @@ export class TestHost {
|
|
|
3091
3091
|
#bindingKey(id) {
|
|
3092
3092
|
return this.coreCommands.has(id) ? id : `${this.manifestId}/${id}`;
|
|
3093
3093
|
}
|
|
3094
|
+
/**
|
|
3095
|
+
* Change the keys of one of the plugin's commands as the user does in
|
|
3096
|
+
* Keyboard Shortcuts (SDK 1.6.6): `inputs` replaces its keys (`[]`
|
|
3097
|
+
* clears them, as Ignore Default Shortcuts does for every default),
|
|
3098
|
+
* `null` gives it its own defaults back. It writes
|
|
3099
|
+
* {@link TestHost.bindings | bindings.overlay}, and the plugin hears it
|
|
3100
|
+
* as from the player: `bindings.changed` and, when what
|
|
3101
|
+
* `plugin.commands.keys` reads changed, `commands.keys`.
|
|
3102
|
+
*
|
|
3103
|
+
* @example Check that a key hint follows a rebinding
|
|
3104
|
+
* The view showed W for Brightness −; after the user moves it to
|
|
3105
|
+
* Shift+W, it shows the new key. `rebind` changes the overlay and
|
|
3106
|
+
* pushes the new labels, and `settle` lets the binding run again. The
|
|
3107
|
+
* test host is a Mac unless `systemInfo.os` says otherwise, hence ⇧W.
|
|
3108
|
+
* ```ts
|
|
3109
|
+
* import assert from "node:assert/strict";
|
|
3110
|
+
* declare const host: TestHost;
|
|
3111
|
+
* // ---cut---
|
|
3112
|
+
* host.rebind("brightness.down", ["Shift+W"]);
|
|
3113
|
+
* await host.settle();
|
|
3114
|
+
* assert.equal(host.window("picture").props["brightness-down"], "⇧W");
|
|
3115
|
+
* ```
|
|
3116
|
+
*
|
|
3117
|
+
* @capability None.
|
|
3118
|
+
* @throws `Error` `command` is not a command of the plugin.
|
|
3119
|
+
* @since 1.6.6
|
|
3120
|
+
*/
|
|
3121
|
+
rebind(command, inputs) {
|
|
3122
|
+
if (!this.commands.has(command))
|
|
3123
|
+
throw new Error(`test host: no command ${command}`);
|
|
3124
|
+
const key = this.#bindingKey(command);
|
|
3125
|
+
if (inputs === null)
|
|
3126
|
+
this.bindings.overlay.delete(key);
|
|
3127
|
+
else
|
|
3128
|
+
this.bindings.overlay.set(key, inputs.map(canonical));
|
|
3129
|
+
this.emit("bindings.changed", null);
|
|
3130
|
+
this.#keysChanged();
|
|
3131
|
+
}
|
|
3132
|
+
/** The labels `commands.keys` last answered or pushed, as JSON. */
|
|
3133
|
+
#keysSent = "";
|
|
3134
|
+
/** The binding table revision of `commands.keys` (a counter here). */
|
|
3135
|
+
#keysRevision = 0;
|
|
3136
|
+
/**
|
|
3137
|
+
* `commands.keys` as the player answers it: each of the plugin's own
|
|
3138
|
+
* commands with the labels of the keys it holds now (the overlay, else
|
|
3139
|
+
* its defaults; not a key an earlier command holds), as menus write
|
|
3140
|
+
* them on `systemInfo.os`.
|
|
3141
|
+
*/
|
|
3142
|
+
#keys() {
|
|
3143
|
+
const claims = this.#claims();
|
|
3144
|
+
const mac = this.systemInfo.os === "macos";
|
|
3145
|
+
const keys = {};
|
|
3146
|
+
for (const [id, { declaration }] of this.commands) {
|
|
3147
|
+
const scope = declaration.focus ? "panel" : "global";
|
|
3148
|
+
const current = this.bindings.overlay.get(this.#bindingKey(id)) ??
|
|
3149
|
+
declaration.bindings ??
|
|
3150
|
+
[];
|
|
3151
|
+
const labels = [];
|
|
3152
|
+
for (const input of current) {
|
|
3153
|
+
if (claims.get(`${scope} ${input}`)?.[0] !== id)
|
|
3154
|
+
continue;
|
|
3155
|
+
const label = keyLabel(input, mac);
|
|
3156
|
+
if (label !== null && !labels.includes(label))
|
|
3157
|
+
labels.push(label);
|
|
3158
|
+
}
|
|
3159
|
+
keys[id] = labels;
|
|
3160
|
+
}
|
|
3161
|
+
return { keys, revision: this.#keysRevision };
|
|
3162
|
+
}
|
|
3163
|
+
/** Push `commands.keys` when the labels changed and the plugin listens. */
|
|
3164
|
+
#keysChanged() {
|
|
3165
|
+
this.#keysRevision++;
|
|
3166
|
+
if (!this.subscriptions.has("commands.keys"))
|
|
3167
|
+
return;
|
|
3168
|
+
const now = this.#keys();
|
|
3169
|
+
const text = JSON.stringify(now.keys);
|
|
3170
|
+
if (text === this.#keysSent)
|
|
3171
|
+
return;
|
|
3172
|
+
this.#keysSent = text;
|
|
3173
|
+
this.emit("commands.keys", now);
|
|
3174
|
+
}
|
|
3094
3175
|
/**
|
|
3095
3176
|
* The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"`
|
|
3096
3177
|
* to inputs; preset it to simulate saved overrides). `press` honours it.
|
|
@@ -3324,8 +3405,8 @@ export class TestHost {
|
|
|
3324
3405
|
* The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
|
|
3325
3406
|
* shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
|
|
3326
3407
|
* later one stays a menu item only). Empty once the command is disposed.
|
|
3327
|
-
* Whether the
|
|
3328
|
-
* the
|
|
3408
|
+
* Whether one is in the strip or its » menu is the player's business
|
|
3409
|
+
* (the room and the other plugins' buttons).
|
|
3329
3410
|
*
|
|
3330
3411
|
* @example Click the plugin's bottom-bar button in a test
|
|
3331
3412
|
* The test finds the plugin's Store button, clicks it and checks that the
|
|
@@ -4182,11 +4263,20 @@ export class TestHost {
|
|
|
4182
4263
|
await host.settle();
|
|
4183
4264
|
},
|
|
4184
4265
|
});
|
|
4266
|
+
// The player pushes the new command's keys after the answer.
|
|
4267
|
+
queueMicrotask(() => this.#keysChanged());
|
|
4185
4268
|
return { revision: 1 };
|
|
4186
4269
|
},
|
|
4187
4270
|
"commands.unregister": (p) => {
|
|
4188
4271
|
if (!this.commands.delete(String(p.id)))
|
|
4189
4272
|
throw new HostError("missing", `command ${p.id} is not registered`);
|
|
4273
|
+
queueMicrotask(() => this.#keysChanged());
|
|
4274
|
+
},
|
|
4275
|
+
// Player 0.7.6: the labels of the plugin's own commands.
|
|
4276
|
+
"commands.keys": () => {
|
|
4277
|
+
const now = this.#keys();
|
|
4278
|
+
this.#keysSent = JSON.stringify(now.keys);
|
|
4279
|
+
return now;
|
|
4190
4280
|
},
|
|
4191
4281
|
"commands.update": (p) => {
|
|
4192
4282
|
// The request itself, as the player reads it (capabilities/commands.rs
|
|
@@ -5102,6 +5192,7 @@ export class TestHost {
|
|
|
5102
5192
|
throw new HostError("missing", `no command ${key}`);
|
|
5103
5193
|
this.bindings.overlay.set(key, [...p.inputs]);
|
|
5104
5194
|
this.emit("bindings.changed", null);
|
|
5195
|
+
this.#keysChanged();
|
|
5105
5196
|
},
|
|
5106
5197
|
"bindings.reset": (p) => {
|
|
5107
5198
|
if (p.plugin === undefined)
|
|
@@ -5109,6 +5200,7 @@ export class TestHost {
|
|
|
5109
5200
|
else
|
|
5110
5201
|
this.bindings.overlay.delete(`${p.plugin}/${p.command}`);
|
|
5111
5202
|
this.emit("bindings.changed", null);
|
|
5203
|
+
this.#keysChanged();
|
|
5112
5204
|
},
|
|
5113
5205
|
"packages.list": () => structuredClone(this.packages),
|
|
5114
5206
|
"packages.setEnabled": (p) => {
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -954,17 +954,17 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
|
|
|
954
954
|
* A command's button on the bottom bar: {@link CommandOptions.button}.
|
|
955
955
|
*
|
|
956
956
|
* @remarks
|
|
957
|
-
* **Where it shows.** The
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* `order`,
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* [
|
|
957
|
+
* **Where it shows.** The player draws the bottom bar's button strip and
|
|
958
|
+
* decides where it goes: today at the right end of the bottom bar; this may
|
|
959
|
+
* change (older players let the bar place `BarButtons` itself). Buttons
|
|
960
|
+
* are ordered by `order`, then by plugin id; lower `order` comes first and
|
|
961
|
+
* stays visible longest. When the strip runs out of room, or more than 8
|
|
962
|
+
* plugin buttons are declared, the remaining buttons move to the strip's »
|
|
963
|
+
* menu, in the same order. A button is never dropped: it is either in the
|
|
964
|
+
* strip or in the » menu, and its command keeps its menu entry, its key and
|
|
965
|
+
* its place in the command palette. Users may pin and unpin buttons later;
|
|
966
|
+
* do not rely on a position. See
|
|
967
|
+
* [Bottom-bar buttons](../../docs/explanation/commands.md#bottom-bar-buttons).
|
|
968
968
|
*
|
|
969
969
|
* **What it shows.** The icon, the command's title (translated, a trailing
|
|
970
970
|
* "…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
|
|
@@ -974,9 +974,9 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
|
|
|
974
974
|
* a command with `checked` shows a small dot under its icon while checked.
|
|
975
975
|
* A click runs the command exactly like a click on its menu item.
|
|
976
976
|
*
|
|
977
|
-
* **Limits.** One button per plugin
|
|
978
|
-
*
|
|
979
|
-
*
|
|
977
|
+
* **Limits.** One button per plugin, the first it declares (a later one
|
|
978
|
+
* stays a menu item only), and eight in the strip (`BAR_BUTTON_LIMITS`); the
|
|
979
|
+
* rest are in the » menu. The button goes
|
|
980
980
|
* with its command: disposing the command, or disabling, stopping or
|
|
981
981
|
* uninstalling the plugin, removes it.
|
|
982
982
|
*
|
|
@@ -984,8 +984,7 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
|
|
|
984
984
|
* A Store icon appears in the bottom bar's button strip; a click opens
|
|
985
985
|
* the store window, exactly like the menu item. The player draws the
|
|
986
986
|
* button from the command's title, keys and state, and places it by
|
|
987
|
-
* `order` (200 is where the player's own Plugin Store button sits)
|
|
988
|
-
* bar that does not place `BarButtons` shows only the menu item.
|
|
987
|
+
* `order` (200 is where the player's own Plugin Store button sits).
|
|
989
988
|
* ```ts
|
|
990
989
|
* const store = plugin.windows.create("ui/store.slint");
|
|
991
990
|
* plugin.commands.add({
|
|
@@ -1238,8 +1237,8 @@ export interface CommandOptions {
|
|
|
1238
1237
|
/**
|
|
1239
1238
|
* A button for this command on the bottom bar (SDK 1.5.29, player 0.5.10):
|
|
1240
1239
|
* an icon and a place, `{ icon: "store", order: 200 }`. Needs a `path`: the
|
|
1241
|
-
* menu entry is where the command stays reachable
|
|
1242
|
-
*
|
|
1240
|
+
* menu entry is where the command stays reachable wherever the button
|
|
1241
|
+
* is (the strip or its » menu). See
|
|
1243
1242
|
* {@link CommandButton} for where it shows, what it shows and the limits.
|
|
1244
1243
|
* Fails with `invalid` for an unknown icon name, an order outside 0–999 or
|
|
1245
1244
|
* a button without a path.
|
|
@@ -1451,6 +1450,81 @@ export interface Commands {
|
|
|
1451
1450
|
* @since 1.0.0
|
|
1452
1451
|
*/
|
|
1453
1452
|
add(options: CommandOptions): Command;
|
|
1453
|
+
/**
|
|
1454
|
+
* The keys of one of this plugin's own commands (its local id, as in
|
|
1455
|
+
* {@link CommandOptions.id}), written as the player's menus and command
|
|
1456
|
+
* palette write them: `["W"]`, `["Ctrl+B"]`, `["⌘B"]` on macOS, arrows as
|
|
1457
|
+
* `←`. Read-only, and always what is bound now: the user's own keys
|
|
1458
|
+
* after a change in Keyboard Shortcuts, none when the user cleared them
|
|
1459
|
+
* or turned on Ignore Default Shortcuts, and none for a key another
|
|
1460
|
+
* command holds. Mouse bindings are left out, and so are `Cmd`
|
|
1461
|
+
* bindings outside macOS (as in the menus). The first label is the one
|
|
1462
|
+
* the menus show.
|
|
1463
|
+
*
|
|
1464
|
+
* @remarks
|
|
1465
|
+
* For key hints beside a panel's controls (`KeyHint` and the `keys` of
|
|
1466
|
+
* `ToolTipArea` in `@vestra/widgets.slint`). Read it in a
|
|
1467
|
+
* {@link View.bind} function and the view follows every change; or
|
|
1468
|
+
* listen with {@link Commands.onKeysChange}. The first read asks the
|
|
1469
|
+
* player, and until its answer arrives (a few milliseconds) every
|
|
1470
|
+
* command reads `[]`; a binding that read it runs again then.
|
|
1471
|
+
*
|
|
1472
|
+
* Only the plugin's own commands: an id the plugin has not added (or
|
|
1473
|
+
* another plugin's) reads `[]`. Needs nothing beyond `commands`, not
|
|
1474
|
+
* `bindings.manage` (the whole binding table, every plugin's, is
|
|
1475
|
+
* {@link Bindings.list}). Without the `commands` capability every
|
|
1476
|
+
* command reads `[]`. Players before 0.7.6 have no `keys` (the player
|
|
1477
|
+
* builds `plugin` from its own SDK) and no `KeyHint` in their widgets,
|
|
1478
|
+
* so declare `"minPlayerVersion": "0.7.6"`.
|
|
1479
|
+
*
|
|
1480
|
+
* @example Show a slider's keys beside it
|
|
1481
|
+
* The Brightness row shows its two keys, W and E by default, as key caps
|
|
1482
|
+
* (`KeyHint` in `ui/picture.slint`). `keys` is read inside the `bind` function,
|
|
1483
|
+
* so the view gets the new labels whenever the user changes a key or
|
|
1484
|
+
* clears it (an empty list hides the cap).
|
|
1485
|
+
* ```ts
|
|
1486
|
+
* const pictureWindow = plugin.windows.create("ui/picture.slint");
|
|
1487
|
+
* plugin.commands.add({ id: "brightness.down", path: ["@video", "Brightness −"], bindings: ["W"], run() {} });
|
|
1488
|
+
* plugin.commands.add({ id: "brightness.up", path: ["@video", "Brightness +"], bindings: ["E"], run() {} });
|
|
1489
|
+
* pictureWindow.bind(() => ({
|
|
1490
|
+
* // Read in bind: the view follows every key change; "" hides a cap.
|
|
1491
|
+
* brightnessDown: plugin.commands.keys("brightness.down")[0] ?? "",
|
|
1492
|
+
* brightnessUp: plugin.commands.keys("brightness.up")[0] ?? "",
|
|
1493
|
+
* }));
|
|
1494
|
+
* ```
|
|
1495
|
+
*
|
|
1496
|
+
* @capability commands
|
|
1497
|
+
* @player 0.7.6 (declare `"minPlayerVersion": "0.7.6"`; older players
|
|
1498
|
+
* have no `keys`: calling it throws a `TypeError`).
|
|
1499
|
+
* @since 1.6.6
|
|
1500
|
+
*/
|
|
1501
|
+
keys(id: string): readonly string[];
|
|
1502
|
+
/**
|
|
1503
|
+
* Run `listener` after the keys of any of this plugin's commands changed
|
|
1504
|
+
* (read them again with {@link Commands.keys}): the user changed or
|
|
1505
|
+
* cleared a key, turned Ignore Default Shortcuts on or off, a command with
|
|
1506
|
+
* keys was added or removed, or the player's first answer arrived. At most every
|
|
1507
|
+
* 200 ms. A plugin in standby that listens is started for it, as for a
|
|
1508
|
+
* key change in {@link Bindings.onChange}.
|
|
1509
|
+
*
|
|
1510
|
+
* @capability commands
|
|
1511
|
+
*
|
|
1512
|
+
* @example Name the current key in a status line
|
|
1513
|
+
* The OSD tells the user the new key after they change it. The listener
|
|
1514
|
+
* carries no data; it reads the labels with `keys`.
|
|
1515
|
+
* ```ts
|
|
1516
|
+
* plugin.commands.onKeysChange(() => {
|
|
1517
|
+
* const [key] = plugin.commands.keys("toggle-blur");
|
|
1518
|
+
* if (key) void plugin.osd.show("Blur: {}", [key]);
|
|
1519
|
+
* else void plugin.osd.show("Blur has no key");
|
|
1520
|
+
* });
|
|
1521
|
+
* ```
|
|
1522
|
+
*
|
|
1523
|
+
* @player 0.7.6 (declare `"minPlayerVersion": "0.7.6"`; older players
|
|
1524
|
+
* have no `onKeysChange`).
|
|
1525
|
+
* @since 1.6.6
|
|
1526
|
+
*/
|
|
1527
|
+
onKeysChange(listener: () => void): Disposable;
|
|
1454
1528
|
}
|
|
1455
1529
|
/**
|
|
1456
1530
|
* An image file for a Slint `image` property, made by {@link Ui.image}.
|
package/dist/wire.d.ts
CHANGED
|
@@ -441,7 +441,7 @@ export interface Transport {
|
|
|
441
441
|
locale?: string;
|
|
442
442
|
}
|
|
443
443
|
/** The events a plugin subscribes to (`events.subscribe`). */
|
|
444
|
-
type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes" | "video.upscaling.changed";
|
|
444
|
+
type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "commands.keys" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes" | "video.upscaling.changed";
|
|
445
445
|
export declare function createWire(transport: Transport): {
|
|
446
446
|
processing: {
|
|
447
447
|
state: () => Promise<import("./processing.ts").ProcessingState>;
|
|
@@ -453,6 +453,7 @@ export declare function createWire(transport: Transport): {
|
|
|
453
453
|
revision: number;
|
|
454
454
|
}>;
|
|
455
455
|
unregister: (id: string) => Promise<void>;
|
|
456
|
+
keys: () => Promise<import("./commands.ts").CommandKeys>;
|
|
456
457
|
update: (id: string, revision: number, state: import("./commands.ts").CommandState, when?: import("./commands.ts").CommandWhen) => Promise<{
|
|
457
458
|
revision: number;
|
|
458
459
|
}>;
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vestra Player SDK
|
|
2
2
|
|
|
3
|
-
> @vestra-player/sdk 1.6.
|
|
3
|
+
> @vestra-player/sdk 1.6.8: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
|
|
4
4
|
|
|
5
5
|
## Read the documentation
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
// Command buttons of
|
|
1
|
+
// Command buttons of the bottom bar (player 0.5.10, SDK 1.5.29): the buttons
|
|
2
2
|
// that plugins' commands contribute (`commands.add({ button })`) and the
|
|
3
|
-
// player's
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
3
|
+
// player's own. Since SDK 1.6.8 (menu redesign step 3a) the player draws
|
|
4
|
+
// the strip itself, at the right end of the bottom slot, its 40px cells on
|
|
5
|
+
// the slot's bottom edge (a bar's 40px button row), and also when no plugin
|
|
6
|
+
// mounts a bottom bar: `[plugin buttons…][»][Command Palette][Toggle
|
|
7
|
+
// Sidebar]`. A bottom bar places nothing: it lays its controls out left of
|
|
8
|
+
// the strip and says how wide they must stay:
|
|
9
|
+
// import { BarButtonsHost } from "@vestra/bar-buttons.slint";
|
|
10
|
+
// export component MyBar inherits Rectangle {
|
|
11
|
+
// out property <length> content-min-width: ...; // the least your row needs
|
|
12
|
+
// // your row ends at: root.width - BarButtonsHost.width - 4px
|
|
13
|
+
// }
|
|
14
|
+
// The player gives its strip `bar width - content-min-width - 4px` (all of
|
|
15
|
+
// the width without `content-min-width`), so plugin buttons never cover
|
|
16
|
+
// what the bar needs (its time label, UI-BAR-10). Plugin buttons go left to
|
|
17
|
+
// right by ascending `order`, then plugin id, then registration; at most 8
|
|
18
|
+
// are in the strip, and the ones past 8 or past the room are in the `»`
|
|
19
|
+
// menu, in the same order: the lowest order stays longest. `»` shows only
|
|
20
|
+
// while something is in it; the player's own cells always show.
|
|
21
|
+
//
|
|
22
|
+
// `BarButtons` (below) is deprecated: the strip a bar placed itself before
|
|
23
|
+
// SDK 1.6.8. While a bar still places it, the player draws no second strip.
|
|
13
24
|
//
|
|
14
25
|
// The cells look like the bar's own icon buttons: a 32×32 plate on hover
|
|
15
26
|
// and press, the bar's tooltip (ToolTipArea). Every enabled cell draws its
|
|
@@ -43,14 +54,56 @@ export struct BarButtonItem {
|
|
|
43
54
|
dimmed: bool,
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
// Filled by the player
|
|
57
|
+
// Filled by the player. A bar reads `width` (to end its own row left of
|
|
58
|
+
// the strip) and `fixed`; everything else is the player's.
|
|
47
59
|
export global BarButtonsHost {
|
|
48
|
-
// The plugins' buttons, left to right, at most 8.
|
|
60
|
+
// The plugins' buttons for the strip, left to right, at most 8.
|
|
49
61
|
in property <[BarButtonItem]> buttons;
|
|
62
|
+
// How many plugin buttons are past those 8 (always in the `»` menu).
|
|
63
|
+
in property <int> overflow;
|
|
50
64
|
// The sidebar toggle (`token` "" while there is none).
|
|
51
65
|
in property <BarButtonItem> toggle;
|
|
52
|
-
//
|
|
66
|
+
// The command palette (`host/palette`; `token` "" while there is none).
|
|
67
|
+
in property <BarButtonItem> palette;
|
|
68
|
+
// Set when a (deprecated) `BarButtons` exists in this window.
|
|
53
69
|
in-out property <bool> placed;
|
|
70
|
+
// The width the player gives its strip (the player sets it).
|
|
71
|
+
in-out property <length> room;
|
|
72
|
+
// The player's own cells, always shown (step 3b adds Adjust).
|
|
73
|
+
private property <int> own: (root.palette.token != "" ? 1 : 0) + (root.toggle.token != "" ? 1 : 0);
|
|
74
|
+
// The cells a bar must always leave free, 41px each: the player's own,
|
|
75
|
+
// and the `»` cell whenever any plugin button exists (drawn only on
|
|
76
|
+
// overflow, but always reserved, so `»` never covers the bar). Never
|
|
77
|
+
// depends on `room`. A bar that keeps an optional control ahead of the
|
|
78
|
+
// plugins' buttons counts it in `content-min-width` while it fits
|
|
79
|
+
// beside these.
|
|
80
|
+
out property <int> fixed: root.own + (root.buttons.length > 0 || root.overflow > 0 ? 1 : 0);
|
|
81
|
+
// The strip's layout for a room, as pure functions, so the player's
|
|
82
|
+
// strip binds them to its own `room` (no frame behind the shell's
|
|
83
|
+
// layout) and the properties below give the same numbers for `room`.
|
|
84
|
+
// The plugin buttons in the strip (the first of `buttons`) at `room`.
|
|
85
|
+
public pure function shown-in(room: length) -> int {
|
|
86
|
+
// Cells left for plugin buttons and `»`.
|
|
87
|
+
let free = Math.max(0, Math.floor(room / 41px) - root.own);
|
|
88
|
+
if root.overflow == 0 && root.buttons.length <= free {
|
|
89
|
+
return root.buttons.length;
|
|
90
|
+
}
|
|
91
|
+
return Math.max(0, Math.min(root.buttons.length, free - 1));
|
|
92
|
+
}
|
|
93
|
+
// Whether `»` shows at `room` (a button past 8 or past the room).
|
|
94
|
+
public pure function more-in(room: length) -> bool {
|
|
95
|
+
return root.shown-in(room) < root.buttons.length || root.overflow > 0;
|
|
96
|
+
}
|
|
97
|
+
// The strip's width at `room` (0 while a bar places `BarButtons`).
|
|
98
|
+
public pure function width-in(room: length) -> length {
|
|
99
|
+
if root.placed {
|
|
100
|
+
return 0px;
|
|
101
|
+
}
|
|
102
|
+
return (root.shown-in(room) + (root.more-in(room) ? 1 : 0) + root.own) * 41px;
|
|
103
|
+
}
|
|
104
|
+
out property <int> shown: root.shown-in(root.room);
|
|
105
|
+
out property <bool> more: root.more-in(root.room);
|
|
106
|
+
out property <length> width: root.width-in(root.room);
|
|
54
107
|
// A click on the cell with this token.
|
|
55
108
|
callback clicked(string);
|
|
56
109
|
}
|
|
@@ -127,8 +180,11 @@ component Cell inherits Rectangle {
|
|
|
127
180
|
}
|
|
128
181
|
}
|
|
129
182
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
183
|
+
// Deprecated (SDK 1.6.8; the player places its strip): the strip a bar
|
|
184
|
+
// placed at its right end with the width it could spare. A bar that still
|
|
185
|
+
// places it shows this one instead of the player's (no palette cell, no
|
|
186
|
+
// `»`): the cells that fit in `room`, lost from its outer (left) end
|
|
187
|
+
// inward, the sidebar toggle last; it is as wide as the cells it shows.
|
|
132
188
|
export component BarButtons {
|
|
133
189
|
// The width the bar can give the strip; the cells that fit show.
|
|
134
190
|
in property <length> room;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Compact controls built on the Vestra theme (core-ui-design.md §2.1):
|
|
2
|
-
// import { Button, Slider, ToolTipArea, UrlInputRow } from "@vestra/widgets.slint";
|
|
2
|
+
// import { Button, KeyHint, Slider, ToolTipArea, ToolTipKey, UrlInputRow } from "@vestra/widgets.slint";
|
|
3
3
|
// import { Choice, Check, Radio, RadioGroup, RadioMark, Tab, Tabs } from "@vestra/widgets.slint";
|
|
4
4
|
// Icons: @vestra/icons.slint. Other controls come from std-widgets, which the
|
|
5
5
|
// host compiles as fluent-dark and switches to light for a light palette.
|
|
@@ -9,10 +9,48 @@
|
|
|
9
9
|
// so a click on a panel control leaves the player's shortcuts (Space, the
|
|
10
10
|
// arrows) with the video, as on macOS. A focused control shows a 1px accent
|
|
11
11
|
// ring.
|
|
12
|
-
import { TextEdit, ListView } from "std-widgets.slint";
|
|
12
|
+
import { TextEdit, ListView, Palette } from "std-widgets.slint";
|
|
13
13
|
import { Theme } from "theme.slint";
|
|
14
14
|
import { Icon } from "icons.slint";
|
|
15
15
|
|
|
16
|
+
// A key cap: the label of a shortcut as the player's menus write it
|
|
17
|
+
// (`plugin.commands.keys`: `W`, `Ctrl+B`, `⌘B` on macOS), in small
|
|
18
|
+
// monospace `text-dim` type inside a 1px border. Put it at the right end of
|
|
19
|
+
// a control's row, the minus key before the plus key (`W` `E`). An empty
|
|
20
|
+
// `text` draws nothing and takes no space, so a command without a key (or
|
|
21
|
+
// one the user cleared) shows no cap; the element stays the same either way.
|
|
22
|
+
export component KeyHint {
|
|
23
|
+
in property <string> text;
|
|
24
|
+
private property <bool> shown: root.text != "";
|
|
25
|
+
visible: root.shown;
|
|
26
|
+
width: root.shown ? max(16px, label.preferred-width + 8px) : 0px;
|
|
27
|
+
height: root.shown ? 15px : 0px;
|
|
28
|
+
accessible-role: AccessibleRole.text;
|
|
29
|
+
accessible-label: root.text;
|
|
30
|
+
Rectangle {
|
|
31
|
+
border-width: 1px;
|
|
32
|
+
border-radius: 3px;
|
|
33
|
+
// The cap's edge: a step above the panel in both palettes.
|
|
34
|
+
border-color: Theme.icon-disabled;
|
|
35
|
+
background: Theme.bg-list;
|
|
36
|
+
label := Text {
|
|
37
|
+
text: root.text;
|
|
38
|
+
font-family: Theme.font-mono;
|
|
39
|
+
font-size: Theme.font-xs;
|
|
40
|
+
color: Theme.text-dim;
|
|
41
|
+
horizontal-alignment: center;
|
|
42
|
+
vertical-alignment: center;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// One key in a tooltip (`ToolTipArea.keys`): its label (`KeyHint`) and what
|
|
48
|
+
// it does, a word or a sign (`−`, `+`). An empty `key` is left out.
|
|
49
|
+
export struct ToolTipKey {
|
|
50
|
+
key: string,
|
|
51
|
+
text: string,
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
// Hover for about half a second to show `text` beside the pointer. The tip
|
|
17
55
|
// closes when the pointer leaves, or presses on content that does not take
|
|
18
56
|
// the press itself; it shows again on the next hover. It is Slint's
|
|
@@ -20,8 +58,16 @@ import { Icon } from "icons.slint";
|
|
|
20
58
|
// pointer, and the tip does not take the keyboard focus. (Up to SDK 1.5.20 the tip was a
|
|
21
59
|
// PopupWindow, which swallowed the first press after it showed and took the
|
|
22
60
|
// focus while it was open.)
|
|
61
|
+
// With `keys` (SDK 1.6.6) the tip names the control's keys after its title,
|
|
62
|
+
// each as a key cap and what it does: "Brightness [W] − [E] +"; `detail`
|
|
63
|
+
// follows dimmed ("· double-click: Default"). Changing `keys` or `detail`
|
|
64
|
+
// changes the tip's text, never which elements exist while hovering.
|
|
23
65
|
export component ToolTipArea {
|
|
24
66
|
in property <string> text;
|
|
67
|
+
in property <[ToolTipKey]> keys;
|
|
68
|
+
in property <string> detail;
|
|
69
|
+
// The tip names keys or a detail: drawn with key caps.
|
|
70
|
+
private property <bool> rich: root.keys.length > 0 || root.detail != "";
|
|
25
71
|
// The tip is showing (read-only); it follows the tip's delay closely,
|
|
26
72
|
// not exactly.
|
|
27
73
|
out property <bool> showing;
|
|
@@ -44,10 +90,56 @@ export component ToolTipArea {
|
|
|
44
90
|
}
|
|
45
91
|
}
|
|
46
92
|
// Removing it closes an open tip.
|
|
47
|
-
if root.text != "" && !root.done: Tooltip {
|
|
93
|
+
if root.text != "" && !root.done && !root.rich: Tooltip {
|
|
48
94
|
// Interpolated text is plain: `*` or `_` in it are not markup.
|
|
49
95
|
text: @markdown("\{root.text}");
|
|
50
96
|
}
|
|
97
|
+
// The tip with key caps, drawn as the text tip is (std-widgets'
|
|
98
|
+
// ToolTipImpl). Its own element: one Tooltip per parent.
|
|
99
|
+
if root.text != "" && !root.done && root.rich: Rectangle {
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 100%;
|
|
102
|
+
Tooltip {
|
|
103
|
+
Rectangle {
|
|
104
|
+
background: Palette.alternate-background;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
border-width: 1px;
|
|
107
|
+
border-color: Palette.border;
|
|
108
|
+
HorizontalLayout {
|
|
109
|
+
padding-left: 9px;
|
|
110
|
+
padding-right: 9px;
|
|
111
|
+
padding-top: 6px;
|
|
112
|
+
padding-bottom: 6px;
|
|
113
|
+
Text {
|
|
114
|
+
text: root.text;
|
|
115
|
+
color: Palette.foreground;
|
|
116
|
+
vertical-alignment: center;
|
|
117
|
+
}
|
|
118
|
+
for entry in root.keys: HorizontalLayout {
|
|
119
|
+
padding-left: entry.key == "" ? 0px : 10px;
|
|
120
|
+
spacing: entry.key == "" ? 0px : 4px;
|
|
121
|
+
VerticalLayout {
|
|
122
|
+
alignment: center;
|
|
123
|
+
KeyHint { text: entry.key; }
|
|
124
|
+
}
|
|
125
|
+
Text {
|
|
126
|
+
text: entry.key == "" ? "" : entry.text;
|
|
127
|
+
color: Palette.foreground;
|
|
128
|
+
vertical-alignment: center;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
HorizontalLayout {
|
|
132
|
+
padding-left: root.detail == "" ? 0px : 10px;
|
|
133
|
+
Text {
|
|
134
|
+
text: root.detail;
|
|
135
|
+
color: Theme.text-dim;
|
|
136
|
+
vertical-alignment: center;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
51
143
|
}
|
|
52
144
|
Timer {
|
|
53
145
|
interval: 500ms;
|