@vestra-player/sdk 1.5.0 → 1.5.2
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/README.md +29 -22
- package/dist/plugin/packages.js +5 -0
- package/dist/plugin/services.js +61 -11
- package/dist/plugin/testing.d.ts +49 -2
- package/dist/plugin/testing.js +153 -10
- package/dist/plugin/types.d.ts +329 -23
- package/dist/plugin/ui.js +99 -3
- package/dist/wire.d.ts +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.2
|
|
4
|
+
|
|
5
|
+
**Sidebar tabs** (the player after 0.4.0). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.1 need no change. The new members work against older players too (see each one).
|
|
6
|
+
|
|
7
|
+
- **Several sidebar panels.** A plugin mounting in the sidebar while another plugin's panel is there no longer fails: its `ready` resolves and the panel is a second tab. The player draws a 31px tab strip (the playlist's) above the current tab while there are two or more; with one panel nothing changes. Shown, hidden and floating apply to the whole sidebar. Tabs are labelled with the mount's `title` (else the plugin's name); built-in plugins' tabs come first, the playlist's is the first.
|
|
8
|
+
- **`Panel.state`** (`PanelState`: `active`, `preferred`, `effective`, `reason`, `detached`) and **`Panel.onChange`**: the panel's own state. A panel in a background tab has `active: false`, `effective: false` and `reason: "otherTab"`. Older players report the position's state with `active: true`.
|
|
9
|
+
- **`Panel.show()`** makes the panel's tab current and shows the sidebar; **`Panel.toggle()`** hides the sidebar when the panel's tab is shown, otherwise shows it (a background tab comes forward). Use them for a "Show my panel" command: `checked: () => !!list.state?.preferred && !!list.state?.active`. Older players: `setVisible(true)` / `toggle()` of the position.
|
|
10
|
+
- **`PanelSlot.panels`** (`SlotPanel[]`: `plugin`, `title`, `active`) lists a position's tabs, `null` from older players; `PanelSlot.onChange` also runs when only the tabs change. **`PanelSlot.activate(plugin)`** switches the current tab (older players ignore it). `SlotState` is unchanged.
|
|
11
|
+
- **The `tabbed` property.** If a sidebar panel's bridge global declares `in property <bool> tabbed`, the player sets it to `true` while it draws its tab strip, so a panel with a tab strip of its own (the playlist) hides it. Drop coordinates (`Panel.onDrop`) start below the strip.
|
|
12
|
+
- **`Panel.showMenu`** in a background tab rejects with `unavailable`.
|
|
13
|
+
- **Taken positions** (player 0.4.x after 0.4.0, documented now): the bottom bar and the backdrop take one panel; another plugin's mount there rejects `ready` with `conflict` (「底栏已被 vestra.playback 使用」), as does a ninth sidebar tab. This refusal, and `conflict` from `player.provideSequence` while another plugin provides next/previous, do not fail activation in the player: the plugin runs without its panel or sequence. Players 0.4.0 and older answered a taken bottom bar or sidebar with `ui-prepare` and failed the activation.
|
|
14
|
+
- **`PluginInfo.warnings`** (`PluginWarning`: `code` `"slot-occupied"` or `"sequence-taken"`, `slot`, `owner`): what a running plugin was refused because another plugin holds it. Empty from older players.
|
|
15
|
+
- **`TestHost`** models both: `host.mountOther(slot, plugin, { title })` and `host.unmountOther(plugin)` simulate another plugin's panel (a sidebar tab, or a taken bottom bar or backdrop: the plugin's mount then rejects with `conflict`), `host.selectTab(plugin)` the user clicking a tab; `host.layout.sidebar.panels` / `.owner` are the tabs and the current one, `layout.set` takes `active`. `startPlugin` does not fail on a taken position, like the player.
|
|
16
|
+
- Examples: panel-menu no longer asks you not to load it with the playlist; it is a second tab there. Its list ends in a stretching filler: without it the rows' fixed heights capped the player's main window at its minimum height.
|
|
17
|
+
|
|
18
|
+
## 1.5.1
|
|
19
|
+
|
|
20
|
+
Fix; no API or type changes.
|
|
21
|
+
|
|
22
|
+
- **`storage.json` saves no longer collide.** Every save wrote through the same temporary file (`<name>.json.tmp-<pid>`), and only the writes of one store were queued. Two stores on the same file (the same name opened twice, in one scope or two) could therefore rename each other's temporary file away mid-save, and the save failed with `ENOENT` (on `chmod` or `rename`); on Windows two renames onto the file at once also fail with `EPERM`. Seen intermittently on Windows.
|
|
23
|
+
- Writes to one file are now queued together, whichever store makes them, and land in the order they were made.
|
|
24
|
+
- Each write has a temporary file of its own, made owner-only (`0600`) through its file handle before it gets the store's name, and removed again if the write fails.
|
|
25
|
+
- On Windows, replacing the file is retried for up to 2 s while another program (a virus scanner, the search indexer) briefly holds it open.
|
|
26
|
+
|
|
3
27
|
## 1.5.0
|
|
4
28
|
|
|
5
29
|
**Own visual identity: the Aurora palettes and the V-Line icons** (player 0.4.0). Additive; `apiVersion` stays `vestra/1`. Plugins that use the additions set `"minPlayerVersion": "0.4.0"`.
|
package/README.md
CHANGED
|
@@ -33,7 +33,13 @@ npm i -D @vestra-player/sdk # Node.js 26 or newer
|
|
|
33
33
|
`manifest.json` and `main.ts`:
|
|
34
34
|
|
|
35
35
|
```json
|
|
36
|
-
{
|
|
36
|
+
{
|
|
37
|
+
"id": "examples.hello",
|
|
38
|
+
"version": "0.1.0",
|
|
39
|
+
"apiVersion": "vestra/1",
|
|
40
|
+
"entry": "main.ts",
|
|
41
|
+
"capabilities": ["commands", "ui.osd"]
|
|
42
|
+
}
|
|
37
43
|
```
|
|
38
44
|
|
|
39
45
|
```ts
|
|
@@ -54,33 +60,34 @@ Run `npx vestra-plugin pack` to check and package it, or start the player with `
|
|
|
54
60
|
|
|
55
61
|
Complete, tested plugins in [`examples/`](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples). The [examples page](https://vestrasys.github.io/vestra-player-sdk/documents/Examples.html) shows each one with a screenshot and a step-by-step walkthrough.
|
|
56
62
|
|
|
57
|
-
| Example
|
|
58
|
-
|
|
|
59
|
-
| [hello](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/hello)
|
|
60
|
-
| [blur](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/blur)
|
|
61
|
-
| [signal-generator](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/signal-generator) | Generating video and audio frames yourself
|
|
62
|
-
| [bookmark](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/bookmark)
|
|
63
|
-
| [panel-menu](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/panel-menu)
|
|
64
|
-
| [
|
|
65
|
-
| [
|
|
63
|
+
| Example | What you learn | Walkthrough |
|
|
64
|
+
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
65
|
+
| [hello](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/hello) | The smallest plugin: a manifest, one menu command, an on-screen message. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Hello.html) |
|
|
66
|
+
| [blur](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/blur) | A video filter driven by one slider in a tool window. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Blur.html) |
|
|
67
|
+
| [signal-generator](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/signal-generator) | Generating video and audio frames yourself: colour bars and a sine tone. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Signal_generator.html) |
|
|
68
|
+
| [bookmark](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/bookmark) | A tool window bound to the playback state, with Mark and Jump buttons. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Bookmark.html) |
|
|
69
|
+
| [panel-menu](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/panel-menu) | A sidebar panel with its own right-click menu. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Panel_menu.html) |
|
|
70
|
+
| [menus](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/menus) | Radio items, separator groups and a menu item whose title changes. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Menus.html) |
|
|
71
|
+
| [theme-widgets](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/theme-widgets) | A tool window drawn with the player's theme and widgets. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Theme_widgets.html) |
|
|
72
|
+
| [markdown](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/markdown) | A window that shows a Markdown document. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Markdown.html) |
|
|
66
73
|
|
|
67
74
|
## Command line
|
|
68
75
|
|
|
69
|
-
| Command
|
|
70
|
-
|
|
|
71
|
-
| `vestra-plugin check [dir...]`
|
|
72
|
-
| `vestra-plugin test [dir...]`
|
|
73
|
-
| `vestra-plugin build [dir...] [--version x.y.z]`
|
|
74
|
-
| `vestra-plugin pack [dir...] [--version x.y.z] [--out dir]` | Builds, then packs `dist/` as `<id>-<version>.vxp`.
|
|
76
|
+
| Command | Does |
|
|
77
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
78
|
+
| `vestra-plugin check [dir...]` | Checks the manifest, then a strict type check of the plugin and its tests. |
|
|
79
|
+
| `vestra-plugin test [dir...]` | Runs `node --test` on `tests/*.test.{ts,mjs}`. |
|
|
80
|
+
| `vestra-plugin build [dir...] [--version x.y.z]` | Writes `dist/`: types stripped, files copied, manifest versioned. |
|
|
81
|
+
| `vestra-plugin pack [dir...] [--version x.y.z] [--out dir]` | Builds, then packs `dist/` as `<id>-<version>.vxp`. |
|
|
75
82
|
|
|
76
83
|
## Compatibility
|
|
77
84
|
|
|
78
|
-
| SDK | Plugin API | Vestra Player
|
|
79
|
-
| --- |
|
|
80
|
-
| 1.0 | `vestra/1` | 0.1 or newer
|
|
81
|
-
| 1.1 | `vestra/1` | 0.2 or newer for the 1.1 additions
|
|
82
|
-
| 1.2 | `vestra/1` | 0.2 or newer
|
|
83
|
-
| 1.3 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions (menu ids, `Theme.danger`, empty video chains)
|
|
85
|
+
| SDK | Plugin API | Vestra Player | Node.js |
|
|
86
|
+
| --- | ---------- | ----------------------------------------------------------------------------------------- | ---------------------------- |
|
|
87
|
+
| 1.0 | `vestra/1` | 0.1 or newer | 22.18 or newer |
|
|
88
|
+
| 1.1 | `vestra/1` | 0.2 or newer for the 1.1 additions | 22.18 or newer |
|
|
89
|
+
| 1.2 | `vestra/1` | 0.2 or newer | 26 (shipped with the player) |
|
|
90
|
+
| 1.3 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions (menu ids, `Theme.danger`, empty video chains) | 26 (shipped with the player) |
|
|
84
91
|
| 1.4 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions. `ThemeId` is `"vestra"` only (see the CHANGELOG) | 26 (shipped with the player) |
|
|
85
92
|
| 1.5 | `vestra/1` | 0.2 or newer; 0.3 for the 1.3 additions; 0.4 for the 1.5 additions (`vestra-light`, `@vestra/icons.slint`) | 26 (shipped with the player) |
|
|
86
93
|
|
package/dist/plugin/packages.js
CHANGED
|
@@ -220,4 +220,9 @@ const info = (plugin) => Object.freeze({
|
|
|
220
220
|
builtin: plugin.builtin === true,
|
|
221
221
|
source: plugin.source ? Object.freeze({ ...plugin.source }) : null,
|
|
222
222
|
dataDirectory: plugin.dataDirectory ?? null,
|
|
223
|
+
warnings: Object.freeze((plugin.warnings ?? []).map((warning) => Object.freeze({
|
|
224
|
+
code: warning.code,
|
|
225
|
+
...(warning.slot === undefined ? {} : { slot: warning.slot }),
|
|
226
|
+
...(warning.owner === undefined ? {} : { owner: warning.owner }),
|
|
227
|
+
}))),
|
|
223
228
|
});
|
package/dist/plugin/services.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* run in this plugin's own process.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync, renameSync } from "node:fs";
|
|
7
|
-
import {
|
|
7
|
+
import { mkdir, open, rename, rm, stat } from "node:fs/promises";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { failure } from "./runtime.js";
|
|
10
10
|
import { systemServices } from "./local.js";
|
|
@@ -101,6 +101,54 @@ export function tools(scope) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
const NAME = /^[a-z0-9-]{1,64}$/;
|
|
104
|
+
/** Writes so far in this process; numbers each write's temporary file. */
|
|
105
|
+
let written = 0;
|
|
106
|
+
/**
|
|
107
|
+
* The pending writes of each file, whichever store they come from: the same
|
|
108
|
+
* name opened twice must not write or rename into one file at once.
|
|
109
|
+
*/
|
|
110
|
+
const writes = new Map();
|
|
111
|
+
/** Write `text` to `path` atomically, owner-only (0600). */
|
|
112
|
+
async function writeAtomic(path, text) {
|
|
113
|
+
// A name of its own per write, so no other write renames it away.
|
|
114
|
+
const temporary = `${path}.tmp-${process.pid}-${++written}`;
|
|
115
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
116
|
+
try {
|
|
117
|
+
// Settings can hold private paths and history. The mode is set through
|
|
118
|
+
// the handle, before the file has its name.
|
|
119
|
+
const file = await open(temporary, "w", 0o600);
|
|
120
|
+
try {
|
|
121
|
+
await file.chmod(0o600);
|
|
122
|
+
await file.writeFile(text);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await file.close();
|
|
126
|
+
}
|
|
127
|
+
await replace(temporary, path);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Rename over `to`. Windows refuses while another program (a virus scanner,
|
|
136
|
+
* the search indexer) has `to` open for a moment: retry for up to 2 s there.
|
|
137
|
+
*/
|
|
138
|
+
async function replace(from, to) {
|
|
139
|
+
for (let attempt = 0;; attempt++)
|
|
140
|
+
try {
|
|
141
|
+
return await rename(from, to);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const code = error.code;
|
|
145
|
+
if (process.platform !== "win32" ||
|
|
146
|
+
!(code === "EPERM" || code === "EACCES" || code === "EBUSY") ||
|
|
147
|
+
attempt >= 20)
|
|
148
|
+
throw error;
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 10 + attempt * 10));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
104
152
|
class Store {
|
|
105
153
|
#scope;
|
|
106
154
|
#path;
|
|
@@ -159,17 +207,19 @@ class Store {
|
|
|
159
207
|
return this.#writing;
|
|
160
208
|
this.#dirty = false;
|
|
161
209
|
const text = `${JSON.stringify(this.#value, null, 2)}\n`;
|
|
162
|
-
// One write at a time, in order; the newest value is written
|
|
163
|
-
|
|
210
|
+
// One write per file at a time, in order; the newest value is written
|
|
211
|
+
// last. The queue is per file, not per store (see `writes`).
|
|
212
|
+
const path = this.#path;
|
|
213
|
+
const write = (writes.get(path) ?? Promise.resolve())
|
|
164
214
|
.catch(() => { })
|
|
165
|
-
.then(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
215
|
+
.then(() => writeAtomic(path, text));
|
|
216
|
+
const done = () => {
|
|
217
|
+
if (writes.get(path) === write)
|
|
218
|
+
writes.delete(path);
|
|
219
|
+
};
|
|
220
|
+
writes.set(path, write);
|
|
221
|
+
write.then(done, done);
|
|
222
|
+
this.#writing = write;
|
|
173
223
|
return this.#writing;
|
|
174
224
|
}
|
|
175
225
|
async dispose() {
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -238,9 +238,16 @@ export declare class TestHost implements Transport {
|
|
|
238
238
|
* ```
|
|
239
239
|
*/
|
|
240
240
|
readonly layout: {
|
|
241
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* The bottom bar position: `preferred`, `effective`, `reason`,
|
|
243
|
+
* `detached`, `owner` and `panels` (its one panel).
|
|
244
|
+
*/
|
|
242
245
|
bottom: LayoutSlot | null;
|
|
243
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* The sidebar position: `preferred`, `effective`, `reason`, `detached`;
|
|
248
|
+
* `panels` are its tabs in order (`{ owner, plugin, title }`), `owner`
|
|
249
|
+
* the current tab's (the plugin under test is owner 1).
|
|
250
|
+
*/
|
|
244
251
|
sidebar: LayoutSlot | null;
|
|
245
252
|
};
|
|
246
253
|
/** Every `osd.show` text, newest last. */
|
|
@@ -554,6 +561,46 @@ export declare class TestHost implements Transport {
|
|
|
554
561
|
* appeared; `true`: there is none again); pushed to the plugin.
|
|
555
562
|
*/
|
|
556
563
|
setBackdropEffective(effective: boolean): void;
|
|
564
|
+
/**
|
|
565
|
+
* Simulate another plugin mounting a panel, before or after the plugin
|
|
566
|
+
* under test, as the player handles it: in the sidebar it is another tab
|
|
567
|
+
* (after the existing ones; the first panel's tab is current); the bottom
|
|
568
|
+
* bar and the backdrop take one panel, so the plugin's own mount there
|
|
569
|
+
* then rejects with `conflict` (not an activation failure, as in the
|
|
570
|
+
* player). Pushed as a `window.state` change.
|
|
571
|
+
*
|
|
572
|
+
* @param slot - The position.
|
|
573
|
+
* @param plugin - The other plugin's manifest id.
|
|
574
|
+
* @param options - `title`: its tab label (default: `plugin`).
|
|
575
|
+
*
|
|
576
|
+
* @example The playlist next to this plugin's sidebar panel
|
|
577
|
+
* ```ts
|
|
578
|
+
* const host = new TestHost();
|
|
579
|
+
* host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
|
|
580
|
+
* const { plugin } = await startPlugin(activate, { host, directory });
|
|
581
|
+
* assert.equal(host.layout.sidebar?.panels?.length, 2);
|
|
582
|
+
* ```
|
|
583
|
+
*
|
|
584
|
+
* @since 1.5.2
|
|
585
|
+
*/
|
|
586
|
+
mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
|
|
587
|
+
title?: string;
|
|
588
|
+
}): void;
|
|
589
|
+
/**
|
|
590
|
+
* Simulate another plugin's panel going away ({@link TestHost.mountOther}):
|
|
591
|
+
* when its tab was current, the next tab (else the previous one) is.
|
|
592
|
+
*
|
|
593
|
+
* @since 1.5.2
|
|
594
|
+
*/
|
|
595
|
+
unmountOther(plugin: string): void;
|
|
596
|
+
/**
|
|
597
|
+
* Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin
|
|
598
|
+
* under test's `manifestId`, or one of {@link TestHost.mountOther})
|
|
599
|
+
* becomes the current tab. Pushed as a `window.state` change.
|
|
600
|
+
*
|
|
601
|
+
* @since 1.5.2
|
|
602
|
+
*/
|
|
603
|
+
selectTab(plugin: string): void;
|
|
557
604
|
/**
|
|
558
605
|
* The display layer (`plugin.video.view()`): who owns it (this plugin is
|
|
559
606
|
* `manifestId`) and the values the player holds.
|
package/dist/plugin/testing.js
CHANGED
|
@@ -110,6 +110,20 @@ const readyCapabilities = {
|
|
|
110
110
|
close: true,
|
|
111
111
|
snapshotSource: true,
|
|
112
112
|
};
|
|
113
|
+
/** Refusals for a position another plugin holds: not activation failures (`startPlugin`). */
|
|
114
|
+
const takenErrors = new WeakSet();
|
|
115
|
+
const taken = (message) => {
|
|
116
|
+
const error = new HostError("conflict", message);
|
|
117
|
+
takenErrors.add(error);
|
|
118
|
+
return error;
|
|
119
|
+
};
|
|
120
|
+
const SLOT_LABELS = {
|
|
121
|
+
bottom: "底栏",
|
|
122
|
+
sidebar: "侧栏",
|
|
123
|
+
backdrop: "视频背景",
|
|
124
|
+
};
|
|
125
|
+
/** The player's limit of sidebar tabs. */
|
|
126
|
+
const MAX_SIDEBAR_TABS = 8;
|
|
113
127
|
/**
|
|
114
128
|
* An in-memory fake Vestra Player for plugin tests.
|
|
115
129
|
*
|
|
@@ -531,6 +545,109 @@ export class TestHost {
|
|
|
531
545
|
this.backdrop.effective = effective;
|
|
532
546
|
this.emit("window.state", this.#windowFacts());
|
|
533
547
|
}
|
|
548
|
+
#nextOwner = 2;
|
|
549
|
+
/** Owners of other plugins' panels, by plugin id ({@link TestHost.mountOther}). */
|
|
550
|
+
#others = new Map();
|
|
551
|
+
/**
|
|
552
|
+
* Simulate another plugin mounting a panel, before or after the plugin
|
|
553
|
+
* under test, as the player handles it: in the sidebar it is another tab
|
|
554
|
+
* (after the existing ones; the first panel's tab is current); the bottom
|
|
555
|
+
* bar and the backdrop take one panel, so the plugin's own mount there
|
|
556
|
+
* then rejects with `conflict` (not an activation failure, as in the
|
|
557
|
+
* player). Pushed as a `window.state` change.
|
|
558
|
+
*
|
|
559
|
+
* @param slot - The position.
|
|
560
|
+
* @param plugin - The other plugin's manifest id.
|
|
561
|
+
* @param options - `title`: its tab label (default: `plugin`).
|
|
562
|
+
*
|
|
563
|
+
* @example The playlist next to this plugin's sidebar panel
|
|
564
|
+
* ```ts
|
|
565
|
+
* const host = new TestHost();
|
|
566
|
+
* host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
|
|
567
|
+
* const { plugin } = await startPlugin(activate, { host, directory });
|
|
568
|
+
* assert.equal(host.layout.sidebar?.panels?.length, 2);
|
|
569
|
+
* ```
|
|
570
|
+
*
|
|
571
|
+
* @since 1.5.2
|
|
572
|
+
*/
|
|
573
|
+
mountOther(slot, plugin, options = {}) {
|
|
574
|
+
if (this.#others.has(plugin))
|
|
575
|
+
throw new Error(`${plugin} already has a panel`);
|
|
576
|
+
const owner = this.#nextOwner++;
|
|
577
|
+
if (slot === "backdrop") {
|
|
578
|
+
if (this.backdrop)
|
|
579
|
+
throw new Error("the backdrop is taken");
|
|
580
|
+
this.backdrop = { owner: plugin, effective: true };
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
if (slot === "bottom" && this.layout.bottom)
|
|
584
|
+
throw new Error("the bottom bar is taken");
|
|
585
|
+
this.#addPanel(slot, { owner, plugin, title: options.title ?? plugin });
|
|
586
|
+
}
|
|
587
|
+
this.#others.set(plugin, { slot, owner });
|
|
588
|
+
this.emit("window.state", this.#windowFacts());
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Simulate another plugin's panel going away ({@link TestHost.mountOther}):
|
|
592
|
+
* when its tab was current, the next tab (else the previous one) is.
|
|
593
|
+
*
|
|
594
|
+
* @since 1.5.2
|
|
595
|
+
*/
|
|
596
|
+
unmountOther(plugin) {
|
|
597
|
+
const other = this.#others.get(plugin);
|
|
598
|
+
if (!other)
|
|
599
|
+
throw new Error(`${plugin} has no panel`);
|
|
600
|
+
this.#others.delete(plugin);
|
|
601
|
+
if (other.slot === "backdrop")
|
|
602
|
+
this.backdrop = null;
|
|
603
|
+
else
|
|
604
|
+
this.#removePanel(other.slot, other.owner);
|
|
605
|
+
this.emit("window.state", this.#windowFacts());
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin
|
|
609
|
+
* under test's `manifestId`, or one of {@link TestHost.mountOther})
|
|
610
|
+
* becomes the current tab. Pushed as a `window.state` change.
|
|
611
|
+
*
|
|
612
|
+
* @since 1.5.2
|
|
613
|
+
*/
|
|
614
|
+
selectTab(plugin) {
|
|
615
|
+
const slot = this.layout.sidebar;
|
|
616
|
+
const panel = slot?.panels?.find((entry) => entry.plugin === plugin);
|
|
617
|
+
if (!slot || !panel)
|
|
618
|
+
throw new Error(`${plugin} has no sidebar tab`);
|
|
619
|
+
slot.owner = panel.owner;
|
|
620
|
+
this.emit("window.state", this.#windowFacts());
|
|
621
|
+
}
|
|
622
|
+
#addPanel(slot, panel) {
|
|
623
|
+
const state = this.layout[slot];
|
|
624
|
+
if (state)
|
|
625
|
+
state.panels = [...(state.panels ?? []), panel];
|
|
626
|
+
else
|
|
627
|
+
this.layout[slot] = {
|
|
628
|
+
owner: panel.owner,
|
|
629
|
+
preferred: true,
|
|
630
|
+
effective: true,
|
|
631
|
+
reason: null,
|
|
632
|
+
detached: false,
|
|
633
|
+
panels: [panel],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
#removePanel(slot, owner) {
|
|
637
|
+
const state = this.layout[slot];
|
|
638
|
+
if (!state)
|
|
639
|
+
return;
|
|
640
|
+
const panels = state.panels ?? [];
|
|
641
|
+
const index = panels.findIndex((panel) => panel.owner === owner);
|
|
642
|
+
const rest = panels.filter((panel) => panel.owner !== owner);
|
|
643
|
+
if (rest.length === 0) {
|
|
644
|
+
this.layout[slot] = null;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
state.panels = rest;
|
|
648
|
+
if (state.owner === owner)
|
|
649
|
+
state.owner = (rest[index] ?? rest[rest.length - 1]).owner;
|
|
650
|
+
}
|
|
534
651
|
/**
|
|
535
652
|
* The display layer (`plugin.video.view()`): who owns it (this plugin is
|
|
536
653
|
* `manifestId`) and the values the player holds.
|
|
@@ -1710,8 +1827,18 @@ export class TestHost {
|
|
|
1710
1827
|
"ui.mount": (p) => {
|
|
1711
1828
|
if (this.panel)
|
|
1712
1829
|
throw new HostError("conflict", "owner already mounted");
|
|
1713
|
-
|
|
1714
|
-
|
|
1830
|
+
// Like the player: one panel in the bottom bar and the backdrop, tabs
|
|
1831
|
+
// in the sidebar (up to 8).
|
|
1832
|
+
const holder = p.slot === "backdrop"
|
|
1833
|
+
? this.backdrop?.owner
|
|
1834
|
+
: p.slot === "bottom"
|
|
1835
|
+
? this.layout.bottom?.panels?.[0]?.plugin
|
|
1836
|
+
: undefined;
|
|
1837
|
+
if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
|
|
1838
|
+
throw taken(`${SLOT_LABELS[String(p.slot)]}已被 ${holder} 使用`);
|
|
1839
|
+
if (p.slot === "sidebar" &&
|
|
1840
|
+
(this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
|
|
1841
|
+
throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
|
|
1715
1842
|
this.#seeImages(p.properties);
|
|
1716
1843
|
const host = this;
|
|
1717
1844
|
this.panel = {
|
|
@@ -1734,25 +1861,25 @@ export class TestHost {
|
|
|
1734
1861
|
return { status: "ready", prepareMs: 0, shellGeneration: 1 };
|
|
1735
1862
|
}
|
|
1736
1863
|
const slot = String(p.slot);
|
|
1737
|
-
this
|
|
1864
|
+
this.#addPanel(slot, {
|
|
1738
1865
|
owner: 1,
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
detached: false,
|
|
1743
|
-
};
|
|
1866
|
+
plugin: this.manifestId,
|
|
1867
|
+
title: typeof p.title === "string" && p.title ? p.title : this.manifestId,
|
|
1868
|
+
});
|
|
1744
1869
|
return { status: "ready", prepareMs: 0, shellGeneration: 1 };
|
|
1745
1870
|
},
|
|
1746
1871
|
"ui.unmount": () => {
|
|
1747
1872
|
if (this.panel?.slot === "backdrop")
|
|
1748
1873
|
this.backdrop = null;
|
|
1749
1874
|
else if (this.panel)
|
|
1750
|
-
this
|
|
1875
|
+
this.#removePanel(this.panel.slot, 1);
|
|
1751
1876
|
this.panel = null;
|
|
1752
1877
|
},
|
|
1753
1878
|
"ui.menu": (p) => {
|
|
1754
1879
|
if (!this.panel)
|
|
1755
1880
|
throw new HostError("unavailable", "panel not shown");
|
|
1881
|
+
if (this.panel.slot === "sidebar" && this.layout.sidebar?.owner !== 1)
|
|
1882
|
+
throw new HostError("unavailable", "the panel's tab is not the current one");
|
|
1756
1883
|
const items = p.items;
|
|
1757
1884
|
for (const id of items)
|
|
1758
1885
|
if (id !== null && !this.commands.has(id))
|
|
@@ -1773,6 +1900,12 @@ export class TestHost {
|
|
|
1773
1900
|
throw new HostError("missing", "no panel at this position");
|
|
1774
1901
|
if (p.detached !== undefined && slot === "bottom")
|
|
1775
1902
|
throw new HostError("invalid", "the bottom bar cannot be detached");
|
|
1903
|
+
if (p.active !== undefined && p.active !== null) {
|
|
1904
|
+
const panel = state.panels?.find((entry) => entry.plugin === p.active);
|
|
1905
|
+
if (!panel)
|
|
1906
|
+
throw new HostError("missing", `${String(p.active)} has no panel at this position`);
|
|
1907
|
+
state.owner = panel.owner;
|
|
1908
|
+
}
|
|
1776
1909
|
if (typeof p.visible === "boolean") {
|
|
1777
1910
|
state.preferred = p.visible;
|
|
1778
1911
|
state.effective = p.visible;
|
|
@@ -2756,7 +2889,17 @@ export async function startPlugin(activate, options = {}) {
|
|
|
2756
2889
|
mkdirSync(data, { recursive: true });
|
|
2757
2890
|
const instance = await createPlugin(host, manifest, directory, data, host.app);
|
|
2758
2891
|
await activate(instance.plugin);
|
|
2759
|
-
|
|
2892
|
+
// Like the player: a position another plugin holds is not an activation failure.
|
|
2893
|
+
for (;;) {
|
|
2894
|
+
try {
|
|
2895
|
+
await instance.activated();
|
|
2896
|
+
break;
|
|
2897
|
+
}
|
|
2898
|
+
catch (error) {
|
|
2899
|
+
if (!(error instanceof Error && takenErrors.has(error)))
|
|
2900
|
+
throw error;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2760
2903
|
await host.settle();
|
|
2761
2904
|
return {
|
|
2762
2905
|
/** The test host the plugin talks to. */
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -1212,6 +1212,46 @@ export interface SlotState {
|
|
|
1212
1212
|
/** The sidebar floats in its own window; always `false` for the bottom bar. */
|
|
1213
1213
|
detached: boolean;
|
|
1214
1214
|
}
|
|
1215
|
+
/**
|
|
1216
|
+
* One panel of a position ({@link PanelSlot.panels}): a tab of the sidebar.
|
|
1217
|
+
*
|
|
1218
|
+
* @since 1.5.2
|
|
1219
|
+
* @category UI
|
|
1220
|
+
*/
|
|
1221
|
+
export interface SlotPanel {
|
|
1222
|
+
/** The manifest id of the plugin that mounted it (pass it to {@link PanelSlot.activate}). */
|
|
1223
|
+
plugin: string;
|
|
1224
|
+
/** Its tab label: the `title` of {@link Panels.mount}, else the plugin's name. */
|
|
1225
|
+
title: string;
|
|
1226
|
+
/** Its tab is the current one (the panel the position shows). */
|
|
1227
|
+
active: boolean;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* The state of one panel ({@link Panel.state}): its position's state, seen
|
|
1231
|
+
* from this panel, which may be one of several sidebar tabs.
|
|
1232
|
+
*
|
|
1233
|
+
* @since 1.5.2
|
|
1234
|
+
* @category UI
|
|
1235
|
+
*/
|
|
1236
|
+
export interface PanelState {
|
|
1237
|
+
/**
|
|
1238
|
+
* Its tab is the current one. Always `true` for the bottom bar, the
|
|
1239
|
+
* backdrop and a sidebar panel without other tabs.
|
|
1240
|
+
*/
|
|
1241
|
+
active: boolean;
|
|
1242
|
+
/** The position's preference (the user's choice to show it). */
|
|
1243
|
+
preferred: boolean;
|
|
1244
|
+
/** This panel is on screen now: the position is, and this is its current tab. */
|
|
1245
|
+
effective: boolean;
|
|
1246
|
+
/**
|
|
1247
|
+
* Why this panel is not on screen, `null` while it is: `otherTab` while
|
|
1248
|
+
* another plugin's tab is the current one (whatever the position's state),
|
|
1249
|
+
* else the position's reason ({@link SlotState.reason}).
|
|
1250
|
+
*/
|
|
1251
|
+
reason: SlotState["reason"] | "otherTab";
|
|
1252
|
+
/** The sidebar floats in its own window (with all its tabs). */
|
|
1253
|
+
detached: boolean;
|
|
1254
|
+
}
|
|
1215
1255
|
/**
|
|
1216
1256
|
* The bottom bar or the sidebar position (`plugin.panels.bottom`,
|
|
1217
1257
|
* `plugin.panels.sidebar`), independent of who mounted the panel there.
|
|
@@ -1229,23 +1269,48 @@ export interface SlotState {
|
|
|
1229
1269
|
*
|
|
1230
1270
|
* @capability ui.mount; `events` to follow changes made elsewhere.
|
|
1231
1271
|
*
|
|
1232
|
-
* @example A "Show sidebar" menu item that works with any sidebar panel
|
|
1233
|
-
*
|
|
1272
|
+
* @example A "Show sidebar" menu item that works with any sidebar panel
|
|
1273
|
+
* ```ts
|
|
1274
|
+
* const sidebar = plugin.panels.sidebar;
|
|
1275
|
+
* plugin.commands.add({
|
|
1276
|
+
* id: "sidebar",
|
|
1277
|
+
* path: ["Tools", "Show sidebar"],
|
|
1278
|
+
* checked: () => sidebar.state?.preferred ?? false,
|
|
1279
|
+
* enabled: () => sidebar.state !== null,
|
|
1280
|
+
* run: () => sidebar.toggle(),
|
|
1281
|
+
* });
|
|
1282
|
+
* ```
|
|
1234
1283
|
*
|
|
1235
1284
|
* @see {@link Panels}, {@link SlotState}
|
|
1236
1285
|
* @since 1.0.0
|
|
1237
1286
|
* @category UI
|
|
1238
1287
|
*/
|
|
1239
1288
|
export interface PanelSlot {
|
|
1240
|
-
/**
|
|
1289
|
+
/**
|
|
1290
|
+
* The current state; `null` while no plugin has a panel there (or without
|
|
1291
|
+
* `ui.mount`). With sidebar tabs it is the whole position's: shown,
|
|
1292
|
+
* hidden and floating apply to all tabs, which {@link PanelSlot.panels}
|
|
1293
|
+
* lists.
|
|
1294
|
+
*/
|
|
1241
1295
|
readonly state: SlotState | null;
|
|
1296
|
+
/**
|
|
1297
|
+
* The position's panels in tab order: the sidebar's tabs, the bottom
|
|
1298
|
+
* bar's one panel; `null` while there is none (as `state`), and from
|
|
1299
|
+
* players without sidebar tabs (0.4.0 and older). Refreshed with `state`.
|
|
1300
|
+
*
|
|
1301
|
+
* @throws Never.
|
|
1302
|
+
*
|
|
1303
|
+
* @since 1.5.2
|
|
1304
|
+
*/
|
|
1305
|
+
readonly panels: readonly SlotPanel[] | null;
|
|
1242
1306
|
/**
|
|
1243
1307
|
* Run code when the position's state changes.
|
|
1244
1308
|
*
|
|
1245
1309
|
* @remarks
|
|
1246
1310
|
* Needs `events`: without it the listener runs only after this plugin's own
|
|
1247
|
-
* changes. Called with `null` when the panel there is unmounted
|
|
1248
|
-
*
|
|
1311
|
+
* changes. Called with `null` when the panel there is unmounted, and also
|
|
1312
|
+
* when only the tabs changed ({@link PanelSlot.panels}: a panel added or
|
|
1313
|
+
* removed, another tab current). Errors go to {@link Scope.onError}.
|
|
1249
1314
|
*
|
|
1250
1315
|
* @capability ui.mount and events
|
|
1251
1316
|
*
|
|
@@ -1316,6 +1381,41 @@ export interface PanelSlot {
|
|
|
1316
1381
|
* @since 1.0.0
|
|
1317
1382
|
*/
|
|
1318
1383
|
toggle(): Promise<void>;
|
|
1384
|
+
/**
|
|
1385
|
+
* Make a panel's tab the current one, without showing or hiding the
|
|
1386
|
+
* position.
|
|
1387
|
+
*
|
|
1388
|
+
* @remarks
|
|
1389
|
+
* The sidebar holds the panels of several plugins as tabs (see
|
|
1390
|
+
* {@link Panels.mount}); this switches which one it shows, like a click on
|
|
1391
|
+
* its tab. To show this plugin's own panel, use {@link Panel.show}. The
|
|
1392
|
+
* bottom bar has one panel: activating it changes nothing. Players
|
|
1393
|
+
* without sidebar tabs (0.4.0 and older) ignore it.
|
|
1394
|
+
*
|
|
1395
|
+
* @capability ui.mount
|
|
1396
|
+
*
|
|
1397
|
+
* @param plugin - The manifest id of the plugin whose panel to show, one
|
|
1398
|
+
* of {@link PanelSlot.panels}.
|
|
1399
|
+
* @returns Resolves once the layout has the new current tab, with `state`
|
|
1400
|
+
* refreshed.
|
|
1401
|
+
*
|
|
1402
|
+
* @throws `missing` (rejects) No panel is mounted at this position, or
|
|
1403
|
+
* none of `plugin`.
|
|
1404
|
+
* @throws `unavailable` (rejects) The player runs without a user interface.
|
|
1405
|
+
* @throws `capability` (rejects) The manifest does not declare `ui.mount`.
|
|
1406
|
+
*
|
|
1407
|
+
* @example A command that shows the playlist's tab
|
|
1408
|
+
* ```ts
|
|
1409
|
+
* plugin.commands.add({
|
|
1410
|
+
* id: "to-playlist",
|
|
1411
|
+
* title: "Show the playlist tab",
|
|
1412
|
+
* run: () => plugin.panels.sidebar.activate("vestra.playlist"),
|
|
1413
|
+
* });
|
|
1414
|
+
* ```
|
|
1415
|
+
*
|
|
1416
|
+
* @since 1.5.2
|
|
1417
|
+
*/
|
|
1418
|
+
activate(plugin: string): Promise<void>;
|
|
1319
1419
|
}
|
|
1320
1420
|
/**
|
|
1321
1421
|
* The sidebar position: a {@link PanelSlot} that can also float in a window
|
|
@@ -1330,8 +1430,17 @@ export interface PanelSlot {
|
|
|
1330
1430
|
*
|
|
1331
1431
|
* @capability ui.mount
|
|
1332
1432
|
*
|
|
1333
|
-
* @example
|
|
1334
|
-
*
|
|
1433
|
+
* @example A "Detach sidebar" menu item
|
|
1434
|
+
* ```ts
|
|
1435
|
+
* const sidebar = plugin.panels.sidebar;
|
|
1436
|
+
* plugin.commands.add({
|
|
1437
|
+
* id: "detach",
|
|
1438
|
+
* path: ["Tools", "Detach sidebar"],
|
|
1439
|
+
* checked: () => sidebar.state?.detached ?? false,
|
|
1440
|
+
* enabled: () => sidebar.state !== null,
|
|
1441
|
+
* run: () => sidebar.setDetached(!sidebar.state?.detached),
|
|
1442
|
+
* });
|
|
1443
|
+
* ```
|
|
1335
1444
|
*
|
|
1336
1445
|
* @since 1.0.0
|
|
1337
1446
|
* @category UI
|
|
@@ -1400,14 +1509,25 @@ export interface Panel extends View, Disposable {
|
|
|
1400
1509
|
*
|
|
1401
1510
|
* @remarks
|
|
1402
1511
|
* Mounts made while `activate` runs must succeed before the plugin is
|
|
1403
|
-
* ready: a rejection fails activation
|
|
1404
|
-
*
|
|
1512
|
+
* ready: a rejection fails activation, except `conflict` for a taken
|
|
1513
|
+
* position (below), which the player (0.4.x after 0.4.0) and `TestHost`
|
|
1514
|
+
* do not count: the plugin runs without its panel, its commands and keys
|
|
1515
|
+
* work, and the player shows the refusal (an OSD message,
|
|
1516
|
+
* {@link PluginInfo.warnings} and the text in {@link PluginInfo.error}).
|
|
1517
|
+
* Later failures also go to {@link Scope.onError}.
|
|
1518
|
+
*
|
|
1519
|
+
* A second sidebar panel is not refused: it resolves as another tab
|
|
1520
|
+
* (see {@link Panel.state}). Built-in plugins mount first at start-up,
|
|
1521
|
+
* so the playlist's tab is first and a built-in panel keeps its position.
|
|
1405
1522
|
*
|
|
1406
1523
|
* @throws `ui-prepare` (rejects) The Slint file does not compile, or a
|
|
1407
1524
|
* bound property is not declared on the bridge global; `message` has the
|
|
1408
|
-
* diagnostics.
|
|
1409
|
-
*
|
|
1410
|
-
* @throws `conflict` (rejects) Another plugin
|
|
1525
|
+
* diagnostics. (Players 0.4.0 and older also answer a taken bottom bar
|
|
1526
|
+
* or sidebar with it.)
|
|
1527
|
+
* @throws `conflict` (rejects) Another plugin's panel holds the bottom bar
|
|
1528
|
+
* or the backdrop (shown, or being mounted), or the sidebar has all the
|
|
1529
|
+
* tabs it takes (8); `message` names the position and the holder
|
|
1530
|
+
* (「侧栏已被 vestra.playlist 使用」). Not an activation failure.
|
|
1411
1531
|
* @throws `busy` (rejects) The player is still preparing this plugin's
|
|
1412
1532
|
* previous mount, or its queue of mounts is full.
|
|
1413
1533
|
* @throws `invalid` (rejects) `title` is longer than 128 bytes or has
|
|
@@ -1418,6 +1538,107 @@ export interface Panel extends View, Disposable {
|
|
|
1418
1538
|
* @throws `capability` (rejects) The manifest does not declare `ui.mount`.
|
|
1419
1539
|
*/
|
|
1420
1540
|
readonly ready: Promise<void>;
|
|
1541
|
+
/**
|
|
1542
|
+
* This panel's state: its position's, and whether its tab is the current
|
|
1543
|
+
* one; `null` until {@link Panel.ready} resolved (and after `dispose`).
|
|
1544
|
+
*
|
|
1545
|
+
* @remarks
|
|
1546
|
+
* Read when first used; with `events` it then follows every change (the
|
|
1547
|
+
* user switching tabs, other plugins, the window size), without `events`
|
|
1548
|
+
* only this plugin's own changes. A sidebar panel in a background tab
|
|
1549
|
+
* has `active: false`, `effective: false` and `reason: "otherTab"`.
|
|
1550
|
+
*
|
|
1551
|
+
* @throws Never; `null` stands for "not mounted".
|
|
1552
|
+
*
|
|
1553
|
+
* @since 1.5.2
|
|
1554
|
+
*/
|
|
1555
|
+
readonly state: PanelState | null;
|
|
1556
|
+
/**
|
|
1557
|
+
* Run code when this panel's state changes.
|
|
1558
|
+
*
|
|
1559
|
+
* @remarks
|
|
1560
|
+
* Needs `events`: without it the listener runs only after this plugin's
|
|
1561
|
+
* own changes. Called with `null` when the panel is unmounted. Errors go
|
|
1562
|
+
* to {@link Scope.onError}.
|
|
1563
|
+
*
|
|
1564
|
+
* @capability ui.mount and events
|
|
1565
|
+
*
|
|
1566
|
+
* @param listener - Receives the new state.
|
|
1567
|
+
* @returns A handle that removes the listener.
|
|
1568
|
+
*
|
|
1569
|
+
* @example Say when the user switched to another tab
|
|
1570
|
+
* ```ts
|
|
1571
|
+
* const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
1572
|
+
* panel.onChange((state) => {
|
|
1573
|
+
* if (state?.reason === "otherTab") console.error("list in a background tab");
|
|
1574
|
+
* });
|
|
1575
|
+
* ```
|
|
1576
|
+
*
|
|
1577
|
+
* @since 1.5.2
|
|
1578
|
+
*/
|
|
1579
|
+
onChange(listener: (state: PanelState | null) => void): Disposable;
|
|
1580
|
+
/**
|
|
1581
|
+
* Show this panel: make its tab the current one and show its position.
|
|
1582
|
+
*
|
|
1583
|
+
* @remarks
|
|
1584
|
+
* Sets the position's preference like {@link PanelSlot.setVisible}`(true)`
|
|
1585
|
+
* (remembered; in fullscreen the sidebar overlay pops out). On players
|
|
1586
|
+
* without sidebar tabs (0.4.0 and older) it is just that. Waits for
|
|
1587
|
+
* {@link Panel.ready} first.
|
|
1588
|
+
*
|
|
1589
|
+
* @capability ui.mount
|
|
1590
|
+
*
|
|
1591
|
+
* @returns Resolves once the layout shows the panel's tab, with `state`
|
|
1592
|
+
* refreshed.
|
|
1593
|
+
*
|
|
1594
|
+
* @throws `invalid` (rejects) The panel is the backdrop, which the player
|
|
1595
|
+
* shows by itself.
|
|
1596
|
+
* @throws `unavailable` (rejects) The player runs without a user interface.
|
|
1597
|
+
* @throws The errors of {@link Panel.ready} (rejects) The mount failed.
|
|
1598
|
+
*
|
|
1599
|
+
* @example
|
|
1600
|
+
* ```ts
|
|
1601
|
+
* const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
1602
|
+
* plugin.commands.add({ id: "show", title: "Show the list", run: () => list.show() });
|
|
1603
|
+
* ```
|
|
1604
|
+
*
|
|
1605
|
+
* @since 1.5.2
|
|
1606
|
+
*/
|
|
1607
|
+
show(): Promise<void>;
|
|
1608
|
+
/**
|
|
1609
|
+
* Show this panel, or hide its position when it is shown.
|
|
1610
|
+
*
|
|
1611
|
+
* @remarks
|
|
1612
|
+
* Reads the state from the player first. When the position is shown
|
|
1613
|
+
* (`preferred`) and this panel's tab is the current one, it hides the
|
|
1614
|
+
* position; otherwise it does {@link Panel.show}: a panel in a background
|
|
1615
|
+
* tab comes to the front instead of the sidebar closing. A "Show list"
|
|
1616
|
+
* command checked by `state.preferred && state.active` matches it. Waits
|
|
1617
|
+
* for {@link Panel.ready} first.
|
|
1618
|
+
*
|
|
1619
|
+
* @capability ui.mount
|
|
1620
|
+
*
|
|
1621
|
+
* @returns Resolves once the layout has changed, with `state` refreshed.
|
|
1622
|
+
*
|
|
1623
|
+
* @throws `invalid` (rejects) The panel is the backdrop.
|
|
1624
|
+
* @throws `unavailable` (rejects) The player runs without a user interface.
|
|
1625
|
+
* @throws The errors of {@link Panel.ready} (rejects) The mount failed.
|
|
1626
|
+
*
|
|
1627
|
+
* @example A toggle command for a sidebar panel (the playlist's F6)
|
|
1628
|
+
* ```ts
|
|
1629
|
+
* const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
1630
|
+
* plugin.commands.add({
|
|
1631
|
+
* id: "list",
|
|
1632
|
+
* path: ["@screen", "List"],
|
|
1633
|
+
* bindings: ["F6"],
|
|
1634
|
+
* checked: () => (list.state?.preferred ?? false) && (list.state?.active ?? false),
|
|
1635
|
+
* run: () => list.toggle(),
|
|
1636
|
+
* });
|
|
1637
|
+
* ```
|
|
1638
|
+
*
|
|
1639
|
+
* @since 1.5.2
|
|
1640
|
+
*/
|
|
1641
|
+
toggle(): Promise<void>;
|
|
1421
1642
|
/**
|
|
1422
1643
|
* Keep the panel on screen while the user works in it.
|
|
1423
1644
|
*
|
|
@@ -1471,8 +1692,8 @@ export interface Panel extends View, Disposable {
|
|
|
1471
1692
|
* @returns Resolves once the menu is open (not when the user picks).
|
|
1472
1693
|
*
|
|
1473
1694
|
* @throws `missing` (rejects) An item is not a command of this plugin.
|
|
1474
|
-
* @throws `unavailable` (rejects) The panel is not on screen
|
|
1475
|
-
* backdrop.
|
|
1695
|
+
* @throws `unavailable` (rejects) The panel is not on screen (also: its
|
|
1696
|
+
* sidebar tab is not the current one), or it is the backdrop.
|
|
1476
1697
|
* @throws `invalid` (rejects) No items, more than 32, or `at` is not
|
|
1477
1698
|
* finite.
|
|
1478
1699
|
* @throws The errors of {@link Panel.ready} (rejects) The mount failed.
|
|
@@ -1714,12 +1935,59 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
|
|
|
1714
1935
|
*
|
|
1715
1936
|
* @capability ui.settings
|
|
1716
1937
|
*
|
|
1717
|
-
* @example Two preferences on a page
|
|
1718
|
-
*
|
|
1938
|
+
* @example Two preferences on a page, kept in a store
|
|
1939
|
+
* ```ts
|
|
1940
|
+
* const store = plugin.storage.json("blur", {
|
|
1941
|
+
* defaults: { restoreOnStart: false, messages: true },
|
|
1942
|
+
* });
|
|
1943
|
+
* const page = plugin.settings.page("ui/settings.slint", {
|
|
1944
|
+
* id: "blur",
|
|
1945
|
+
* category: "video",
|
|
1946
|
+
* title: "Blur",
|
|
1947
|
+
* });
|
|
1948
|
+
* page.bind(() => ({
|
|
1949
|
+
* restoreOnStart: store.value.restoreOnStart,
|
|
1950
|
+
* messages: store.value.messages,
|
|
1951
|
+
* }));
|
|
1952
|
+
* page.on("restoreToggled", (on: boolean) =>
|
|
1953
|
+
* store.save({ ...store.value, restoreOnStart: on }),
|
|
1954
|
+
* );
|
|
1955
|
+
* page.on("messagesToggled", (on: boolean) =>
|
|
1956
|
+
* store.save({ ...store.value, messages: on }),
|
|
1957
|
+
* );
|
|
1958
|
+
* ```
|
|
1959
|
+
*
|
|
1960
|
+
* `ui/settings.slint` exports the bridge global and the layout:
|
|
1961
|
+
*
|
|
1962
|
+
* ```slint
|
|
1963
|
+
* import { CheckBox } from "std-widgets.slint";
|
|
1964
|
+
* import { Theme } from "@vestra/theme.slint";
|
|
1719
1965
|
*
|
|
1720
|
-
*
|
|
1966
|
+
* export global BlurPage {
|
|
1967
|
+
* in-out property <bool> restore-on-start;
|
|
1968
|
+
* in-out property <bool> messages: true;
|
|
1969
|
+
* callback restore-toggled(bool);
|
|
1970
|
+
* callback messages-toggled(bool);
|
|
1971
|
+
* }
|
|
1721
1972
|
*
|
|
1722
|
-
* {
|
|
1973
|
+
* export component BlurSettings inherits Rectangle {
|
|
1974
|
+
* VerticalLayout {
|
|
1975
|
+
* padding: Theme.dialog-padding;
|
|
1976
|
+
* spacing: 6px;
|
|
1977
|
+
* alignment: start;
|
|
1978
|
+
* CheckBox {
|
|
1979
|
+
* text: "Turn blur back on when the player starts";
|
|
1980
|
+
* checked <=> BlurPage.restore-on-start;
|
|
1981
|
+
* toggled => { BlurPage.restore-toggled(self.checked); }
|
|
1982
|
+
* }
|
|
1983
|
+
* CheckBox {
|
|
1984
|
+
* text: "Show on-screen messages";
|
|
1985
|
+
* checked <=> BlurPage.messages;
|
|
1986
|
+
* toggled => { BlurPage.messages-toggled(self.checked); }
|
|
1987
|
+
* }
|
|
1988
|
+
* }
|
|
1989
|
+
* }
|
|
1990
|
+
* ```
|
|
1723
1991
|
*
|
|
1724
1992
|
* @see {@link Settings.page}, [Windows, panels, settings pages, dialogs and the OSD](../../docs/concepts/ui.md)
|
|
1725
1993
|
* @since 1.0.0
|
|
@@ -1854,7 +2122,7 @@ export interface Settings {
|
|
|
1854
2122
|
* @throws `capability` (rejects) The manifest does not declare
|
|
1855
2123
|
* `ui.settings`.
|
|
1856
2124
|
*
|
|
1857
|
-
* @example An "Options…" button in a tool window
|
|
2125
|
+
* @example An "Options…" button in a tool window
|
|
1858
2126
|
* ```ts
|
|
1859
2127
|
* const controls = plugin.windows.create("ui/window.slint");
|
|
1860
2128
|
* controls.on("openOptions", () => plugin.settings.show("blur"));
|
|
@@ -1967,9 +2235,17 @@ export interface Panels {
|
|
|
1967
2235
|
* floats over the video and hides after 3 seconds without activity.
|
|
1968
2236
|
* - `"sidebar"`: right of the video, the window's full height, as wide as the
|
|
1969
2237
|
* user drags it (at least 317). The user can detach it into a floating
|
|
1970
|
-
* window titled `title`.
|
|
2238
|
+
* window titled `title`. Several plugins may mount here (player 0.4.x
|
|
2239
|
+
* after 0.4.0): each panel is a tab, labelled `title` (else the plugin's
|
|
2240
|
+
* name), and the player draws a 31px tab strip above the current one
|
|
2241
|
+
* while there are two or more (with one panel, nothing changes).
|
|
2242
|
+
* Showing, hiding and detaching apply to the whole sidebar. If the
|
|
2243
|
+
* bridge global declares `in property <bool> tabbed`, the player sets
|
|
2244
|
+
* it to `true` while it draws the strip, so a panel with a tab strip of
|
|
2245
|
+
* its own can hide it. Panel coordinates (drops) start below the strip.
|
|
1971
2246
|
* - `"backdrop"`: the whole video area, under the OSD, while there is no
|
|
1972
|
-
* video picture (see {@link Panels.backdrop}). One plugin at a time
|
|
2247
|
+
* video picture (see {@link Panels.backdrop}). One plugin at a time (as
|
|
2248
|
+
* for the bottom bar, another one's mount rejects with `conflict`); it
|
|
1973
2249
|
* takes no pointer or keyboard input. While it is mounted, the player does
|
|
1974
2250
|
* not show the cover art of audio files itself (from the next file
|
|
1975
2251
|
* opened on), so the plugin can draw it.
|
|
@@ -3051,8 +3327,14 @@ export interface OsdPanel extends Disposable {
|
|
|
3051
3327
|
*
|
|
3052
3328
|
* @capability ui.osd
|
|
3053
3329
|
*
|
|
3054
|
-
* @example On-screen messages that the user can turn off
|
|
3055
|
-
*
|
|
3330
|
+
* @example On-screen messages that the user can turn off
|
|
3331
|
+
* ```ts
|
|
3332
|
+
* const store = plugin.storage.json("prefs", { defaults: { messages: true } });
|
|
3333
|
+
* const say = (text: string) => {
|
|
3334
|
+
* if (store.value.messages) void plugin.osd.show(text);
|
|
3335
|
+
* };
|
|
3336
|
+
* say("Blur on");
|
|
3337
|
+
* ```
|
|
3056
3338
|
*
|
|
3057
3339
|
* @see {@link OsdPanel}, [Windows, panels, settings pages, dialogs and the OSD](../../docs/concepts/ui.md)
|
|
3058
3340
|
* @since 1.0.0
|
|
@@ -3729,6 +4011,30 @@ export interface PluginInfo {
|
|
|
3729
4011
|
* the player has none. The path's layout is not part of the API.
|
|
3730
4012
|
*/
|
|
3731
4013
|
dataDirectory: string | null;
|
|
4014
|
+
/**
|
|
4015
|
+
* What a running plugin was refused because another plugin holds it: a
|
|
4016
|
+
* panel position (`slot-occupied`, with `slot`) or the next/previous
|
|
4017
|
+
* sequence (`sequence-taken`). Neither fails activation; `error` has the
|
|
4018
|
+
* same as text. Empty from players before 0.4.x (after 0.4.0).
|
|
4019
|
+
* Always set by the SDK; optional so that code building `PluginInfo`
|
|
4020
|
+
* values itself (tests) keeps compiling.
|
|
4021
|
+
*
|
|
4022
|
+
* @since 1.5.2
|
|
4023
|
+
*/
|
|
4024
|
+
warnings?: readonly PluginWarning[];
|
|
4025
|
+
}
|
|
4026
|
+
/**
|
|
4027
|
+
* One refusal in {@link PluginInfo.warnings}.
|
|
4028
|
+
*
|
|
4029
|
+
* @since 1.5.2
|
|
4030
|
+
*/
|
|
4031
|
+
export interface PluginWarning {
|
|
4032
|
+
/** `"slot-occupied"` or `"sequence-taken"`; a newer player may add codes. */
|
|
4033
|
+
code: string;
|
|
4034
|
+
/** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
|
|
4035
|
+
slot?: string;
|
|
4036
|
+
/** The manifest id of the plugin holding it (absent for a full sidebar). */
|
|
4037
|
+
owner?: string;
|
|
3732
4038
|
}
|
|
3733
4039
|
/**
|
|
3734
4040
|
* `plugin.packages`: the plugins the player knows. `list` and `onChange`
|
package/dist/plugin/ui.js
CHANGED
|
@@ -323,6 +323,7 @@ export function exported(directory, file, kind) {
|
|
|
323
323
|
}
|
|
324
324
|
class PanelHandle extends ViewBase {
|
|
325
325
|
ready;
|
|
326
|
+
#slot;
|
|
326
327
|
#mounted = false;
|
|
327
328
|
#locks = { focused: false, dragging: false, popupOpen: false };
|
|
328
329
|
#interaction;
|
|
@@ -330,6 +331,7 @@ class PanelHandle extends ViewBase {
|
|
|
330
331
|
if (scope.runtime.views.has("$mount"))
|
|
331
332
|
throw new Error("a plugin can mount only one panel");
|
|
332
333
|
super(scope);
|
|
334
|
+
this.#slot = slot;
|
|
333
335
|
const runtime = scope.runtime;
|
|
334
336
|
runtime.views.set("$mount", (callback, args) => this.dispatch(callback, args));
|
|
335
337
|
this.#interaction = new Latest((locks) => runtime.host.ui.interaction(locks));
|
|
@@ -364,6 +366,51 @@ class PanelHandle extends ViewBase {
|
|
|
364
366
|
write(property, value) {
|
|
365
367
|
return this.scope.runtime.host.ui.setMount(property, value);
|
|
366
368
|
}
|
|
369
|
+
get state() {
|
|
370
|
+
const service = layoutOf(this.scope.runtime);
|
|
371
|
+
service.start();
|
|
372
|
+
return this.live()
|
|
373
|
+
? panelState(service.state, this.#slot, this.scope.runtime.manifest.id)
|
|
374
|
+
: null;
|
|
375
|
+
}
|
|
376
|
+
onChange(listener) {
|
|
377
|
+
const scope = this.scope;
|
|
378
|
+
let last = JSON.stringify(this.state);
|
|
379
|
+
const watch = layoutOf(scope.runtime).watch(() => {
|
|
380
|
+
const state = this.state;
|
|
381
|
+
const next = JSON.stringify(state);
|
|
382
|
+
if (next === last)
|
|
383
|
+
return;
|
|
384
|
+
last = next;
|
|
385
|
+
scope.run(listener, state);
|
|
386
|
+
});
|
|
387
|
+
return scope.add({
|
|
388
|
+
dispose: async () => {
|
|
389
|
+
scope.forget(watch);
|
|
390
|
+
await watch.dispose();
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
async show() {
|
|
395
|
+
await this.ready;
|
|
396
|
+
if (this.#slot === "backdrop")
|
|
397
|
+
throw failure("invalid", "the player shows the backdrop by itself");
|
|
398
|
+
await layoutOf(this.scope.runtime).set(this.#slot, {
|
|
399
|
+
active: this.scope.runtime.manifest.id,
|
|
400
|
+
visible: true,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async toggle() {
|
|
404
|
+
await this.ready;
|
|
405
|
+
if (this.#slot === "backdrop")
|
|
406
|
+
throw failure("invalid", "the player shows the backdrop by itself");
|
|
407
|
+
await layoutOf(this.scope.runtime).read();
|
|
408
|
+
const state = this.state;
|
|
409
|
+
if (state?.preferred && state.active)
|
|
410
|
+
await layoutOf(this.scope.runtime).set(this.#slot, { visible: false });
|
|
411
|
+
else
|
|
412
|
+
await this.show();
|
|
413
|
+
}
|
|
367
414
|
async showMenu(items, at) {
|
|
368
415
|
await this.ready;
|
|
369
416
|
await this.scope.runtime.host.ui.menu(items.map((item) => item?.id ?? null), at.x, at.y);
|
|
@@ -388,8 +435,38 @@ class PanelHandle extends ViewBase {
|
|
|
388
435
|
this.#mounted = false;
|
|
389
436
|
await this.release();
|
|
390
437
|
this.scope.runtime.views.delete("$mount");
|
|
438
|
+
// Listeners of `onChange` hear `null`.
|
|
439
|
+
this.scope.runtime.invalidate();
|
|
440
|
+
await layoutOf(this.scope.runtime).refresh();
|
|
391
441
|
}
|
|
392
442
|
}
|
|
443
|
+
/** One panel's view of its position (`Panel.state`). */
|
|
444
|
+
function panelState(layout, slot, plugin) {
|
|
445
|
+
if (slot === "backdrop")
|
|
446
|
+
return (layout.backdrop && {
|
|
447
|
+
active: true,
|
|
448
|
+
preferred: true,
|
|
449
|
+
effective: layout.backdrop.effective,
|
|
450
|
+
reason: null,
|
|
451
|
+
detached: false,
|
|
452
|
+
});
|
|
453
|
+
const state = layout[slot];
|
|
454
|
+
if (!state)
|
|
455
|
+
return null;
|
|
456
|
+
// Players without tabs (no `panels`): the position holds this panel only.
|
|
457
|
+
const panels = layout.tabs[slot];
|
|
458
|
+
const own = panels?.find((panel) => panel.plugin === plugin);
|
|
459
|
+
if (panels && !own)
|
|
460
|
+
return null;
|
|
461
|
+
const active = own?.active ?? true;
|
|
462
|
+
return {
|
|
463
|
+
active,
|
|
464
|
+
preferred: state.preferred,
|
|
465
|
+
effective: state.effective && active,
|
|
466
|
+
reason: active ? state.reason : "otherTab",
|
|
467
|
+
detached: state.detached,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
393
470
|
export const windows = (scope) => ({
|
|
394
471
|
create: (file, options) => new WindowHandle(scope, file, options?.id ?? basename(file, extname(file))),
|
|
395
472
|
});
|
|
@@ -400,7 +477,12 @@ export const windows = (scope) => ({
|
|
|
400
477
|
*/
|
|
401
478
|
class LayoutService {
|
|
402
479
|
#runtime;
|
|
403
|
-
state = {
|
|
480
|
+
state = {
|
|
481
|
+
bottom: null,
|
|
482
|
+
sidebar: null,
|
|
483
|
+
tabs: { bottom: null, sidebar: null },
|
|
484
|
+
backdrop: null,
|
|
485
|
+
};
|
|
404
486
|
#listeners = new Set();
|
|
405
487
|
#started = false;
|
|
406
488
|
#reading = false;
|
|
@@ -444,9 +526,17 @@ class LayoutService {
|
|
|
444
526
|
reason: slot.reason,
|
|
445
527
|
detached: slot.detached,
|
|
446
528
|
};
|
|
529
|
+
const tabs = (slot) => slot?.panels
|
|
530
|
+
? Object.freeze(slot.panels.map((panel) => Object.freeze({
|
|
531
|
+
plugin: panel.plugin,
|
|
532
|
+
title: panel.title,
|
|
533
|
+
active: panel.owner === slot.owner,
|
|
534
|
+
})))
|
|
535
|
+
: null;
|
|
447
536
|
const next = {
|
|
448
537
|
bottom: view(raw.bottom),
|
|
449
538
|
sidebar: view(raw.sidebar),
|
|
539
|
+
tabs: { bottom: tabs(raw.bottom), sidebar: tabs(raw.sidebar) },
|
|
450
540
|
backdrop: raw.backdrop
|
|
451
541
|
? Object.freeze({ effective: raw.backdrop.effective === true })
|
|
452
542
|
: null,
|
|
@@ -477,10 +567,15 @@ function slot(scope, name) {
|
|
|
477
567
|
service().start();
|
|
478
568
|
return service().state[name];
|
|
479
569
|
},
|
|
570
|
+
get panels() {
|
|
571
|
+
service().start();
|
|
572
|
+
return service().state.tabs[name];
|
|
573
|
+
},
|
|
480
574
|
onChange(listener) {
|
|
481
|
-
|
|
575
|
+
const seen = (layout) => JSON.stringify([layout[name], layout.tabs[name]]);
|
|
576
|
+
let last = seen(service().state);
|
|
482
577
|
const watch = service().watch((layout) => {
|
|
483
|
-
const next =
|
|
578
|
+
const next = seen(layout);
|
|
484
579
|
if (next === last)
|
|
485
580
|
return;
|
|
486
581
|
last = next;
|
|
@@ -500,6 +595,7 @@ function slot(scope, name) {
|
|
|
500
595
|
await service().set(name, { visible: !(current?.preferred ?? true) });
|
|
501
596
|
},
|
|
502
597
|
setDetached: (detached) => service().set(name, { detached }),
|
|
598
|
+
activate: (plugin) => service().set(name, { active: plugin }),
|
|
503
599
|
};
|
|
504
600
|
}
|
|
505
601
|
function backdrop(scope) {
|
package/dist/wire.d.ts
CHANGED
|
@@ -112,6 +112,12 @@ export interface PackageInfo {
|
|
|
112
112
|
builtin?: boolean;
|
|
113
113
|
source?: WireSource | null;
|
|
114
114
|
dataDirectory?: string | null;
|
|
115
|
+
/** What the plugin was refused because another plugin holds it (player 0.4.x, after 0.4.0). */
|
|
116
|
+
warnings?: {
|
|
117
|
+
code: string;
|
|
118
|
+
slot?: string;
|
|
119
|
+
owner?: string;
|
|
120
|
+
}[];
|
|
115
121
|
}
|
|
116
122
|
/** Where an installed package came from (`packages.install` `source`). */
|
|
117
123
|
export type WireSource = {
|
|
@@ -182,13 +188,24 @@ export interface LayoutBackdrop {
|
|
|
182
188
|
owner: number;
|
|
183
189
|
effective: boolean;
|
|
184
190
|
}
|
|
191
|
+
/** One panel of a position, in tab order (`LayoutSlot.panels`). */
|
|
192
|
+
export interface LayoutPanel {
|
|
193
|
+
owner: number;
|
|
194
|
+
/** The manifest id of the plugin that mounted it. */
|
|
195
|
+
plugin: string;
|
|
196
|
+
/** The tab label: the mount's `title`, else the plugin's name. */
|
|
197
|
+
title: string;
|
|
198
|
+
}
|
|
185
199
|
/** One position (bottom bar or sidebar) as `layout.state` reports it. */
|
|
186
200
|
export interface LayoutSlot {
|
|
201
|
+
/** The current tab's panel (the only panel of the bottom bar). */
|
|
187
202
|
owner: number;
|
|
188
203
|
preferred: boolean;
|
|
189
204
|
effective: boolean;
|
|
190
205
|
reason: "userHidden" | "space" | "fullscreen" | "idle" | null;
|
|
191
206
|
detached: boolean;
|
|
207
|
+
/** The position's panels in tab order; absent from players without sidebar tabs (0.4.0 and older). */
|
|
208
|
+
panels?: LayoutPanel[];
|
|
192
209
|
}
|
|
193
210
|
export interface PlaybackStats {
|
|
194
211
|
backend: string;
|
|
@@ -470,6 +487,8 @@ export declare function createWire(transport: Transport): {
|
|
|
470
487
|
slot: "bottom" | "sidebar";
|
|
471
488
|
visible?: boolean;
|
|
472
489
|
detached?: boolean;
|
|
490
|
+
/** The plugin id of the panel whose tab becomes the current one. */
|
|
491
|
+
active?: string;
|
|
473
492
|
}) => Promise<void>;
|
|
474
493
|
};
|
|
475
494
|
events: {
|