@vestra-player/sdk 1.5.29 → 1.5.30

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/llms-full.txt CHANGED
@@ -1,8 +1,8 @@
1
- # @vestra-player/sdk 1.5.29: full reference
1
+ # @vestra-player/sdk 1.5.30: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
5
- How to read this file: the declarations are TypeScript declaration files whose doc comments keep only the description and the tags @capability (the manifest capability a call needs), @player (the oldest player version), @param, @returns, @throws (`code` (throws | rejects) condition) and @deprecated. Examples and longer remarks are in the documentation site and in node_modules/@vestra-player/sdk/dist/**/*.d.ts. Only what is declared here exists; do not use other members.
5
+ How to read this file: the declarations are TypeScript declaration files whose doc comments keep the description, @remarks, the tags @capability (the manifest capability a call needs), @player (the oldest player version), @param, @returns, @throws (`code` (throws | rejects) condition) and @deprecated, and every @example (a task title, a lead saying what it does and how it works, then code). @see, @since and the rest are on the documentation site and in node_modules/@vestra-player/sdk/dist/**/*.d.ts. Only what is declared here exists; do not use other members.
6
6
 
7
7
  Limits: 160 commands per plugin and 1024 in the player (`limit`); menu ids for the first path segment: `@open`, `@playback`, `@subtitles`, `@video`, `@audio`, `@screen`, `@settings`; manifest: id ≤ 128 bytes, name, author ≤ 64 characters, punchline ≤ 120, description ≤ 2000, tags ≤ 8 of ≤ 24 characters, license ≤ 64, sourceUrl ≤ 2048 bytes, icon PNG or JPEG ≤ 512 KB and 1024 px per side, readme, changelog ≤ 256 KB.
8
8
 
@@ -3304,6 +3304,18 @@ interface Disposable {
3304
3304
  }
3305
3305
  ```
3306
3306
 
3307
+ Example: Stop listening once the file is ready
3308
+
3309
+ The listener reacts to the first time the source becomes ready and then
3310
+ stops being called. Every registration returns a `Disposable`;
3311
+ disposing it removes just that listener, before the scope that owns it
3312
+ ends.
3313
+ ```ts
3314
+ const subscription = plugin.player.onChange((state) => {
3315
+ if (state.phase === "ready") void subscription.dispose();
3316
+ });
3317
+ ```
3318
+
3307
3319
  ### PluginError
3308
3320
 
3309
3321
  Every API failure is an ordinary `Error` with a stable string `code`.
@@ -3365,6 +3377,21 @@ type PluginError = Error & {
3365
3377
  };
3366
3378
  ```
3367
3379
 
3380
+ Example: Ignore a seek that lost a race, rethrow anything else
3381
+
3382
+ The plugin seeks to 0:30 and stays quiet when another file was opened in
3383
+ between. Every failure is an `Error` with a stable `code`; `stale` means
3384
+ the source changed since the state the call was based on and nothing
3385
+ was done, so it is safe to drop. Other codes are real failures.
3386
+ ```ts
3387
+ try {
3388
+ await plugin.player.seek(30);
3389
+ } catch (error) {
3390
+ if ((error as PluginError).code === "stale") return; // someone else changed the source
3391
+ throw error;
3392
+ }
3393
+ ```
3394
+
3368
3395
  ### Scope
3369
3396
 
3370
3397
  A group of handles released together.
@@ -3472,6 +3499,14 @@ interface Scope extends Disposable {
3472
3499
  /**
3473
3500
  * Run `cleanup` when this scope is disposed (for example `clearInterval` or closing a socket). It may be async; errors are logged.
3474
3501
  * @returns A handle that cancels the registration early (without running `cleanup`).
3502
+ * @example Stop a timer when the plugin stops
3503
+ * A once-a-second refresh runs as long as the plugin does. `onDispose`
3504
+ * runs the cleanup when the scope is disposed, which for `plugin` is
3505
+ * when the player stops the plugin, so the timer never outlives it.
3506
+ * ```ts
3507
+ * const timer = setInterval(() => plugin.update(), 1000); // re-evaluate bindings
3508
+ * plugin.onDispose(() => clearInterval(timer));
3509
+ * ```
3475
3510
  */
3476
3511
  onDispose(cleanup: () => void | Promise<void>): Disposable;
3477
3512
  /**
@@ -3482,6 +3517,16 @@ interface Scope extends Disposable {
3482
3517
  * Rejections of async handlers count too, and so do failures of
3483
3518
  * registrations made after activation (a command whose `ready` rejects,
3484
3519
  * a property write the player refused).
3520
+ * @example Show errors from handlers on the OSD
3521
+ * When a menu command, a window callback or a listener of the plugin
3522
+ * fails, the user sees why. The SDK catches what those handlers throw
3523
+ * (and their rejected promises) and passes it here instead of only
3524
+ * logging it; the message is data in a translated template.
3525
+ * ```ts
3526
+ * plugin.onError((error) => {
3527
+ * void plugin.osd.show("Bookmarks: {}", [error.message]);
3528
+ * });
3529
+ * ```
3485
3530
  */
3486
3531
  onError(listener: (error: PluginError) => void): Disposable;
3487
3532
  /**
@@ -3496,6 +3541,30 @@ interface Scope extends Disposable {
3496
3541
  }
3497
3542
  ```
3498
3543
 
3544
+ Example: Rebuild a dynamic submenu with a child scope
3545
+
3546
+ Subtitles ▸ Track lists the current file's subtitle tracks and is
3547
+ rebuilt whenever they change. Commands added through a child scope
3548
+ belong to it; disposing the scope removes all of them at once, and a
3549
+ new child scope collects the next set.
3550
+ ```ts
3551
+ let tracks = plugin.scope();
3552
+ plugin.subtitles.onChange(async (state) => {
3553
+ await tracks.dispose(); // removes the previous menu items
3554
+ tracks = plugin.scope();
3555
+ state.tracks.forEach((track, i) =>
3556
+ tracks.commands.add({
3557
+ id: `track.${i}`,
3558
+ path: ["Subtitles", "Track", track.title],
3559
+ order: 300 + i,
3560
+ radio: true,
3561
+ checked: () => plugin.subtitles.state?.selected?.id === track.id,
3562
+ run: () => plugin.subtitles.select(track),
3563
+ }),
3564
+ );
3565
+ });
3566
+ ```
3567
+
3499
3568
  ### Plugin
3500
3569
 
3501
3570
  The object the player passes to a plugin's `activate`: the root
@@ -3538,6 +3607,25 @@ interface Plugin extends Scope {
3538
3607
  }
3539
3608
  ```
3540
3609
 
3610
+ Example: Write a minimal plugin with one menu item
3611
+
3612
+ Tools ▸ Say hello, or Ctrl+H, shows "Hello from" and the plugin's id.
3613
+ The player imports `main.ts`, calls its default export with the
3614
+ `Plugin` object, and keeps the registrations made during activation;
3615
+ the manifest declares the capabilities `commands` and `ui.osd`.
3616
+ ```ts
3617
+ import type { Plugin } from "@vestra-player/sdk";
3618
+
3619
+ export default async function activate(plugin: Plugin) {
3620
+ plugin.commands.add({
3621
+ id: "hello",
3622
+ path: ["Tools", "Say hello"],
3623
+ bindings: ["Ctrl+H"], // a default the user can change
3624
+ run: () => plugin.osd.show("Hello from {}", [plugin.id]),
3625
+ });
3626
+ }
3627
+ ```
3628
+
3541
3629
  ### CommandState
3542
3630
 
3543
3631
  The state of a command's menu item, as the player shows it.
@@ -3564,6 +3652,22 @@ Scope.update); only changes are sent to the player.
3564
3652
  type Bindable<T> = T | (() => T);
3565
3653
  ```
3566
3654
 
3655
+ Example: Mix a computed state with a fixed one
3656
+
3657
+ The Loop item carries a check mark while a loop is set and is always
3658
+ enabled. A function is evaluated again in every update pass (after each
3659
+ handler and player state change) and only changes are sent; a plain
3660
+ value never changes.
3661
+ ```ts
3662
+ plugin.commands.add({
3663
+ id: "loop",
3664
+ path: ["Playback", "Loop"],
3665
+ checked: () => plugin.player.state.loop != null, // re-evaluated
3666
+ enabled: true, // a fixed value
3667
+ run: () => plugin.player.setLoop(null),
3668
+ });
3669
+ ```
3670
+
3567
3671
  ### MenuId
3568
3672
 
3569
3673
  A built-in top-level menu of the player, named in a command path as
@@ -3589,6 +3693,32 @@ type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "open" | "
3589
3693
 
3590
3694
  A command's button on the bottom bar: CommandOptions.button.
3591
3695
 
3696
+ **Where it shows.** The bottom bar places the player's strip of command
3697
+ buttons, `BarButtons` from `@vestra/bar-buttons.slint`, at its right end;
3698
+ the player fills it with the buttons of every plugin, left to right by
3699
+ `order`, and its own sidebar toggle rightmost. When the window is narrow
3700
+ the strip loses cells from its outer (left) end inward: the lowest order
3701
+ goes first, the sidebar toggle last. A
3702
+ bottom bar that does not place `BarButtons` shows no buttons: the
3703
+ command's menu entry stays, and the player logs the buttons it cannot
3704
+ show and whose bar hides them. The built-in bar (`vestra.playback`) places
3705
+ it; see
3706
+ Writing a bottom bar.
3707
+
3708
+ **What it shows.** The icon, the command's title (translated, a trailing
3709
+ "…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
3710
+ `enabled`, `busy` and `reason` as the menu item has them: a disabled or
3711
+ busy command's button is drawn disabled, its tooltip adds the reason, and
3712
+ a click runs nothing. A command with `checked` shows its icon in the
3713
+ accent colour while checked. A click runs the command exactly like a
3714
+ click on its menu item.
3715
+
3716
+ **Limits.** One button per plugin and eight in the player
3717
+ (`BAR_BUTTON_LIMITS`): a button past them is left out (its command and
3718
+ menu entry stay) and the player logs it with `limit`. The button goes
3719
+ with its command: disposing the command, or disabling, stopping or
3720
+ uninstalling the plugin, removes it.
3721
+
3592
3722
  - @player 0.5.10; older players show the menu entry without a button (declare `"minPlayerVersion": "0.5.10"` when the button matters).
3593
3723
 
3594
3724
  ```ts
@@ -3604,6 +3734,23 @@ interface CommandButton {
3604
3734
  }
3605
3735
  ```
3606
3736
 
3737
+ Example: Give a command a button in the bottom bar
3738
+
3739
+ A Store icon appears in the bottom bar's button strip; a click opens
3740
+ the store window, exactly like the menu item. The player draws the
3741
+ button from the command's title, keys and state, and places it by
3742
+ `order` (200 is where the player's own Plugin Store button sits); a
3743
+ bar that does not place `BarButtons` shows only the menu item.
3744
+ ```ts
3745
+ const store = plugin.windows.create("ui/store.slint");
3746
+ plugin.commands.add({
3747
+ id: "open",
3748
+ path: ["Plugin Store…"], // a button needs a menu path
3749
+ button: { icon: "store", order: 200 },
3750
+ run: () => store.show(),
3751
+ });
3752
+ ```
3753
+
3607
3754
  ### CommandOptions
3608
3755
 
3609
3756
  The declaration passed to Commands.add.
@@ -3663,6 +3810,22 @@ interface CommandOptions {
3663
3810
  * Text (SDK 1.5.11, player 0.4.3): `plugin.i18n.text("Track {}
3664
3811
  * ({})", [n, codec])`. The menu shows it translated and filled in; it
3665
3812
  * merges by its English rendering ("Track 2 (opus)").
3813
+ * @example Put items into built-in menus, one with a track number
3814
+ * "Blur" appears in the Video menu and "Track 2 (opus)" under Audio ▸
3815
+ * Audio Track, both in the user's language. `@video` and `@audio` name
3816
+ * the built-in menus by id; "Audio Track" is a submenu key that merges
3817
+ * with any other plugin's same key; the `text()` segment is translated
3818
+ * with the plugin's catalog and then filled in with the number and codec.
3819
+ * ```ts
3820
+ * plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run: toggle });
3821
+ * const { text } = plugin.i18n;
3822
+ * // A segment with data: translated as "Track {} ({})", then filled in.
3823
+ * plugin.commands.add({
3824
+ * id: "track-2",
3825
+ * path: ["@audio", "Audio Track", text("Track {} ({})", [2, "opus"])],
3826
+ * run: select,
3827
+ * });
3828
+ * ```
3666
3829
  */
3667
3830
  path?: (string | Text)[];
3668
3831
  /** Menu label (English source text, shown translated, or a Text); defaults to the last path segment. */
@@ -3730,7 +3893,28 @@ from the menu and the binding table; the id can be added again right away.
3730
3893
  interface Command extends Disposable {
3731
3894
  /** The local id given in CommandOptions.id. */
3732
3895
  readonly id: string;
3733
- /** Replace some state fields (values or functions). Last write wins. */
3896
+ /**
3897
+ * Replace some state fields (values or functions). Last write wins.
3898
+ * @example Show a command as busy while it works
3899
+ * The Export item shows as busy with "Exporting…" as its reason while the
3900
+ * export runs, then returns to normal. `set` replaces only the fields it
3901
+ * names and the SDK sends the new state to the player; a busy command's
3902
+ * menu item and key do not run it again until it is set back.
3903
+ * ```ts
3904
+ * const exportCommand = plugin.commands.add({
3905
+ * id: "export",
3906
+ * path: ["@video", "Export…"],
3907
+ * run: async () => {
3908
+ * exportCommand.set({ busy: true, reason: "Exporting…" });
3909
+ * try {
3910
+ * await runExport();
3911
+ * } finally {
3912
+ * exportCommand.set({ busy: false, reason: null }); // also on failure
3913
+ * }
3914
+ * },
3915
+ * });
3916
+ * ```
3917
+ */
3734
3918
  set(state: Partial<{
3735
3919
  [K in keyof CommandState]: Bindable<CommandState[K]>;
3736
3920
  }>): void;
@@ -3754,6 +3938,23 @@ interface Commands {
3754
3938
  * Titles and paths cannot change in place: put changing items (track
3755
3939
  * lists, presets) in a child Scope and rebuild it.
3756
3940
  * @throws invalid synchronously for a malformed path (including an unknown `@` menu id), order, group or binding; `conflict` for an id this plugin has already declared and not disposed.
3941
+ * @example Pause playback from a menu item
3942
+ * Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
3943
+ * while paused and is greyed out when nothing can be paused. The player
3944
+ * calls `run` when the user picks the item or presses its key; `enabled`
3945
+ * and `checked` are functions the SDK evaluates again after every
3946
+ * handler and player state change, sending only what changed.
3947
+ * ```ts
3948
+ * const toggle = plugin.commands.add({
3949
+ * id: "toggle-pause",
3950
+ * path: ["Playback", "Pause"],
3951
+ * order: 210, // position among the Playback menu's items
3952
+ * bindings: ["Ctrl+P"],
3953
+ * enabled: () => plugin.player.state.can.pause,
3954
+ * checked: () => plugin.player.state.paused,
3955
+ * run: () => plugin.player.setPaused(!plugin.player.state.paused),
3956
+ * });
3957
+ * ```
3757
3958
  */
3758
3959
  add(options: CommandOptions): Command;
3759
3960
  }
@@ -3763,6 +3964,18 @@ interface Commands {
3763
3964
 
3764
3965
  An image file for a Slint `image` property, made by Ui.image.
3765
3966
 
3967
+ Opaque: pass it where the Slint type is `image`, with View.set or
3968
+ View.bind: the whole value of an `image` property or, from SDK
3969
+ 1.5.19 (player 0.5.1), an `image` field at any depth, such as the logo of
3970
+ each row of a list. Making the reference does not read the file: the
3971
+ player checks and decodes it each time it receives the value (a property
3972
+ write, a window opening; Ui.image has the rules and limits).
3973
+
3974
+ A reference belongs to the Scope that created it. Once that scope
3975
+ is disposed, writing the reference fails with `invalid` (reported to
3976
+ Scope.onError); an image already on screen stays until the
3977
+ property changes or the view closes.
3978
+
3766
3979
  ```ts
3767
3980
  interface ImageRef {
3768
3981
  /** The path given to Ui.image, unchanged. */
@@ -3772,6 +3985,43 @@ interface ImageRef {
3772
3985
  }
3773
3986
  ```
3774
3987
 
3988
+ Example: Show a cover image from the data directory
3989
+
3990
+ A window opens showing `cover.png`, which the plugin saved in its data
3991
+ directory. `ui.image` only makes a reference, without reading the file;
3992
+ the player checks and decodes the file when it receives the value, so a
3993
+ missing file fails that write with `missing` (here: `show` rejects, as
3994
+ it is an initial property). A missing image nested inside a value is
3995
+ drawn empty instead.
3996
+ ```ts
3997
+ const cover = plugin.windows.create("ui/cover.slint");
3998
+ // `art` is an `image` property in ui/cover.slint.
3999
+ cover.set({ art: plugin.ui.image(`${plugin.dataDirectory}/cover.png`) });
4000
+ await cover.show();
4001
+ ```
4002
+
4003
+ Example: Show a logo in each row of a list
4004
+
4005
+ Each row of the list draws its own small logo next to its name. An
4006
+ `ImageRef` may sit in any field of a property value (SDK 1.5.19, player
4007
+ 0.5.1), so the rows carry their icons; `maxSide: 64` decodes each logo
4008
+ at most 64 px wide, which keeps the view's image memory small.
4009
+ ```slint
4010
+ export struct Row { key: string, name: string, icon: image }
4011
+ ```
4012
+ ```ts
4013
+ const items = [{ id: "a", name: "A", iconPath: `${plugin.dataDirectory}/a.png` }];
4014
+ const list = plugin.windows.create("ui/list.slint");
4015
+ list.bind(() => ({
4016
+ rows: items.map((item) => ({
4017
+ key: item.id,
4018
+ name: item.name,
4019
+ // About twice the drawn size (32 px) stays sharp on dense screens.
4020
+ icon: plugin.ui.image(item.iconPath, { maxSide: 64 }),
4021
+ })),
4022
+ }));
4023
+ ```
4024
+
3775
4025
  ### ImageOptions
3776
4026
 
3777
4027
  Options of Ui.image and Ui.remoteImage.
@@ -3791,6 +4041,18 @@ interface ImageOptions {
3791
4041
  A Markdown document for `MarkdownView` or `MarkdownBlocks`, made by
3792
4042
  Ui.markdown.
3793
4043
 
4044
+ Opaque, like ImageRef: pass it where the Slint type is
4045
+ `[MarkdownBlock]`: the whole value of such a property (the `blocks` of
4046
+ `MarkdownView` from `@vestra/markdown.slint`) or, from SDK 1.5.4, a
4047
+ `[MarkdownBlock]` field at any depth of a property value, such as the
4048
+ `body` of each row of a chat's message list. It carries the text, the
4049
+ base address and the local copies of its remote images; the player parses
4050
+ and draws it. Writing a document whose text did not change (a new
4051
+ reference for the same text included) costs the player no parsing, and a
4052
+ changed document updates only the blocks that changed. It belongs to the
4053
+ scope that created it: once that scope is disposed, writing it fails with
4054
+ `invalid`, wherever it is in the value.
4055
+
3794
4056
  - @player 0.2.0; nested in a value: the player after 0.4.0
3795
4057
 
3796
4058
  ```ts
@@ -3802,6 +4064,18 @@ interface MarkdownRef {
3802
4064
  }
3803
4065
  ```
3804
4066
 
4067
+ Example: Show chat messages, one document per row
4068
+
4069
+ The chat window shows a formatted assistant message. `ui.markdown`
4070
+ resolves once the document is ready (here without downloading images);
4071
+ the reference goes into the `body` field of a row, whose Slint type is
4072
+ `[MarkdownBlock]`, and the player parses and draws it.
4073
+ ```ts
4074
+ const chat = plugin.windows.create("ui/chat.slint");
4075
+ const body = await plugin.ui.markdown("**Hello**", { remoteImages: false });
4076
+ chat.set({ messages: [{ id: "m1", role: "assistant", body }] });
4077
+ ```
4078
+
3805
4079
  ### MarkdownOptions
3806
4080
 
3807
4081
  Options of Ui.markdown: where relative links and images point, and
@@ -3823,6 +4097,46 @@ interface MarkdownOptions {
3823
4097
  The value of one Slint property: JSON with Markdown documents and images
3824
4098
  at any depth (PropData).
3825
4099
 
4100
+ JSON becomes Slint values as you would expect: booleans for `bool`,
4101
+ finite numbers for `int` and `float`, strings for `string`, arrays for
4102
+ models (`[T]`), objects for structs. Struct field names are passed
4103
+ unchanged, so write them as the Slint file spells them; a name with `_`
4104
+ is refused (Slint treats `_` and `-` as the same, so use camelCase or
4105
+ kebab-case).
4106
+
4107
+ A Text or TextList goes where the Slint type is `string`,
4108
+ at any depth (SDK 1.5.11, player 0.4.3): the player shows it translated
4109
+ with the plugin's catalog, so a `[string]` model (a ComboBox) can mix
4110
+ words and data. The limits below count it as the string it becomes.
4111
+
4112
+ A MarkdownRef goes where the Slint type is `[MarkdownBlock]`: the
4113
+ whole value, or a field at any depth (SDK 1.5.4; older players refuse a
4114
+ nested one). An ImageRef goes where the Slint type is `image`: the
4115
+ whole value, or a field at any depth (SDK 1.5.19, player 0.5.1; older
4116
+ players refuse a nested one with `invalid`). The images nested in the
4117
+ values of one view (a window, panel or settings page) may show at most
4118
+ 32 MB of decoded pixels together (`IMAGE_LIMITS.viewBytes` in
4119
+ `@vestra-player/sdk/runtime`): a write
4120
+ that would pass it is refused with `limit`; use
4121
+ ImageOptions.maxSide for small pictures such as logos and
4122
+ thumbnails.
4123
+
4124
+ Limits per value (`UI_VALUE_LIMITS` in `@vestra-player/sdk/testing`,
4125
+ player 0.4.3; older players: 4096 values and 1024 rows): nesting depth 8,
4126
+ 16384 values in total (every array, object, string, number, boolean and
4127
+ null counts, the value itself included; a document or an image is one), 4096 rows
4128
+ per array, 64 KiB per string or field name and 1 MiB of text in total,
4129
+ field names and the text of its Markdown documents included (256 KB per
4130
+ document). A list of rows with 8 fields is 9 values per row, so about
4131
+ 1800 rows fit. The whole request must also fit the player's 1 MiB
4132
+ message. A value over a limit is refused with code `limit` and the
4133
+ property keeps what it showed (see View.set); `TestHost` refuses
4134
+ it the same way.
4135
+
4136
+ The limits bound the work of one write, not the size of your data: for
4137
+ a list that can grow without bound, send the rows near the view and the
4138
+ row count, and move that window as the user scrolls.
4139
+
3826
4140
  ```ts
3827
4141
  type PropValue = PropData | ImageRef;
3828
4142
  ```
@@ -3838,34 +4152,108 @@ type PropData = null | boolean | number | string | MarkdownRef | ImageRef | Text
3838
4152
  };
3839
4153
  ```
3840
4154
 
4155
+ Example: Type a message list whose rows carry a document
4156
+
4157
+ A property value for a chat's message list, typed so that TypeScript
4158
+ accepts a Markdown document inside each row. `PropData` is the JSON a
4159
+ property takes plus documents and images at any depth; the player turns
4160
+ each object into a Slint struct by its field names.
4161
+ ```ts
4162
+ const messages: PropData = [
4163
+ { id: "m1", role: "user", body: await plugin.ui.markdown("Hi") },
4164
+ ];
4165
+ ```
4166
+
3841
4167
  ### Props
3842
4168
 
3843
4169
  Properties to write to a view, by name.
3844
4170
 
4171
+ Names may be camelCase: the SDK turns every capital letter into `-` plus
4172
+ the lower-case letter (`canMark` → `can-mark`); names already in
4173
+ kebab-case are kept. Only the property names are converted, not the
4174
+ fields of struct values.
4175
+
3845
4176
  ```ts
3846
4177
  type Props = Record<string, PropValue>;
3847
4178
  ```
3848
4179
 
4180
+ Example: Prepare a window's initial properties
4181
+
4182
+ An object of property values by name, as `windows.create` and
4183
+ `View.set` take them. Names match the Slint file (`canMark` for
4184
+ `can-mark`), and each value becomes the Slint type of its property: a
4185
+ boolean, and an array of structs for a model.
4186
+ ```ts
4187
+ const props: Props = { canMark: true, rows: [{ rowId: "a", title: "First" }] };
4188
+ ```
4189
+
3849
4190
  ### Ui
3850
4191
 
3851
4192
  Values for Slint properties that are not plain JSON: images from files and
3852
4193
  Markdown documents. `plugin.ui`.
3853
4194
 
4195
+ A Slint file can only name images compiled into it (`@image-url`). To show
4196
+ an image the plugin picks at run time (cover art, a thumbnail it rendered,
4197
+ a downloaded icon), make an ImageRef with Ui.image and
4198
+ write it to an `image` property. To show formatted text, make a
4199
+ MarkdownRef with Ui.markdown and give it to `MarkdownView`.
4200
+ Both work in tool windows, panels (the backdrop and the floating sidebar
4201
+ included) and settings pages.
4202
+
3854
4203
  - @capability None.
3855
4204
 
3856
4205
  ```ts
3857
4206
  interface Ui {
3858
4207
  /**
3859
4208
  * Reference a PNG or JPEG file for an `image` property.
4209
+ *
4210
+ * Returns at once without touching the file. The player checks the file
4211
+ * each time it receives the reference (a property write, a window or
4212
+ * settings page opening, the main window being rebuilt): it must be inside this plugin's package
4213
+ * directory or Plugin.dataDirectory after following links, at most
4214
+ * 16 MB and 4096×4096 pixels, and PNG or JPEG by content (the extension
4215
+ * does not matter). A failed check of a whole-value image is a failed
4216
+ * property write: `missing` for a file that does not exist, `invalid`
4217
+ * otherwise. It goes to Scope.onError and the property keeps its
4218
+ * previous image; in the initial properties of a tool window it makes
4219
+ * Window.show reject. A nested image (a field of a row, SDK
4220
+ * 1.5.19) that fails its check is drawn empty instead, and the rest of
4221
+ * the value is shown, as a Markdown image shows its alt text.
4222
+ *
4223
+ * The player caches decoded images per plugin (up to 64 MB, least recently
4224
+ * used first) by path, size and modification time, and does not watch
4225
+ * files. After rewriting a file under the same path, call `image()` again
4226
+ * and write the new reference: the SDK does not send a value that did not
4227
+ * change, so the player would not look at the file again.
3860
4228
  * @capability None.
3861
4229
  * @param path - Absolute path of the image file, inside the package directory or the data directory.
3862
4230
  * @param options - `maxSide` (SDK 1.5.19, player 0.5.1): decode the image scaled down, aspect kept, so that neither side is over it.
3863
4231
  * @returns A new ImageRef, owned by this scope.
3864
4232
  * @throws invalid (throws) `path` is not a non-empty string, or `maxSide` is not an integer 1–4096.
4233
+ * @example Show a thumbnail the plugin rendered
4234
+ * A window shows a PNG the plugin wrote into its data directory. The file
4235
+ * must be inside the package or data directory; `image` returns at once.
4236
+ * The player decodes the file when it receives the value and notices a
4237
+ * rewritten file by its size and modification time; the plugin still
4238
+ * calls `image()` again after rewriting it, because the SDK does not send
4239
+ * a value that did not change.
4240
+ * ```ts
4241
+ * const window = plugin.windows.create("ui/thumbnail.slint");
4242
+ * const file = `${plugin.dataDirectory}/thumbnail.png`;
4243
+ * // … write the PNG to `file` (for example with plugin.tools.run("ffmpeg", …)) …
4244
+ * window.set({ thumbnail: plugin.ui.image(file) });
4245
+ * await window.show();
4246
+ * ```
3865
4247
  */
3866
4248
  image(path: string, options?: ImageOptions): ImageRef;
3867
4249
  /**
3868
4250
  * Download an `https:` PNG or JPEG image (a thumbnail, a repository's plugin icon) and reference the local copy, as Ui.image does.
4251
+ *
4252
+ * Downloads like Ui.markdown's remote images: Node's `fetch` in
4253
+ * this plugin's process, at most 4 MB and 10 s per image, 4 downloads at a
4254
+ * time per plugin, into `<dataDirectory>/.vestra-cache/images/`; the cache
4255
+ * keeps 32 MB and fetches an address again after 24 hours, so a list that
4256
+ * shows the same thumbnails again reads them from disk.
3869
4257
  * @capability None (the plugin process has network access).
3870
4258
  * @player Any (the reference is an ordinary ImageRef); nested in a value, player 0.5.1.
3871
4259
  * @param url - The image's `https:` address.
@@ -3873,10 +4261,55 @@ interface Ui {
3873
4261
  * @returns The reference, owned by this scope, once the file is in the cache.
3874
4262
  * @throws invalid (rejects) `url` is not an `https:` address, or `maxSide` is not an integer 1–4096.
3875
4263
  * @throws unavailable (rejects) the download failed, timed out, was too large, or is not a PNG or JPEG image; or the plugin has no data directory.
4264
+ * @example Show search results with downloaded thumbnails
4265
+ * A results window lists each hit with its thumbnail, or a placeholder
4266
+ * when a thumbnail cannot be fetched. `remoteImage` downloads the image
4267
+ * in the plugin's process into the data directory's cache (at most 4 at a
4268
+ * time) and resolves with a local reference; a failed download rejects
4269
+ * with `unavailable`, which the `catch` turns into the placeholder.
4270
+ * ```ts
4271
+ * const results = [{ title: "A", thumbnailUrl: "https://example.com/a.jpg" }];
4272
+ * const placeholder = plugin.ui.image(`${plugin.dataDirectory}/blank.png`);
4273
+ * const rows = await Promise.all(
4274
+ * results.map(async (r) => ({
4275
+ * title: r.title,
4276
+ * thumb: await plugin.ui
4277
+ * .remoteImage(r.thumbnailUrl, { maxSide: 320 })
4278
+ * .catch(() => placeholder), // `unavailable`: download failed
4279
+ * })),
4280
+ * );
4281
+ * plugin.windows.create("ui/results.slint").set({ rows });
4282
+ * ```
3876
4283
  */
3877
4284
  remoteImage(url: string, options?: ImageOptions): Promise<ImageRef>;
3878
4285
  /**
3879
4286
  * Prepare a Markdown document for `MarkdownView` (`@vestra/markdown.slint`).
4287
+ *
4288
+ * The player renders headings, paragraphs (bold, italic, strikethrough,
4289
+ * inline code, links), lists and task lists, code blocks, block quotes,
4290
+ * images, tables and rules; embedded HTML is not shown. A click on a link
4291
+ * calls the view's `link-clicked(url)` callback with the address resolved
4292
+ * against `base`; opening it is up to the plugin (for example with
4293
+ * System.openUrl).
4294
+ *
4295
+ * Images: PNG or JPEG. Local ones follow Ui.image's rules (this
4296
+ * plugin's package or data directory). `https:` images are downloaded by
4297
+ * this call into `<dataDirectory>/.vestra-cache/markdown/` (each at most
4298
+ * 4 MB and 10 s, at most 64 per document; the cache keeps 32 MB and fetches
4299
+ * an address again after 24 hours). A failed or unsupported image (SVG,
4300
+ * GIF, `http:`…) shows its alt text; the call itself does not fail for it.
4301
+ *
4302
+ * The text is at most 256 KB; very long documents are cut off after 4000
4303
+ * blocks.
4304
+ *
4305
+ * `MarkdownView` scrolls one document. `MarkdownBlocks` (SDK 1.5.4, players
4306
+ * after 0.4.0) draws one without scrolling, as tall as its content, for a
4307
+ * layout of your own or a `ListView` row. For a list of documents, write
4308
+ * the whole list with one document per row: the player parses only the
4309
+ * documents that changed and updates the list in place (rows added or
4310
+ * removed at the end, a growing document's changed blocks), so re-sending
4311
+ * the list on every streamed chunk keeps the scroll position and costs
4312
+ * little.
3880
4313
  * @capability None.
3881
4314
  * @player 0.2.0 (set `"minPlayerVersion": "0.2.0"` in the manifest)
3882
4315
  * @param text - The Markdown source (CommonMark with GitHub tables, task lists and strikethrough), at most 256 KB as UTF-8.
@@ -3884,56 +4317,363 @@ interface Ui {
3884
4317
  * @returns The document, owned by this scope, once its remote images are downloaded (or have failed).
3885
4318
  * @throws invalid (rejects) `text` is not a string or is over 256 KB, or `base` is neither an `http(s):` address nor an absolute path.
3886
4319
  * @throws unsupported (rejects) The player is older than 0.2.0.
4320
+ * @example Build a chat with one Markdown document per row
4321
+ * A chat window lists messages as formatted text, with a Copy button on
4322
+ * each code block that puts the code on the clipboard. Each row's `body`
4323
+ * is a document from `ui.markdown`; the whole list is written again on
4324
+ * each change, and the player parses only the documents that changed.
4325
+ * `MarkdownBlocks` with `code-copy` calls `copy-code`, which the plugin
4326
+ * handles with `on("copyCode")`.
4327
+ *
4328
+ * `ui/chat.slint`:
4329
+ *
4330
+ * ```slint
4331
+ * import { ListView } from "std-widgets.slint";
4332
+ * import { MarkdownBlocks, MarkdownBlock } from "@vestra/markdown.slint";
4333
+ * export struct Message { id: string, role: string, body: [MarkdownBlock] }
4334
+ * export component Chat inherits Window {
4335
+ * in property <[Message]> messages;
4336
+ * callback copy-code(string);
4337
+ * ListView {
4338
+ * for message in root.messages: VerticalLayout {
4339
+ * padding: 8px;
4340
+ * MarkdownBlocks {
4341
+ * blocks: message.body;
4342
+ * code-copy: true;
4343
+ * copy-code(code) => { root.copy-code(code); }
4344
+ * }
4345
+ * }
4346
+ * }
4347
+ * }
4348
+ * ```
4349
+ *
4350
+ * ```ts
4351
+ * const chat = plugin.windows.create("ui/chat.slint");
4352
+ * const history: { id: string; role: string; text: string }[] = [];
4353
+ * chat.on("copyCode", (code: string) => plugin.clipboard.writeText(code));
4354
+ * // Re-send the whole list: unchanged documents cost the player nothing.
4355
+ * async function render() {
4356
+ * const messages = [];
4357
+ * for (const { id, role, text } of history)
4358
+ * messages.push({ id, role, body: await plugin.ui.markdown(text) });
4359
+ * chat.set({ messages });
4360
+ * }
4361
+ * history.push({ id: "1", role: "user", text: "Show me `ls`." });
4362
+ * await render();
4363
+ * await chat.show();
4364
+ * ```
4365
+ * @example Show a README from the web
4366
+ * A window shows a README downloaded from the web, its images included,
4367
+ * and clicked links open in the browser. `base` makes relative links and
4368
+ * images resolve against the README's address; `markdown` resolves after
4369
+ * its `https:` images are downloaded. A click on a link makes
4370
+ * `MarkdownView` call `link-clicked`, which `ui/readme.slint` forwards as
4371
+ * `open-link`; the plugin opens the address with `openUrl`.
4372
+ * ```ts
4373
+ * const readmeUrl = "https://example.com/plugin/README.md";
4374
+ * const response = await fetch(readmeUrl);
4375
+ * if (!response.ok) throw new Error(`README: HTTP ${response.status}`);
4376
+ * const text = await response.text();
4377
+ * const window = plugin.windows.create("ui/readme.slint");
4378
+ * // Bound before the first `show`, as a tool window requires.
4379
+ * window.on("openLink", (url: string) => plugin.system.openUrl(url));
4380
+ * window.set({ doc: await plugin.ui.markdown(text, { base: readmeUrl }) });
4381
+ * await window.show();
4382
+ * ```
3887
4383
  */
3888
4384
  markdown(text: string, options?: MarkdownOptions): Promise<MarkdownRef>;
3889
4385
  }
3890
4386
  ```
3891
4387
 
4388
+ Example: Show a Markdown file in a window (examples/markdown)
4389
+
4390
+ A menu item opens a window with a formatted Markdown file that ships
4391
+ with the plugin. The plugin reads the file, turns it into a document
4392
+ with `ui.markdown` (relative images resolve against the plugin's
4393
+ directory through `base`) and writes it to the window's `doc` property,
4394
+ which the Slint file hands to `MarkdownView`.
4395
+
4396
+ ```ts
4397
+ // Relative links and images in sample.md resolve against this folder.
4398
+ const directory = fileURLToPath(new URL(".", import.meta.url));
4399
+ const window = plugin.windows.create("ui/document.slint");
4400
+ plugin.commands.add({
4401
+ id: "show",
4402
+ path: ["Examples", "Markdown example…"],
4403
+ run: async () => {
4404
+ const text = readFileSync(new URL("sample.md", import.meta.url), "utf8");
4405
+ // `doc` is the window's [MarkdownBlock] property, drawn by MarkdownView.
4406
+ window.set({ doc: await plugin.ui.markdown(text, { base: directory }) });
4407
+ await window.show();
4408
+ },
4409
+ });
4410
+ ```
4411
+
4412
+ `ui/document.slint`:
4413
+
4414
+ ```slint
4415
+ import { MarkdownView, MarkdownBlock } from "@vestra/markdown.slint";
4416
+ import { Theme } from "@vestra/theme.slint";
4417
+
4418
+ export component Document inherits Window {
4419
+ title: "Markdown example";
4420
+ preferred-width: 520px;
4421
+ preferred-height: 560px;
4422
+ min-width: 280px;
4423
+ min-height: 200px;
4424
+ background: Theme.bg-panel;
4425
+ in property <[MarkdownBlock]> doc;
4426
+ MarkdownView {
4427
+ blocks: root.doc;
4428
+ }
4429
+ }
4430
+ ```
4431
+
3892
4432
  ### View
3893
4433
 
3894
4434
  The surface that tool windows, panels and settings pages share: properties
3895
4435
  out to Slint, callbacks in from Slint.
3896
4436
 
4437
+ The plugin's TypeScript and its Slint files run in different processes and
4438
+ exchange two kinds of messages. Plugin → UI: `in` and `in-out` properties,
4439
+ written with View.set or kept up to date with View.bind.
4440
+ UI → plugin: Slint callbacks (returning nothing), handled with
4441
+ View.on. A Window declares them on its exported component;
4442
+ a Panel and a SettingsPage declare them on the file's
4443
+ exported global, the bridge. The plugin cannot call Slint functions or read
4444
+ a property back: a value the user changes reaches it only through a
4445
+ callback.
4446
+
4447
+ `bind` is the usual way: the SDK re-evaluates bound functions in every
4448
+ update pass (after each handler it calls, after player state changes, on
4449
+ Scope.update) and sends only what changed, at most one write in
4450
+ flight per view. `set` sends now, even an unchanged value.
4451
+
4452
+ Writes that fail (a name the Slint file does not declare, a value of the
4453
+ wrong type, an image that fails its check): a bound value is not sent
4454
+ again until it changes; a `set` value is retried with back-off (0.25 s,
4455
+ doubling up to 30 s); the first failure of a series is reported to
4456
+ Scope.onError.
4457
+
4458
+ A control whose value both the user and the plugin change needs a two-way
4459
+ binding to an `in-out` property (`value <=> root.level`). A one-way
4460
+ binding (`value: root.level`) is broken by Slint the first time the user
4461
+ moves the control.
4462
+
3897
4463
  - @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
3898
4464
 
3899
4465
  ```ts
3900
4466
  interface View {
3901
4467
  /**
3902
4468
  * Handle a Slint callback.
4469
+ *
4470
+ * The handler receives the callback's arguments as JSON (struct fields
4471
+ * spelled as in Slint). It may be async. An error it throws, or a rejection
4472
+ * of its promise, goes to Scope.onError. After it returns, and
4473
+ * again when its promise settles, the SDK runs an update pass, so state it
4474
+ * changed reaches bound properties without further calls (state changed
4475
+ * after an `await` appears only when the promise settles; call
4476
+ * Scope.update to show it earlier).
4477
+ *
4478
+ * Calling `on` again with the same name replaces the handler. A callback
4479
+ * the plugin has no handler for is ignored.
4480
+ *
4481
+ * **Tool windows** forward only the callback names bound when the window
4482
+ * opens: bind every name before the first Window.show. While the
4483
+ * window is open, binding a new name throws; after a close the next `show`
4484
+ * registers the names bound by then. A name the Slint file does not declare
4485
+ * makes `show` reject. **Panels and settings pages** forward every
4486
+ * callback of their bridge global, so bind whenever you like.
3903
4487
  * @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
3904
4488
  * @param callback - The Slint callback's name, in camelCase (`toggleEffect`) or as written in Slint (`toggle-effect`).
3905
4489
  * @param handler - Receives the callback's arguments.
3906
4490
  * @returns A handle that removes this handler (if it is still the current one for the name).
3907
4491
  * @throws Error without a code (throws) On an open Window, for a name that has no handler yet.
4492
+ * @example Keep a slider and the plugin's value in step
4493
+ * Moving the window's brightness slider updates the plugin's value, and
4494
+ * the slider shows it back. The Slint callback `brightness-changed`
4495
+ * reaches the handler with the slider's value as JSON; after the handler
4496
+ * returns, the SDK runs an update pass and `bind` sends the new value.
4497
+ * Callbacks of a tool window must be bound before its first `show`.
4498
+ * ```ts
4499
+ * const picture = plugin.windows.create("ui/picture.slint");
4500
+ * let brightness = 0;
4501
+ * // camelCase here, `brightness-changed` in the Slint file.
4502
+ * picture.on("brightnessChanged", (value: number) => {
4503
+ * brightness = value;
4504
+ * });
4505
+ * picture.bind(() => ({ brightness }));
4506
+ * await picture.show();
4507
+ * ```
3908
4508
  */
3909
4509
  on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
3910
4510
  /**
3911
4511
  * Write properties now, even values that did not change.
4512
+ *
4513
+ * Each value given is sent once with the next write, even if it equals the
4514
+ * last value sent. That makes `set` the way to pull a control back to the
4515
+ * model value after the user moved it, or to put a one-off value (a
4516
+ * document, an image) on screen. While the view is not open (a window
4517
+ * before `show`, a panel before `ready`) the values are kept and become its
4518
+ * initial properties.
4519
+ *
4520
+ * Do not `set` a property that a View.bind function also returns:
4521
+ * the next update pass replaces the set value with the bound one.
4522
+ *
4523
+ * Never throws. A write that fails is retried with back-off and reported
4524
+ * to Scope.onError (the first failure of a run of failures; the
4525
+ * message names the property). A value over the limits of
4526
+ * PropValue fails with `limit`. As an initial property (written
4527
+ * while the view is not open) it makes Window.show reject; a
4528
+ * panel or settings page still opens without it (the property keeps its
4529
+ * default) and the failure goes to the plugin's `onError` (player 0.4.3;
4530
+ * older players left it out silently).
3912
4531
  * @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
3913
4532
  * @param props - Properties by name; see Props.
4533
+ * @example Put a drop-down back when the user picks an entry the plugin refuses
4534
+ * Choosing "Custom" snaps the drop-down back to the last real preset.
4535
+ * The plugin cannot read a property back, only hear the callback; `set`
4536
+ * sends the value even though it equals the one sent before, which is
4537
+ * what pulls a control the user moved back to the plugin's value.
4538
+ * ```ts
4539
+ * const window = plugin.windows.create("ui/presets.slint");
4540
+ * let presetIndex = 0;
4541
+ * window.on("presetChosen", (index: number) => {
4542
+ * if (index > 2) window.set({ presetIndex }); // "Custom" is not a preset
4543
+ * else presetIndex = index;
4544
+ * });
4545
+ * ```
3914
4546
  */
3915
4547
  set(props: Props): void;
3916
4548
  /**
3917
4549
  * Keep properties equal to a function of the plugin's state.
4550
+ *
4551
+ * The SDK calls `props` in every update pass (and right after `bind`) and
4552
+ * sends the values that differ from the ones it sent last. Because an
4553
+ * unchanged value is not sent again, text the user typed into an `in-out`
4554
+ * property stays until the plugin's value for it changes.
4555
+ *
4556
+ * Keep the function quick and synchronous: it runs often. An error it
4557
+ * throws is reported to Scope.onError and the other bindings still
4558
+ * run. A view can have several bindings; when two return the same name,
4559
+ * the one bound later wins.
4560
+ *
4561
+ * Never throws. Writes that fail are not repeated until the value
4562
+ * changes, and are reported to Scope.onError like a failed
4563
+ * View.set (a value over the limits of PropValue: `limit`).
3918
4564
  * @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
3919
4565
  * @param props - Returns the properties, by name.
3920
4566
  * @returns A handle that stops this binding (the values last sent stay).
4567
+ * @example Show what is playing and whether it can seek
4568
+ * The status window always shows the current file and enables its seek
4569
+ * controls only for seekable sources. The SDK calls the function right
4570
+ * away and again in every update pass (after player state changes and
4571
+ * after each handler) and sends only the values that changed.
4572
+ * ```ts
4573
+ * const status = plugin.windows.create("ui/status.slint");
4574
+ * status.bind(() => ({
4575
+ * title: plugin.player.state.source ?? "Nothing playing",
4576
+ * canSeek: plugin.player.state.can.seek,
4577
+ * }));
4578
+ * ```
3921
4579
  */
3922
4580
  bind(props: () => Props): Disposable;
3923
4581
  }
3924
4582
  ```
3925
4583
 
4584
+ Example: Wire a window's label and buttons to the plugin (examples/bookmark)
4585
+
4586
+ A Bookmark window shows the saved position and two buttons that save
4587
+ it and jump back to it. `bind` keeps the label and the buttons' enabled
4588
+ state equal to the plugin's state: the SDK evaluates the function again
4589
+ after every handler and player change and sends only what changed. The
4590
+ buttons' Slint callbacks reach the plugin through `on`.
4591
+
4592
+ ```ts
4593
+ const window = plugin.windows.create("ui/bookmark.slint");
4594
+ // Re-evaluated after every handler and player change; only changes are sent.
4595
+ window.bind(() => ({
4596
+ label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
4597
+ canMark: player.state.position !== null,
4598
+ canJump: mark !== null && player.state.can.seek,
4599
+ }));
4600
+ // The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
4601
+ window.on("mark", () => (mark = player.state.position));
4602
+ window.on("jump", () => player.seek(mark!));
4603
+ ```
4604
+
4605
+ `ui/bookmark.slint`:
4606
+
4607
+ ```slint
4608
+ import { Button, VerticalBox, HorizontalBox } from "std-widgets.slint";
4609
+ export component BookmarkWindow inherits Window {
4610
+ title: "Bookmark";
4611
+ preferred-width: 320px;
4612
+ in property <string> label: "No bookmark";
4613
+ in property <bool> can-mark;
4614
+ in property <bool> can-jump;
4615
+ callback mark();
4616
+ callback jump();
4617
+ VerticalBox {
4618
+ Text { text: root.label; }
4619
+ HorizontalBox {
4620
+ Button { text: "Mark this position"; enabled: root.can-mark; clicked => { root.mark(); } }
4621
+ Button { text: "Jump to bookmark"; enabled: root.can-jump; clicked => { root.jump(); } }
4622
+ }
4623
+ }
4624
+ }
4625
+ ```
4626
+
3926
4627
  ### Window
3927
4628
 
3928
4629
  A tool window: a separate operating-system window drawn from one of the
3929
4630
  plugin's `.slint` files, made by Windows.create.
3930
4631
 
4632
+ **What it is.** A top-level window with its own title bar: the user can
4633
+ move it, resize it (if the Slint file allows) and close it. It is not
4634
+ modal and not attached to the main window: playback and the main window
4635
+ keep working while it is open. Its title, size and content come from the
4636
+ Slint `Window` component (`title`, `width` or `preferred-width`…); the
4637
+ player applies its palette and the system font before the window appears.
4638
+ The system chooses its position; the player does not remember it.
4639
+ MainWindow.setAlwaysOnTop also floats tool windows.
4640
+
4641
+ **Life cycle.**
4642
+ 1. `create()` makes the handle; nothing is on screen.
4643
+ 2. Bind callbacks with View.on and properties with
4644
+ View.bind or View.set.
4645
+ 3. Window.show opens the window with those properties.
4646
+ Window.hide hides it and keeps its state.
4647
+ 4. Window.close, or the user (title-bar button or the
4648
+ standard close keys), destroys the window instance; only the
4649
+ user's close calls Window.onClose. The handle stays usable: the
4650
+ next `show()` opens a fresh instance with the current properties.
4651
+ 5. `dispose()`, disposing the owning scope or the plugin stopping closes
4652
+ it for good (without `onClose`); the id can then be used again.
4653
+
4654
+ Command key bindings do not fire while a tool window has focus; handle
4655
+ keys in Slint (a `FocusScope`). The standard close keys never reach the
4656
+ window's Slint code.
4657
+
4658
+ Testing: `host.window(id)` of `@vestra-player/sdk/testing` has the
4659
+ window's `props`, `visible`, `emit(callback, ...args)` and `userClose()`.
4660
+
3931
4661
  - @capability ui.window
3932
4662
 
3933
4663
  ```ts
3934
4664
  interface Window extends View, Disposable {
3935
4665
  /**
3936
4666
  * Open the window, or show it again if it is hidden.
4667
+ *
4668
+ * When no window instance exists (the first call, or after a close), the
4669
+ * player compiles the Slint file and opens a new window. Its initial
4670
+ * properties are the bound values and the values last passed to
4671
+ * View.set, so no default values flash, and the callbacks bound
4672
+ * so far are registered. When the window is hidden, it is shown again with
4673
+ * its state.
4674
+ *
4675
+ * `show`, `hide` and `close` of one window run one after another in call
4676
+ * order. After `dispose()`, `show` does nothing.
3937
4677
  * @capability ui.window
3938
4678
  * @returns Resolves once the player has shown the window.
3939
4679
  * @throws capability (rejects) The manifest does not declare `ui.window`.
@@ -3941,20 +4681,72 @@ interface Window extends View, Disposable {
3941
4681
  * @throws host (rejects) The Slint file is missing, outside the package directory or does not compile; it exports more than one component; or a bound property or callback is not declared in it. `message` has the reason, including the compiler's diagnostics.
3942
4682
  * @throws missing (rejects) An image in the initial properties does not exist (see Ui.image).
3943
4683
  * @throws invalid (rejects) An image in the initial properties fails another check: outside the allowed directories, too large, not PNG or JPEG.
4684
+ * @example Tell the user why a window did not open
4685
+ * When the inspector cannot open, the user sees why on the OSD instead
4686
+ * of nothing happening. `show` compiles the Slint file and opens the
4687
+ * window; it rejects with a code, such as `host` for a Slint file that
4688
+ * does not compile or `unavailable` without a user interface.
4689
+ * ```ts
4690
+ * const window = plugin.windows.create("ui/inspector.slint");
4691
+ * try {
4692
+ * await window.show();
4693
+ * } catch (error) {
4694
+ * // The code is data; `message` has the details for the log.
4695
+ * await plugin.osd.show("Cannot open the inspector ({})", [(error as PluginError).code]);
4696
+ * }
4697
+ * ```
3944
4698
  */
3945
4699
  show(): Promise<void>;
3946
4700
  /**
3947
4701
  * Hide the window without closing it.
4702
+ *
4703
+ * The instance and everything in it stay; Window.opened stays
4704
+ * `true` and Window.show brings the window back as it was. Does
4705
+ * nothing when the window is not open.
3948
4706
  * @capability ui.window
3949
4707
  * @returns Resolves once the window is hidden.
3950
4708
  * @throws missing (rejects) The user closed the window just before (the close had not reached the plugin yet).
4709
+ * @example Show and hide a window from a checkable menu item
4710
+ * Audio ▸ Levels shows the window with a check mark and hides it again.
4711
+ * `hide` keeps the window's instance and state, so showing it again is
4712
+ * instant; the check mark follows `visible`, and `onClose` resets it when
4713
+ * the user closes the window with its title-bar button.
4714
+ * ```ts
4715
+ * const levels = plugin.windows.create("ui/levels.slint");
4716
+ * let visible = false; // what the check mark shows
4717
+ * plugin.commands.add({
4718
+ * id: "levels",
4719
+ * path: ["@audio", "Levels"],
4720
+ * checked: () => visible,
4721
+ * run: async () => {
4722
+ * visible = !visible;
4723
+ * await (visible ? levels.show() : levels.hide());
4724
+ * },
4725
+ * });
4726
+ * levels.onClose(() => (visible = false));
4727
+ * ```
3951
4728
  */
3952
4729
  hide(): Promise<void>;
3953
4730
  /**
3954
4731
  * Close the window and destroy its instance; the handle stays usable.
4732
+ *
4733
+ * The next Window.show opens a fresh instance with the current
4734
+ * properties; state that only the Slint side had (text the plugin did not
4735
+ * record) is gone. Window.onClose is not called. Does nothing when
4736
+ * the window is not open. Unlike `dispose()`, the handle, its handlers and
4737
+ * its bindings stay.
3955
4738
  * @capability ui.window
3956
4739
  * @returns Resolves once the window is closed.
3957
4740
  * @throws missing (rejects) The user closed the window just before.
4741
+ * @example Close the window from its own button
4742
+ * The editor's Done button closes the editor window. `close` destroys
4743
+ * the window instance but keeps the handle, its handlers and bindings,
4744
+ * so the next `show` opens a fresh window; `onClose` is not called for
4745
+ * the plugin's own close.
4746
+ * ```ts
4747
+ * const editor = plugin.windows.create("ui/editor.slint");
4748
+ * editor.on("done", () => editor.close());
4749
+ * ```
3958
4750
  */
3959
4751
  close(): Promise<void>;
3960
4752
  /**
@@ -3963,30 +4755,128 @@ interface Window extends View, Disposable {
3963
4755
  readonly opened: boolean;
3964
4756
  /**
3965
4757
  * Run code when the user closes the window.
4758
+ *
4759
+ * Called for the title-bar close button and the
4760
+ * standard close keys. When it runs, the window
4761
+ * instance is gone and Window.opened is already `false`; a later
4762
+ * `show()` opens a fresh one. It is not called for the plugin's own
4763
+ * Window.close or `dispose()`, nor when the plugin stops. Errors
4764
+ * it throws go to Scope.onError, and an update pass follows.
4765
+ *
4766
+ * Stop what the window needed here: timers, polling, a live preview.
3966
4767
  * @capability ui.window
3967
4768
  * @param listener - Called with no arguments; may be async.
3968
4769
  * @returns A handle that removes the listener.
4770
+ * @example Remember that the user closed the window
4771
+ * The plugin records that the notes window was closed, so it does not
4772
+ * reopen it at the next start. `onClose` runs for the title-bar button,
4773
+ * the player's close keys (Cmd+W, Ctrl+W, Ctrl+F4) and the system's own
4774
+ * close request such as Alt+F4 (not for the plugin's own `close`); the
4775
+ * store writes the change to `window.json` shortly after.
4776
+ * ```ts
4777
+ * const store = plugin.storage.json("window", { defaults: { open: false } });
4778
+ * const window = plugin.windows.create("ui/notes.slint");
4779
+ * window.onClose(() => store.save({ open: false }));
4780
+ * ```
3969
4781
  */
3970
4782
  onClose(listener: () => unknown): Disposable;
3971
4783
  }
3972
4784
  ```
3973
4785
 
4786
+ Example: Open a window from a menu command (examples/bookmark)
4787
+
4788
+ Playback ▸ Bookmark… opens the Bookmark window. `create` only makes a
4789
+ handle; the command's `run` calls `show`, which compiles the Slint file
4790
+ and opens the window with the bound properties the first time, and
4791
+ brings back the same window afterwards.
4792
+
4793
+ ```ts
4794
+ const window = plugin.windows.create("ui/bookmark.slint");
4795
+ // Re-evaluated after every handler and player change; only changes are sent.
4796
+ window.bind(() => ({
4797
+ label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
4798
+ canMark: player.state.position !== null,
4799
+ canJump: mark !== null && player.state.can.seek,
4800
+ }));
4801
+ // The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
4802
+ window.on("mark", () => (mark = player.state.position));
4803
+ window.on("jump", () => player.seek(mark!));
4804
+
4805
+ plugin.commands.add({
4806
+ id: "bookmark.open",
4807
+ path: ["@playback", "Bookmark…"],
4808
+ order: 270,
4809
+ run: () => window.show(), // opens it, or brings back the open one
4810
+ });
4811
+ ```
4812
+
4813
+ Example: Stop work when the user closes the window
4814
+
4815
+ A clock window ticks every second while it is open and stops costing
4816
+ anything once the user closes it. `plugin.update()` makes the SDK
4817
+ evaluate the bound time again from a timer; `onClose` runs only for the
4818
+ user's close, so the plugin stops the timer there.
4819
+ ```ts
4820
+ const clock = plugin.windows.create("ui/clock.slint");
4821
+ let timer: NodeJS.Timeout | undefined;
4822
+ clock.bind(() => ({ time: new Date().toLocaleTimeString() }));
4823
+ clock.onClose(() => {
4824
+ clearInterval(timer);
4825
+ timer = undefined;
4826
+ });
4827
+ plugin.commands.add({
4828
+ id: "clock",
4829
+ path: ["@screen", "Clock…"],
4830
+ run: async () => {
4831
+ await clock.show();
4832
+ // Re-evaluate `bind` once a second: bound values change only then.
4833
+ timer ??= setInterval(() => plugin.update(), 1000);
4834
+ },
4835
+ });
4836
+ ```
4837
+
3974
4838
  ### Windows
3975
4839
 
3976
4840
  Create tool windows: separate windows drawn from the plugin's `.slint`
3977
4841
  files. `plugin.windows`.
3978
4842
 
4843
+ Use a tool window for something the user opens, works with and closes: a
4844
+ picture adjustment panel, an inspector, an editor. For content that should
4845
+ stay inside the player's main window, mount a panel; for
4846
+ preferences, add a settings page; for a yes/no
4847
+ question, use Dialogs.confirm. A plugin can have any number of
4848
+ windows.
4849
+
3979
4850
  - @capability ui.window
3980
4851
 
3981
4852
  ```ts
3982
4853
  interface Windows {
3983
4854
  /**
3984
4855
  * Create a window handle for a `.slint` file; nothing opens until Window.show.
4856
+ *
4857
+ * The file must export exactly one component, usually
4858
+ * `export component Name inherits Window`; helper components without
4859
+ * `export` are allowed. The window's properties and callbacks are declared
4860
+ * on that component. The file is not read here: a missing or broken file
4861
+ * makes Window.show reject.
4862
+ *
4863
+ * Bind every callback with View.on before the first `show()`.
3985
4864
  * @capability ui.window (checked by Window.show).
3986
4865
  * @param file - Path relative to the package directory, with `/` on every system, for example `"ui/picture.slint"`. It must stay inside the package.
3987
4866
  * @param options - `id`: the window's name, unique among this plugin's windows until the window is disposed. Default: the file name without its extension (`ui/picture.slint` → `picture`). Give distinct ids to open the same file more than once.
3988
4867
  * @returns A Window owned by this scope.
3989
4868
  * @throws conflict (throws) This plugin already has a window with this id (SDK 1.3.0; before, a plain `Error` without `code`).
4869
+ * @example Open two windows from one Slint file
4870
+ * Two compare windows open side by side, one labelled A and one B. A
4871
+ * window's id defaults to its file name, so the second window of the
4872
+ * same file needs its own `id`; each handle has its own properties.
4873
+ * ```ts
4874
+ * const left = plugin.windows.create("ui/compare.slint", { id: "compare-left" });
4875
+ * const right = plugin.windows.create("ui/compare.slint", { id: "compare-right" });
4876
+ * left.set({ side: "A" });
4877
+ * right.set({ side: "B" });
4878
+ * await Promise.all([left.show(), right.show()]);
4879
+ * ```
3990
4880
  */
3991
4881
  create(file: string, options?: {
3992
4882
  id?: string;
@@ -3994,6 +4884,32 @@ interface Windows {
3994
4884
  }
3995
4885
  ```
3996
4886
 
4887
+ Example: Create a window and open it from a menu (examples/bookmark)
4888
+
4889
+ A Bookmark window opens from Playback ▸ Bookmark…. `create` returns a
4890
+ handle without opening anything; its bindings and callbacks are set up
4891
+ first, and `show` opens the window when the command runs.
4892
+
4893
+ ```ts
4894
+ const window = plugin.windows.create("ui/bookmark.slint");
4895
+ // Re-evaluated after every handler and player change; only changes are sent.
4896
+ window.bind(() => ({
4897
+ label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
4898
+ canMark: player.state.position !== null,
4899
+ canJump: mark !== null && player.state.can.seek,
4900
+ }));
4901
+ // The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
4902
+ window.on("mark", () => (mark = player.state.position));
4903
+ window.on("jump", () => player.seek(mark!));
4904
+
4905
+ plugin.commands.add({
4906
+ id: "bookmark.open",
4907
+ path: ["@playback", "Bookmark…"],
4908
+ order: 270,
4909
+ run: () => window.show(), // opens it, or brings back the open one
4910
+ });
4911
+ ```
4912
+
3997
4913
  ### SlotState
3998
4914
 
3999
4915
  The state of the bottom bar or sidebar position, whoever mounted the
@@ -4066,6 +4982,16 @@ interface PanelState {
4066
4982
  The bottom bar or the sidebar position (`plugin.panels.bottom`,
4067
4983
  `plugin.panels.sidebar`), independent of who mounted the panel there.
4068
4984
 
4985
+ The position does not belong to the plugin that mounted the panel: any
4986
+ plugin with `ui.mount` may read and toggle it, so a "Show playlist" command
4987
+ can live in a different plugin than the playlist. The player remembers
4988
+ the sidebar's visibility, width, detached state and floating window
4989
+ position across launches.
4990
+
4991
+ `state` is read when first used. With `events` it then follows every
4992
+ change (the user, other plugins, the window size); without `events` it
4993
+ refreshes only after this plugin's own changes.
4994
+
4069
4995
  - @capability ui.mount; `events` to follow changes made elsewhere.
4070
4996
 
4071
4997
  ```ts
@@ -4081,48 +5007,138 @@ interface PanelSlot {
4081
5007
  readonly panels: readonly SlotPanel[] | null;
4082
5008
  /**
4083
5009
  * Run code when the position's state changes.
5010
+ *
5011
+ * Needs `events`: without it the listener runs only after this plugin's own
5012
+ * changes. Called with `null` when the panel there is unmounted, and also
5013
+ * when only the tabs changed (PanelSlot.panels: a panel added or
5014
+ * removed, another tab current). Errors go to Scope.onError.
4084
5015
  * @capability ui.mount and events
4085
5016
  * @param listener - Receives the new state.
4086
5017
  * @returns A handle that removes the listener.
5018
+ * @example Tell the user when the window is too narrow for the sidebar
5019
+ * When the sidebar should show but the window has no room for it, the
5020
+ * OSD says so. With `events`, the listener runs on every change of the
5021
+ * position, whoever caused it. The player reports `reason: "space"` when
5022
+ * the preference is to show the sidebar but the window is too small.
5023
+ * ```ts
5024
+ * plugin.panels.sidebar.onChange((state) => {
5025
+ * // `null`: no panel is mounted in the sidebar any more.
5026
+ * if (state?.reason === "space") void plugin.osd.show("Widen the window to see the playlist");
5027
+ * });
5028
+ * ```
4087
5029
  */
4088
5030
  onChange(listener: (state: SlotState | null) => void): Disposable;
4089
5031
  /**
4090
5032
  * Show or hide the panel at this position: set the user's preference.
5033
+ *
5034
+ * The same as the user's menu item: the preference is remembered. Showing
5035
+ * may still leave the panel off screen (`reason: "space"`); in fullscreen,
5036
+ * `true` pops the sidebar overlay out at once.
4091
5037
  * @capability ui.mount
4092
5038
  * @param visible - The new preference.
4093
5039
  * @returns Resolves once the layout has the new preference, with `state` refreshed.
4094
5040
  * @throws missing (rejects) No panel is mounted at this position.
4095
5041
  * @throws unavailable (rejects) The player runs without a user interface.
4096
5042
  * @throws capability (rejects) The manifest does not declare `ui.mount`.
5043
+ * @example Hide the bottom bar for a clean picture
5044
+ * The control bar disappears, as if the user had hidden it from the menu,
5045
+ * and stays hidden at the next start because the preference is
5046
+ * remembered. `setVisible` rejects with `missing` when no panel is in the
5047
+ * bottom bar, which this plugin treats as nothing to hide.
5048
+ * ```ts
5049
+ * await plugin.panels.bottom.setVisible(false).catch((error: PluginError) => {
5050
+ * if (error.code !== "missing") throw error; // no bar mounted: nothing to do
5051
+ * });
5052
+ * ```
4097
5053
  */
4098
5054
  setVisible(visible: boolean): Promise<void>;
4099
5055
  /**
4100
5056
  * Flip the user's preference, using the player's current value.
5057
+ *
5058
+ * Reads the state from the player first, so it is right even without
5059
+ * `events`.
4101
5060
  * @capability ui.mount
4102
5061
  * @returns Resolves once the layout has the new preference.
4103
5062
  * @throws missing (rejects) No panel is mounted at this position.
4104
5063
  * @throws unavailable (rejects) The player runs without a user interface.
4105
5064
  * @throws capability (rejects) The manifest does not declare `ui.mount`.
5065
+ * @example Toggle the control bar from a checkable menu item
5066
+ * Screen ▸ Control bar shows or hides the bottom bar and carries a check
5067
+ * mark while it is shown. `toggle` reads the player's current preference
5068
+ * first, so it is right even without `events`; `checked` is evaluated
5069
+ * again after the command runs and follows `state`.
5070
+ * ```ts
5071
+ * plugin.commands.add({
5072
+ * id: "toggle-bar",
5073
+ * path: ["@screen", "Control bar"],
5074
+ * // The user's preference, remembered across launches.
5075
+ * checked: () => plugin.panels.bottom.state?.preferred ?? false,
5076
+ * run: () => plugin.panels.bottom.toggle(),
5077
+ * });
5078
+ * ```
4106
5079
  */
4107
5080
  toggle(): Promise<void>;
4108
5081
  /**
4109
5082
  * Make a panel's tab the current one, without showing or hiding the position.
5083
+ *
5084
+ * The sidebar holds the panels of several plugins as tabs (see
5085
+ * Panels.mount); this switches which one it shows, like a click on
5086
+ * its tab. To show this plugin's own panel, use Panel.show. The
5087
+ * bottom bar has one panel: activating it changes nothing. Players
5088
+ * without sidebar tabs (0.4.0 and older) ignore it.
4110
5089
  * @capability ui.mount
4111
5090
  * @param plugin - The manifest id of the plugin whose panel to show, one of PanelSlot.panels.
4112
5091
  * @returns Resolves once the layout has the new current tab, with `state` refreshed.
4113
5092
  * @throws missing (rejects) No panel is mounted at this position, or none of `plugin`.
4114
5093
  * @throws unavailable (rejects) The player runs without a user interface.
4115
5094
  * @throws capability (rejects) The manifest does not declare `ui.mount`.
5095
+ * @example Switch the sidebar to the playlist's tab
5096
+ * A command makes the playlist the sidebar's current tab without showing
5097
+ * or hiding the sidebar, like a click on its tab. `activate` takes the
5098
+ * manifest id of the plugin whose tab to show and rejects with
5099
+ * `missing` when that plugin has no panel there.
5100
+ * ```ts
5101
+ * plugin.commands.add({
5102
+ * id: "to-playlist",
5103
+ * title: "Show the playlist tab",
5104
+ * run: () => plugin.panels.sidebar.activate("vestra.playlist"),
5105
+ * });
5106
+ * ```
4116
5107
  */
4117
5108
  activate(plugin: string): Promise<void>;
4118
5109
  }
4119
5110
  ```
4120
5111
 
5112
+ Example: Add a "Show sidebar" item that works with any sidebar panel
5113
+
5114
+ Tools ▸ Show sidebar shows or hides the sidebar, whichever plugin's
5115
+ panel is in it, with a check mark while it is shown. The position does
5116
+ not belong to the plugin that mounted the panel: `state` is `null`
5117
+ while nobody has a panel there (the item is disabled then), and
5118
+ `toggle` flips the user's remembered preference.
5119
+ ```ts
5120
+ const sidebar = plugin.panels.sidebar;
5121
+ plugin.commands.add({
5122
+ id: "sidebar",
5123
+ path: ["Tools", "Show sidebar"],
5124
+ // `state` is null while no plugin has a panel in the sidebar.
5125
+ checked: () => sidebar.state?.preferred ?? false,
5126
+ enabled: () => sidebar.state !== null,
5127
+ run: () => sidebar.toggle(),
5128
+ });
5129
+ ```
5130
+
4121
5131
  ### SidebarSlot
4122
5132
 
4123
5133
  The sidebar position: a PanelSlot that can also float in a window
4124
5134
  of its own.
4125
5135
 
5136
+ Detached, the sidebar panel is shown in a separate window (300×408 by
5137
+ default, at least 300×250) with the panel's `title`. The plugin's
5138
+ properties and callbacks stay the same, but the window holds a new
5139
+ instance of the component. Closing the floating window (its close button
5140
+ or the standard close keys) is the same as `setVisible(false)`.
5141
+
4126
5142
  - @capability ui.mount
4127
5143
 
4128
5144
  ```ts
@@ -4135,21 +5151,76 @@ interface SidebarSlot extends PanelSlot {
4135
5151
  * @throws missing (rejects) No panel is mounted in the sidebar.
4136
5152
  * @throws unavailable (rejects) The player runs without a user interface.
4137
5153
  * @throws capability (rejects) The manifest does not declare `ui.mount`.
5154
+ * @example Float the sidebar or dock it back
5155
+ * The sidebar moves into its own window, or back into the main window
5156
+ * when it already floats. `state` says whether it is detached now;
5157
+ * `setDetached` resolves once the layout changed, with `state` refreshed.
5158
+ * ```ts
5159
+ * const sidebar = plugin.panels.sidebar;
5160
+ * await sidebar.setDetached(!sidebar.state?.detached);
5161
+ * ```
4138
5162
  */
4139
5163
  setDetached(detached: boolean): Promise<void>;
4140
5164
  }
4141
5165
  ```
4142
5166
 
5167
+ Example: Add a "Detach sidebar" item
5168
+
5169
+ Tools ▸ Detach sidebar floats the sidebar in its own window and docks
5170
+ it again, with a check mark while it floats. `setDetached` moves all
5171
+ the sidebar's tabs; the floating window holds new instances of the
5172
+ panels, which keep the plugins' properties and callbacks.
5173
+ ```ts
5174
+ const sidebar = plugin.panels.sidebar;
5175
+ plugin.commands.add({
5176
+ id: "detach",
5177
+ path: ["Tools", "Detach sidebar"],
5178
+ // Detaching moves every tab of the sidebar into the floating window.
5179
+ checked: () => sidebar.state?.detached ?? false,
5180
+ enabled: () => sidebar.state !== null,
5181
+ run: () => sidebar.setDetached(!sidebar.state?.detached),
5182
+ });
5183
+ ```
5184
+
4143
5185
  ### Panel
4144
5186
 
4145
5187
  A panel in the player's main window, made by Panels.mount.
4146
5188
 
5189
+ Properties and callbacks live on the file's bridge global (see
5190
+ Panels). Unlike a tool window, a panel forwards every callback of
5191
+ the global, so View.on has no "bind before show" rule.
5192
+
5193
+ **Life cycle.** `mount()` returns at once; in the next microtask the SDK
5194
+ sends the mount with every property bound or set so far. The player queues
5195
+ mounts and rebuilds its main window with the panel; Panel.ready
5196
+ then resolves. Rebuilding happens whenever any plugin mounts or unmounts a
5197
+ panel, and detaching the sidebar puts a new instance in a floating window.
5198
+ The player restores the bridge properties to the values last written, but
5199
+ state kept only inside the component (a scroll position, a property the
5200
+ plugin never writes) starts over: keep what matters in bridge properties.
5201
+ `dispose()`, disposing the scope or stopping the plugin unmounts it.
5202
+
5203
+ Testing: `host.mounted()` of `@vestra-player/sdk/testing` has the panel's
5204
+ `slot`, `props` and `emit(callback, ...args)`.
5205
+
4147
5206
  - @capability ui.mount
4148
5207
 
4149
5208
  ```ts
4150
5209
  interface Panel extends View, Disposable {
4151
5210
  /**
4152
5211
  * Resolves once the player has mounted the panel and the main window shows it; rejects when the mount failed.
5212
+ *
5213
+ * Mounts made while `activate` runs must succeed before the plugin is
5214
+ * ready: a rejection fails activation, except `conflict` for a taken
5215
+ * position (below), which the player (0.4.x after 0.4.0) and `TestHost`
5216
+ * do not count: the plugin runs without its panel, its commands and keys
5217
+ * work, and the player shows the refusal (an OSD message,
5218
+ * PluginInfo.warnings and the text in PluginInfo.error).
5219
+ * Later failures also go to Scope.onError.
5220
+ *
5221
+ * A second sidebar panel is not refused: it resolves as another tab
5222
+ * (see Panel.state). Built-in plugins mount first at start-up,
5223
+ * so the playlist's tab is first and a built-in panel keeps its position.
4153
5224
  * @throws ui-prepare (rejects) The Slint file does not compile, or a bound property is not declared on the bridge global; `message` has the diagnostics. (Players 0.4.0 and older also answer a taken bottom bar or sidebar with it.)
4154
5225
  * @throws conflict (rejects) Another plugin's panel holds the bottom bar or the backdrop (shown, or being mounted), or the sidebar has all the tabs it takes (8); `message` names the position and the holder (「侧栏已被 vestra.playlist 使用」). Not an activation failure.
4155
5226
  * @throws busy (rejects) The player is still preparing this plugin's previous mount, or its queue of mounts is full.
@@ -4161,38 +5232,117 @@ interface Panel extends View, Disposable {
4161
5232
  readonly ready: Promise<void>;
4162
5233
  /**
4163
5234
  * This panel's state: its position's, and whether its tab is the current one; `null` until Panel.ready resolved (and after `dispose`).
5235
+ *
5236
+ * Read when first used; with `events` it then follows every change (the
5237
+ * user switching tabs, other plugins, the window size), without `events`
5238
+ * only this plugin's own changes. A sidebar panel in a background tab
5239
+ * has `active: false`, `effective: false` and `reason: "otherTab"`.
4164
5240
  * @throws Never; `null` stands for "not mounted".
4165
5241
  */
4166
5242
  readonly state: PanelState | null;
4167
5243
  /**
4168
5244
  * Run code when this panel's state changes.
5245
+ *
5246
+ * Needs `events`: without it the listener runs only after this plugin's
5247
+ * own changes. Called with `null` when the panel is unmounted. Errors go
5248
+ * to Scope.onError.
4169
5249
  * @capability ui.mount and events
4170
5250
  * @param listener - Receives the new state.
4171
5251
  * @returns A handle that removes the listener.
5252
+ * @example Notice when the list moves to a background tab
5253
+ * The plugin logs when the user switches the sidebar to another plugin's
5254
+ * tab, for example to pause work the list does. With `events` the
5255
+ * listener runs on every change. `reason: "otherTab"` exists only in a
5256
+ * panel's own state: the SDK sets it when the sidebar's current tab is
5257
+ * another plugin's (the position's state never has it).
5258
+ * ```ts
5259
+ * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
5260
+ * panel.onChange((state) => {
5261
+ * if (state?.reason === "otherTab") console.error("list in a background tab");
5262
+ * });
5263
+ * ```
4172
5264
  */
4173
5265
  onChange(listener: (state: PanelState | null) => void): Disposable;
4174
5266
  /**
4175
5267
  * Show this panel: make its tab the current one and show its position.
5268
+ *
5269
+ * Sets the position's preference like PanelSlot.setVisible`(true)`
5270
+ * (remembered; in fullscreen the sidebar overlay pops out). On players
5271
+ * without sidebar tabs (0.4.0 and older) it is just that. Waits for
5272
+ * Panel.ready first.
4176
5273
  * @capability ui.mount
4177
5274
  * @returns Resolves once the layout shows the panel's tab, with `state` refreshed.
4178
5275
  * @throws invalid (rejects) The panel is the backdrop, which the player shows by itself.
4179
5276
  * @throws unavailable (rejects) The player runs without a user interface.
4180
5277
  * @throws The errors of Panel.ready (rejects) The mount failed.
5278
+ * @example Bring the plugin's sidebar panel to the front
5279
+ * A command shows the List tab: it becomes the current tab and the
5280
+ * sidebar is shown, and the preference is remembered. `show` waits for
5281
+ * the mount first, so it can run right after `mount`.
5282
+ * ```ts
5283
+ * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
5284
+ * plugin.commands.add({ id: "show", title: "Show the list", run: () => list.show() });
5285
+ * ```
4181
5286
  */
4182
5287
  show(): Promise<void>;
4183
5288
  /**
4184
5289
  * Show this panel, or hide its position when it is shown.
5290
+ *
5291
+ * Reads the state from the player first. When the position is shown
5292
+ * (`preferred`) and this panel's tab is the current one, it hides the
5293
+ * position; otherwise it does Panel.show: a panel in a background
5294
+ * tab comes to the front instead of the sidebar closing. A "Show list"
5295
+ * command checked by `state.preferred && state.active` matches it. Waits
5296
+ * for Panel.ready first.
4185
5297
  * @capability ui.mount
4186
5298
  * @returns Resolves once the layout has changed, with `state` refreshed.
4187
5299
  * @throws invalid (rejects) The panel is the backdrop.
4188
5300
  * @throws unavailable (rejects) The player runs without a user interface.
4189
5301
  * @throws The errors of Panel.ready (rejects) The mount failed.
5302
+ * @example Toggle a sidebar panel with F6, like the playlist
5303
+ * F6 and Screen ▸ List show the List tab, or hide the sidebar when the
5304
+ * list is already in front. `toggle` reads the player's state first:
5305
+ * it hides the position only when it is shown and this panel's tab is
5306
+ * the current one, otherwise it shows the panel.
5307
+ * ```ts
5308
+ * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
5309
+ * plugin.commands.add({
5310
+ * id: "list",
5311
+ * path: ["@screen", "List"],
5312
+ * bindings: ["F6"],
5313
+ * // Checked while the sidebar is shown with this list in front.
5314
+ * checked: () => (list.state?.preferred ?? false) && (list.state?.active ?? false),
5315
+ * run: () => list.toggle(),
5316
+ * });
5317
+ * ```
4190
5318
  */
4191
5319
  toggle(): Promise<void>;
4192
5320
  /**
4193
5321
  * Keep the panel on screen while the user works in it.
5322
+ *
5323
+ * In fullscreen the bottom bar shows only while the pointer is in the bar's
5324
+ * own band at the bottom of the screen: at once when the pointer enters it,
5325
+ * hidden 500 ms after it leaves (player 0.5.1; keys and pointer motion
5326
+ * elsewhere do not reveal it), and the fullscreen sidebar overlay collapses
5327
+ * when the pointer leaves it. A lock keeps the bar shown. Set a
5328
+ * lock while a text field has focus, a drag is in progress or a popup is
5329
+ * open, and clear it afterwards. Locks are merged with the earlier ones;
5330
+ * the last value of each wins. The player already locks a panel while the
5331
+ * pointer is over it. No effect on the backdrop.
5332
+ *
5333
+ * Never throws: a failed write is reported to Scope.onError.
4194
5334
  * @capability ui.mount
4195
5335
  * @param locks - The locks to change; omitted ones keep their value.
5336
+ * @example Keep the bar on screen while a search field has focus
5337
+ * In fullscreen the bottom bar stays visible while the user types in
5338
+ * its search field, instead of hiding when the pointer leaves it. The
5339
+ * Slint side reports the field's focus through a callback, and
5340
+ * `setInteraction` sets or clears the `focused` lock; other locks keep
5341
+ * their values.
5342
+ * ```ts
5343
+ * const panel = plugin.panels.mount("bottom", "ui/bar.slint");
5344
+ * panel.on("searchFocused", (focused: boolean) => panel.setInteraction({ focused }));
5345
+ * ```
4196
5346
  */
4197
5347
  setInteraction(locks: Partial<{
4198
5348
  /** A text field (or another control taking keys) has focus. */
@@ -4204,6 +5354,12 @@ interface Panel extends View, Disposable {
4204
5354
  }>): void;
4205
5355
  /**
4206
5356
  * Pop up a menu of this plugin's commands at a point in the panel.
5357
+ *
5358
+ * A one-level menu, styled like the main menu (check marks, shortcut text),
5359
+ * in the window that shows the panel. Picking an item runs that command;
5360
+ * `null` is a separator. Commands without a menu `path` work too, which
5361
+ * makes context-only commands possible. Call it when the right button is
5362
+ * released, as the main menu does. Waits for Panel.ready first.
4207
5363
  * @capability ui.mount
4208
5364
  * @param items - At most 32 commands of this plugin, or `null` for a separator.
4209
5365
  * @param at - Where to open it, in the window's logical pixels: in Slint, `(self.absolute-position.x + self.mouse-x) / 1px` (and the same for `y`).
@@ -4212,6 +5368,20 @@ interface Panel extends View, Disposable {
4212
5368
  * @throws unavailable (rejects) The panel is not on screen (also: its sidebar tab is not the current one), or it is the backdrop.
4213
5369
  * @throws invalid (rejects) No items, more than 32, or `at` is not finite.
4214
5370
  * @throws The errors of Panel.ready (rejects) The mount failed.
5371
+ * @example Open a context menu where the user right-clicked (examples/panel-menu)
5372
+ * Right-clicking the list opens a menu with "Select the first item" and
5373
+ * "Remove the selected item". The Slint file passes the pointer position
5374
+ * in window pixels with its `context-menu` callback; `showMenu` opens the
5375
+ * plugin's commands there, and picking one runs that command.
5376
+ *
5377
+ * ```ts
5378
+ * // Callbacks of the bridge global; `bind` above sends the new selection.
5379
+ * panel.on("select", (index: number) => (selected = index));
5380
+ * // x and y: the pointer in window pixels, from the Slint file.
5381
+ * panel.on("contextMenu", (x: number, y: number) =>
5382
+ * panel.showMenu([first, null, remove], { x, y }),
5383
+ * );
5384
+ * ```
4215
5385
  */
4216
5386
  showMenu(items: (Command | null)[], at: {
4217
5387
  /** Logical pixels from the window's left edge. */
@@ -4221,9 +5391,29 @@ interface Panel extends View, Disposable {
4221
5391
  }): Promise<void>;
4222
5392
  /**
4223
5393
  * Receive the files dropped on this panel.
5394
+ *
5395
+ * Covers the docked panel and the floating sidebar window. While a listener
5396
+ * exists, drops on the panel come here as one group (not split by
5397
+ * extension) instead of going to the Drops providers. Drops on
5398
+ * tool windows and the Options window are ignored; in fullscreen, the
5399
+ * bottom bar floats over the video and drops there count as drops on the
5400
+ * video area.
4224
5401
  * @capability ui.drop
4225
5402
  * @param listener - Receives the paths in the order the file manager gave them, the point in panel coordinates (logical pixels from the panel's top-left corner) and the keys held.
4226
5403
  * @returns A handle that removes the listener.
5404
+ * @example Add dropped files to a list
5405
+ * Files dropped on the Files panel are added to its list. While the
5406
+ * listener exists, drops on the panel come here as one group instead of
5407
+ * going to the drop providers; after the listener returns, the SDK runs
5408
+ * an update pass and `bind` sends the longer list.
5409
+ * ```ts
5410
+ * const files: string[] = [];
5411
+ * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "Files" });
5412
+ * panel.bind(() => ({ rows: files }));
5413
+ * panel.onDrop(({ paths }) => {
5414
+ * files.push(...paths); // in the order the file manager gave them
5415
+ * });
5416
+ * ```
4227
5417
  */
4228
5418
  onDrop(listener: (event: {
4229
5419
  /** Absolute paths of the dropped files and folders. */
@@ -4239,6 +5429,74 @@ interface Panel extends View, Disposable {
4239
5429
  }
4240
5430
  ```
4241
5431
 
5432
+ Example: Mount a sidebar list with a right-click menu (examples/panel-menu)
5433
+
5434
+ The sidebar gets an "Example list" tab whose rows can be selected and
5435
+ have a context menu. `mount` returns at once and the player adds the
5436
+ panel when it rebuilds its main window; `bind` keeps the rows and the
5437
+ selection on the panel's bridge global, and the `context-menu` callback
5438
+ opens a menu of the plugin's commands where the user clicked.
5439
+
5440
+ ```ts
5441
+ const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
5442
+ title: "Example list",
5443
+ });
5444
+ // Sent when the panel mounts, then whenever a value changes.
5445
+ panel.bind(() => ({ rows: items, selected }));
5446
+
5447
+ // Callbacks of the bridge global; `bind` above sends the new selection.
5448
+ panel.on("select", (index: number) => (selected = index));
5449
+ // x and y: the pointer in window pixels, from the Slint file.
5450
+ panel.on("contextMenu", (x: number, y: number) =>
5451
+ panel.showMenu([first, null, remove], { x, y }),
5452
+ );
5453
+ ```
5454
+
5455
+ `ui/panel.slint`:
5456
+
5457
+ ```slint
5458
+ import { Theme } from "@vestra/theme.slint";
5459
+
5460
+ export global ListBridge {
5461
+ in property <[string]> rows;
5462
+ in property <int> selected;
5463
+ callback select(int);
5464
+ callback context-menu(float, float);
5465
+ }
5466
+
5467
+ export component ListPanel inherits Rectangle {
5468
+ background: Theme.bg-list;
5469
+ min-width: 200px;
5470
+ VerticalLayout {
5471
+ for row[index] in ListBridge.rows: Rectangle {
5472
+ height: 20px;
5473
+ background: index == ListBridge.selected ? Theme.selected-bg : transparent;
5474
+ Text {
5475
+ x: 6px;
5476
+ text: (index + 1) + ". " + row;
5477
+ color: index == ListBridge.selected ? Theme.text : Theme.text-list;
5478
+ font-size: Theme.font-body;
5479
+ vertical-alignment: center;
5480
+ }
5481
+ TouchArea {
5482
+ pointer-event(event) => {
5483
+ if event.kind == PointerEventKind.down {
5484
+ ListBridge.select(index);
5485
+ }
5486
+ if event.kind == PointerEventKind.up && event.button == PointerEventButton.right {
5487
+ ListBridge.context-menu((self.absolute-position.x + self.mouse-x) / 1px,
5488
+ (self.absolute-position.y + self.mouse-y) / 1px);
5489
+ }
5490
+ }
5491
+ }
5492
+ }
5493
+ // Takes the space below the rows. Without it the layout's maximum
5494
+ // height (three 20px rows) would cap the player's main window.
5495
+ Rectangle { vertical-stretch: 1; }
5496
+ }
5497
+ }
5498
+ ```
5499
+
4242
5500
  ### DropModifiers
4243
5501
 
4244
5502
  Keys held when the files were dropped.
@@ -4280,9 +5538,30 @@ interface Drops {
4280
5538
  * Take files dropped on the video area. The first provider registered for
4281
5539
  * an extension gets it; files nobody takes are ignored.
4282
5540
  *
4283
- * Paths are grouped by extension (case-insensitive) in the order the file
5541
+ * Paths are grouped by provider (an extension, case-insensitive, belongs
5542
+ * to the provider that registered it first) in the order the file
4284
5543
  * manager gave them, one call per provider. Folders are not expanded.
4285
5544
  * @throws invalid synchronously for malformed extensions.
5545
+ * @example Load dropped subtitle files
5546
+ * Dropping .srt or .ass files on the video loads each one as a subtitle
5547
+ * track. Each extension belongs to the provider that registered it first;
5548
+ * the player groups the dropped paths by provider and calls each provider
5549
+ * once with all of its paths, so .srt and .ass files dropped together
5550
+ * arrive in one call. (A panel under the pointer that listens with
5551
+ * `onDrop` takes the whole drop first.) The plugin reads each file itself
5552
+ * and uploads its bytes with `subtitles.load`.
5553
+ * ```ts
5554
+ * plugin.drops.provide({
5555
+ * extensions: ["srt", "ass"],
5556
+ * drop: async ({ paths }) => {
5557
+ * // Only .srt and .ass files reach this provider.
5558
+ * for (const path of paths)
5559
+ * await plugin.subtitles.load(await readFile(path), {
5560
+ * format: path.endsWith(".ass") ? "ass" : "srt",
5561
+ * });
5562
+ * },
5563
+ * });
5564
+ * ```
4286
5565
  */
4287
5566
  provide(handler: DropHandler): Disposable;
4288
5567
  }
@@ -4295,6 +5574,10 @@ The player's palettes: `vestra`, the dark default, and `vestra-light`
4295
5574
  Both draw on the logo's colours. Read colours from `Theme` instead of
4296
5575
  branching on the id: a later version may add palettes.
4297
5576
 
5577
+ The player treats an id it does not have as `vestra`: plugins built with an
5578
+ older SDK that still ask for a palette removed in SDK 1.4.0 keep working,
5579
+ and a player older than 0.4.0 answers `vestra-light` with `vestra`.
5580
+
4298
5581
  ```ts
4299
5582
  type ThemeId = "vestra" | "vestra-light";
4300
5583
  ```
@@ -4303,6 +5586,19 @@ type ThemeId = "vestra" | "vestra-light";
4303
5586
 
4304
5587
  The player's palette, which every view uses. `plugin.appearance`.
4305
5588
 
5589
+ The player sets the palette on every view (the main window and its panels,
5590
+ the floating sidebar, tool windows, the Options window) before it appears
5591
+ and again when the palette changes. Slint code that reads its colours from
5592
+ `Theme` (`import { Theme } from "@vestra/theme.slint"`) follows it with no
5593
+ plugin code; hard-coded colours do not. Only colours change: corner radii,
5594
+ font sizes and control sizes are the same in every palette. A light palette
5595
+ also switches the std-widgets controls (`std-widgets.slint`) to their light
5596
+ colours, and keeps the video area dark.
5597
+
5598
+ The player saves the choice; it stays after the plugin that set it stops.
5599
+ Tell the user about a change yourself (for example with
5600
+ Osd.show).
5601
+
4306
5602
  - @capability None to read `theme`; `events` to follow changes; `ui.theme` to change it.
4307
5603
 
4308
5604
  ```ts
@@ -4311,24 +5607,62 @@ interface Appearance {
4311
5607
  readonly theme: ThemeId;
4312
5608
  /**
4313
5609
  * Run code when the palette changes.
5610
+ *
5611
+ * Needs `events`: without it the listener never runs for other plugins'
5612
+ * changes. Views need no help to follow the palette; use this for things
5613
+ * the plugin draws itself (a rendered image, colours it computes). Errors
5614
+ * go to Scope.onError.
4314
5615
  * @capability events
4315
5616
  * @param listener - Receives the new palette.
4316
5617
  * @returns A handle that removes the listener.
5618
+ * @example Redraw a picture the plugin renders when the palette changes
5619
+ * The plugin renders a chart image in the palette's colours and draws it
5620
+ * again when the user or another plugin switches palettes. Views that use
5621
+ * `Theme` follow on their own; this listener (which needs `events`) is
5622
+ * for what the plugin draws itself.
5623
+ * ```ts
5624
+ * plugin.appearance.onChange((theme) => {
5625
+ * void renderChart(theme === "vestra"); // the dark default
5626
+ * });
5627
+ * ```
4317
5628
  */
4318
5629
  onChange(listener: (theme: ThemeId) => void): Disposable;
4319
5630
  /**
4320
5631
  * Switch the palette of every view.
5632
+ *
5633
+ * The player applies it to every open view without interrupting a drag or
5634
+ * an open menu, and saves it across restarts.
4321
5635
  * @capability ui.theme
4322
5636
  * @param theme - The palette to use.
4323
5637
  * @returns Resolves once the player has accepted the change; Appearance.theme has the new value.
4324
5638
  * @throws invalid (rejects) `theme` is not a ThemeId.
4325
5639
  * @throws capability (rejects) The manifest does not declare `ui.theme`.
5640
+ * @example Switch to the light palette
5641
+ * Every view of the player turns light, and stays light after a restart.
5642
+ * The player applies the palette to every open view and saves the
5643
+ * choice; when the promise resolves, `appearance.theme` already has the
5644
+ * new value. Without `ui.theme` it rejects with `capability`.
5645
+ * ```ts
5646
+ * await plugin.appearance.set("vestra-light");
5647
+ * ```
4326
5648
  */
4327
5649
  set(theme: ThemeId): Promise<void>;
4328
- /** The saved interface language: `"system"` (follow the system's languages, the default) or a Locale. */
5650
+ /**
5651
+ * The saved interface language: `"system"` (follow the system's languages, the default) or a Locale.
5652
+ *
5653
+ * This is the choice for the next start. The language of this run is
5654
+ * I18n.locale and never changes while the player runs. Read before
5655
+ * activation; with `events` it follows other plugins' changes, without it
5656
+ * only this plugin's own. Players before 0.4.3 report `"system"`.
5657
+ */
4329
5658
  readonly language: LanguagePreference;
4330
5659
  /**
4331
5660
  * Save the interface language for the next start.
5661
+ *
5662
+ * The player keeps the choice in its interface state; the language
5663
+ * changes when the player starts again (tell the user, for example "Takes
5664
+ * effect after restart"). Every plugin with `events` hears the change in
5665
+ * Appearance.language.
4332
5666
  * @capability ui.theme
4333
5667
  * @player 0.4.3
4334
5668
  * @param language - `"system"` or a Locale.
@@ -4336,17 +5670,45 @@ interface Appearance {
4336
5670
  * @throws invalid (rejects) `language` is not `"system"` or a Locale.
4337
5671
  * @throws capability (rejects) The manifest does not declare `ui.theme`.
4338
5672
  * @throws unsupported (rejects) A player before 0.4.3.
5673
+ * @example Choose German for the next start
5674
+ * The player will start in German next time, and the user is told so.
5675
+ * `setLanguage` only saves the choice: the language of this run
5676
+ * (`i18n.locale`) never changes while the player runs. Players before
5677
+ * 0.4.3 reject it with `unsupported`.
5678
+ * ```ts
5679
+ * await plugin.appearance.setLanguage("de");
5680
+ * await plugin.osd.show("Takes effect after restart"); // translated by the player
5681
+ * ```
4339
5682
  */
4340
5683
  setLanguage(language: LanguagePreference): Promise<void>;
4341
5684
  }
4342
5685
  ```
4343
5686
 
5687
+ Example: Offer the light palette as a menu item
5688
+
5689
+ Settings ▸ Light colours switches every view of the player to the light
5690
+ palette and carries a radio mark while it is in use. `set` asks the
5691
+ player to switch (it saves the choice); `theme` holds the palette in use,
5692
+ and `checked` reads it again after every handler.
5693
+ ```ts
5694
+ plugin.commands.add({
5695
+ id: "light-colours",
5696
+ path: ["@settings", "Light colours"],
5697
+ radio: true, // a round mark instead of a check mark
5698
+ checked: () => plugin.appearance.theme === "vestra-light",
5699
+ run: () => plugin.appearance.set("vestra-light"),
5700
+ });
5701
+ ```
5702
+
4344
5703
  ### Locale
4345
5704
 
4346
5705
  An interface language of the player: English (`en`, the source language
4347
5706
  of every plugin text) or a translation. The list is `LOCALES` in
4348
5707
  `@vestra-player/sdk/testing`.
4349
5708
 
5709
+ Simplified Chinese is `zh-Hans`. There is no Traditional Chinese: a
5710
+ system set to Traditional Chinese (Taiwan, Hong Kong, Macao) gets English.
5711
+
4350
5712
  ```ts
4351
5713
  type Locale = "en" | "zh-Hans" | "ja" | "ko" | "de" | "fr" | "es";
4352
5714
  ```
@@ -4380,6 +5742,18 @@ A text the player translates with the plugin's catalog and fills in:
4380
5742
  English source text with its arguments. Made by I18n.text; plain
4381
5743
  data, not a translation (the plugin never sees the translated string).
4382
5744
 
5745
+ Accepted wherever the player shows a plugin's text: command titles, path
5746
+ segments and reasons, Osd.show and OsdPanel.set (as the
5747
+ text or an argument), the texts of Dialogs.confirm, file dialog
5748
+ titles and `saveFile`'s `fileName`, and property values where Slint
5749
+ expects a `string` (at any depth: a `[string]` model, a struct field).
5750
+ Nested in another text's arguments it is translated too. Requires player
5751
+ 0.4.3. Limits: `TEXT_LIMITS` in `@vestra-player/sdk/testing`.
5752
+
5753
+ The wire form `{ $vestraText, args }` is what the player reads; write it
5754
+ with I18n.text, so that `vestra-plugin i18n extract` finds the
5755
+ source text.
5756
+
4383
5757
  ```ts
4384
5758
  interface Text {
4385
5759
  /** The English source text: a template with `{}`, `{0}` or `{name}`. */
@@ -4445,6 +5819,30 @@ type TextValue = string | Text | TextList;
4445
5819
 
4446
5820
  The interface language of this run. `plugin.i18n`.
4447
5821
 
5822
+ Plugins write no translation code. Every user-visible text is English
5823
+ source text in one of three places, and the player translates it with
5824
+ the plugin's own catalog (`locales/<lang>.po`, compiled into the package
5825
+ by `vestra-plugin build`):
5826
+
5827
+ 1. declarations: command titles and paths, settings page and panel
5828
+ titles, the manifest's `name`, `punchline`, `description` and `tags`;
5829
+ 2. Slint: `@tr("…")` in the plugin's `.slint` files;
5830
+ 3. sentences the plugin hands the player: Osd.show,
5831
+ OsdPanel.set and Dialogs.confirm take a template and
5832
+ its arguments (`osd.show("Speed {}×", [rate])`), never a sentence
5833
+ composed in TypeScript.
5834
+
5835
+ Where words and data cannot be separated that way (a menu item with a
5836
+ number, a `[string]` model, a sentence made of translated parts), the
5837
+ plugin passes a text value (I18n.text, I18n.list; SDK
5838
+ 1.5.11): data the player translates, never a translated string.
5839
+
5840
+ The language is fixed when the player starts: I18n.locale never
5841
+ changes during a run. Read it for what is not text, such as the order of
5842
+ names or a date format. `vestra-plugin i18n extract` collects the texts
5843
+ into `locales/messages.pot`; see the
5844
+ i18n guide.
5845
+
4448
5846
  - @capability None.
4449
5847
 
4450
5848
  ```ts
@@ -4469,6 +5867,27 @@ interface I18n {
4469
5867
  * @param args - Values for its placeholders.
4470
5868
  * @throws invalid A malformed or over-limit value (`TEXT_LIMITS`).
4471
5869
  * @capability None.
5870
+ * @example Fill a drop-down with fixed words and data
5871
+ * The drop-down lists "No Audio" and one "Track 7 · en" entry per track,
5872
+ * in the user's language. A `[string]` model may hold `text()` values: the
5873
+ * player translates each source text with the plugin's catalog and fills
5874
+ * in its arguments, so the plugin never composes translated strings.
5875
+ * ```ts
5876
+ * const { text } = plugin.i18n;
5877
+ * const picker = plugin.windows.create("ui/clip.slint");
5878
+ * const tracks = [{ id: 7, title: "en" }];
5879
+ * // Each entry is translated, then filled in: "Track 7 · en".
5880
+ * picker.set({
5881
+ * labels: [text("No Audio"), ...tracks.map((t) => text("Track {} · {}", [t.id, t.title]))],
5882
+ * });
5883
+ * ```
5884
+ * @example Build a message from translated parts
5885
+ * The OSD says "Download failed: Connection timed out", both parts in the
5886
+ * user's language. An argument made with `text()` is translated too,
5887
+ * while string arguments are shown as they are.
5888
+ * ```ts
5889
+ * await plugin.osd.show("Download failed: {}", [plugin.i18n.text("Connection timed out")]);
5890
+ * ```
4472
5891
  */
4473
5892
  text(source: string, args?: TemplateArgs): Text;
4474
5893
  /**
@@ -4477,6 +5896,15 @@ interface I18n {
4477
5896
  * @param style - `"list"` by default.
4478
5897
  * @throws invalid A malformed or over-limit value.
4479
5898
  * @capability None.
5899
+ * @example List file names the way the language joins them
5900
+ * The OSD says "Removed missing shaders: a.glsl, b.glsl" in English and
5901
+ * joins the names with "、" in Chinese. `list` makes a value the player
5902
+ * joins per interface language; the names themselves are data and stay
5903
+ * as they are.
5904
+ * ```ts
5905
+ * const missing = ["a.glsl", "b.glsl"];
5906
+ * await plugin.osd.show("Removed missing shaders: {}", [plugin.i18n.list(missing)]);
5907
+ * ```
4480
5908
  */
4481
5909
  list(items: readonly TextArg[], style?: ListStyle): TextList;
4482
5910
  /**
@@ -4485,6 +5913,25 @@ interface I18n {
4485
5913
  * @param args - Values for its placeholders.
4486
5914
  * @throws invalid A malformed or over-limit value.
4487
5915
  * @capability None.
5916
+ * @example Fail an open with a message the user can read
5917
+ * Opening a `.ch8` file the plugin cannot load shows "The program file
5918
+ * was not found." in the user's language. The player shows the message of
5919
+ * an error thrown from `resolve`; `i18n.error` makes an `Error` whose text
5920
+ * the player translates with this plugin's catalog, while its `message`
5921
+ * stays English for logs and older players.
5922
+ * ```ts
5923
+ * plugin.resolvers.add({
5924
+ * id: "chip8",
5925
+ * extensions: ["ch8"],
5926
+ * async resolve(path, signal) {
5927
+ * const program = await readFile(path, { signal }).catch(() => {
5928
+ * // Shown as the open's failure, translated (locales/<lang>.po).
5929
+ * throw plugin.i18n.error("The program file was not found.");
5930
+ * });
5931
+ * return startEmulator(program);
5932
+ * },
5933
+ * });
5934
+ * ```
4488
5935
  */
4489
5936
  error(source: string, args?: TemplateArgs): TextError;
4490
5937
  /**
@@ -4502,11 +5949,32 @@ interface I18n {
4502
5949
  * @returns The translated string.
4503
5950
  * @throws invalid A malformed value.
4504
5951
  * @capability None.
5952
+ * @example Copy version information in the user's language
5953
+ * "Copy info" puts two translated lines on the clipboard. Text that
5954
+ * leaves the player cannot be translated by it, so `resolve` turns the
5955
+ * values into the string the player would show, using this plugin's
5956
+ * catalog; inside the player, pass the values themselves instead.
5957
+ * ```ts
5958
+ * const { text, list, resolve } = plugin.i18n;
5959
+ * const lines = list([text("Version {}", ["1.0"]), text("Cores: {}", [8])], "lines");
5960
+ * await plugin.clipboard.writeText(resolve(lines));
5961
+ * ```
4505
5962
  */
4506
5963
  resolve(value: TextValue): string;
4507
5964
  }
4508
5965
  ```
4509
5966
 
5967
+ Example: Format a date the way the interface language writes it
5968
+
5969
+ The OSD shows today's date in the user's language and date format. The
5970
+ sentence is a template the player translates; only the data in it, the
5971
+ date, is formatted by the plugin, with `i18n.locale` (the language the
5972
+ player runs in, fixed for the whole run).
5973
+ ```ts
5974
+ const day = new Date().toLocaleDateString(plugin.i18n.locale);
5975
+ await plugin.osd.show("Today is {}", [day]);
5976
+ ```
5977
+
4510
5978
  ### TextError
4511
5979
 
4512
5980
  An `Error` the player shows translated (I18n.error).
@@ -4532,12 +6000,33 @@ type SettingsCategory = "general" | "playback" | "subtitles" | "video" | "audio"
4532
6000
 
4533
6001
  A page in the player's Options window, made by Settings.page.
4534
6002
 
6003
+ A View like a panel: properties and callbacks live on the file's
6004
+ bridge global, every callback of the global is forwarded, and there is no
6005
+ "bind before show" rule. The page exists from `ready`
6006
+ on, whether or not the Options window is open; property writes while it is
6007
+ closed are kept and applied when it opens. When pages are added or removed
6008
+ while the window is open, the player rebuilds it and restores every page's
6009
+ properties to the values last written, so mirror every user change in the
6010
+ plugin's state (and `bind` it), or it reverts.
6011
+
6012
+ `dispose()`, disposing the scope or stopping the plugin removes the page;
6013
+ if it was showing, the window shows another page.
6014
+
6015
+ Testing: `host.settingsPage(id)` of `@vestra-player/sdk/testing` has the
6016
+ page's `props` and `emit(callback, ...args)`.
6017
+
4535
6018
  - @capability ui.settings
4536
6019
 
4537
6020
  ```ts
4538
6021
  type SettingsPage = View & Disposable & {
4539
6022
  /**
4540
6023
  * Resolves once the player has registered the page; rejects when it refused it.
6024
+ *
6025
+ * The player compiles the page on its own at registration, so a broken
6026
+ * page fails here and does not affect the Options window. Pages added
6027
+ * while `activate` runs must succeed before the plugin is ready: a
6028
+ * rejection fails activation. Later failures also go to
6029
+ * Scope.onError.
4541
6030
  * @throws ui-prepare (rejects) The Slint file does not compile; `message` has the diagnostics.
4542
6031
  * @throws invalid (rejects) The plugin already has 8 pages, or `order` is beyond ±1,000,000.
4543
6032
  * @throws host (rejects) The file is missing or outside the package directory.
@@ -4547,22 +6036,117 @@ type SettingsPage = View & Disposable & {
4547
6036
  };
4548
6037
  ```
4549
6038
 
6039
+ Example: Put two preferences on a page and keep them in a store
6040
+
6041
+ Options ▸ Video ▸ Blur shows two check boxes whose values survive
6042
+ restarts. The checkboxes are bound two-way (`<=>`) to `in-out`
6043
+ properties of the bridge global, so the user's clicks show at once; the
6044
+ `toggled` callbacks tell the plugin, which saves the new value, and
6045
+ `bind` writes the stored values when the page opens.
6046
+ ```ts
6047
+ const store = plugin.storage.json("blur", {
6048
+ defaults: { restoreOnStart: false, messages: true },
6049
+ });
6050
+ const page = plugin.settings.page("ui/settings.slint", {
6051
+ id: "blur",
6052
+ category: "video",
6053
+ title: "Blur",
6054
+ });
6055
+ page.bind(() => ({
6056
+ restoreOnStart: store.value.restoreOnStart,
6057
+ messages: store.value.messages,
6058
+ }));
6059
+ // The bridge's `restore-toggled(bool)` callback, in camelCase.
6060
+ page.on("restoreToggled", (on: boolean) =>
6061
+ store.save({ ...store.value, restoreOnStart: on }),
6062
+ );
6063
+ page.on("messagesToggled", (on: boolean) =>
6064
+ store.save({ ...store.value, messages: on }),
6065
+ );
6066
+ ```
6067
+
6068
+ `ui/settings.slint` exports the bridge global and the layout:
6069
+
6070
+ ```slint
6071
+ import { CheckBox } from "std-widgets.slint";
6072
+ import { Theme } from "@vestra/theme.slint";
6073
+
6074
+ // The bridge: what the plugin writes and the callbacks it handles.
6075
+ export global BlurPage {
6076
+ in-out property <bool> restore-on-start;
6077
+ in-out property <bool> messages: true;
6078
+ callback restore-toggled(bool);
6079
+ callback messages-toggled(bool);
6080
+ }
6081
+
6082
+ export component BlurSettings inherits Rectangle {
6083
+ VerticalLayout {
6084
+ padding: Theme.dialog-padding;
6085
+ spacing: 6px;
6086
+ alignment: start;
6087
+ CheckBox {
6088
+ text: "Turn blur back on when the player starts";
6089
+ checked <=> BlurPage.restore-on-start;
6090
+ toggled => { BlurPage.restore-toggled(self.checked); }
6091
+ }
6092
+ CheckBox {
6093
+ text: "Show on-screen messages";
6094
+ checked <=> BlurPage.messages;
6095
+ toggled => { BlurPage.messages-toggled(self.checked); }
6096
+ }
6097
+ }
6098
+ }
6099
+ ```
6100
+
4550
6101
  ### Settings
4551
6102
 
4552
6103
  Add pages to the player's Options window. `plugin.settings`.
4553
6104
 
6105
+ Use a settings page for preferences that change rarely. The player owns
6106
+ the Options window (640×480 by default, at least 600×420, resizable): a
6107
+ category tree on the left, the selected page on the right, a Close button
6108
+ at the bottom. The standard close keys close it, and command key bindings
6109
+ do not fire while it has focus. Opening it does not affect playback.
6110
+
6111
+ A page file exports **one component and one global**, like a panel: the
6112
+ global (the bridge) declares the properties and callbacks, the component
6113
+ lays the page out and reads them. Pages are 440 logical pixels wide; the
6114
+ window scrolls a taller page.
6115
+
4554
6116
  - @capability ui.settings
4555
6117
 
4556
6118
  ```ts
4557
6119
  interface Settings {
4558
6120
  /**
4559
6121
  * Add a page to the Options window.
6122
+ *
6123
+ * The SDK finds the component and the global from the file's
6124
+ * `export component` and `export global` lines; the file must export one
6125
+ * of each. In the next microtask it registers the page with every property
6126
+ * bound or set so far; `ready` reports the result.
6127
+ *
6128
+ * Within a category, pages are sorted by `order`, then by plugin id and
6129
+ * page id.
4560
6130
  * @capability ui.settings
4561
6131
  * @param file - Path relative to the package directory, for example `"ui/settings.slint"`.
4562
6132
  * @param options - The page's identity and place: `id` (unique in this plugin, 1–64 letters, digits, `.`, `-` or `_`), `category`, `title` (shown in the tree, 1–24 characters) and `order` (an integer within ±1,000,000, default 0; smaller first).
4563
6133
  * @returns A SettingsPage owned by this scope.
4564
6134
  * @throws invalid (throws) A bad `id`, `category`, `title` or `order`, or this plugin already has a page with this `id`.
4565
6135
  * @throws Error without a code (throws) The file does not export exactly one component and one global. A file that cannot be read throws Node's file system error (`code` `ENOENT` and so on).
6136
+ * @example Register a page and report when the player refuses it
6137
+ * An "My plugin" page appears under Plugins, or the reason it could not
6138
+ * be added is logged. `page` returns at once; the player compiles the
6139
+ * page on its own, and `ready` rejects (for example with `ui-prepare`
6140
+ * and the compiler's diagnostics) when it refuses it.
6141
+ * ```ts
6142
+ * const page = plugin.settings.page("ui/advanced.slint", {
6143
+ * id: "advanced",
6144
+ * category: "plugins",
6145
+ * title: "My plugin",
6146
+ * order: 10, // after pages with a smaller order in the category
6147
+ * });
6148
+ * page.ready.catch((error: PluginError) => console.error(error.message));
6149
+ * ```
4566
6150
  */
4567
6151
  page(file: string, options: {
4568
6152
  /** Unique in this plugin: 1–64 letters, digits, `.`, `-` or `_`. */
@@ -4576,6 +6160,10 @@ interface Settings {
4576
6160
  }): SettingsPage;
4577
6161
  /**
4578
6162
  * Open the Options window at one of this plugin's pages, or bring it to the front.
6163
+ *
6164
+ * Without `pageId` it shows the page that is selected; when the window
6165
+ * first opens, that is the page the user saw last (the player remembers
6166
+ * it), else the first one.
4579
6167
  * @capability ui.settings
4580
6168
  * @param pageId - The `id` of one of this plugin's pages.
4581
6169
  * @returns Resolves once the window is shown.
@@ -4583,16 +6171,48 @@ interface Settings {
4583
6171
  * @throws unavailable (rejects) The player runs without a user interface.
4584
6172
  * @throws ui (rejects) The window could not be built (for example a page wrote a property its file does not declare).
4585
6173
  * @throws capability (rejects) The manifest does not declare `ui.settings`.
6174
+ * @example Open the plugin's page from an "Options…" button
6175
+ * The button in the plugin's window opens the Options window at the
6176
+ * plugin's Blur page, or brings it to the front. `show` takes the id the
6177
+ * page was registered with and rejects with `invalid` for any other.
6178
+ * ```ts
6179
+ * const controls = plugin.windows.create("ui/window.slint");
6180
+ * controls.on("openOptions", () => plugin.settings.show("blur"));
6181
+ * ```
4586
6182
  */
4587
6183
  show(pageId?: string): Promise<void>;
4588
6184
  }
4589
6185
  ```
4590
6186
 
6187
+ Example: Add a page that edits a stored setting
6188
+
6189
+ Options ▸ Playback ▸ Resume shows the plugin's Resume switch. `page`
6190
+ registers the page with the Options window (compiled by the player on
6191
+ its own), `bind` sends the stored value, and the page's callback saves
6192
+ the user's change.
6193
+ ```ts
6194
+ const store = plugin.storage.json("settings", { defaults: { resume: true } });
6195
+ const page = plugin.settings.page("ui/settings.slint", {
6196
+ id: "resume",
6197
+ category: "playback",
6198
+ title: "Resume",
6199
+ });
6200
+ page.bind(() => ({ resume: store.value.resume }));
6201
+ // The bridge's `resume-toggled(bool)`: save, and `bind` sends it back.
6202
+ page.on("resumeToggled", (on: boolean) => store.save({ ...store.value, resume: on }));
6203
+ ```
6204
+
4591
6205
  ### BackdropSlot
4592
6206
 
4593
6207
  The backdrop position (`plugin.panels.backdrop`): whether a backdrop is
4594
6208
  mounted and whether it is shown now.
4595
6209
 
6210
+ The player decides when the backdrop shows: while the source is empty or
6211
+ loading, and while ready media has no video picture (audio files). The
6212
+ plugin decides what to draw, from Player.state and
6213
+ Player.stats. With `events`, `state` follows every change; without
6214
+ it, it refreshes only when this plugin mounts.
6215
+
4596
6216
  - @capability ui.mount; `events` to follow changes.
4597
6217
 
4598
6218
  ```ts
@@ -4607,6 +6227,13 @@ interface BackdropSlot {
4607
6227
  * @capability ui.mount and events
4608
6228
  * @param listener - Receives the new state (`null`: no backdrop).
4609
6229
  * @returns A handle that removes the listener.
6230
+ * @example Log when the backdrop shows or hides
6231
+ * The plugin's log records each change of the backdrop position. The
6232
+ * listener runs when the player starts or stops showing the backdrop
6233
+ * and with `null` when it is unmounted.
6234
+ * ```ts
6235
+ * plugin.panels.backdrop.onChange((state) => console.log("backdrop", state));
6236
+ * ```
4610
6237
  */
4611
6238
  onChange(listener: (state: {
4612
6239
  readonly effective: boolean;
@@ -4614,17 +6241,75 @@ interface BackdropSlot {
4614
6241
  }
4615
6242
  ```
4616
6243
 
6244
+ Example: React when a video picture appears
6245
+
6246
+ The OSD says "Video is playing" when the backdrop gives way to a video
6247
+ picture. The player decides when the backdrop shows (nothing open,
6248
+ loading, audio without a picture); `effective` turns false when a video
6249
+ frame replaces it.
6250
+ ```ts
6251
+ plugin.panels.backdrop.onChange((state) => {
6252
+ // `null`: no backdrop is mounted.
6253
+ if (state?.effective === false) void plugin.osd.show("Video is playing");
6254
+ });
6255
+ ```
6256
+
4617
6257
  ### Panels
4618
6258
 
4619
6259
  Put a panel into the player's main window: the bottom bar, the sidebar or
4620
6260
  the backdrop. `plugin.panels`.
4621
6261
 
6262
+ A panel is part of the player's layout: it resizes with the main window,
6263
+ follows fullscreen and auto-hides with the player's own controls. Use one
6264
+ for content that should stay with the video (a playlist, chapters, a
6265
+ custom control bar, cover art); use a tool window for a
6266
+ tool the user opens and closes.
6267
+
6268
+ A panel file exports **one component and one global**. The global is the
6269
+ bridge: the plugin writes its properties and handles its callbacks. The
6270
+ component lays the panel out and reads and calls the global. The split
6271
+ exists because the player composes the component into its own windows: a
6272
+ new instance of the component is made whenever the main window is rebuilt
6273
+ or the sidebar moves to or from its floating window, while the global is
6274
+ one stable address.
6275
+
6276
+ Each plugin mounts at most one panel, and each position holds one panel
6277
+ for all plugins. Panels.bottom and Panels.sidebar describe
6278
+ the positions, whoever mounted there.
6279
+
4622
6280
  - @capability ui.mount
4623
6281
 
4624
6282
  ```ts
4625
6283
  interface Panels {
4626
6284
  /**
4627
6285
  * Mount a Slint component in the bottom bar, the sidebar or the backdrop.
6286
+ *
6287
+ * Returns at once; the panel is on screen when Panel.ready
6288
+ * resolves. Handlers and bindings made right after `mount()` (in the same
6289
+ * synchronous code) are part of the first values.
6290
+ *
6291
+ * - `"bottom"`: under the video, as wide as the video column, as tall as the
6292
+ * component's preferred height. It may draw above its slot into the video
6293
+ * area (negative `y`); the player does not clip it. In fullscreen it
6294
+ * floats over the video and shows only while the pointer is in its band
6295
+ * at the bottom of the screen (hidden 500 ms after the pointer leaves;
6296
+ * Panel.setInteraction locks keep it).
6297
+ * - `"sidebar"`: right of the video, the window's full height, as wide as the
6298
+ * user drags it (at least 317). The user can detach it into a floating
6299
+ * window titled `title`. Several plugins may mount here (player 0.4.x
6300
+ * after 0.4.0): each panel is a tab, labelled `title` (else the plugin's
6301
+ * name), and the player draws a 31px tab strip above the current one
6302
+ * while there are two or more (with one panel, nothing changes).
6303
+ * Showing, hiding and detaching apply to the whole sidebar. If the
6304
+ * bridge global declares `in property <bool> tabbed`, the player sets
6305
+ * it to `true` while it draws the strip, so a panel with a tab strip of
6306
+ * its own can hide it. Panel coordinates (drops) start below the strip.
6307
+ * - `"backdrop"`: the whole video area, under the OSD, while there is no
6308
+ * video picture (see Panels.backdrop). One plugin at a time (as
6309
+ * for the bottom bar, another one's mount rejects with `conflict`); it
6310
+ * takes no pointer or keyboard input. While it is mounted, the player does
6311
+ * not show the cover art of audio files itself (from the next file
6312
+ * opened on), so the plugin can draw it.
4628
6313
  * @capability ui.mount
4629
6314
  * @param slot - Where to mount the panel.
4630
6315
  * @param file - Path relative to the package directory, for example `"ui/playlist.slint"`.
@@ -4632,6 +6317,19 @@ interface Panels {
4632
6317
  * @returns A Panel owned by this scope; await Panel.ready for the result.
4633
6318
  * @throws Error without a code (throws) This plugin already has a panel, or the file exports no component or global, or several and `options` does not name one. A file that cannot be read throws Node's file system error (`code` `ENOENT` and so on).
4634
6319
  * @throws The errors of Panel.ready (rejects `ready`) The player refused the mount.
6320
+ * @example Mount a bottom bar with a seek slider
6321
+ * The plugin's own control bar appears under the video, its slider
6322
+ * following the playback position and seeking when dragged. Bindings and
6323
+ * handlers made right after `mount` are part of the first values;
6324
+ * `ready` resolves once the main window shows the bar, and rejects with
6325
+ * `conflict` when another plugin's panel holds the bottom bar.
6326
+ * ```ts
6327
+ * const bar = plugin.panels.mount("bottom", "ui/bar.slint");
6328
+ * // Sent again whenever the position changes.
6329
+ * bar.bind(() => ({ position: plugin.player.state.position ?? 0 }));
6330
+ * bar.on("seek", (seconds: number) => plugin.player.seek(seconds));
6331
+ * await bar.ready;
6332
+ * ```
4635
6333
  */
4636
6334
  mount(slot: "bottom" | "sidebar" | "backdrop", file: string, options?: {
4637
6335
  /** The exported component to mount, if the file exports several. */
@@ -4650,6 +6348,67 @@ interface Panels {
4650
6348
  }
4651
6349
  ```
4652
6350
 
6351
+ Example: Mount a list in the sidebar (examples/panel-menu)
6352
+
6353
+ The sidebar gets an "Example list" tab showing three rows. `mount`
6354
+ returns at once; the SDK sends the mount with the values bound so far,
6355
+ and the player rebuilds its main window with the panel. The Slint file
6356
+ exports the component and the bridge global whose properties `bind`
6357
+ writes.
6358
+
6359
+ ```ts
6360
+ const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
6361
+ title: "Example list",
6362
+ });
6363
+ // Sent when the panel mounts, then whenever a value changes.
6364
+ panel.bind(() => ({ rows: items, selected }));
6365
+ ```
6366
+
6367
+ `ui/panel.slint`:
6368
+
6369
+ ```slint
6370
+ import { Theme } from "@vestra/theme.slint";
6371
+
6372
+ export global ListBridge {
6373
+ in property <[string]> rows;
6374
+ in property <int> selected;
6375
+ callback select(int);
6376
+ callback context-menu(float, float);
6377
+ }
6378
+
6379
+ export component ListPanel inherits Rectangle {
6380
+ background: Theme.bg-list;
6381
+ min-width: 200px;
6382
+ VerticalLayout {
6383
+ for row[index] in ListBridge.rows: Rectangle {
6384
+ height: 20px;
6385
+ background: index == ListBridge.selected ? Theme.selected-bg : transparent;
6386
+ Text {
6387
+ x: 6px;
6388
+ text: (index + 1) + ". " + row;
6389
+ color: index == ListBridge.selected ? Theme.text : Theme.text-list;
6390
+ font-size: Theme.font-body;
6391
+ vertical-alignment: center;
6392
+ }
6393
+ TouchArea {
6394
+ pointer-event(event) => {
6395
+ if event.kind == PointerEventKind.down {
6396
+ ListBridge.select(index);
6397
+ }
6398
+ if event.kind == PointerEventKind.up && event.button == PointerEventButton.right {
6399
+ ListBridge.context-menu((self.absolute-position.x + self.mouse-x) / 1px,
6400
+ (self.absolute-position.y + self.mouse-y) / 1px);
6401
+ }
6402
+ }
6403
+ }
6404
+ }
6405
+ // Takes the space below the rows. Without it the layout's maximum
6406
+ // height (three 20px rows) would cap the player's main window.
6407
+ Rectangle { vertical-stretch: 1; }
6408
+ }
6409
+ }
6410
+ ```
6411
+
4653
6412
  ### MarkKind
4654
6413
 
4655
6414
  What a timeline mark stands for; readers choose colours by it.
@@ -4683,6 +6442,19 @@ This plugin's own marks; disposing it removes them.
4683
6442
  interface TimelineLayer extends Disposable {
4684
6443
  /**
4685
6444
  * Replace the layer (last write wins; `[]` clears it). At most 1000 marks, titles ≤ 100 characters, times within the media. Failures go to `onError`; with `ifCurrent`, a write for another source is dropped silently. A title is data (a chapter's or bookmark's own name), or a Text the player translates (`text("Chapter {}", [n])`, SDK 1.5.11); other plugins read the shown string.
6445
+ * @example Show the file's bookmarks on the timeline
6446
+ * Bookmark marks appear on the seek bar at their times, titled with the
6447
+ * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
6448
+ * the player drops the write if another file opened since `seen` was
6449
+ * read, so bookmarks of the previous file never land on the new one.
6450
+ * ```ts
6451
+ * const layer = plugin.timeline.layer();
6452
+ * const seen = plugin.player.state; // the file the bookmarks belong to
6453
+ * layer.set(
6454
+ * bookmarks.map((b) => ({ time: b.time, kind: "bookmark", title: b.note })),
6455
+ * { ifCurrent: seen },
6456
+ * );
6457
+ * ```
4686
6458
  */
4687
6459
  set(marks: {
4688
6460
  time: number;
@@ -4791,11 +6563,42 @@ interface MainWindow {
4791
6563
  *
4792
6564
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
4793
6565
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
6566
+ * @example Toggle picture-in-picture where the system has it
6567
+ * The video moves into a floating picture-in-picture window, or back.
6568
+ * `state.pictureInPicture` says whether the system supports it and
6569
+ * whether it is on; the plugin only calls `setPictureInPicture` where it
6570
+ * is supported.
6571
+ * ```ts
6572
+ * if (plugin.window.state.pictureInPicture.supported)
6573
+ * await plugin.window.setPictureInPicture(!plugin.window.state.pictureInPicture.active);
6574
+ * ```
4794
6575
  */
4795
6576
  setPictureInPicture(on: boolean): Promise<void>;
4796
6577
  }
4797
6578
  ```
4798
6579
 
6580
+ Example: Resize the window to the video's own size
6581
+
6582
+ Screen ▸ Original size makes the video area as many logical points as
6583
+ the video has pixels (one pixel per pixel at a display scale of 100 %).
6584
+ `stats()` reports the video's size;
6585
+ `setVideoArea` resizes the window around the video column (bars and a
6586
+ docked sidebar added), scales it down if it would not fit the screen,
6587
+ and is refused in fullscreen, hence the `enabled` check.
6588
+ ```ts
6589
+ plugin.commands.add({
6590
+ id: "size-1x",
6591
+ path: ["Screen", "Original size"],
6592
+ enabled: () => !plugin.window.state.fullscreen,
6593
+ run: async () => {
6594
+ const stats = await plugin.player.stats();
6595
+ // No video stream (an audio file): nothing to size to.
6596
+ if (stats.video?.width && stats.video.height)
6597
+ await plugin.window.setVideoArea({ width: stats.video.width, height: stats.video.height });
6598
+ },
6599
+ });
6600
+ ```
6601
+
4799
6602
  ### AppInfo
4800
6603
 
4801
6604
  `plugin.app`: fixed for the life of the plugin process.
@@ -4830,6 +6633,18 @@ interface App extends AppInfo {
4830
6633
  }
4831
6634
  ```
4832
6635
 
6636
+ Example: Describe the player and the system for a bug report
6637
+
6638
+ The plugin builds a line such as "Vestra 0.5.10 on macOS 26.2" to copy
6639
+ into a bug report. `app.version` is known from the start; each
6640
+ `system()` call asks the player, which reads the displays at that moment.
6641
+ The operating system's name and version it works out once, at the first
6642
+ call, and reuses them.
6643
+ ```ts
6644
+ const system = await plugin.app.system();
6645
+ const text = `Vestra ${plugin.app.version} on ${system.name} ${system.version ?? ""}`;
6646
+ ```
6647
+
4833
6648
  ### SystemInfo
4834
6649
 
4835
6650
  What App.system reports.
@@ -4905,12 +6720,37 @@ interface ConfirmOptions {
4905
6720
  The system's own dialogs: a confirmation alert and the open and save file
4906
6721
  dialogs. `plugin.dialogs`.
4907
6722
 
6723
+ The plugin passes text and gets the answer; the system draws the dialog,
6724
+ so it looks and behaves like every other dialog on the system. Each call
6725
+ resolves when the user answers; there is no time limit and no way to close
6726
+ a dialog from code. Only one dialog (of any kind, from any plugin) is open
6727
+ at a time: another call meanwhile rejects with `busy`. Playback continues
6728
+ while a dialog is open; command key bindings pause.
6729
+
6730
+ **Platforms:** Dialogs.confirm needs macOS or Windows (other
6731
+ systems reject it with `unsupported`); the file dialogs work on every
6732
+ system.
6733
+
6734
+ For anything more than a question or a path, use a tool
6735
+ window.
6736
+
4908
6737
  - @capability ui.dialog
4909
6738
 
4910
6739
  ```ts
4911
6740
  interface Dialogs {
4912
6741
  /**
4913
6742
  * Ask the user to confirm something, in a system alert.
6743
+ *
6744
+ * **macOS:** an `NSAlert` sheet on the active player window (usually the
6745
+ * tool window the user just clicked in), else on the main window.
6746
+ *
6747
+ * **Windows:** a task dialog owned by the active player window, titled
6748
+ * "Vestra Player"; `detail` follows `message` after a blank line. A
6749
+ * `checkbox` is asked as a second yes/no dialog after the user confirms
6750
+ * (on cancel it is not asked and `checked` is `false`).
6751
+ *
6752
+ * Remembering "Don't ask again" is up to the plugin (see
6753
+ * Scope.storage).
4914
6754
  * @capability ui.dialog
4915
6755
  * @param options - The text and buttons; see ConfirmOptions.
4916
6756
  * @returns The answer: `confirmed` is `true` when the user picked the confirm button (Escape, the cancel button or closing the dialog give `false`); `checked` is the checkbox's state (`false` without one).
@@ -4919,6 +6759,23 @@ interface Dialogs {
4919
6759
  * @throws unavailable (rejects) The player runs without a user interface.
4920
6760
  * @throws unsupported (rejects) A system other than macOS and Windows.
4921
6761
  * @throws capability (rejects) The manifest does not declare `ui.dialog`.
6762
+ * @example Confirm before replacing the playlist
6763
+ * The user sees "Replace the current playlist?" with a Replace button and
6764
+ * the playlist is replaced only after a yes. The system draws the alert
6765
+ * and the promise resolves when the user answers; only one dialog is
6766
+ * open at a time, so a second one meanwhile rejects with `busy`.
6767
+ * ```ts
6768
+ * try {
6769
+ * const { confirmed } = await plugin.dialogs.confirm({
6770
+ * message: "Replace the current playlist?",
6771
+ * confirm: "Replace",
6772
+ * });
6773
+ * if (confirmed) await plugin.osd.show("Playlist replaced");
6774
+ * } catch (error) {
6775
+ * if ((error as PluginError).code !== "busy") throw error;
6776
+ * // Another dialog is already open: let the user finish that one.
6777
+ * }
6778
+ * ```
4922
6779
  */
4923
6780
  confirm(options: ConfirmOptions): Promise<{
4924
6781
  /** The user picked the confirm button. */
@@ -4935,6 +6792,14 @@ interface Dialogs {
4935
6792
  * @throws unavailable (rejects) The player runs without a user interface.
4936
6793
  * @throws invalid (rejects) `title` is longer than 256 bytes.
4937
6794
  * @throws capability (rejects) The manifest does not declare `ui.dialog`.
6795
+ * @example Let the user pick a subtitle file
6796
+ * The system's Open dialog appears titled "Choose a subtitle file" and
6797
+ * the chosen path is shown on the OSD. The promise resolves when the
6798
+ * user answers: with the absolute path, or `null` when they cancelled.
6799
+ * ```ts
6800
+ * const path = await plugin.dialogs.openFile({ title: "Choose a subtitle file" });
6801
+ * if (path !== null) await plugin.osd.show("Chosen: {}", [path]); // a path is data
6802
+ * ```
4938
6803
  */
4939
6804
  openFile(options?: {
4940
6805
  /** The dialog's title (English source text or a Text), at most 256 bytes. */
@@ -4949,6 +6814,14 @@ interface Dialogs {
4949
6814
  * @throws unavailable (rejects) The player runs without a user interface.
4950
6815
  * @throws invalid (rejects) `title` is longer than 256 bytes.
4951
6816
  * @throws capability (rejects) The manifest does not declare `ui.dialog`.
6817
+ * @example Let the user pick several files at once
6818
+ * The Open dialog allows selecting several files; the OSD says how many
6819
+ * were added. The promise resolves with the absolute paths, or with an
6820
+ * empty array when the user cancelled.
6821
+ * ```ts
6822
+ * const paths = await plugin.dialogs.openFiles({ title: "Add to the playlist" });
6823
+ * if (paths.length) await plugin.osd.show("{} files added", [paths.length]);
6824
+ * ```
4952
6825
  */
4953
6826
  openFiles(options?: {
4954
6827
  /** The dialog's title (English source text or a Text), at most 256 bytes. */
@@ -4963,6 +6836,14 @@ interface Dialogs {
4963
6836
  * @throws unavailable (rejects) The player runs without a user interface.
4964
6837
  * @throws invalid (rejects) `title` is longer than 256 bytes.
4965
6838
  * @throws capability (rejects) The manifest does not declare `ui.dialog`.
6839
+ * @example Let the user choose a folder for snapshots
6840
+ * The system's folder dialog appears titled "Save snapshots in". It
6841
+ * resolves with the folder's absolute path, or `null` when the user
6842
+ * cancelled, in which case the plugin stops there.
6843
+ * ```ts
6844
+ * const folder = await plugin.dialogs.openDirectory({ title: "Save snapshots in" });
6845
+ * if (folder === null) return; // cancelled
6846
+ * ```
4966
6847
  */
4967
6848
  openDirectory(options?: {
4968
6849
  /** The dialog's title (English source text or a Text), at most 256 bytes. */
@@ -4970,6 +6851,8 @@ interface Dialogs {
4970
6851
  }): Promise<string | null>;
4971
6852
  /**
4972
6853
  * Ask the user where to save a file.
6854
+ *
6855
+ * The dialog only returns a path; writing the file is up to the plugin.
4973
6856
  * @capability ui.dialog
4974
6857
  * @param options - `title`: the dialog's title, at most 256 bytes. `fileName`: the proposed file name (a plain name without `/` or `\`, at most 256 bytes).
4975
6858
  * @returns The absolute path, or `null` when the user cancelled.
@@ -4977,6 +6860,14 @@ interface Dialogs {
4977
6860
  * @throws unavailable (rejects) The player runs without a user interface.
4978
6861
  * @throws invalid (rejects) `title` is too long, or `fileName` is too long or contains `/` or `\`.
4979
6862
  * @throws capability (rejects) The manifest does not declare `ui.dialog`.
6863
+ * @example Ask where to save a snapshot
6864
+ * The system's Save dialog proposes "frame.png" and returns where the
6865
+ * user wants it. The dialog only returns the path (or `null` when
6866
+ * cancelled); writing the file is up to the plugin afterwards.
6867
+ * ```ts
6868
+ * const path = await plugin.dialogs.saveFile({ title: "Save snapshot", fileName: "frame.png" });
6869
+ * if (path === null) return; // cancelled
6870
+ * ```
4980
6871
  */
4981
6872
  saveFile(options?: {
4982
6873
  /** The dialog's title (English source text or a Text), at most 256 bytes. */
@@ -4989,6 +6880,32 @@ interface Dialogs {
4989
6880
  }
4990
6881
  ```
4991
6882
 
6883
+ Example: Ask before deleting, remembering "Don't ask again"
6884
+
6885
+ Deleting asks for confirmation in a system alert with a "Don't ask
6886
+ again" box; once the user ticks it, later deletes go ahead without
6887
+ asking. `confirm` resolves with the button and the checkbox when the
6888
+ user answers; the texts are templates the player translates, so the
6889
+ count goes in `args`. Remembering the box is up to the plugin.
6890
+ ```ts
6891
+ const store = plugin.storage.json("confirm", { defaults: { askBeforeDelete: true } });
6892
+ async function deleteAll(count: number) {
6893
+ if (store.value.askBeforeDelete) {
6894
+ const { confirmed, checked } = await plugin.dialogs.confirm({
6895
+ message: "Delete {} bookmarks?",
6896
+ args: [count],
6897
+ detail: "This cannot be undone.",
6898
+ confirm: "Delete",
6899
+ destructive: true,
6900
+ checkbox: "Don't ask again",
6901
+ });
6902
+ if (!confirmed) return;
6903
+ if (checked) store.save({ askBeforeDelete: false });
6904
+ }
6905
+ // … delete …
6906
+ }
6907
+ ```
6908
+
4992
6909
  ### AudioTrack
4993
6910
 
4994
6911
  One audio track of the current media (PlayerState.audioTracks).
@@ -5133,6 +7050,23 @@ interface Conditional {
5133
7050
  }
5134
7051
  ```
5135
7052
 
7053
+ Example: Play the next file only if nothing else was opened
7054
+
7055
+ When the plugin's file ends, the next one starts, unless the user or
7056
+ another plugin opened something in the meantime. `onEnd` passes the
7057
+ state at the end; `open` with `ifCurrent: ended` is refused with `stale`
7058
+ when the source changed after that state, so nothing is replaced.
7059
+ ```ts
7060
+ media.onEnd(async (ended) => {
7061
+ try {
7062
+ await plugin.player.open(next, { ifCurrent: ended });
7063
+ } catch (error) {
7064
+ // Someone opened another file: leave it playing.
7065
+ if ((error as PluginError).code !== "stale") throw error;
7066
+ }
7067
+ });
7068
+ ```
7069
+
5136
7070
  ### Media
5137
7071
 
5138
7072
  A source this plugin opened.
@@ -5179,6 +7113,15 @@ interface Scrub {
5179
7113
  * the target of a keyframe seek is always sent. Omitted or `false`: exact,
5180
7114
  * as before. Older players ignore the option and seek exactly: set
5181
7115
  * `"minPlayerVersion": "0.5.3"` when you rely on it.
7116
+ * @example Scrub on keyframes for a fast picture
7117
+ * While dragging, the picture jumps to the nearest earlier keyframe at
7118
+ * once instead of decoding up to the exact frame. With `keyframes: true`
7119
+ * the player lands each target on the last keyframe at or before it; the
7120
+ * plugin follows the user's keyframe-seek setting.
7121
+ * ```ts
7122
+ * const keyframes = true; // the plugin's own setting
7123
+ * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
7124
+ * ```
5182
7125
  */
5183
7126
  seek(seconds: number, options?: {
5184
7127
  keyframes?: boolean;
@@ -5190,6 +7133,21 @@ interface Scrub {
5190
7133
  }
5191
7134
  ```
5192
7135
 
7136
+ Example: Seek while the user drags a slider
7137
+
7138
+ The video follows the slider while it is dragged. `scrub()` starts a
7139
+ gesture from the current state; each `seek` is conditional on the
7140
+ previous one and only the newest target is sent while one is running,
7141
+ so a fast drag does not queue seeks. A change from outside (another file,
7142
+ the user pausing) ends the gesture and later seeks resolve `false`.
7143
+ ```ts
7144
+ let scrub: Scrub | null = null;
7145
+ bar.on("dragStart", () => (scrub = plugin.player.scrub()));
7146
+ // Latest value wins: superseded targets resolve false, not an error.
7147
+ bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
7148
+ bar.on("dragEnd", () => scrub?.end());
7149
+ ```
7150
+
5193
7151
  ### StreamFrame
5194
7152
 
5195
7153
  Frames a stream supplies on demand: RGBA pixels and 48 kHz stereo f32 PCM.
@@ -5414,6 +7372,24 @@ interface Player {
5414
7372
  * TCP with a token on Windows. Rejects with a StreamStartError
5415
7373
  * (`timeout`), `stream`, `device`, or `replaced` when another source
5416
7374
  * replaced it (or it was closed) before it ran.
7375
+ * @example Play pictures the plugin draws itself
7376
+ * The player shows a 320×240 picture the plugin renders, with silence as
7377
+ * its sound, as the current source. The SDK pulls frames on the audio
7378
+ * clock: each `frame()` returns one picture (RGBA bytes) and the audio
7379
+ * that plays with it, here 1/60 s, so the picture runs at 60 frames a
7380
+ * second; `frame()` may run several times back to back to refill the
7381
+ * lead. `onStop` tells the plugin when and why the stream ended.
7382
+ * ```ts
7383
+ * const frame = new Uint8Array(320 * 240 * 4); // RGBA, reused per frame
7384
+ * const silence = new Float32Array(2 * 800); // 1/60 s of stereo at 48 kHz
7385
+ * const stream = await plugin.player.openStream({
7386
+ * width: 320,
7387
+ * height: 240,
7388
+ * frame: () => ({ video: render(frame), audio: silence }),
7389
+ * });
7390
+ * // `error` is null when the stream stopped normally.
7391
+ * stream.onStop((error) => error && plugin.osd.show(error.message));
7392
+ * ```
5417
7393
  */
5418
7394
  openStream(options: StreamOptions): Promise<Stream>;
5419
7395
  /** Pause or resume. */
@@ -5433,7 +7409,28 @@ interface Player {
5433
7409
  volume?: number;
5434
7410
  muted?: boolean;
5435
7411
  }): Promise<boolean>;
5436
- /** Become the sequence provider (one at a time; another provider rejects with `conflict` through the scope's `onError`, or fails activation). Dispose to stop. */
7412
+ /**
7413
+ * Become the sequence provider (one at a time; another provider rejects with `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
7414
+ * @example Drive the player's Next and Previous from a playlist
7415
+ * The player's Next and Previous items and keys play the plugin's list.
7416
+ * The player calls `next` and `previous` when the user asks; `canNext`
7417
+ * and `canPrevious` are evaluated again after every handler the SDK runs,
7418
+ * and while they return false the player disables its items ("No next
7419
+ * item").
7420
+ * ```ts
7421
+ * const playAt = async (at: number) => {
7422
+ * index = at;
7423
+ * await plugin.player.open(items[at]!);
7424
+ * };
7425
+ * // Called by the player's Next / Previous items, keys and buttons.
7426
+ * plugin.player.provideSequence({
7427
+ * next: () => playAt(index + 1),
7428
+ * previous: () => playAt(index - 1),
7429
+ * canNext: () => index + 1 < items.length,
7430
+ * canPrevious: () => index > 0,
7431
+ * });
7432
+ * ```
7433
+ */
5437
7434
  provideSequence(provider: SequenceProvider): Disposable;
5438
7435
  /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
5439
7436
  next(): Promise<void>;
@@ -5514,6 +7511,22 @@ interface Player {
5514
7511
  }
5515
7512
  ```
5516
7513
 
7514
+ Example: Open a file and wait until it plays
7515
+
7516
+ The file opens and the plugin learns whether it started playing within
7517
+ ten seconds. `open` resolves once the player accepted the load and
7518
+ returns a `Media` handle; `ready` waits for playback and rejects with
7519
+ `failed` (load error), `replaced` (another source took over) or an
7520
+ `AbortError` when the timeout fires, which closes the media.
7521
+ ```ts
7522
+ const media = await plugin.player.open("/Movies/clip.mp4"); // load accepted
7523
+ try {
7524
+ await media.ready({ signal: AbortSignal.timeout(10_000) });
7525
+ } catch (error) {
7526
+ void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
7527
+ }
7528
+ ```
7529
+
5517
7530
  ### HostAction
5518
7531
 
5519
7532
  The core's basic playback actions (menu commands owned by the player),
@@ -5554,6 +7567,21 @@ interface Host {
5554
7567
  * `invalid` for an unknown id. Needs `commands`.
5555
7568
  *
5556
7569
  * Prefer this over reimplementing playback buttons.
7570
+ * @example Make a bar button play and pause like Space
7571
+ * The plugin's own Play button behaves exactly like the player's Space
7572
+ * key, including its on-screen text. `host.run` runs the core action
7573
+ * with the player's checks; it resolves when the action finished, and
7574
+ * rejects with `unavailable` while the action is disabled (nothing open).
7575
+ * ```ts
7576
+ * bar.on("playPause", async () => {
7577
+ * try {
7578
+ * await plugin.host.run("play-pause");
7579
+ * } catch (error) {
7580
+ * if ((error as PluginError).code !== "unavailable") throw error;
7581
+ * // Nothing to play: the button simply does nothing.
7582
+ * }
7583
+ * });
7584
+ * ```
5557
7585
  */
5558
7586
  run(action: HostAction): Promise<void>;
5559
7587
  /**
@@ -5569,6 +7597,14 @@ interface Host {
5569
7597
  * once the seek is submitted, as before. `invalid` for `seconds` out of
5570
7598
  * range.
5571
7599
  * @player 0.5.1 (set `"minPlayerVersion": "0.5.1"`: older players ignore `seconds` and seek by the `setStepSeek` step)
7600
+ * @example Add a 30-second skip that adds up with the arrow keys
7601
+ * The plugin's "+30 s" button seeks forward 30 seconds and shows the same
7602
+ * text as →. Passing `seconds` makes the core step seek use that step
7603
+ * with the player's mode (exact or keyframes); quick presses of it and of
7604
+ * → add up into one seek.
7605
+ * ```ts
7606
+ * bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
7607
+ * ```
5572
7608
  */
5573
7609
  run(action: "seek-backward" | "seek-forward", options: {
5574
7610
  seconds: number;
@@ -5582,21 +7618,55 @@ This plugin's information block on the OSD: multi-line text over the
5582
7618
  video that stays until the plugin changes or removes it. Made by
5583
7619
  Osd.panel.
5584
7620
 
7621
+ The block sits under the OSD message line, on a translucent dark plate
7622
+ (see Osd for sizes), and takes no input. When
7623
+ several plugins have one, they are stacked from top to bottom. `dispose()`,
7624
+ disposing the scope or stopping the plugin removes it.
7625
+
5585
7626
  - @capability ui.osd
5586
7627
 
5587
7628
  ```ts
5588
7629
  interface OsdPanel extends Disposable {
5589
7630
  /**
5590
7631
  * Show the block with this text, change it, or hide it.
7632
+ *
7633
+ * Latest value wins: calling it on every state change is fine, the same
7634
+ * text is not sent twice and only the newest text is sent while a write is
7635
+ * in flight. Never throws; a failed write goes to Scope.onError
7636
+ * (`invalid` for too much text, `unavailable` without a user interface,
7637
+ * `capability` without `ui.osd`).
5591
7638
  * @capability ui.osd
5592
7639
  * @param text - The text, with `\n` between lines: at most 32 lines and 4096 bytes once translated and formatted. `null` hides the block. A template, like Osd.show's.
5593
7640
  * @param args - Values for its placeholders (player 0.4.3); `null` or omitted for none.
5594
7641
  * @param options - `maxColumns`: cut long lines (SDK 1.5.11).
7642
+ * @example Show two loop points, then hide the block
7643
+ * The block shows "A: 12.0 s" and "B: 18.5 s" on two lines, then
7644
+ * disappears. The text is a template the player translates and fills
7645
+ * in; `\n` separates lines, and `null` hides the block.
7646
+ * ```ts
7647
+ * const info = plugin.osd.panel();
7648
+ * info.set("A: {} s\nB: {} s", ["12.0", "18.5"]);
7649
+ * info.set(null); // hide it
7650
+ * ```
5595
7651
  */
5596
7652
  set(text: TextValue | null, args?: TemplateArgs | null, options?: OsdPanelOptions): void;
5597
7653
  }
5598
7654
  ```
5599
7655
 
7656
+ Example: Show live playback numbers over the video
7657
+
7658
+ A block over the video shows the position and the duration, updated
7659
+ as playback moves. `player.onChange` runs after the SDK updated the
7660
+ state (with `events`); `set` never throws and sends only the newest
7661
+ text while one is in flight, so calling it on every change is fine.
7662
+ ```ts
7663
+ const info = plugin.osd.panel();
7664
+ plugin.player.onChange((state) =>
7665
+ // A template with data arguments; the player translates "{} / {} s".
7666
+ info.set("{} / {} s", [state.position?.toFixed(1) ?? "–", state.duration?.toFixed(1) ?? "–"]),
7667
+ );
7668
+ ```
7669
+
5600
7670
  ### OsdPanelOptions
5601
7671
 
5602
7672
  Options of OsdPanel.set.
@@ -5615,12 +7685,50 @@ interface OsdPanelOptions {
5615
7685
  Text the player draws over the video: short messages and an information
5616
7686
  block. `plugin.osd`.
5617
7687
 
7688
+ The on-screen display (OSD) is the lightest way to tell the user something:
7689
+ no Slint file, no window, visible in fullscreen. A message
7690
+ (Osd.show) is one line at the top left of the video that
7691
+ disappears after 2 seconds; a newer message, from any plugin, replaces it.
7692
+ The information block (Osd.panel) stays until the plugin changes
7693
+ or removes it.
7694
+
7695
+ How the player draws them (a player before 0.4.0 drew bold white text
7696
+ with a black outline instead):
7697
+
7698
+ - **Message**: white text on a translucent dark plate with rounded
7699
+ corners and a gradient line along its bottom edge, 10px from the
7700
+ video area's left and top edges.
7701
+ - **Font size** follows the video area: 3 % of its height, rounded and
7702
+ clamped to 12–48px (about 18px in the default window, 32px in
7703
+ fullscreen at 1080 pixels high). The plate's padding and corners scale
7704
+ with it.
7705
+ - **Information block**: 4px below the message plate, on the same kind of
7706
+ plate; its font scales in proportion (11px when the message is 16px),
7707
+ clamped to 9–28px.
7708
+
7709
+ How long a text may be is in
7710
+ Limits: on-screen display
7711
+ (OSD_LIMITS).
7712
+
5618
7713
  - @capability ui.osd
5619
7714
 
5620
7715
  ```ts
5621
7716
  interface Osd {
5622
7717
  /**
5623
7718
  * Show a one-line message at the top left of the video for 2 seconds.
7719
+ *
7720
+ * Replaces the message on screen, whichever plugin showed it. A message
7721
+ * that is on screen when the plugin stops still disappears on time.
7722
+ *
7723
+ * The text is a template in English: the player shows its translation
7724
+ * from the plugin's catalog, with the placeholders (`{}`, `{0}`, `{name}`;
7725
+ * `{{` and `}}` for braces) filled from `args`. Without `args` the text
7726
+ * shows as it is (braces included). Untranslated, the English template is
7727
+ * formatted. Never compose the sentence in TypeScript
7728
+ * (`` `Speed ${rate}×` ``): translators can only translate whole templates.
7729
+ * An argument that is itself words is a Text (SDK 1.5.11), and a
7730
+ * message made of a varying number of parts is a TextList of
7731
+ * texts; `text` may also be a `Text` (then without `args`).
5624
7732
  * @capability ui.osd
5625
7733
  * @param text - The template, 1–200 characters once translated and formatted; line breaks become spaces.
5626
7734
  * @param args - Values for its placeholders (player 0.4.3; older players show the template as it is).
@@ -5628,18 +7736,51 @@ interface Osd {
5628
7736
  * @throws invalid (rejects) The shown text is empty, only white space, or longer than 200 characters, or `args` is not a TemplateArgs.
5629
7737
  * @throws unavailable (rejects) The player runs without a user interface.
5630
7738
  * @throws capability (rejects) The manifest does not declare `ui.osd`.
7739
+ * @example Confirm an action and show a value
7740
+ * The OSD shows "Bookmark added", then "Speed 1.5×" in the user's
7741
+ * language. The text is English source the player translates with the
7742
+ * plugin's catalog; `{}` is filled in with the argument, which stays as
7743
+ * it is because it is data.
7744
+ * ```ts
7745
+ * await plugin.osd.show("Bookmark added");
7746
+ * await plugin.osd.show("Speed {}×", [plugin.player.state.rate]);
7747
+ * ```
5631
7748
  */
5632
7749
  show(text: TextValue, args?: TemplateArgs): Promise<void>;
5633
7750
  /**
5634
7751
  * Create this plugin's information block.
7752
+ *
7753
+ * Nothing is shown until OsdPanel.set. A plugin has one block;
7754
+ * dispose it before creating another.
5635
7755
  * @capability ui.osd
5636
7756
  * @returns An OsdPanel owned by this scope.
5637
7757
  * @throws Error without a code (throws) This plugin already has a block.
7758
+ * @example Show a status block over the video
7759
+ * A small block over the video says "Loop: off" until the plugin changes
7760
+ * or hides it. Each plugin has one block, stacked with other plugins'
7761
+ * blocks; a second `panel()` call throws.
7762
+ * ```ts
7763
+ * const info = plugin.osd.panel();
7764
+ * info.set("Loop: off");
7765
+ * ```
5638
7766
  */
5639
7767
  panel(): OsdPanel;
5640
7768
  }
5641
7769
  ```
5642
7770
 
7771
+ Example: Show on-screen messages the user can turn off
7772
+
7773
+ "Blur on" appears over the video unless the user turned messages off in
7774
+ the plugin's settings. `osd.show` asks the player to draw the text for a
7775
+ moment; the plugin checks its own stored preference before each call.
7776
+ ```ts
7777
+ const store = plugin.storage.json("prefs", { defaults: { messages: true } });
7778
+ const say = (text: string) => {
7779
+ if (store.value.messages) void plugin.osd.show(text);
7780
+ };
7781
+ say("Blur on");
7782
+ ```
7783
+
5643
7784
  ### FilterStatus
5644
7785
 
5645
7786
  The state of a filter, display layer or render options handle.
@@ -5698,6 +7839,19 @@ same chain applies to processed snapshots and exports.
5698
7839
  type VideoFilter = Filter<VideoNode[]>;
5699
7840
  ```
5700
7841
 
7842
+ Example: Brighten the picture and report when it fails
7843
+
7844
+ Every video plays a little brighter with more contrast, and the OSD
7845
+ says why when the player cannot apply it. `set` records the desired
7846
+ chain (last write wins, one request in flight); the player reapplies it
7847
+ for each new source, and `onStatus` reports `applied` or `failed`.
7848
+ ```ts
7849
+ const picture = plugin.video.filter("picture");
7850
+ // `unsupported` while a generated stream plays: its frames skip filters.
7851
+ picture.onStatus((s) => s.state === "failed" && plugin.osd.show(s.error.message));
7852
+ picture.set([{ kind: "eq", brightness: 0.1, contrast: 1.1, saturation: 1, gamma: 1 }]);
7853
+ ```
7854
+
5701
7855
  ### AudioFilter
5702
7856
 
5703
7857
  This plugin's own audio chain plus its audio delay in 10 ms steps.
@@ -5717,6 +7871,16 @@ type AudioFilter = Filter<{
5717
7871
  }>;
5718
7872
  ```
5719
7873
 
7874
+ Example: Lower the volume and delay the sound
7875
+
7876
+ Every file plays 6 dB quieter, with its sound 120 ms later to match a
7877
+ slow display. `set` sends the plugin's whole chain and delay; the
7878
+ player applies it to each source and adds the delays of all plugins.
7879
+ ```ts
7880
+ const audio = plugin.audio.filter("sound");
7881
+ audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 }); // 10 ms steps
7882
+ ```
7883
+
5720
7884
  ### ViewSettings
5721
7885
 
5722
7886
  How the video sits in the video area; not part of snapshots or exports.
@@ -5759,6 +7923,18 @@ interface VideoView extends Disposable {
5759
7923
  }
5760
7924
  ```
5761
7925
 
7926
+ Example: Fill the window, then zoom in
7927
+
7928
+ The video fills the video area (cropped instead of letterboxed), then
7929
+ zooms to 150 % shifted a little to the right. The first plugin to call
7930
+ `view()` owns the display layer; each `set` merges into its settings,
7931
+ which stay across sources until the plugin resets them or stops.
7932
+ ```ts
7933
+ const view = plugin.video.view();
7934
+ view.set({ fit: "fill" });
7935
+ view.set({ zoom: 1.5, panX: 0.1 }); // merged with the previous write
7936
+ ```
7937
+
5762
7938
  ### RenderSettings
5763
7939
 
5764
7940
  GPU renderer options; not part of snapshots or exports.
@@ -5801,6 +7977,21 @@ interface RenderOptions extends Disposable {
5801
7977
  }
5802
7978
  ```
5803
7979
 
7980
+ Example: Tone-map HDR video and hide the menu where it cannot work
7981
+
7982
+ On a GPU-rendering player HDR video is tone-mapped with BT.2390 for a
7983
+ 250-nit display; under software rendering the plugin hides its HDR menu.
7984
+ `set` merges the values into the plugin's render options and the
7985
+ player reports the result through `onStatus`: `failed` with
7986
+ `unsupported` means the player renders in software (Windows, Linux).
7987
+ ```ts
7988
+ const render = plugin.video.render();
7989
+ render.onStatus((s) => {
7990
+ if (s.state === "failed" && s.error.code === "unsupported") hideHdrMenu();
7991
+ });
7992
+ render.set({ toneMapping: "bt.2390", targetPeak: 250 }); // nits
7993
+ ```
7994
+
5804
7995
  ### SubtitleTrack
5805
7996
 
5806
7997
  A subtitle track of the current source.
@@ -5855,7 +8046,7 @@ interface SubtitlesState {
5855
8046
  /** Font family name for display. */
5856
8047
  family: string;
5857
8048
  }[];
5858
- /** The effective style, from whichever plugin set it last. */
8049
+ /** The effective style: among the plugins that set a layer, the one whose manifest id sorts last (not the most recent call); `null` without one. */
5859
8050
  readonly style: SubtitleStyle | null;
5860
8051
  /** Whether the renderer confirmed the selection. */
5861
8052
  readonly trackConfirmation: "waiting" | "applied" | "uncertain";
@@ -5918,11 +8109,36 @@ interface Subtitles {
5918
8109
  }): Promise<SubtitleTrack>;
5919
8110
  /** Remove a track this plugin loaded. */
5920
8111
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
5921
- /** This plugin's style layer; `null` removes it. Last write wins. */
8112
+ /**
8113
+ * Set this plugin's style layer; `null` removes it. The newest call of this plugin replaces its earlier layer. When several plugins have a layer, the player uses the one whose manifest id sorts last, whatever the order of the calls.
8114
+ */
5922
8115
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
5923
8116
  }
5924
8117
  ```
5925
8118
 
8119
+ Example: Load an SRT file as the secondary subtitle
8120
+
8121
+ An English SRT file shows as a second subtitle line next to the
8122
+ film's own subtitles, in white with an outline. The plugin reads the
8123
+ file and uploads its bytes with `load`, which adds a track to the
8124
+ current source and selects it as the secondary subtitle; `refresh`
8125
+ reads the subtitle state for its font list, and `setStyle` sends this
8126
+ plugin's style layer naming one of those fonts.
8127
+ ```ts
8128
+ const bytes = await readFile(path);
8129
+ await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
8130
+ const { fonts } = await plugin.subtitles.refresh();
8131
+ if (!fonts.length) return;
8132
+ await plugin.subtitles.setStyle({
8133
+ fontId: fonts[0]!.id,
8134
+ fontSize: 40, // at the 1080p reference height
8135
+ color: "white",
8136
+ outline: 2,
8137
+ marginPercent: 5,
8138
+ offsetMs: 0,
8139
+ });
8140
+ ```
8141
+
5926
8142
  ### CaptureOptions
5927
8143
 
5928
8144
  Options of MediaCapture.capture. Conditional on `player.state`
@@ -6053,6 +8269,28 @@ interface MediaCapture {
6053
8269
  }
6054
8270
  ```
6055
8271
 
8272
+ Example: Save the current frame as JPEG
8273
+
8274
+ The frame on screen, with the plugin's own picture effects, is saved
8275
+ where the user chooses. `capture` freezes the source as it was in
8276
+ `seen` (and fails with `stale` if the user opened another file while
8277
+ the dialog was open); `export` decodes offline without disturbing
8278
+ playback, and `done` resolves with the job's final status.
8279
+ ```ts
8280
+ const seen = plugin.player.state;
8281
+ const destination = await plugin.dialogs.saveFile({ fileName: "frame.jpg" });
8282
+ if (!destination) return;
8283
+ const snapshot = await plugin.media.capture({ ifCurrent: seen, processed: true });
8284
+ // Release the frozen frame whatever happens to the export.
8285
+ try {
8286
+ const job = await snapshot.export({ format: "jpeg", destination, quality: 85 });
8287
+ const end = await job.done;
8288
+ if (end.status !== "completed") throw new Error(`export ${end.status}`);
8289
+ } finally {
8290
+ await snapshot.dispose();
8291
+ }
8292
+ ```
8293
+
6056
8294
  ### ResolverOptions
6057
8295
 
6058
8296
  Declaration of Resolvers.add: a web resolver
@@ -6087,6 +8325,24 @@ playlist, next / previous, the recent list, a drop that calls `open`),
6087
8325
  the player asks this plugin for a stream it generates instead of
6088
8326
  playing the file. Needs `source.resolve` and `producer.av`.
6089
8327
 
8328
+ The open keeps everything of the original call: its `ifCurrent`, its
8329
+ operation (the opener's `open` and `media.ready()` settle as for any
8330
+ file), `paused` (the stream starts paused), and its path as
8331
+ `state.source`, with `state.kind` `"stream"`. A thrown error fails that
8332
+ open: `phase` `"error"` with the error's message as `state.error`,
8333
+ word for word. The stream belongs to this plugin, as with
8334
+ Player.openStream.
8335
+
8336
+ Cancellation: `signal` aborts when the open is replaced, closed
8337
+ (`player.close()`, F4) or times out (30 seconds), or this resolver is
8338
+ disposed. Cancelled after `resolve` returned but before the stream ran,
8339
+ the SDK releases the stream and never calls `started`.
8340
+
8341
+ One extension has one resolver: another plugin's resolver for it makes
8342
+ Resolver.ready reject with `conflict` without failing activation
8343
+ (the player shows a warning). When this plugin stops, its extensions are
8344
+ free again and such files go to the player as before.
8345
+
6090
8346
  ```ts
6091
8347
  interface FileResolverOptions {
6092
8348
  /** Local id, unique within the plugin. */
@@ -6100,6 +8356,32 @@ interface FileResolverOptions {
6100
8356
  }
6101
8357
  ```
6102
8358
 
8359
+ Example: Play CHIP-8 programs with the plugin's own emulator
8360
+
8361
+ Opening a `.ch8` file anywhere in the player (Open dialog, playlist,
8362
+ recent list) runs it in the plugin's emulator instead of failing as an
8363
+ unknown format. The player calls `resolve` with the path; the plugin
8364
+ reads the program and returns a stream whose `frame()` the SDK pulls on
8365
+ the audio clock, one 1/60 s frame per call. `started` runs once the
8366
+ stream plays; `signal` aborts the read when the open is replaced or
8367
+ closed.
8368
+ ```ts
8369
+ plugin.resolvers.add({
8370
+ id: "chip8",
8371
+ extensions: ["ch8"], // lower case, no dot; matches Game.CH8 too
8372
+ async resolve(path, signal) {
8373
+ const frame = emulator(await readFile(path, { signal }));
8374
+ return {
8375
+ kind: "stream",
8376
+ width: 64, // CHIP-8's display
8377
+ height: 32,
8378
+ frame,
8379
+ started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
8380
+ };
8381
+ },
8382
+ });
8383
+ ```
8384
+
6103
8385
  ### ResolvedStream
6104
8386
 
6105
8387
  What a file resolver returns: a stream, with the same frame contract as
@@ -6159,6 +8441,18 @@ interface Resolvers {
6159
8441
  * The extensions some plugin's file resolver handles (SDK 1.5.8, player 0.4.2): lower case, without the dot, sorted, each once; not who registered them. Reading needs no capability; `[]` from older players. Without `events` it is the list from when the plugin first read it. Use it to accept such files beside the player's own media types, for example in a playlist's drop or folder scan.
6160
8442
  * @capability None.
6161
8443
  * @player 0.4.2
8444
+ * @example Accept files that a resolver plugin can play
8445
+ * A plugin's own file filter also accepts types other plugins turn into
8446
+ * streams. `fileTypes` lists the extensions every running file resolver
8447
+ * claims (lower case, no dot); the SDK keeps it current with `events`.
8448
+ * ```ts
8449
+ * const MEDIA = ["mp4", "mkv", "mp3"];
8450
+ * // Extensions compare in lower case, without the dot.
8451
+ * const accepted = (path: string) => {
8452
+ * const extension = path.split(".").pop()?.toLowerCase() ?? "";
8453
+ * return MEDIA.includes(extension) || plugin.resolvers.fileTypes.includes(extension);
8454
+ * };
8455
+ * ```
6162
8456
  */
6163
8457
  readonly fileTypes: readonly string[];
6164
8458
  /**
@@ -6167,11 +8461,40 @@ interface Resolvers {
6167
8461
  * @returns A handle that removes the listener.
6168
8462
  * @capability `events`
6169
8463
  * @player 0.4.2
8464
+ * @example Say which file types became playable
8465
+ * When a plugin with a file resolver starts or stops, the OSD lists the
8466
+ * extra file types now accepted. The listener (which needs `events`)
8467
+ * receives the new list each time it changes.
8468
+ * ```ts
8469
+ * plugin.resolvers.onFileTypesChange((types) => {
8470
+ * void plugin.osd.show("Also accepted: {}", [plugin.i18n.list(types)]);
8471
+ * });
8472
+ * ```
6170
8473
  */
6171
8474
  onFileTypesChange(listener: (types: string[]) => void): Disposable;
6172
8475
  }
6173
8476
  ```
6174
8477
 
8478
+ Example: Play pages of a video site
8479
+
8480
+ Opening a `https://video.example.com/…` page plays its video. When the
8481
+ user opens a URL whose scheme and host this resolver declares, the
8482
+ player asks the plugin first; `resolve` returns the stream's address,
8483
+ and `signal` aborts the lookup when the open is replaced or closed.
8484
+ ```ts
8485
+ plugin.resolvers.add({
8486
+ id: "example",
8487
+ schemes: ["https"],
8488
+ hosts: ["video.example.com"],
8489
+ async resolve(url, signal) {
8490
+ const response = await fetch(url, { signal }); // the site's JSON for the page
8491
+ if (!response.ok) throw new Error(`video.example.com answered ${response.status}`);
8492
+ const page = await response.json();
8493
+ return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
8494
+ },
8495
+ });
8496
+ ```
8497
+
6175
8498
  ### PackageManifest
6176
8499
 
6177
8500
  The manifest of a package, as validated. The display fields and
@@ -6440,6 +8763,21 @@ interface Packages {
6440
8763
  }
6441
8764
  ```
6442
8765
 
8766
+ Example: Install a plugin from an unpacked package
8767
+
8768
+ The plugin installs a package directory it downloaded and unpacked, and
8769
+ reports why when it does not work. `validate` asks the player to check
8770
+ the package and returns a candidate; `install` starts the change and
8771
+ `done` resolves with its final status: `committed` only once the new
8772
+ plugin reported that it is running, otherwise `failed`, `cancelled`,
8773
+ `failedDegraded` or `tooLate`.
8774
+ ```ts
8775
+ const candidate = await plugin.packages.validate(unpackedDirectory);
8776
+ const change = await candidate.install();
8777
+ // `message` says why a change did not commit.
8778
+ if ((await change.done) !== "committed") throw new Error(change.message ?? "install failed");
8779
+ ```
8780
+
6443
8781
  ### BindingRef
6444
8782
 
6445
8783
  A command: manifest id (`"host"` for the player's own) and command id.
@@ -6517,6 +8855,18 @@ interface Bindings {
6517
8855
  }
6518
8856
  ```
6519
8857
 
8858
+ Example: Unbind the player's Mute key, then restore it
8859
+
8860
+ The player's Mute command loses its default key, and later gets it
8861
+ back. `set` with `[]` saves an override that unbinds the command
8862
+ (remembered across launches); `reset` removes the override, so the
8863
+ command's own default bindings apply again.
8864
+ ```ts
8865
+ await plugin.bindings.set({ plugin: "host", command: "mute" }, []);
8866
+ // later
8867
+ await plugin.bindings.reset({ plugin: "host", command: "mute" });
8868
+ ```
8869
+
6520
8870
  ### Clipboard
6521
8871
 
6522
8872
  The system clipboard (`clipboard`). Unavailable without a user interface.
@@ -6537,6 +8887,21 @@ interface Clipboard {
6537
8887
  }
6538
8888
  ```
6539
8889
 
8890
+ Example: Copy the current file and position
8891
+
8892
+ The clipboard holds "path @ seconds" for pasting into a note. The
8893
+ player writes the system clipboard; it rejects with `unsupported` on
8894
+ systems other than macOS and Windows and `failed` when the system
8895
+ refuses, which the plugin reports.
8896
+ ```ts
8897
+ const { source, position } = plugin.player.state;
8898
+ try {
8899
+ await plugin.clipboard.writeText(`${source} @ ${position}`); // data, not shown
8900
+ } catch (error) {
8901
+ void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
8902
+ }
8903
+ ```
8904
+
6540
8905
  ### Power
6541
8906
 
6542
8907
  Keeping the computer awake (`playback.control`).
@@ -6598,6 +8963,25 @@ interface Tools {
6598
8963
  }
6599
8964
  ```
6600
8965
 
8966
+ Example: Read a file's format with ffprobe
8967
+
8968
+ The plugin gets a media file's container details as JSON. `available`
8969
+ says whether ffprobe can be found (`VESTRA_FFPROBE`, then `PATH`); `run`
8970
+ starts it from the plugin's own process without a shell and resolves
8971
+ with its output as bytes once it exits, or rejects when it fails
8972
+ (`failed`, with the last stderr line) or takes longer than `timeoutMs`.
8973
+ ```ts
8974
+ if (await plugin.tools.available("ffprobe")) {
8975
+ const { stdout } = await plugin.tools.run(
8976
+ "ffprobe",
8977
+ ["-v", "error", "-show_format", "-of", "json", path],
8978
+ { timeoutMs: 10_000 }, // give up on a stuck file after 10 s
8979
+ );
8980
+ const info = JSON.parse(new TextDecoder().decode(stdout));
8981
+ void info.format; // container, duration, bit rate…
8982
+ }
8983
+ ```
8984
+
6601
8985
  ### JsonStore
6602
8986
 
6603
8987
  A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
@@ -6627,6 +9011,22 @@ interface Storage {
6627
9011
  *
6628
9012
  * The file is read synchronously on creation; `debounceMs` defaults to
6629
9013
  * 300. Files are created with mode 0600. `invalid` for a bad name.
9014
+ * @example Keep settings and a recent list in a JSON file
9015
+ * The plugin remembers whether to resume and the last ten files across
9016
+ * restarts. `json` reads `settings.json` from the data directory at once
9017
+ * (or uses `defaults` when it is missing or fails `validate`); `save`
9018
+ * replaces the value and writes the file 300 ms later, and stopping the
9019
+ * plugin writes a pending save first.
9020
+ * ```ts
9021
+ * type Settings = { resume: boolean; recent: string[] };
9022
+ * const store = plugin.storage.json<Settings>("settings", {
9023
+ * defaults: { resume: true, recent: [] },
9024
+ * validate: (v): v is Settings =>
9025
+ * typeof v === "object" && v !== null && typeof (v as Settings).resume === "boolean",
9026
+ * });
9027
+ * // Only `save` writes: changing `store.value` in place is not saved.
9028
+ * store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
9029
+ * ```
6630
9030
  */
6631
9031
  json<T>(name: string, options: {
6632
9032
  defaults: T;
@@ -6700,6 +9100,20 @@ interface SessionStorage {
6700
9100
  }
6701
9101
  ```
6702
9102
 
9103
+ Example: Keep a work queue across a plugin restart
9104
+
9105
+ A download queue survives the plugin being reloaded or crashing, and is
9106
+ empty again when the player starts next time. Session storage lives in
9107
+ the player process for one player run; each `set` is kept once its
9108
+ promise resolves, so nothing is lost if the plugin dies right after.
9109
+ ```ts
9110
+ // A queue the plugin is working through: after a reload or a crash it
9111
+ // goes on where it stopped, after the player quits it starts afresh.
9112
+ const pending = (await plugin.storage.session.get<string[]>("queue")) ?? [];
9113
+ pending.push("https://example.com/a.mkv");
9114
+ await plugin.storage.session.set("queue", pending);
9115
+ ```
9116
+
6703
9117
  ### System
6704
9118
 
6705
9119
  `plugin.system`: files and links, executed by the player. No capability.
@@ -6718,6 +9132,22 @@ interface System {
6718
9132
  }
6719
9133
  ```
6720
9134
 
9135
+ Example: Show an exported file and open the help page
9136
+
9137
+ The file manager opens with the exported file selected, then the
9138
+ browser opens the plugin's help page. The player performs both on the
9139
+ plugin's behalf; they reject with `unsupported` on systems other than
9140
+ macOS and Windows, which the plugin turns into a message.
9141
+ ```ts
9142
+ try {
9143
+ await plugin.system.reveal(exportedFile);
9144
+ await plugin.system.openUrl("https://example.com/help");
9145
+ } catch (error) {
9146
+ if ((error as PluginError).code !== "unsupported") throw error;
9147
+ await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
9148
+ }
9149
+ ```
9150
+
6721
9151
  ### Secrets
6722
9152
 
6723
9153
  Per-plugin secrets (API keys, passwords) in the system's secret store:
@@ -6741,6 +9171,27 @@ interface Secrets {
6741
9171
  }
6742
9172
  ```
6743
9173
 
9174
+ Example: Ask for an API token once and keep it in the keychain
9175
+
9176
+ The user types the token the first time; later runs read it from the
9177
+ system's secret store. `get` resolves `null` when nothing is stored; a
9178
+ store that is missing or refuses access rejects (`unsupported`,
9179
+ `unavailable`, `failed`), and then the plugin keeps the token for this
9180
+ run only and asks again next time.
9181
+ ```ts
9182
+ let token: string | null = null;
9183
+ try {
9184
+ token = await plugin.secrets.get("api-token");
9185
+ } catch {
9186
+ // No usable store: fall through and ask.
9187
+ }
9188
+ if (token === null) {
9189
+ token = await askUser();
9190
+ if (token)
9191
+ await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
9192
+ }
9193
+ ```
9194
+
6744
9195
  ### VideoNode
6745
9196
 
6746
9197
  One node of a VideoFilter chain, by `kind`. Values outside the
@@ -6879,6 +9330,24 @@ type VideoNode =
6879
9330
  };
6880
9331
  ```
6881
9332
 
9333
+ Example: Brighten, sharpen and rotate the picture
9334
+
9335
+ The video plays a little brighter and more saturated, slightly sharper
9336
+ and turned a quarter clockwise. The nodes run in array order; `set` sends
9337
+ the whole chain as this plugin's desired value (last write wins), and the
9338
+ player applies it to every source until it changes, needing
9339
+ `processing.video`, `playback.read` and `events`.
9340
+ ```ts
9341
+ const chain: VideoNode[] = [
9342
+ // Live fields: moving them later changes the picture in place.
9343
+ { kind: "eq", brightness: 0.05, contrast: 1.1, saturation: 1.2, gamma: 1 },
9344
+ // Not live: changing `amount` later rebuilds the stage (a one-frame stall).
9345
+ { kind: "sharpen", amount: 0.5, size: "small" },
9346
+ { kind: "transform", rotate: 90, flipH: false, flipV: false },
9347
+ ];
9348
+ plugin.video.filter("picture").set(chain);
9349
+ ```
9350
+
6882
9351
  ### AudioNode
6883
9352
 
6884
9353
  One node of an AudioFilter chain, by `kind`. Values outside the
@@ -6935,6 +9404,22 @@ type AudioNode =
6935
9404
  };
6936
9405
  ```
6937
9406
 
9407
+ Example: Lift the voices and play in mono
9408
+
9409
+ Speech around 1 kHz is 3 dB louder and both speakers play the same mixed
9410
+ channel. `set` sends this plugin's whole audio chain (the nodes in order,
9411
+ plus an optional `delayMs`); the player joins it with other plugins'
9412
+ chains and applies it to every source (`processing.audio`,
9413
+ `playback.read`, `events`).
9414
+ ```ts
9415
+ plugin.audio.filter("sound").set({
9416
+ nodes: [
9417
+ { kind: "equalizer", frequency: 1000, q: 1, gainDb: 3 }, // +3 dB at 1 kHz
9418
+ { kind: "channels", mode: "mono" }, // both channels get (L + R) / 2
9419
+ ],
9420
+ });
9421
+ ```
9422
+
6938
9423
  ### ResolvedMedia
6939
9424
 
6940
9425
  What a resolver returns (WebResolverOptions.resolve): the playable
@@ -6956,12 +9441,50 @@ type ResolvedMedia = {
6956
9441
  };
6957
9442
  ```
6958
9443
 
9444
+ Example: Play a page whose video and audio are separate streams
9445
+
9446
+ Opening a `https://video.example/…` page plays it with its title. When
9447
+ the user opens a URL whose scheme and host a web resolver declares, the
9448
+ player calls its `resolve(url, signal)`; the plugin looks up the streams
9449
+ and returns them, and the player plays `primary` with the separate
9450
+ `audio` track, sending the `headers` with every request for both.
9451
+ ```ts
9452
+ plugin.resolvers.add({
9453
+ id: "video-example",
9454
+ schemes: ["https"],
9455
+ hosts: ["video.example"],
9456
+ async resolve(url, signal) {
9457
+ // The site's own API (made up here) names the streams of a page.
9458
+ const api = `https://video.example/api/streams?page=${encodeURIComponent(url)}`;
9459
+ const response = await fetch(api, { signal });
9460
+ // The player shows a failed resolve's message to the user.
9461
+ if (!response.ok)
9462
+ throw new Error(`video.example answered ${response.status} for this page`);
9463
+ const info = (await response.json()) as {
9464
+ title: string;
9465
+ video: string;
9466
+ audio: string;
9467
+ };
9468
+ const headers = { Referer: url }; // identical for both streams
9469
+ const media: ResolvedMedia = {
9470
+ kind: "media",
9471
+ originalUrl: url,
9472
+ title: info.title,
9473
+ primary: { url: info.video, headers },
9474
+ audio: { url: info.audio, headers },
9475
+ };
9476
+ return media;
9477
+ },
9478
+ });
9479
+ ```
9480
+
6959
9481
  ### SubtitleStyle
6960
9482
 
6961
9483
  A subtitle style layer (Subtitles.setStyle). Sizes are at the
6962
- 1080p reference height and scale with the video. The effective style is
6963
- the layer of the plugin that set one last; it also applies to the
6964
- secondary subtitle.
9484
+ 1080p reference height and scale with the video. Each plugin has one
9485
+ layer; when several plugins set one, the effective style is the layer of
9486
+ the plugin whose manifest id sorts last (the order of the calls does not
9487
+ matter). It also applies to the secondary subtitle.
6965
9488
 
6966
9489
  ```ts
6967
9490
  type SubtitleStyle = {
@@ -6992,6 +9515,30 @@ type SubtitleStyle = {
6992
9515
  };
6993
9516
  ```
6994
9517
 
9518
+ Example: Show large yellow subtitles a quarter second earlier
9519
+
9520
+ Subtitles turn yellow at size 44 on a 60 % opaque box, 6 % above the
9521
+ bottom of the video, and appear 250 ms earlier. `refresh` reads the
9522
+ subtitle state from the player, whose `fonts` lists the font ids a style
9523
+ may name; `setStyle` then sends this plugin's style layer
9524
+ (`subtitles.control`, `subtitles.read`). It shows unless another plugin
9525
+ with a manifest id that sorts later has a layer too: the player picks the
9526
+ effective layer by manifest id, not by which call came last.
9527
+ ```ts
9528
+ const { fonts } = await plugin.subtitles.refresh();
9529
+ if (!fonts.length) return; // no font to name: leave the style alone
9530
+ const style: SubtitleStyle = {
9531
+ fontId: fonts[0]!.id, // a font the player offers
9532
+ fontSize: 44, // at the 1080p reference height; scales with the video
9533
+ color: "yellow",
9534
+ outline: 2,
9535
+ marginPercent: 6, // bottom margin, % of the video height
9536
+ offsetMs: -250, // negative: shown earlier
9537
+ box: 60, // background box opacity
9538
+ };
9539
+ await plugin.subtitles.setStyle(style);
9540
+ ```
9541
+
6995
9542
  ### Json
6996
9543
 
6997
9544
  A JSON value: what crosses the wire to the player. Slint property values,
@@ -7067,6 +9614,22 @@ interface FakeWindow {
7067
9614
  }
7068
9615
  ```
7069
9616
 
9617
+ Example: Change a setting in a tool window from a test
9618
+
9619
+ The test opens the plugin's Picture window, moves its brightness control
9620
+ and checks that the plugin wrote the value back. `emit` fires a Slint
9621
+ callback as if the user used the control; the plugin's `on` handler runs
9622
+ and sets properties, which reach the host as requests, so `settle()`
9623
+ waits for them before `props` is read.
9624
+ ```ts
9625
+ await host.command("open-picture").run(); // the plugin calls window.show()
9626
+ const picture = host.window("picture");
9627
+ assert.equal(picture.visible, true);
9628
+ picture.emit("adjust", "brightness", 40); // fire a Slint callback
9629
+ await host.settle();
9630
+ assert.equal(picture.props["brightness"], 40);
9631
+ ```
9632
+
7070
9633
  ### FileDialogText
7071
9634
 
7072
9635
  A file dialog as the player shows it (TestHost.fileDialogs).
@@ -7125,6 +9688,21 @@ interface FakeCommand {
7125
9688
  }
7126
9689
  ```
7127
9690
 
9691
+ Example: Run a checkable menu item from a test
9692
+
9693
+ The test checks the Loop item's menu place, picks it and sees its check
9694
+ mark turn on. `host.command(id)` is the host's record of what the plugin
9695
+ declared and the state it last sent; `run()` calls the plugin's `run`
9696
+ like the menu, after which the SDK re-evaluates the command's state and
9697
+ sends it, and `run()` resolves once the host is idle again.
9698
+ ```ts
9699
+ const toggle = host.command("toggle-loop");
9700
+ assert.deepEqual(toggle.declaration.path, ["Playback", "Loop"]);
9701
+ assert.equal(toggle.state.checked, false);
9702
+ await toggle.run();
9703
+ assert.equal(host.command("toggle-loop").state.checked, true);
9704
+ ```
9705
+
7128
9706
  ### FakeBarButton
7129
9707
 
7130
9708
  A command's bottom-bar button (`CommandOptions.button`) as the player shows
@@ -7261,6 +9839,16 @@ class TestHost implements Transport {
7261
9839
  readonly coreCommands: Map<string, FakeCommand>;
7262
9840
  /**
7263
9841
  * Both panel positions as the player reports them (`null` while no panel is mounted there). You may change a slot directly and then call `host.emit("window.state", {})` to simulate a change made elsewhere.
9842
+ * @example Hide the sidebar and check the player's layout
9843
+ * With the plugin's panel in the sidebar, toggling the sidebar records
9844
+ * that the user prefers it hidden. `toggle()` reads the slot from the
9845
+ * player, asks for the opposite of its `preferred` flag and resolves once
9846
+ * the player answered, so the host's `layout` already shows the new value.
9847
+ * ```ts
9848
+ * assert.equal(host.layout.sidebar?.preferred, true);
9849
+ * await plugin.panels.sidebar.toggle();
9850
+ * assert.equal(host.layout.sidebar?.preferred, false);
9851
+ * ```
7264
9852
  */
7265
9853
  readonly layout: {
7266
9854
  /** The bottom bar position: `preferred`, `effective`, `reason`, `detached`, `owner` and `panels` (its one panel). */
@@ -7289,6 +9877,17 @@ class TestHost implements Transport {
7289
9877
  * `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
7290
9878
  * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
7291
9879
  * passing; set `locale` to test what a user sees now.
9880
+ * @example Check a menu path in Simplified Chinese
9881
+ * The test starts the plugin with a Chinese interface and checks the
9882
+ * menu path its Blur item shows. The `locale` option sets
9883
+ * `host.locale` before activation, so the plugin's `locales/zh-Hans.po`
9884
+ * is loaded; the built-in menu `@video` shows in the player's own words
9885
+ * (视频), the item's title in the plugin's translation.
9886
+ * ```ts
9887
+ * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
9888
+ * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
9889
+ * await stop();
9890
+ * ```
7292
9891
  */
7293
9892
  get locale(): Locale;
7294
9893
  set locale(locale: Locale);
@@ -7302,6 +9901,13 @@ class TestHost implements Transport {
7302
9901
  * @param text - English source text or template.
7303
9902
  * @param args - Values for its placeholders.
7304
9903
  * @returns The shown text.
9904
+ * @example Compare with a translated message
9905
+ * The test expects the text a Chinese user sees for a template the plugin
9906
+ * shows. `translate` looks the English source up in the plugin's catalog
9907
+ * for `host.locale` and fills in the arguments, the way the player does.
9908
+ * ```ts
9909
+ * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
9910
+ * ```
7305
9911
  */
7306
9912
  translate(text: TextValue, args?: TemplateArgs): string;
7307
9913
  /** The saved interface language (`plugin.appearance.language`), changed by `appearance.setLanguage` or TestHost.setLanguage. */
@@ -7337,7 +9943,20 @@ class TestHost implements Transport {
7337
9943
  /** The provider's `canPrevious`, as last evaluated. */
7338
9944
  canPrevious: boolean;
7339
9945
  } | null;
7340
- /** What `player.stats()` returns. Fields added in later rounds may be left out; the SDK fills them with `null`. */
9946
+ /**
9947
+ * What `player.stats()` returns. Fields added in later rounds may be left out; the SDK fills them with `null`.
9948
+ * @example Pretend a 1080p H.264 video is playing
9949
+ * The plugin's `player.stats()` now reports a 1920×1080 H.264 stream at
9950
+ * 24 fps. The host answers `stats()` with this object as it stands, so a
9951
+ * test sets it before the plugin asks; fields it leaves out come back as
9952
+ * `null` from the SDK.
9953
+ * ```ts
9954
+ * host.stats = {
9955
+ * ...host.stats, // keep the other fields
9956
+ * video: { codec: "h264", width: 1920, height: 1080, fps: 24 },
9957
+ * } as typeof host.stats;
9958
+ * ```
9959
+ */
7341
9960
  stats: PlaybackStats;
7342
9961
  /** Every `subtitles.load`: its options and bytes, newest last. */
7343
9962
  readonly subtitleLoads: {
@@ -7359,7 +9978,27 @@ class TestHost implements Transport {
7359
9978
  };
7360
9979
  /** The effective `power.preventSleep` mode. */
7361
9980
  power: "off" | "playing";
7362
- /** The fake `ffmpeg` / `ffprobe` behind `plugin.tools`. A tool without a handler is not found (`available()` is false, `run()` rejects with `unavailable`). */
9981
+ /**
9982
+ * The fake `ffmpeg` / `ffprobe` behind `plugin.tools`. A tool without a handler is not found (`available()` is false, `run()` rejects with `unavailable`).
9983
+ * @example Fake ffprobe and a failing ffmpeg
9984
+ * The plugin's Probe command gets an empty stream list from ffprobe, and
9985
+ * any ffmpeg run fails as it would on a broken file. `handle` makes the
9986
+ * tool available and answers each `tools.run` with the handler's result;
9987
+ * a thrown `HostError` makes the run reject with that code. `calls`
9988
+ * records every run with its arguments.
9989
+ * ```ts
9990
+ * host.tools.handle("ffprobe", (args) => {
9991
+ * assert.ok(args.includes("-show_streams")); // what the plugin asked for
9992
+ * return { stdout: JSON.stringify({ streams: [] }) }; // a file with no streams
9993
+ * });
9994
+ * // Every ffmpeg run rejects with code `failed`.
9995
+ * host.tools.handle("ffmpeg", () => {
9996
+ * throw new HostError("failed", "Invalid data found when processing input");
9997
+ * });
9998
+ * await host.command("probe").run();
9999
+ * assert.equal(host.tools.calls[0]?.tool, "ffprobe");
10000
+ * ```
10001
+ */
7363
10002
  readonly tools: {
7364
10003
  /** Every run: the tool name and its arguments, newest last. */
7365
10004
  calls: {
@@ -7432,6 +10071,20 @@ class TestHost implements Transport {
7432
10071
  * Simulate another plugin publishing its timeline layer (or clearing it with `[]`); pushes `timeline.changed` when the plugin subscribed.
7433
10072
  * @param pluginId - The other plugin's manifest id.
7434
10073
  * @param marks - Its whole layer.
10074
+ * @example Let another plugin publish chapters
10075
+ * The plugin under test sees two chapter marks another plugin put on the
10076
+ * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
10077
+ * `timeline.changed`; once the plugin uses `plugin.timeline` (with
10078
+ * `events`) the SDK keeps `marks` current from that event, and `settle()`
10079
+ * lets it arrive.
10080
+ * ```ts
10081
+ * host.setTimelineLayer("community.chapters", [
10082
+ * { time: 0, kind: "chapter", title: "Intro" },
10083
+ * { time: 95, kind: "chapter", title: "Part 1" },
10084
+ * ]); // replaces that plugin's whole layer; [] would clear it
10085
+ * await host.settle();
10086
+ * assert.equal(plugin.timeline.marks.length, 2);
10087
+ * ```
7435
10088
  */
7436
10089
  setTimelineLayer(pluginId: string, marks: {
7437
10090
  time: number;
@@ -7451,6 +10104,16 @@ class TestHost implements Transport {
7451
10104
  }[];
7452
10105
  /**
7453
10106
  * The file an image property shows, or `null` when the value is not a runtime image. View properties hold images in their wire form; this reads the path back.
10107
+ * @example Check which file a window shows as its cover
10108
+ * The test checks that the Cover window's image comes from the plugin's
10109
+ * data directory. An image property holds the SDK's wire form of
10110
+ * `plugin.ui.image(path)`, not a path; `imagePath` reads the path back.
10111
+ * ```ts
10112
+ * assert.equal(
10113
+ * host.imagePath(host.window("cover").props["cover"]),
10114
+ * join(plugin.dataDirectory, "cover.png"),
10115
+ * );
10116
+ * ```
7454
10117
  */
7455
10118
  imagePath(value: Json | undefined): string | null;
7456
10119
  /** `drops.provide` registrations of this plugin, in order (`id` is the SDK's). */
@@ -7463,9 +10126,24 @@ class TestHost implements Transport {
7463
10126
  /** The panel has an `onDrop` listener. */
7464
10127
  panelDrops: boolean;
7465
10128
  /**
7466
- * Drop files like the player: on a panel with an `onDrop` listener the whole list goes there; otherwise (and on the video area) grouped by extension to the first provider (`folder` for existing directories). Files nobody takes are ignored. Resolves once the host is idle again.
10129
+ * Drop files like the player: on a panel with an `onDrop` listener the whole list goes there; otherwise (and on the video area) each path goes to the first provider of its extension (`folder` for existing directories), one call per provider with all of its paths. Files nobody takes are ignored. Resolves once the host is idle again.
7467
10130
  * @param paths - The dropped paths, in the order the file manager gives.
7468
10131
  * @param options - `target`: `"video"` (default) or `"panel"`; `at`: the drop point in panel coordinates (default `{ x: 0, y: 0 }`); `modifiers`: keys held (default all `false`).
10132
+ * @example Drop files on the video and on the panel
10133
+ * The first drop lands on the video area: each extension belongs to the
10134
+ * provider that registered it first, and each provider gets one call with
10135
+ * all of its paths. The second lands on the panel at a point, with Alt
10136
+ * held, and the panel's `onDrop` gets the whole list. `drop` resolves
10137
+ * once the host is idle.
10138
+ * ```ts
10139
+ * await host.drop(["/media/a.srt", "/media/b.mkv"]); // on the video area
10140
+ * // On the panel, 10 px from its left and 48 px from its top, Alt held.
10141
+ * await host.drop(["/media/list.m3u"], {
10142
+ * target: "panel",
10143
+ * at: { x: 10, y: 48 },
10144
+ * modifiers: { alt: true },
10145
+ * });
10146
+ * ```
7469
10147
  */
7470
10148
  drop(paths: string[], options?: {
7471
10149
  target?: "video" | "panel";
@@ -7511,7 +10189,20 @@ class TestHost implements Transport {
7511
10189
  /** Whether the options window is open. */
7512
10190
  open: boolean;
7513
10191
  };
7514
- /** One settings page by id. Throws when it was never registered. */
10192
+ /**
10193
+ * One settings page by id. Throws when it was never registered.
10194
+ * @example Change a value on the plugin's settings page
10195
+ * The test sets the step on the General page and checks the page shows
10196
+ * it. `emit` fires the page's callback as the user's input would; the
10197
+ * plugin's handler stores the value and its `bind` function sends it back
10198
+ * as a property, which `settle()` waits for.
10199
+ * ```ts
10200
+ * const page = host.settingsPage("general");
10201
+ * page.emit("setStep", 10);
10202
+ * await host.settle();
10203
+ * assert.equal(page.props["step"], 10);
10204
+ * ```
10205
+ */
7515
10206
  settingsPage(id: string): {
7516
10207
  /** The page's settings category. */
7517
10208
  category: string;
@@ -7544,6 +10235,18 @@ class TestHost implements Transport {
7544
10235
  * @param slot - The position.
7545
10236
  * @param plugin - The other plugin's manifest id.
7546
10237
  * @param options - `title`: its tab label (default: `plugin`).
10238
+ * @example Put the playlist next to this plugin's sidebar panel
10239
+ * The sidebar ends up with two tabs: the playlist, mounted first, and the
10240
+ * plugin's own panel after it. `mountOther` before `startPlugin` mounts
10241
+ * another plugin's panel as the player would, so the plugin's mount
10242
+ * during activation becomes the second tab (in the bottom bar or the
10243
+ * backdrop it would be refused with `conflict` instead).
10244
+ * ```ts
10245
+ * const host = new TestHost();
10246
+ * host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
10247
+ * await startPlugin(activate, { host, directory });
10248
+ * assert.equal(host.layout.sidebar?.panels?.length, 2);
10249
+ * ```
7547
10250
  */
7548
10251
  mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
7549
10252
  title?: string;
@@ -7582,10 +10285,32 @@ class TestHost implements Transport {
7582
10285
  setVideoRenderOwner(pluginId: string | null): void;
7583
10286
  /**
7584
10287
  * What the player pushes when mpv cannot compile a shader (code `failed`) or switches to software rendering (`unsupported`, which also sets `videoRender.gpu` to false). Pushed only when this plugin is the owner.
10288
+ * @example Make a shader fail to compile
10289
+ * The plugin's render options report `failed`, as when mpv rejects a
10290
+ * shader. The host pushes the failure only while this plugin owns the
10291
+ * render options, which its first accepted `set` made it; the SDK updates
10292
+ * the handle's `status` when the push arrives, so the test settles first.
10293
+ * ```ts
10294
+ * const render = plugin.video.render();
10295
+ * render.set({ toneMapping: "bt.2390" }); // makes the plugin the owner
10296
+ * await host.settle();
10297
+ * host.failVideoRender("failed", "shader compile failed: ERROR: 0:39: ...");
10298
+ * await host.settle();
10299
+ * assert.equal(render.status.state, "failed");
10300
+ * ```
7585
10301
  */
7586
10302
  failVideoRender(code: "failed" | "unsupported", message: string): void;
7587
10303
  /**
7588
10304
  * The player's processing (filter) state. Wire-level; may change in minor versions. `resources` holds each `video.filter` / `audio.filter` chain the plugin applied, keyed by the SDK's resource id.
10305
+ * @example Check the filter chain the plugin applied
10306
+ * After the plugin set its picture chain, the test reads the one chain
10307
+ * the host holds and compares its nodes. `resources` keeps each applied
10308
+ * `video.filter` / `audio.filter` chain by the SDK's resource id, after
10309
+ * the host validated it with the player's value ranges.
10310
+ * ```ts
10311
+ * const [chain] = [...host.processing.resources.values()];
10312
+ * assert.deepEqual(chain?.nodes, [{ kind: "hue", degrees: 30 }]);
10313
+ * ```
7589
10314
  */
7590
10315
  readonly processing: {
7591
10316
  /** Increases with every accepted change. */
@@ -7660,6 +10385,15 @@ class TestHost implements Transport {
7660
10385
  readonly resolvers: Map<string, Json>;
7661
10386
  /**
7662
10387
  * Resolvers other plugins hold, as `resolvers.add` declares them plus the holder's id: a registration of this plugin that claims one of their extensions (or scheme + host pairs) is refused with `conflict`, which does not fail activation, like the player.
10388
+ * @example Start with another plugin holding a file type
10389
+ * The plugin starts in a player where another plugin already resolves
10390
+ * `.ch8` files. Pushed before `startPlugin`, the entry makes the plugin's
10391
+ * own resolver for `.ch8` fail with `conflict` during activation, which
10392
+ * (as in the player) does not fail the activation.
10393
+ * ```ts
10394
+ * const host = new TestHost();
10395
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
10396
+ * ```
7663
10397
  */
7664
10398
  readonly otherResolvers: {
7665
10399
  /** The manifest id of the plugin holding them (shown in the refusal). */
@@ -7675,6 +10409,17 @@ class TestHost implements Transport {
7675
10409
  fileTypes(): string[];
7676
10410
  /**
7677
10411
  * Push `resolvers.fileTypes` when TestHost.fileTypes changed: called after this plugin's resolvers come and go; call it yourself after changing TestHost.otherResolvers (another plugin started or stopped).
10412
+ * @example Let another plugin's file type appear while running
10413
+ * Another plugin starts and adds `.ch8`; the plugin under test then sees
10414
+ * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
10415
+ * nothing: `updateFileTypes` sends the new list as the player does when
10416
+ * a plugin starts or stops, and `settle()` lets the SDK apply it.
10417
+ * ```ts
10418
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
10419
+ * host.updateFileTypes();
10420
+ * await host.settle();
10421
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
10422
+ * ```
7678
10423
  */
7679
10424
  updateFileTypes(): void;
7680
10425
  /**
@@ -7696,6 +10441,18 @@ class TestHost implements Transport {
7696
10441
  *
7697
10442
  * For `openFile` / `openDirectory` / `saveFile` queue a one-path array;
7698
10443
  * for `openFiles` any number of paths.
10444
+ * @example Answer the plugin's dialogs in advance
10445
+ * The plugin's Open command gets a picked file, then a cancelled file
10446
+ * dialog, then a confirmed question. All answers share one queue, taken
10447
+ * from the front in the order the plugin opens dialogs of any kind: if
10448
+ * the next queued answer is missing or of the wrong kind, the dialog
10449
+ * fails like a lost reply, so queue them in the order the plugin asks.
10450
+ * ```ts
10451
+ * host.dialogAnswers.push(["/media/movie.mkv"]); // openFile picks it
10452
+ * host.dialogAnswers.push(null); // the next file dialog is cancelled
10453
+ * host.dialogAnswers.push({ confirmed: true, checked: false }); // confirm
10454
+ * await host.command("open").run();
10455
+ * ```
7699
10456
  */
7700
10457
  readonly dialogAnswers: (string[] | null | {
7701
10458
  /** The user clicked the confirm button. */
@@ -7776,6 +10533,20 @@ class TestHost implements Transport {
7776
10533
  stepSeekKeyframes: boolean;
7777
10534
  /**
7778
10535
  * Test data: the keyframe times (seconds, ascending) of the current source, for keyframe step seeks. `null` (the default): none modelled, and step seeks are exact even with `stepSeekKeyframes`.
10536
+ * @example Step forward to the next keyframe
10537
+ * With keyframe step seeks on, Forward from 0:12 lands on the keyframe at
10538
+ * 0:20, not at 0:17, and the OSD says so. `keyframes` gives the source's
10539
+ * keyframe times; `setStepSeek` turns on keyframe steps of 5 s, and the
10540
+ * core action `seek-forward` seeks to the first keyframe at or after
10541
+ * the target, as the player does.
10542
+ * ```ts
10543
+ * host.setSource({ phase: "ready", duration: 45, position: 12 });
10544
+ * host.keyframes = [0, 10, 20, 30, 40];
10545
+ * await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
10546
+ * await plugin.host.run("seek-forward"); // target 17 s, next keyframe 20 s
10547
+ * assert.equal(host.source.position, 20);
10548
+ * assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
10549
+ * ```
7779
10550
  */
7780
10551
  keyframes: number[] | null;
7781
10552
  /**
@@ -7811,6 +10582,17 @@ class TestHost implements Transport {
7811
10582
  readonly coreRuns: string[];
7812
10583
  /**
7813
10584
  * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...) that the test host registers like the player, as `host/<id>`. Throws for an unknown id. `run()` executes it with the player's checks and writes its on-screen text to TestHost.osd.
10585
+ * @example Pause through the player's own action
10586
+ * The test pauses playback the way the Space key does and checks that the
10587
+ * host recorded it. Core actions belong to the player (`host/<id>`);
10588
+ * `run()` applies the player's checks, so a source must be ready first,
10589
+ * and every run is appended to `coreRuns`.
10590
+ * ```ts
10591
+ * host.setSource({ phase: "ready", duration: 100, position: 10 });
10592
+ * await host.coreAction("play-pause").run();
10593
+ * assert.equal(host.source.paused, true);
10594
+ * assert.deepEqual(host.coreRuns, ["play-pause"]);
10595
+ * ```
7814
10596
  */
7815
10597
  coreAction(id: string): FakeCommand;
7816
10598
  /**
@@ -7826,6 +10608,20 @@ class TestHost implements Transport {
7826
10608
  * find the one a call uses.
7827
10609
  * @param method - The wire method name.
7828
10610
  * @param handler - Produces the reply from the request parameters.
10611
+ * @example Make opening a folder fail
10612
+ * The plugin's Open Folder command meets a missing folder and must tell
10613
+ * the user. `handle` replaces the host's answer to one wire method; the
10614
+ * `HostError` it throws reaches the plugin as a rejection with code
10615
+ * `missing`, like the player's refusal. `reset` restores the default
10616
+ * behaviour for the rest of the test.
10617
+ * ```ts
10618
+ * host.handle("system.openPath", () => {
10619
+ * throw new HostError("missing", "no such file");
10620
+ * });
10621
+ * await host.command("open-folder").run(); // the plugin catches `missing`
10622
+ * assert.deepEqual(host.osd, ["Folder not found"]);
10623
+ * host.reset("system.openPath");
10624
+ * ```
7829
10625
  */
7830
10626
  handle(method: string, handler: Handler): void;
7831
10627
  /** Restore the default behaviour of a method replaced with TestHost.handle. */
@@ -7839,10 +10635,34 @@ class TestHost implements Transport {
7839
10635
  /**
7840
10636
  * Change the source facts and broadcast them, as if the player's state changed (the user paused, playback reached the end, a file finished loading). Any phase other than `empty` also turns on the source capabilities (pause, seek, rate, ...) unless the patch sets them. `phase: "loading"` makes a mounted backdrop effective, like the player.
7841
10637
  * @param patch - Wire-level source fields, e.g. `phase`, `position`, `duration`, `paused`, `audioTracks`.
10638
+ * @example Load a file, then let it play to the end
10639
+ * The plugin sees a one-minute source become ready, then end on its own.
10640
+ * `setSource` changes the host's source facts and broadcasts them like
10641
+ * the player's events; the SDK updates `player.state` when the event
10642
+ * arrives, so the test settles before reading it.
10643
+ * ```ts
10644
+ * host.setSource({ phase: "ready", duration: 60, position: 0 });
10645
+ * await host.settle();
10646
+ * assert.equal(plugin.player.state.phase, "ready");
10647
+ * host.setSource({ phase: "ended", terminalReason: "natural" });
10648
+ * await host.settle();
10649
+ * ```
7842
10650
  */
7843
10651
  setSource(patch: Partial<SourceState>): void;
7844
10652
  /**
7845
10653
  * Simulate the user changing the main window (resizing it, entering or leaving fullscreen, closing the picture-in-picture window) and broadcast it. Like the player, entering fullscreen collapses an attached, visible sidebar (`effective: false`, `reason: "fullscreen"`) and leaving fullscreen docks it again.
10654
+ * @example Go fullscreen, then report picture-in-picture support
10655
+ * The plugin sees the main window fullscreen at 1920×1080, then learns
10656
+ * that picture-in-picture is available. `setMainWindow` changes the
10657
+ * host's window facts and broadcasts them as the player does when the
10658
+ * user resizes or toggles fullscreen; entering fullscreen also collapses
10659
+ * an attached sidebar.
10660
+ * ```ts
10661
+ * host.setMainWindow({ fullscreen: true, videoArea: { width: 1920, height: 1080 } });
10662
+ * await host.settle();
10663
+ * assert.equal(plugin.window.state.fullscreen, true);
10664
+ * host.setMainWindow({ pictureInPicture: { supported: true, active: false } });
10665
+ * ```
7846
10666
  */
7847
10667
  setMainWindow(patch: Partial<{
7848
10668
  fullscreen: boolean;
@@ -7863,22 +10683,61 @@ class TestHost implements Transport {
7863
10683
  * Simulate another plugin (or the user) opening a source: a new `sourceId`, then the given phase. Calls the plugin made against the old state now fail with `stale`.
7864
10684
  * @param source - The path or URL.
7865
10685
  * @param phase - The phase to end in (default `"ready"`).
10686
+ * @example Let the user open another file meanwhile
10687
+ * A state the plugin captured before stops being current once another
10688
+ * file opens. `openElsewhere` gives the source a new `sourceId`, so
10689
+ * `isCurrent` turns false. Promise-returning calls made with
10690
+ * `ifCurrent: before` then reject with `stale`; calls that return nothing
10691
+ * drop the write silently instead (`TimelineLayer.set` with `ifCurrent`).
10692
+ * ```ts
10693
+ * const before = plugin.player.state;
10694
+ * host.openElsewhere("/media/other.mp4");
10695
+ * await host.settle();
10696
+ * assert.equal(plugin.player.isCurrent(before), false);
10697
+ * ```
7866
10698
  */
7867
10699
  openElsewhere(source: string, phase?: SourceState["phase"]): void;
7868
10700
  /**
7869
10701
  * Simulate a generated stream failing in the player (its audio device lost, its frame connection broken), like the player: the source fails with `message` (`phase: "error"`) and the producer is retired at once. The stream's `onStop` listeners get a `stream` error with `message`.
7870
10702
  * @param producerId - The producer, from TestHost.producers.
7871
10703
  * @param message - The player's failure text.
10704
+ * @example Break the plugin's generated stream
10705
+ * The plugin's stream stops with an error, as when the player loses its
10706
+ * audio device. `failStream` puts the source into `error` with the
10707
+ * message and retires the producer at once; the stream's `onStop`
10708
+ * listeners get a `stream` error carrying the message.
10709
+ * ```ts
10710
+ * const [producer] = host.producers.values();
10711
+ * host.failStream(producer!.id, "producer transport closed");
10712
+ * ```
7872
10713
  */
7873
10714
  failStream(producerId: number, message: string): void;
7874
10715
  /**
7875
10716
  * Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; within a scope the earliest registration first (the player's own commands before the plugin's), as the conflict rule orders them. A disabled command lets the key pass to the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it shadowed, then the global scope. The first enabled one runs; `press` resolves after it ran (`"ran"`), or rejects when it is busy. When only disabled commands hold the key, the first one with a `reason` shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without a reason the key goes on to the focused control (`"passed"`, nothing runs). Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
7876
10717
  * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
10718
+ * @example Press a global key and a panel key
10719
+ * Ctrl+B runs whatever is bound globally; Delete is pressed while the
10720
+ * plugin's panel has focus, so panel bindings are tried first. `press`
10721
+ * follows the player's binding table and resolves with what happened:
10722
+ * `"ran"`, `"reason"` (a disabled command showed why) or `"passed"`.
10723
+ * ```ts
10724
+ * await host.press("Ctrl+B"); // a global binding
10725
+ * await host.press("Delete", { panel: true }); // while the panel has focus
10726
+ * ```
7877
10727
  */
7878
10728
  press(binding: string, options?: {
7879
10729
  panel?: boolean;
7880
10730
  }): Promise<"ran" | "reason" | "passed">;
7881
- /** Bindings declared by more than one command, each as `[binding, winner, ...ignored]` (command ids; core actions as `host/<id>`). Empty when there are none. */
10731
+ /**
10732
+ * Bindings declared by more than one command, each as `[binding, winner, ...ignored]` (command ids; core actions as `host/<id>`). Empty when there are none.
10733
+ * @example Check that no default keys collide
10734
+ * The test fails when two commands, the player's or the plugin's, declare
10735
+ * the same binding. Each entry is `[binding, winner, ...ignored]`, so an
10736
+ * empty list means every key does what its command says.
10737
+ * ```ts
10738
+ * assert.deepEqual(host.conflicts, []);
10739
+ * ```
10740
+ */
7882
10741
  get conflicts(): string[][];
7883
10742
  /** The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"` to inputs; preset it to simulate saved overrides). `press` honours it. */
7884
10743
  readonly bindings: {
@@ -7927,6 +10786,17 @@ class TestHost implements Transport {
7927
10786
  }[];
7928
10787
  /**
7929
10788
  * Packages `validate` and `validateArchive` accept: path (directory or `.vxp` file) → its manifest. Other paths fail with `missing`. `install` adds or upgrades the entry in TestHost.packages following the player's source rules: the same source upgrades, another source needs `replace` (`conflict` otherwise), a built-in id is `unsupported`.
10789
+ * @example Offer a package file the plugin can install
10790
+ * The plugin can validate and install `/downloads/someone.demo-1.0.0.vxp`
10791
+ * as if the file existed. The host accepts only paths listed here (others
10792
+ * fail with `missing`) and returns this manifest from `validate`; an
10793
+ * install then follows the player's source rules.
10794
+ * ```ts
10795
+ * host.candidates.set("/downloads/someone.demo-1.0.0.vxp", {
10796
+ * id: "someone.demo", version: "1.0.0", apiVersion: "vestra/1",
10797
+ * entry: "main.js", capabilities: [],
10798
+ * });
10799
+ * ```
7930
10800
  */
7931
10801
  readonly candidates: Map<string, PackageManifest>;
7932
10802
  /**
@@ -7935,6 +10805,14 @@ class TestHost implements Transport {
7935
10805
  readonly remoteImages: Map<string, string | null>;
7936
10806
  /**
7937
10807
  * The Markdown document a value holds (its wire form read back), or `null` when the value is not one. `images` maps each remote address to the downloaded file. Pass a whole property value or a field of one: a document nested in rows is read the same way.
10808
+ * @example Read back a Markdown message in a chat window
10809
+ * The test checks the Markdown source of the first chat message. Rows
10810
+ * hold the document in its wire form, as `plugin.ui.markdown` produced
10811
+ * it; `markdown` reads it back, nested in a row or not.
10812
+ * ```ts
10813
+ * const rows = host.window("chat").props["messages"] as { body: Json }[];
10814
+ * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
10815
+ * ```
7938
10816
  */
7939
10817
  markdown(value: Json | undefined): MarkdownWire | null;
7940
10818
  /** What `player.audioDevices()` lists. `setAudioDevice` updates `host.source.audioOutput`. */
@@ -7954,14 +10832,46 @@ class TestHost implements Transport {
7954
10832
  command(id: string): FakeCommand;
7955
10833
  /**
7956
10834
  * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a later one stays a menu item only). Empty once the command is disposed. Whether the bar places them is the bar's business: the list is what the player hands to `BarButtons`.
10835
+ * @example Click the plugin's bottom-bar button in a test
10836
+ * The test finds the plugin's Store button, clicks it and checks that the
10837
+ * Store window opened. `barButtons` lists the buttons of the plugin's
10838
+ * commands declared with `button`, as the bar shows them; `click()` runs
10839
+ * the command like its menu item and waits until the host is idle, and
10840
+ * rejects while the command is disabled or busy.
10841
+ * ```ts
10842
+ * const [button] = host.barButtons; // one button per plugin
10843
+ * assert.equal(button?.icon, "store");
10844
+ * await button?.click();
10845
+ * assert.equal(host.window("store").visible, true);
10846
+ * ```
7957
10847
  */
7958
10848
  get barButtons(): readonly FakeBarButton[];
7959
10849
  /**
7960
10850
  * The plugin's tool window with this id (by default the file name without extension: `ui/picture.slint` → `picture`). Throws when it was never opened with `show()`.
10851
+ * @example Press a window's Reset button from a test
10852
+ * The Picture window's Reset callback runs and the brightness returns to
10853
+ * 0. The window is found by its id (the `.slint` file name by default);
10854
+ * `emit` fires the callback the plugin bound, and `settle()` waits for
10855
+ * the property the handler wrote.
10856
+ * ```ts
10857
+ * host.window("picture").emit("reset");
10858
+ * await host.settle();
10859
+ * assert.equal(host.window("picture").props["brightness"], 0);
10860
+ * ```
7961
10861
  */
7962
10862
  window(id: string): FakeWindow;
7963
10863
  /**
7964
10864
  * The plugin's mounted panel (bottom bar, sidebar or backdrop). Throws when none is mounted. Its `props` use Slint (kebab-case) names; `emit` fires a callback on the panel's global.
10865
+ * @example Select a row in the plugin's sidebar panel
10866
+ * The test checks the panel sits in the sidebar and selects its third
10867
+ * row. `emit` fires a callback on the panel's global as a click would;
10868
+ * the plugin's handler runs, and `settle()` waits for what it sent.
10869
+ * ```ts
10870
+ * const panel = host.mounted();
10871
+ * assert.equal(panel.slot, "sidebar");
10872
+ * panel.emit("select", 2);
10873
+ * await host.settle();
10874
+ * ```
7965
10875
  */
7966
10876
  mounted(): Omit<FakeWindow, "callbacks" | "userClose"> & {
7967
10877
  /** `"bottom"`, `"sidebar"` or `"backdrop"`. */
@@ -7971,7 +10881,16 @@ class TestHost implements Transport {
7971
10881
  /** The last `panel.setInteraction(...)` locks, `null` before any. */
7972
10882
  locks: Json;
7973
10883
  };
7974
- /** The parameters of every request of one wire method, oldest first. */
10884
+ /**
10885
+ * The parameters of every request of one wire method, oldest first.
10886
+ * @example Check the request behind an OSD message
10887
+ * The test checks the last OSD request the plugin sent, before
10888
+ * translation. `sent` lists the parameters of every request of one wire
10889
+ * method, oldest first; `host.osd` holds the texts as shown instead.
10890
+ * ```ts
10891
+ * assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
10892
+ * ```
10893
+ */
7975
10894
  sent(method: string): Record<string, Json>[];
7976
10895
  /**
7977
10896
  * Wait until no request is in flight (and the SDK's follow-up passes ran),
@@ -7984,6 +10903,29 @@ class TestHost implements Transport {
7984
10903
  * for: on a loaded machine it can finish after `settle()` returns. Poll
7985
10904
  * the result you assert on instead (second example).
7986
10905
  * @param ms - Extra time to wait, for debounces and timers (default 0).
10906
+ * @example Wait past a debounced save
10907
+ * The plugin's store writes its file 300 ms after the last `save`, so
10908
+ * the test waits 400 ms before it checks what the plugin did next.
10909
+ * `settle(ms)` returns when no request is in flight and at least `ms`
10910
+ * have passed, so the store's timer has fired. The file write itself is
10911
+ * the plugin's own work, which `settle` does not wait for: a test that
10912
+ * reads the file calls `await store.flush()` instead.
10913
+ * ```ts
10914
+ * const store = plugin.storage.json("prefs", { defaults: { step: 5 } });
10915
+ * store.save({ step: 10 });
10916
+ * await host.settle(400); // past storage.json's 300 ms debounce
10917
+ * ```
10918
+ * @example Poll for work the plugin does on its own
10919
+ * The plugin reads and parses a file before it shows a status; `settle`
10920
+ * does not wait for that (it only waits for host requests), so the test
10921
+ * polls the property it asserts on, 20 ms at a time, for up to 2 seconds.
10922
+ * ```ts
10923
+ * const window = host.window("import");
10924
+ * window.emit("load-file"); // the plugin reads and parses the file itself
10925
+ * for (let i = 0; i < 100 && window.props["status-text"] !== expected; i++)
10926
+ * await host.settle(20);
10927
+ * assert.equal(window.props["status-text"], expected);
10928
+ * ```
7987
10929
  */
7988
10930
  settle(ms?: number): Promise<void>;
7989
10931
  /** Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it. */
@@ -7991,6 +10933,34 @@ class TestHost implements Transport {
7991
10933
  }
7992
10934
  ```
7993
10935
 
10936
+ Example: Preset the player, then react to another plugin's file
10937
+
10938
+ The test starts the plugin in a player with the dark theme, opens a file
10939
+ the way another plugin would, and checks the plugin's panel title. A host
10940
+ created first can be preset with data the plugin reads during
10941
+ activation; `startPlugin` then uses it. `openElsewhere` changes the
10942
+ source and broadcasts it, the plugin's listener updates its panel, and
10943
+ `settle()` waits for those requests before the assertion.
10944
+ ```ts
10945
+ import assert from "node:assert/strict";
10946
+ import test from "node:test";
10947
+ import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
10948
+ import activate from "../main.ts";
10949
+
10950
+ test("shows the playing file in the panel", async () => {
10951
+ const host = new TestHost();
10952
+ host.appearance = "vestra"; // read during activation
10953
+ const { stop } = await startPlugin(activate, {
10954
+ directory: new URL("..", import.meta.url),
10955
+ host,
10956
+ });
10957
+ host.openElsewhere("/media/clip.mp4"); // another plugin opened a file
10958
+ await host.settle();
10959
+ assert.equal(host.mounted().props["title"], "clip.mp4");
10960
+ await stop();
10961
+ });
10962
+ ```
10963
+
7994
10964
  ### startPlugin
7995
10965
 
7996
10966
  Activate a plugin against a test host, the way the player's runner does:
@@ -8044,6 +11014,47 @@ function startPlugin(activate: (plugin: Plugin) => unknown, options?: {
8044
11014
  }>;
8045
11015
  ```
8046
11016
 
11017
+ Example: Test that a setting survives a restart
11018
+
11019
+ The test turns on the 10-step option, stops the plugin, starts it again
11020
+ on the same data directory and checks the option is still on. Both runs
11021
+ get a directory the test made (`dataDirectory`): `stop()` disposes the
11022
+ plugin like the player, which writes its pending stores there, and only
11023
+ removes the host's own temporary directory, so the second run reads what
11024
+ the first one saved.
11025
+ ```ts
11026
+ import assert from "node:assert/strict";
11027
+ import { mkdtempSync, rmSync } from "node:fs";
11028
+ import { tmpdir } from "node:os";
11029
+ import { join } from "node:path";
11030
+ import test from "node:test";
11031
+ import { startPlugin } from "@vestra-player/sdk/testing";
11032
+ import activate from "../main.ts";
11033
+
11034
+ test("remembers the step across restarts", async () => {
11035
+ const directory = new URL("..", import.meta.url);
11036
+ const data = mkdtempSync(join(tmpdir(), "data-"));
11037
+ try {
11038
+ const first = await startPlugin(activate, { directory, dataDirectory: data });
11039
+ try {
11040
+ await first.host.command("step-10").run();
11041
+ } finally {
11042
+ await first.stop(); // flushes the plugin's stores into `data`
11043
+ }
11044
+
11045
+ // A restart: a new host and a new plugin instance on the same data.
11046
+ const second = await startPlugin(activate, { directory, dataDirectory: data });
11047
+ try {
11048
+ assert.equal(second.host.command("step-10").state.checked, true);
11049
+ } finally {
11050
+ await second.stop();
11051
+ }
11052
+ } finally {
11053
+ rmSync(data, { recursive: true, force: true });
11054
+ }
11055
+ });
11056
+ ```
11057
+
8047
11058
  ### CapabilityInfo
8048
11059
 
8049
11060
  What one manifest capability is for: the members it opens, the
@@ -8090,6 +11101,27 @@ and `plugin.packages.list`).
8090
11101
  const CAPABILITY_INFO: Readonly<Record<string, CapabilityInfo>>;
8091
11102
  ```
8092
11103
 
11104
+ Example: Check in a test that the manifest declares every required capability
11105
+
11106
+ The test fails, naming the missing capability, when the manifest declares
11107
+ a capability without one its members also need. It reads the plugin's own
11108
+ `manifest.json` and looks each declared name up in `CAPABILITY_INFO`,
11109
+ whose `requires` lists the capabilities that must be declared with it;
11110
+ at run time the player would refuse such a call with code `capability`.
11111
+ ```ts
11112
+ import assert from "node:assert/strict";
11113
+ import { readFile } from "node:fs/promises";
11114
+ import { CAPABILITY_INFO } from "@vestra-player/sdk/testing";
11115
+
11116
+ const manifest: { capabilities: string[] } = JSON.parse(
11117
+ await readFile(new URL("../manifest.json", import.meta.url), "utf8"),
11118
+ );
11119
+ for (const capability of manifest.capabilities)
11120
+ // An unknown name has no entry: `vestra-plugin check` reports it.
11121
+ for (const needed of CAPABILITY_INFO[capability]?.requires ?? [])
11122
+ assert.ok(manifest.capabilities.includes(needed), needed);
11123
+ ```
11124
+
8093
11125
  ### MESSAGE_LIMIT
8094
11126
 
8095
11127
  The longest message a plugin may send the player, in UTF-8 bytes of one
@@ -8106,8 +11138,9 @@ const MESSAGE_LIMIT: number;
8106
11138
 
8107
11139
  The player's command registry caps, the same values as the player's own
8108
11140
  table (the player's tests check that they agree). They are runaway guards,
8109
- not product limits: a command added past them fails with code `limit`
8110
- (its `ready` rejects; during activation, activation fails), commands
11141
+ not product limits: a command added past them is refused by the player
11142
+ with code `limit` (its `ready` rejects; during activation, activation
11143
+ fails; later the failure goes to `Scope.onError`), commands
8111
11144
  already registered are unaffected, and disposing a command frees its slot
8112
11145
  at once. TestHost enforces the same caps.
8113
11146
 
@@ -8123,6 +11156,22 @@ const COMMAND_LIMITS: {
8123
11156
  };
8124
11157
  ```
8125
11158
 
11159
+ Example: Cap a chapter menu well below the command limit
11160
+
11161
+ The Chapters submenu lists at most 50 chapters, however many the file
11162
+ has. Every menu item is a command, and the player counts them per plugin.
11163
+ `add` itself returns at once and never throws `limit`: past `perPlugin`
11164
+ the player refuses the registration, which fails the activation or, later,
11165
+ reaches `Scope.onError`. A try/catch around `add` cannot catch that, so a
11166
+ menu built from media content keeps its own cap and leaves room for the
11167
+ plugin's fixed commands.
11168
+ ```ts
11169
+ import { COMMAND_LIMITS } from "@vestra-player/sdk/testing";
11170
+
11171
+ // 20 slots stay free for the plugin's own commands.
11172
+ const shown = chapters.slice(0, Math.min(50, COMMAND_LIMITS.perPlugin - 20));
11173
+ ```
11174
+
8126
11175
  ### MENU_IDS
8127
11176
 
8128
11177
  The player's built-in top-level menus, by id. Write `@<id>` as the first
@@ -8137,6 +11186,21 @@ agrees with the player's.
8137
11186
  const MENU_IDS: readonly ["open", "playback", "subtitles", "video", "audio", "screen", "settings"];
8138
11187
  ```
8139
11188
 
11189
+ Example: Put a command into the built-in Video menu
11190
+
11191
+ "Blur" appears in the player's Video menu in every interface language.
11192
+ The player replaces a leading `@video` with that menu's key ("Video")
11193
+ before it merges paths, so the item joins the built-in menu; an id that
11194
+ is not in `MENU_IDS` makes `add` fail with `invalid`, which a test can
11195
+ rule out ahead of time.
11196
+ ```ts
11197
+ import assert from "node:assert/strict";
11198
+ import { MENU_IDS } from "@vestra-player/sdk/testing";
11199
+
11200
+ plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run() {} });
11201
+ assert.ok(MENU_IDS.includes("video")); // a menu id, not a title
11202
+ ```
11203
+
8140
11204
  ### BAR_BUTTON_ICONS
8141
11205
 
8142
11206
  The icons a bottom-bar button may name (`CommandOptions.button.icon`):
@@ -8148,6 +11212,19 @@ that this list is its icon set.
8148
11212
  const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back", "forward", "open", "volume", "mute", "playlist", "detach", "attach", "close", "failed", "move-top", "move-up", "move-down", "move-bottom", "search", "chapter", "bookmark", "check-task", "check-box", "menu-check", "submenu", "scroll-up", "scroll-down", "caret-down", "sort-asc", "sort-desc", "tag-remove", "preview-play", "add", "remove", "sort", "fullscreen", "exit-fullscreen", "store", "ai"];
8149
11213
  ```
8150
11214
 
11215
+ Example: Check in a test that the button's icon exists
11216
+
11217
+ The test fails before release if the plugin's bar button names an icon
11218
+ the player does not have. `commands.add` would refuse an unknown name
11219
+ with `invalid`; the player's tests keep `BAR_BUTTON_ICONS` equal to its
11220
+ icon set, so checking against it is the same check without a player.
11221
+ ```ts
11222
+ import assert from "node:assert/strict";
11223
+ import { BAR_BUTTON_ICONS } from "@vestra-player/sdk/testing";
11224
+
11225
+ assert.ok(BAR_BUTTON_ICONS.includes("store")); // the icon main.ts uses
11226
+ ```
11227
+
8151
11228
  ### BAR_BUTTON_LIMITS
8152
11229
 
8153
11230
  The player's limits on bottom-bar buttons (`CommandOptions.button`), the
@@ -8201,6 +11278,25 @@ built-in plugins declare.
8201
11278
  const BUILTIN_SUBMENUS: readonly BuiltinSubmenu[];
8202
11279
  ```
8203
11280
 
11281
+ Example: Add an item to the built-in Snapshot submenu
11282
+
11283
+ "Copy Snapshot" appears in Video ▸ Snapshot next to the built-in capture
11284
+ plugin's items. Paths merge by their English keys, so a command whose
11285
+ path continues with the submenu's keys (`["Video", "Snapshot"]`, the menu
11286
+ written as `@video`) lands in the same submenu whatever the interface
11287
+ language.
11288
+ ```ts
11289
+ import { BUILTIN_SUBMENUS } from "@vestra-player/sdk/testing";
11290
+
11291
+ const snapshot = BUILTIN_SUBMENUS.find((menu) => menu.path.at(-1) === "Snapshot")!;
11292
+ plugin.commands.add({
11293
+ id: "snapshot-to-clipboard",
11294
+ // ["@video", "Snapshot", "Copy Snapshot"]; @video stands for "Video".
11295
+ path: ["@video", ...snapshot.path.slice(1), "Copy Snapshot"],
11296
+ run: copySnapshot,
11297
+ });
11298
+ ```
11299
+
8204
11300
  ### BuiltinCommand
8205
11301
 
8206
11302
  A command the player or one of its built-in plugins registers: an entry
@@ -8240,6 +11336,22 @@ list agrees with what the player and its built-in plugins register.
8240
11336
  const BUILTIN_COMMANDS: readonly BuiltinCommand[];
8241
11337
  ```
8242
11338
 
11339
+ Example: Check in a test that a default key is still free
11340
+
11341
+ The test fails if the player or a built-in plugin already binds Ctrl+K
11342
+ globally, before users ever see a conflict. It collects the default
11343
+ bindings of every built-in command that is not limited to its panel's
11344
+ focus (`focus: null`) and looks for the key the plugin wants; bindings
11345
+ are canonical, so compare them as the player writes them.
11346
+ ```ts
11347
+ import assert from "node:assert/strict";
11348
+ import { BUILTIN_COMMANDS } from "@vestra-player/sdk/testing";
11349
+
11350
+ const taken = BUILTIN_COMMANDS.filter((command) => command.focus === null)
11351
+ .flatMap((command) => command.bindings);
11352
+ assert.ok(!taken.includes("Ctrl+K"));
11353
+ ```
11354
+
8243
11355
  ### LOCALES
8244
11356
 
8245
11357
  The player's interface languages: English (the source language) first,
@@ -8299,6 +11411,28 @@ palettes against its own.
8299
11411
  const THEME_PALETTES: Readonly<Record<"vestra" | "vestra-light", Readonly<Record<string, string>>>>;
8300
11412
  ```
8301
11413
 
11414
+ Example: Check in a test that a colour stays readable in both themes
11415
+
11416
+ The test fails if the plugin's own highlight colour gets too close to
11417
+ the panel background of either palette. Plugins draw with `Theme` tokens
11418
+ in Slint and the player switches palettes; a colour the plugin picks
11419
+ itself does not switch, so a test compares it with each palette's values
11420
+ (hex strings, `#rrggbb`).
11421
+ ```ts
11422
+ import assert from "node:assert/strict";
11423
+ import { THEME_PALETTES } from "@vestra-player/sdk/testing";
11424
+
11425
+ const highlight = "#e07000"; // the plugin's own orange
11426
+ const luminance = (hex: string) => {
11427
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
11428
+ return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; // rough, without gamma
11429
+ };
11430
+ for (const [name, palette] of Object.entries(THEME_PALETTES)) {
11431
+ const gap = Math.abs(luminance(highlight) - luminance(palette["bg-panel"]!));
11432
+ assert.ok(gap > 0.3, `${name}: highlight too close to bg-panel`);
11433
+ }
11434
+ ```
11435
+
8302
11436
  ### BINDING_INPUTS
8303
11437
 
8304
11438
  The names a binding (`CommandOptions.bindings`, `Bindings.set`) is made
@@ -8330,6 +11464,39 @@ const BINDING_INPUTS: Readonly<{
8330
11464
  }>;
8331
11465
  ```
8332
11466
 
11467
+ Example: Check in a test that the plugin's default keys are well formed
11468
+
11469
+ The test fails on a binding the player would refuse with `invalid`, such
11470
+ as modifiers out of order or a misspelt key name. `isBinding` applies the
11471
+ same grammar as the player's parser: the part after the last `+` is the
11472
+ input, everything before it modifiers in the order of `modifiers`; an
11473
+ input is a listed name or one other printable character, and `Shift`
11474
+ never goes with a digit or such a character.
11475
+ ```ts
11476
+ import assert from "node:assert/strict";
11477
+ import { BINDING_INPUTS } from "@vestra-player/sdk/testing";
11478
+
11479
+ const { modifiers, letters, digits, functionKeys, named, mouse } = BINDING_INPUTS;
11480
+ const names = new Set([...letters, ...functionKeys, ...named, ...mouse]);
11481
+ function isBinding(binding: string): boolean {
11482
+ // "Ctrl++" is not a binding: the + key is written "Plus".
11483
+ const parts = binding.split("+");
11484
+ const input = parts.pop()!;
11485
+ const order = parts.map((m) => (modifiers as readonly string[]).indexOf(m));
11486
+ // Each modifier known, at most once, in the listed order.
11487
+ if (order.some((at, i) => at < 0 || (i > 0 && at <= order[i - 1]!)))
11488
+ return false;
11489
+ if (names.has(input)) return true; // letters are upper case: "A"
11490
+ const typed = digits.includes(input) || /^[^\p{L}\p{N}\p{C}\s+]$/u.test(input);
11491
+ // ">" rather than "Shift+.": the character already says Shift.
11492
+ return typed && !parts.includes("Shift");
11493
+ }
11494
+ for (const binding of ["Ctrl+Shift+PageUp", "Alt+Mouse:WheelUp", ">"])
11495
+ assert.ok(isBinding(binding), binding);
11496
+ for (const binding of ["Shift+Ctrl+A", "Ctrl+a", "Shift+."])
11497
+ assert.ok(!isBinding(binding), binding);
11498
+ ```
11499
+
8333
11500
  ### OSD_LIMITS
8334
11501
 
8335
11502
  The player's limits on the on-screen display (Osd), counted on
@@ -8353,6 +11520,23 @@ const OSD_LIMITS: Readonly<{
8353
11520
  }>;
8354
11521
  ```
8355
11522
 
11523
+ Example: Keep a long list inside the information block's limits
11524
+
11525
+ The block shows the first 32 rows of a list, each cut at 60 columns. The
11526
+ player counts the text as shown (after it is translated and formatted); a
11527
+ block over `panelLines` lines or `panelBytes` bytes is refused whole with
11528
+ `invalid`, reported to `Scope.onError` because `set` never throws, so
11529
+ the plugin trims the list itself (for very long non-ASCII rows, check the
11530
+ bytes too).
11531
+ ```ts
11532
+ import { OSD_LIMITS } from "@vestra-player/sdk/testing";
11533
+
11534
+ const panel = plugin.osd.panel();
11535
+ const lines = rows.slice(0, OSD_LIMITS.panelLines);
11536
+ // maxColumns cuts each longer line and ends it with "…".
11537
+ panel.set(lines.join("\n"), null, { maxColumns: 60 });
11538
+ ```
11539
+
8356
11540
  ### SESSION_STORAGE_LIMITS
8357
11541
 
8358
11542
  The player's limits on SessionStorage. A key outside them fails
@@ -8374,6 +11558,29 @@ const SESSION_STORAGE_LIMITS: Readonly<{
8374
11558
  }>;
8375
11559
  ```
8376
11560
 
11561
+ Example: Remember a position per URL without breaking the limits
11562
+
11563
+ The plugin keeps the last position of each URL for this player session
11564
+ and skips a URL too long to be a key. Keys are counted in UTF-8 bytes, so
11565
+ the length is measured with `Buffer.byteLength`, not `length`; a `set`
11566
+ that would take the plugin's entries over 1 MiB rejects with `limit` and
11567
+ changes nothing, so the plugin clears the old entries and goes on.
11568
+ ```ts
11569
+ import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
11570
+
11571
+ const session = plugin.storage.session;
11572
+ if (Buffer.byteLength(url) <= SESSION_STORAGE_LIMITS.keyBytes) {
11573
+ try {
11574
+ await session.set(url, position);
11575
+ } catch (error) {
11576
+ if ((error as PluginError).code !== "limit") throw error;
11577
+ // Full: start over rather than lose the newest position.
11578
+ for (const key of await session.keys()) await session.delete(key);
11579
+ await session.set(url, position);
11580
+ }
11581
+ }
11582
+ ```
11583
+
8377
11584
  ### SECRET_LIMITS
8378
11585
 
8379
11586
  The player's limits on Secrets. A name or value outside them
@@ -8391,6 +11598,32 @@ const SECRET_LIMITS: Readonly<{
8391
11598
  }>;
8392
11599
  ```
8393
11600
 
11601
+ Example: Store an API token only when it fits
11602
+
11603
+ The plugin keeps a token the user pasted, or tells them it is empty or
11604
+ too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
11605
+ `Buffer.byteLength`; `secrets.set` would refuse other values with
11606
+ `invalid`. The store itself can still refuse (`unsupported` without a
11607
+ system store, `unavailable` or `failed` when it denies access): only
11608
+ those codes get the "could not be saved" message, anything else (a
11609
+ missing `secrets` capability) is a bug and is thrown on.
11610
+ ```ts
11611
+ import { SECRET_LIMITS } from "@vestra-player/sdk/testing";
11612
+
11613
+ if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
11614
+ await plugin.osd.show("Paste a token of at most 1024 bytes.");
11615
+ } else {
11616
+ try {
11617
+ await plugin.secrets.set("api-token", token);
11618
+ } catch (error) {
11619
+ const { code } = error as PluginError;
11620
+ if (!["unsupported", "unavailable", "failed"].includes(code)) throw error;
11621
+ // No keychain or access denied: ask again next time.
11622
+ await plugin.osd.show("The token could not be saved.");
11623
+ }
11624
+ }
11625
+ ```
11626
+
8394
11627
  ### UI_VALUE_LIMITS
8395
11628
 
8396
11629
  The player's limits on one property value (a `PropValue` as written
@@ -8424,6 +11657,35 @@ const UI_VALUE_LIMITS: {
8424
11657
  };
8425
11658
  ```
8426
11659
 
11660
+ Example: Send a long playlist as a window of rows
11661
+
11662
+ A list view shows any number of entries while each write stays small:
11663
+ the plugin sends only the rows around the scroll position plus the total,
11664
+ and the view reports where it scrolled to. After the `scrolled` handler
11665
+ returns, the SDK evaluates the `bind` function again and sends the new
11666
+ window. Each row object and each of its fields count as one value toward
11667
+ `values`, and so does the array holding them.
11668
+ ```ts
11669
+ import { UI_VALUE_LIMITS } from "@vestra-player/sdk/testing";
11670
+
11671
+ // 200 rows, or fewer if the limits were smaller: 2 fields per row make
11672
+ // 3 values (the row and its fields), plus 1 for the array.
11673
+ const size = Math.min(
11674
+ 200,
11675
+ UI_VALUE_LIMITS.arrayRows,
11676
+ Math.floor((UI_VALUE_LIMITS.values - 1) / 3),
11677
+ );
11678
+ let first = 0;
11679
+ list.bind(() => ({
11680
+ total: entries.length,
11681
+ rows: entries.slice(first, first + size),
11682
+ }));
11683
+ // The Slint view reports its first visible row; the window follows it.
11684
+ list.on("scrolled", (row: number) => {
11685
+ first = Math.max(0, row - Math.floor(size / 2));
11686
+ });
11687
+ ```
11688
+
8427
11689
  ### IMAGE_LIMITS
8428
11690
 
8429
11691
  The player's runtime image limits (`host/src/ui_image.rs`): a file's
@@ -8474,6 +11736,29 @@ class HostError extends Error {
8474
11736
  }
8475
11737
  ```
8476
11738
 
11739
+ Example: Simulate the player refusing a clipboard write in a test
11740
+
11741
+ The test proves the plugin sees a refused write as an error with code
11742
+ `failed`. `host.handle` replaces the test host's handler for one request
11743
+ method; the `HostError` it throws travels back like the player's own
11744
+ refusal, so the call rejects with that `code` and is not retried.
11745
+ `host.reset` puts the default handler back for later steps.
11746
+ ```ts
11747
+ import assert from "node:assert/strict";
11748
+ import { HostError, startPlugin } from "@vestra-player/sdk/testing";
11749
+ import activate from "../main.ts";
11750
+
11751
+ const { host, plugin, stop } = await startPlugin(activate, {
11752
+ directory: new URL("..", import.meta.url),
11753
+ });
11754
+ host.handle("clipboard.writeText", () => {
11755
+ throw new HostError("failed", "the system refused the write");
11756
+ });
11757
+ await assert.rejects(plugin.clipboard.writeText("x"), { code: "failed" });
11758
+ host.reset("clipboard.writeText"); // writes succeed again
11759
+ await stop();
11760
+ ```
11761
+
8477
11762
  ## Internal types the API refers to
8478
11763
 
8479
11764
  Not exported (do not import them); listed because public declarations use them.
@@ -9704,11 +12989,13 @@ export default async function activate(plugin: Plugin) {
9704
12989
  let mark: number | null = null;
9705
12990
 
9706
12991
  const window = plugin.windows.create("ui/bookmark.slint");
12992
+ // Re-evaluated after every handler and player change; only changes are sent.
9707
12993
  window.bind(() => ({
9708
12994
  label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
9709
12995
  canMark: player.state.position !== null,
9710
12996
  canJump: mark !== null && player.state.can.seek,
9711
12997
  }));
12998
+ // The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
9712
12999
  window.on("mark", () => (mark = player.state.position));
9713
13000
  window.on("jump", () => player.seek(mark!));
9714
13001
 
@@ -9716,7 +13003,7 @@ export default async function activate(plugin: Plugin) {
9716
13003
  id: "bookmark.open",
9717
13004
  path: ["@playback", "Bookmark…"],
9718
13005
  order: 270,
9719
- run: () => window.show(),
13006
+ run: () => window.show(), // opens it, or brings back the open one
9720
13007
  });
9721
13008
  }
9722
13009
  ```
@@ -9872,6 +13159,7 @@ export default async function activate(plugin: Plugin) {
9872
13159
  const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
9873
13160
  title: "Example list",
9874
13161
  });
13162
+ // Sent when the panel mounts, then whenever a value changes.
9875
13163
  panel.bind(() => ({ rows: items, selected }));
9876
13164
 
9877
13165
  const first = plugin.commands.add({
@@ -9889,7 +13177,9 @@ export default async function activate(plugin: Plugin) {
9889
13177
  },
9890
13178
  });
9891
13179
 
13180
+ // Callbacks of the bridge global; `bind` above sends the new selection.
9892
13181
  panel.on("select", (index: number) => (selected = index));
13182
+ // x and y: the pointer in window pixels, from the Slint file.
9893
13183
  panel.on("contextMenu", (x: number, y: number) =>
9894
13184
  panel.showMenu([first, null, remove], { x, y }),
9895
13185
  );
@@ -10400,6 +13690,7 @@ import type { Plugin } from "@vestra-player/sdk";
10400
13690
 
10401
13691
  /** A tool window that shows a Markdown document. */
10402
13692
  export default async function activate(plugin: Plugin) {
13693
+ // Relative links and images in sample.md resolve against this folder.
10403
13694
  const directory = fileURLToPath(new URL(".", import.meta.url));
10404
13695
  const window = plugin.windows.create("ui/document.slint");
10405
13696
  plugin.commands.add({
@@ -10407,6 +13698,7 @@ export default async function activate(plugin: Plugin) {
10407
13698
  path: ["Examples", "Markdown example…"],
10408
13699
  run: async () => {
10409
13700
  const text = readFileSync(new URL("sample.md", import.meta.url), "utf8");
13701
+ // `doc` is the window's [MarkdownBlock] property, drawn by MarkdownView.
10410
13702
  window.set({ doc: await plugin.ui.markdown(text, { base: directory }) });
10411
13703
  await window.show();
10412
13704
  },