@vestra-player/sdk 1.5.1 → 1.5.3

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.
@@ -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.
@@ -1246,15 +1286,31 @@ export interface SlotState {
1246
1286
  * @category UI
1247
1287
  */
1248
1288
  export interface PanelSlot {
1249
- /** 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
+ */
1250
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;
1251
1306
  /**
1252
1307
  * Run code when the position's state changes.
1253
1308
  *
1254
1309
  * @remarks
1255
1310
  * Needs `events`: without it the listener runs only after this plugin's own
1256
- * changes. Called with `null` when the panel there is unmounted. Errors go
1257
- * 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}.
1258
1314
  *
1259
1315
  * @capability ui.mount and events
1260
1316
  *
@@ -1325,6 +1381,41 @@ export interface PanelSlot {
1325
1381
  * @since 1.0.0
1326
1382
  */
1327
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>;
1328
1419
  }
1329
1420
  /**
1330
1421
  * The sidebar position: a {@link PanelSlot} that can also float in a window
@@ -1418,14 +1509,25 @@ export interface Panel extends View, Disposable {
1418
1509
  *
1419
1510
  * @remarks
1420
1511
  * Mounts made while `activate` runs must succeed before the plugin is
1421
- * ready: a rejection fails activation. Later failures also go to
1422
- * {@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.
1423
1522
  *
1424
1523
  * @throws `ui-prepare` (rejects) The Slint file does not compile, or a
1425
1524
  * bound property is not declared on the bridge global; `message` has the
1426
- * diagnostics. Also when another plugin's panel already occupies the
1427
- * bottom bar or the sidebar.
1428
- * @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.
1429
1531
  * @throws `busy` (rejects) The player is still preparing this plugin's
1430
1532
  * previous mount, or its queue of mounts is full.
1431
1533
  * @throws `invalid` (rejects) `title` is longer than 128 bytes or has
@@ -1436,6 +1538,107 @@ export interface Panel extends View, Disposable {
1436
1538
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1437
1539
  */
1438
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>;
1439
1642
  /**
1440
1643
  * Keep the panel on screen while the user works in it.
1441
1644
  *
@@ -1489,8 +1692,8 @@ export interface Panel extends View, Disposable {
1489
1692
  * @returns Resolves once the menu is open (not when the user picks).
1490
1693
  *
1491
1694
  * @throws `missing` (rejects) An item is not a command of this plugin.
1492
- * @throws `unavailable` (rejects) The panel is not on screen, or it is the
1493
- * 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.
1494
1697
  * @throws `invalid` (rejects) No items, more than 32, or `at` is not
1495
1698
  * finite.
1496
1699
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
@@ -2032,9 +2235,17 @@ export interface Panels {
2032
2235
  * floats over the video and hides after 3 seconds without activity.
2033
2236
  * - `"sidebar"`: right of the video, the window's full height, as wide as the
2034
2237
  * user drags it (at least 317). The user can detach it into a floating
2035
- * 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.
2036
2246
  * - `"backdrop"`: the whole video area, under the OSD, while there is no
2037
- * 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
2038
2249
  * takes no pointer or keyboard input. While it is mounted, the player does
2039
2250
  * not show the cover art of audio files itself (from the next file
2040
2251
  * opened on), so the plugin can draw it.
@@ -2560,7 +2771,12 @@ export interface PlayerState {
2560
2771
  readonly kind: "media" | "stream";
2561
2772
  /** The source's life cycle. */
2562
2773
  readonly phase: "empty" | "loading" | "ready" | "ended" | "error";
2563
- /** Path or URL. */
2774
+ /**
2775
+ * Path or URL; `null` when empty. A generated stream started with
2776
+ * {@link Player.openStream} has the opaque `producer://<n>` (do not parse
2777
+ * it); one a file resolver generates ({@link FileResolverOptions}) keeps
2778
+ * the path that was opened. Tell streams apart by `kind`, not by `source`.
2779
+ */
2564
2780
  readonly source: string | null;
2565
2781
  /** Playback is paused. */
2566
2782
  readonly paused: boolean;
@@ -2876,7 +3092,9 @@ export interface Player {
2876
3092
  *
2877
3093
  * It does not wait for playback: use {@link Media.ready}. `paused` opens
2878
3094
  * it paused; aborting `signal` closes it. URLs matching a registered
2879
- * {@link Resolver} are resolved first.
3095
+ * {@link Resolver} are resolved first, and so are local files of an
3096
+ * extension a file resolver claims (the source is then a stream that
3097
+ * plugin generates; `paused` reaches it).
2880
3098
  */
2881
3099
  open(source: string, options?: {
2882
3100
  paused?: boolean;
@@ -3600,8 +3818,13 @@ export interface MediaCapture {
3600
3818
  */
3601
3819
  capture(options?: CaptureOptions): Promise<Snapshot>;
3602
3820
  }
3603
- /** Declaration of {@link Resolvers.add}. */
3604
- export interface ResolverOptions {
3821
+ /**
3822
+ * Declaration of {@link Resolvers.add}: a web resolver
3823
+ * ({@link WebResolverOptions}) or a file resolver ({@link FileResolverOptions}).
3824
+ */
3825
+ export type ResolverOptions = WebResolverOptions | FileResolverOptions;
3826
+ /** A resolver for web pages: `https://host/…` to playable stream URLs. */
3827
+ export interface WebResolverOptions {
3605
3828
  /** Local id, unique within the plugin. */
3606
3829
  id: string;
3607
3830
  /** URL schemes handled. */
@@ -3611,14 +3834,104 @@ export interface ResolverOptions {
3611
3834
  /** Turn a page URL into playable stream URLs. */
3612
3835
  resolve(url: string, signal: AbortSignal): Promise<ResolvedMedia>;
3613
3836
  }
3837
+ /**
3838
+ * A resolver for local files by extension (SDK 1.5.3, player 0.4.1): when
3839
+ * anyone opens such a file with {@link Player.open} (the Open dialog, the
3840
+ * playlist, next / previous, the recent list, a drop that calls `open`),
3841
+ * the player asks this plugin for a stream it generates instead of
3842
+ * playing the file. Needs `source.resolve` and `producer.av`.
3843
+ *
3844
+ * @remarks
3845
+ * The open keeps everything of the original call: its `ifCurrent`, its
3846
+ * operation (the opener's `open` and `media.ready()` settle as for any
3847
+ * file), `paused` (the stream starts paused), and its path as
3848
+ * `state.source`, with `state.kind` `"stream"`. A thrown error fails that
3849
+ * open: `phase` `"error"` with the error's message as `state.error`,
3850
+ * word for word. The stream belongs to this plugin, as with
3851
+ * {@link Player.openStream}.
3852
+ *
3853
+ * Cancellation: `signal` aborts when the open is replaced, closed
3854
+ * (`player.close()`, F4) or times out (30 seconds), or this resolver is
3855
+ * disposed. Cancelled after `resolve` returned but before the stream ran,
3856
+ * the SDK releases the stream and never calls `started`.
3857
+ *
3858
+ * One extension has one resolver: another plugin's resolver for it makes
3859
+ * {@link Resolver.ready} reject with `conflict` without failing activation
3860
+ * (the player shows a warning). When this plugin stops, its extensions are
3861
+ * free again and such files go to the player as before.
3862
+ *
3863
+ * @example
3864
+ * ```ts
3865
+ * const { readFile } = await import("node:fs/promises");
3866
+ * // The plugin's own emulator: one frame per call.
3867
+ * const emulator = (rom: Uint8Array): (() => StreamFrame) => {
3868
+ * const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
3869
+ * return () => ({ video, audio: new Float32Array(1600) });
3870
+ * };
3871
+ * plugin.resolvers.add({
3872
+ * id: "nes",
3873
+ * extensions: ["nes"],
3874
+ * async resolve(path, signal) {
3875
+ * const frame = emulator(await readFile(path, { signal }));
3876
+ * return {
3877
+ * kind: "stream",
3878
+ * width: 256,
3879
+ * height: 240,
3880
+ * frame,
3881
+ * started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
3882
+ * };
3883
+ * },
3884
+ * });
3885
+ * ```
3886
+ */
3887
+ export interface FileResolverOptions {
3888
+ /** Local id, unique within the plugin. */
3889
+ id: string;
3890
+ /**
3891
+ * Lower case, without the dot: 1–16 entries of 1–16 lower-case letters or
3892
+ * digits (not `"folder"`). Only absolute local paths match, by their
3893
+ * extension in any case (`/roms/Game.NES`); URLs never do.
3894
+ */
3895
+ extensions: string[];
3896
+ /** Read the file and describe the stream to generate for it. */
3897
+ resolve(path: string, signal: AbortSignal): Promise<ResolvedStream>;
3898
+ }
3899
+ /**
3900
+ * What a file resolver returns: a stream, with the same frame contract as
3901
+ * {@link StreamOptions} (size limits, `frame()` on the audio clock).
3902
+ */
3903
+ export interface ResolvedStream {
3904
+ /** Always `"stream"`. */
3905
+ kind: "stream";
3906
+ /** Frame width in pixels. */
3907
+ width: number;
3908
+ /** Frame height in pixels. */
3909
+ height: number;
3910
+ /** Produce the next frame. Never called concurrently or while paused. */
3911
+ frame(): StreamFrame;
3912
+ /**
3913
+ * Called with the running stream once the player plays it, synchronously
3914
+ * after activation and before any {@link Stream.onStop}. Not called when
3915
+ * the open was cancelled first. `stream.stop()` closes the source (if
3916
+ * still current); the plugin stopping stops it too.
3917
+ */
3918
+ started?(stream: Stream): void;
3919
+ }
3614
3920
  /** A registered resolver. Disposing it unregisters it and aborts resolutions in flight. */
3615
3921
  export interface Resolver extends Disposable {
3616
- /** Resolves when the player registered it; rejects (e.g. `conflict`) otherwise. */
3922
+ /**
3923
+ * Resolves when the player registered it; rejects otherwise: `conflict`
3924
+ * when another resolver has the extension or the scheme + host (another
3925
+ * plugin's: activation goes on; this plugin's own: activation fails),
3926
+ * `invalid` for a malformed declaration (a player before 0.4.1 also for
3927
+ * `extensions`).
3928
+ */
3617
3929
  readonly ready: Promise<void>;
3618
3930
  }
3619
3931
  /**
3620
- * `plugin.resolvers`: turn web page URLs into playable streams. Needs
3621
- * `source.resolve`.
3932
+ * `plugin.resolvers`: turn web page URLs into playable streams, or local
3933
+ * files into streams this plugin generates. Needs `source.resolve` (file
3934
+ * resolvers also `producer.av`).
3622
3935
  *
3623
3936
  * @example
3624
3937
  * ```ts
@@ -3635,9 +3948,11 @@ export interface Resolver extends Disposable {
3635
3948
  */
3636
3949
  export interface Resolvers {
3637
3950
  /**
3638
- * Register a resolver. When anyone opens a matching URL the player calls
3639
- * `resolve`; `signal` aborts when the open is cancelled or replaced. One
3640
- * scheme + host pair has one resolver (`conflict` otherwise).
3951
+ * Register a resolver. When anyone opens a matching URL or file the
3952
+ * player calls `resolve`; `signal` aborts when the open is cancelled or
3953
+ * replaced. One scheme + host pair, and one extension, has one resolver
3954
+ * (`conflict` otherwise). A rejected `resolve` fails the open with the
3955
+ * error's message.
3641
3956
  */
3642
3957
  add(options: ResolverOptions): Resolver;
3643
3958
  }
@@ -3800,6 +4115,38 @@ export interface PluginInfo {
3800
4115
  * the player has none. The path's layout is not part of the API.
3801
4116
  */
3802
4117
  dataDirectory: string | null;
4118
+ /**
4119
+ * What a running plugin was refused because another plugin holds it: a
4120
+ * panel position (`slot-occupied`, with `slot`) or the next/previous
4121
+ * sequence (`sequence-taken`). Neither fails activation; `error` has the
4122
+ * same as text. Empty from players before 0.4.x (after 0.4.0).
4123
+ * Always set by the SDK; optional so that code building `PluginInfo`
4124
+ * values itself (tests) keeps compiling.
4125
+ *
4126
+ * @since 1.5.2
4127
+ */
4128
+ warnings?: readonly PluginWarning[];
4129
+ }
4130
+ /**
4131
+ * One refusal in {@link PluginInfo.warnings}.
4132
+ *
4133
+ * @since 1.5.2
4134
+ */
4135
+ export interface PluginWarning {
4136
+ /**
4137
+ * `"slot-occupied"`, `"sequence-taken"` or `"resolver-taken"` (player
4138
+ * 0.4.1); a newer player may add codes.
4139
+ */
4140
+ code: string;
4141
+ /** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
4142
+ slot?: string;
4143
+ /**
4144
+ * What was taken, for `resolver-taken`: 「文件类型 .nes」 or the
4145
+ * `scheme://host` of a web resolver.
4146
+ */
4147
+ target?: string;
4148
+ /** The manifest id of the plugin holding it (absent for a full sidebar). */
4149
+ owner?: string;
3803
4150
  }
3804
4151
  /**
3805
4152
  * `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) {