@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 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
- { "id": "examples.hello", "version": "0.1.0", "apiVersion": "vestra/1", "entry": "main.ts", "capabilities": ["commands", "ui.osd"] }
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 | What you learn | Walkthrough |
58
- | --- | --- | --- |
59
- | [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) |
60
- | [blur](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/blur) | Video filters, a tool window with live controls, a shortcut, a settings page, remembered state. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Blur.html) |
61
- | [signal-generator](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/signal-generator) | Generating video and audio frames yourself, phase-continuous audio, audio filters, stream lifecycle. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Signal_generator.html) |
62
- | [bookmark](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/bookmark) | Reading and controlling playback from a tool window. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Bookmark.html) |
63
- | [panel-menu](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/panel-menu) | A sidebar panel with its own context menu, panel-only keys and next/previous. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Panel_menu.html) |
64
- | [core-ui](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/core-ui) | Menus (radio items, groups, changing titles), repeating keys, the shared sidebar, theme widgets. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Core_UI.html) |
65
- | [markdown](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples/markdown) | The Markdown view with local and remote images; the manifest's display fields. | [Step by step](https://vestrasys.github.io/vestra-player-sdk/documents/Example__Markdown.html) |
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 | Does |
70
- | --- | --- |
71
- | `vestra-plugin check [dir...]` | Checks the manifest, then a strict type check of the plugin and its tests. |
72
- | `vestra-plugin test [dir...]` | Runs `node --test` on `tests/*.test.{ts,mjs}`. |
73
- | `vestra-plugin build [dir...] [--version x.y.z]` | Writes `dist/`: types stripped, files copied, manifest versioned. |
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 | Node.js |
79
- | --- | --- | --- | --- |
80
- | 1.0 | `vestra/1` | 0.1 or newer | 22.18 or newer |
81
- | 1.1 | `vestra/1` | 0.2 or newer for the 1.1 additions | 22.18 or newer |
82
- | 1.2 | `vestra/1` | 0.2 or newer | 26 (shipped with the player) |
83
- | 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) |
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
 
@@ -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
  });
@@ -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 { chmod, mkdir, rename, stat, writeFile } from "node:fs/promises";
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 last.
163
- this.#writing = this.#writing
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(async () => {
166
- const temporary = `${this.#path}.tmp-${process.pid}`;
167
- await mkdir(join(this.#path, ".."), { recursive: true });
168
- // Owner-only: settings can hold private paths and history.
169
- await writeFile(temporary, text, { mode: 0o600 });
170
- await chmod(temporary, 0o600);
171
- await rename(temporary, this.#path);
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() {
@@ -238,9 +238,16 @@ export declare class TestHost implements Transport {
238
238
  * ```
239
239
  */
240
240
  readonly layout: {
241
- /** The bottom bar position: `preferred`, `effective`, `reason`, `detached`. */
241
+ /**
242
+ * The bottom bar position: `preferred`, `effective`, `reason`,
243
+ * `detached`, `owner` and `panels` (its one panel).
244
+ */
242
245
  bottom: LayoutSlot | null;
243
- /** The sidebar position: `preferred`, `effective`, `reason`, `detached`. */
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.
@@ -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
- if (p.slot === "backdrop" && this.backdrop)
1714
- throw new HostError("conflict", "another plugin shows the backdrop");
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.layout[slot] = {
1864
+ this.#addPanel(slot, {
1738
1865
  owner: 1,
1739
- preferred: true,
1740
- effective: true,
1741
- reason: null,
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.layout[this.panel.slot] = null;
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
- await instance.activated();
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. */
@@ -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 (examples/core-ui)
1233
- * {@includeCode ../../examples/core-ui/main.ts#sidebar}
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
- /** The current state; `null` while no plugin has a panel there (or without `ui.mount`). */
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. Errors go
1248
- * to {@link Scope.onError}.
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
- * {@includeCode ../../examples/core-ui/main.ts#sidebar}
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. Later failures also go to
1404
- * {@link Scope.onError}.
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. Also when another plugin's panel already occupies the
1409
- * bottom bar or the sidebar.
1410
- * @throws `conflict` (rejects) Another plugin shows the backdrop.
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, or it is the
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 (examples/blur)
1718
- * {@includeCode ../../examples/blur/main.ts#settings-page}
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
- * `ui/settings.slint`:
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
- * {@includeCode ../../examples/blur/ui/settings.slint}
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 (examples/blur)
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; it
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 (examples/blur)
3055
- * {@includeCode ../../examples/blur/main.ts#osd}
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 = { bottom: null, sidebar: null, backdrop: null };
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
- let last = JSON.stringify(service().state[name]);
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 = JSON.stringify(layout[name]);
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {