@vestra-player/sdk 1.5.31 → 1.5.33

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,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.31: full reference
1
+ # @vestra-player/sdk 1.5.33: 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
 
@@ -1134,7 +1134,7 @@ bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 })); //
1134
1134
 
1135
1135
  #### Next and previous
1136
1136
 
1137
- The player's Next and Previous items, keys and buttons ask the **sequence provider**, one plugin at a time, first come first served. The built-in playlist registers as provider when it starts, so a plugin with its own list becomes the provider only while no other plugin holds it (for example with the playlist disabled). Any plugin can ask the provider with `player.next()` and `player.previous()`:
1137
+ The player's Next and Previous items, keys and buttons ask the **sequence provider**, one plugin at a time, first come first served. The built-in playlist registers as provider when it starts and a built-in plugin cannot be disabled, so in practice another plugin's `provideSequence` is refused (`sequence-taken`); only a development load that replaces the playlist under its id gets it. Any plugin can ask the provider with `player.next()` and `player.previous()`:
1138
1138
 
1139
1139
  ```ts
1140
1140
  declare const items: string[];
@@ -1229,6 +1229,85 @@ Names are 1–64 of `a-z 0-9 . _ -`, values 1–1024 bytes, at most 256 names. H
1229
1229
 
1230
1230
  For anything larger (a cache, downloaded files), write under `plugin.dataDirectory` with Node's `fs`. The player deletes the directory only when the plugin is uninstalled together with its data (`packages.uninstall` with `removeData`).
1231
1231
 
1232
+ ## How-to: Load subtitles from a plugin
1233
+
1234
+ **Goal:** your plugin finds subtitles for the playing file (on disk, on a website, by converting another format), adds them as a track, selects them, and optionally styles them.
1235
+
1236
+ #### Capabilities
1237
+
1238
+ ```json
1239
+ {
1240
+ "capabilities": ["subtitles.read", "subtitles.control", "events"]
1241
+ }
1242
+ ```
1243
+
1244
+ `subtitles.read` reads the tracks and the selection; `subtitles.control` changes them (and needs `subtitles.read` too, because every change reads the current selection first); `events` makes the SDK read `plugin.subtitles.state` again when the source or its phase changes. The player has no subtitle event: a selection the user or another plugin changes shows only after `refresh()`.
1245
+
1246
+ #### Read the tracks
1247
+
1248
+ `plugin.subtitles.state` is `null` until first read; `refresh()` reads it from the player. Each track has an `id` (compare tracks by id), a `kind` (`embedded` in the file or `external`, loaded from a file), a `title` and a `language`.
1249
+
1250
+ ```ts
1251
+ const state = plugin.subtitles.state ?? (await plugin.subtitles.refresh());
1252
+ for (const track of state.tracks)
1253
+ console.log(track.id, track.kind, track.language ?? "?", track.title);
1254
+ // The primary and the secondary subtitle; null when off.
1255
+ console.log(state.selected?.id, state.secondary?.id);
1256
+ ```
1257
+
1258
+ #### Select a track, and handle `stale`
1259
+
1260
+ Without options a change reads the newest state itself and applies to it, so it almost never fails. Embedded tracks are chosen by number, though: a `select` after another file opened would pick that file's track with the same number. Pass the state you looked at as `ifCurrent`; then the change fails with `stale`, and does nothing, when the user picked another track or another file opened in between:
1261
+
1262
+ ```ts
1263
+ const state = await plugin.subtitles.refresh();
1264
+ const english = state.tracks.find((track) => track.language === "en");
1265
+ if (english)
1266
+ await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
1267
+ if (error.code !== "stale") throw error; // the user changed it meanwhile
1268
+ });
1269
+ ```
1270
+
1271
+ `select(track, { role: "secondary" })` sets the second line shown together with the primary one (a text track other than the primary); `select(null)` turns a role off. A new source clears the secondary subtitle.
1272
+
1273
+ #### Upload a file
1274
+
1275
+ `load` takes the file's bytes: UTF-8 SRT, or ASS/SSA with `format: "ass"`, at most 2 MiB (SRT up to 10,000 cues, ASS up to 20,000 `Dialogue:` lines). Convert other formats (WebVTT, SAMI) to SRT first. The player adds an `external` track to the current source only, selects it unless `select: "none"`, and resolves with the track once the upload is committed.
1276
+
1277
+ ```ts
1278
+ import { readFile } from "node:fs/promises";
1279
+ import { basename } from "node:path";
1280
+ declare const path: string;
1281
+ // ---cut---
1282
+ const bytes = await readFile(path);
1283
+ const track = await plugin.subtitles.load(bytes, {
1284
+ format: path.endsWith(".ass") ? "ass" : "srt",
1285
+ title: basename(path), // data: shown as it is
1286
+ select: "primary",
1287
+ signal: AbortSignal.timeout(30_000), // discards the upload if not committed in time
1288
+ });
1289
+ await plugin.osd.show("Loaded {}", [track.title]);
1290
+ ```
1291
+
1292
+ A title that mixes words with data is a text value the player translates: `title: plugin.i18n.text("{} · {}", [plugin.i18n.text("Downloaded"), name])`. `remove(track)` takes away a track this plugin loaded; the next source removes it anyway.
1293
+
1294
+ #### Style the subtitles
1295
+
1296
+ `setStyle` sets this plugin's style layer: font (one of `state.fonts`), size at the 1080p reference height, colour, outline, margin, timing offset, box. Each plugin has one layer and its newest call replaces it. When several plugins set a layer, the player shows the one whose **manifest id sorts last**, whatever the order of the calls; `setStyle(null)` takes yours off.
1297
+
1298
+ ```ts
1299
+ const { fonts } = await plugin.subtitles.refresh();
1300
+ if (fonts.length)
1301
+ await plugin.subtitles.setStyle({
1302
+ fontId: fonts[0]!.id,
1303
+ fontSize: 40,
1304
+ color: "white",
1305
+ outline: 2,
1306
+ marginPercent: 5,
1307
+ offsetMs: 0, // positive shows them later
1308
+ });
1309
+ ```
1310
+
1232
1311
  ## How-to: Keep a slider responsive while dragging
1233
1312
 
1234
1313
  **Goal:** a slider in your window drives something live (a video filter, the volume, a preview), the effect follows the pointer while the user drags, and nothing lags behind or queues up.
@@ -3494,9 +3573,32 @@ flushes, a running `tools.run` process is killed and a listener is
3494
3573
  unsubscribed. A Media is the exception: disposing its scope does
3495
3574
  **not** close the media; call Media.close for that.
3496
3575
 
3576
+ Every handle a plugin creates is a `Disposable` owned by the scope that
3577
+ created it; disposing the scope (or stopping the plugin) disposes them in
3578
+ reverse creation order, so a plugin rarely calls `dispose` itself.
3579
+
3580
+ - @capability None of its own: that of the handle.
3581
+
3497
3582
  ```ts
3498
3583
  interface Disposable {
3499
- /** Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects. */
3584
+ /**
3585
+ * Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects.
3586
+ * @capability None of its own: that of the handle.
3587
+ * @throws Never.
3588
+ * @example Remove a command when its feature is turned off
3589
+ * A "Turn feature off" item removes the feature's own menu item at once,
3590
+ * before the plugin stops. `dispose` takes the command out of the menu and
3591
+ * the binding table and resolves once the player confirmed it; the id can
3592
+ * be added again later.
3593
+ * ```ts
3594
+ * const extra = plugin.commands.add({ id: "extra", path: ["Tools", "Extra"], run() {} });
3595
+ * plugin.commands.add({
3596
+ * id: "extra-off",
3597
+ * path: ["Tools", "Turn extra off"],
3598
+ * run: () => extra.dispose(), // never rejects
3599
+ * });
3600
+ * ```
3601
+ */
3500
3602
  dispose(): Promise<void>;
3501
3603
  }
3502
3604
  ```
@@ -3522,7 +3624,6 @@ change).
3522
3624
  | code | meaning |
3523
3625
  | --- | --- |
3524
3626
  | `stale` | A condition no longer held (the source or the subtitle selection changed); nothing was done. See Conditional. |
3525
- | `staleDigest` | PackageCandidate.install: the package's files changed after it was validated; validate it again. |
3526
3627
  | `capability` | The manifest does not declare the capability the call needs. |
3527
3628
  | `invalid` | A bad argument (out of range, malformed, unknown id). |
3528
3629
  | `missing` | The target does not exist (an empty panel position, a missing file, an unknown command). |
@@ -3542,7 +3643,7 @@ change).
3542
3643
  | `backend-uncertain` | The player's backend blocked a filter stage; call `retry()`. |
3543
3644
  | `limit` | A player-wide limit was reached (for example the number of commands), a property value is over the limits of PropValue, or a request is longer than the player's 1 MiB message. |
3544
3645
  | `reserved` | Packages.validate, PackageCandidate.install: the id is `vestra` or starts with `vestra.` (any case), which only the player's built-in plugins use (player 0.4.1). |
3545
- | `versionMismatch` | The package needs a newer player (`minPlayerVersion`) or another `apiVersion`. |
3646
+ | `versionMismatch` | The package needs a newer player (`minPlayerVersion`). |
3546
3647
  | `ui-prepare` | The player could not prepare a mounted panel or a settings page (its Slint file does not compile, a bound property is not declared, or another plugin's panel holds the slot); `message` has the diagnostics. |
3547
3648
  | `ui` | The player could not apply a property to a tool window or a settings page, or could not build the Options window (a property the Slint file does not declare, a value of the wrong type); `message` has Slint's reason. Also reported to Scope.onError without a request: a callback whose arguments the player could not convert, a property it could not show again when it rebuilt a view (an image file removed since). |
3548
3649
  | `host` | Any other failure inside the player; `message` is the player's reason. |
@@ -3605,6 +3706,14 @@ Reading Scope.window, Scope.app, Scope.appearance
3605
3706
  `.theme`, Scope.ui, Scope.tools, Scope.storage and
3606
3707
  Scope.system needs none.
3607
3708
 
3709
+ Every module of the API (`commands`, `windows`, `player`…) is reachable
3710
+ from a scope, and every handle made through it belongs to it. A child
3711
+ scope from Scope.scope is the unit for features that come and
3712
+ go: dispose the child and everything it created is released, while the
3713
+ rest of the plugin keeps running.
3714
+
3715
+ - @capability That of each module (see the modules).
3716
+
3608
3717
  ```ts
3609
3718
  interface Scope extends Disposable {
3610
3719
  /** Menu commands and their default bindings (`commands`). */
@@ -3628,10 +3737,16 @@ interface Scope extends Disposable {
3628
3737
  */
3629
3738
  filter(id: string): VideoFilter;
3630
3739
  /**
3631
- * The display layer (needs `processing.video`). The first plugin to call it owns the layer; another plugin's view fails with `conflict` until the owner disposes or stops. A second call in the same plugin throws.
3740
+ * The display layer (needs `processing.video`). The first plugin to call it owns the layer; another plugin's view fails with `conflict` until the owner disposes or stops.
3741
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3742
+ * @throws Error without a code (throws) A second call in the same plugin while the first view is not disposed.
3632
3743
  */
3633
3744
  view(): VideoView;
3634
- /** The GPU renderer's options (needs `processing.video`), owned like the display layer. `failed` with `unsupported` under software rendering. */
3745
+ /**
3746
+ * The GPU renderer's options (needs `processing.video`), owned like the display layer. Under software rendering the handle is `failed` with `unsupported` after the player's first answer, and `onError` hears it.
3747
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3748
+ * @throws Error without a code (throws) A second call in the same plugin while the first handle is not disposed.
3749
+ */
3635
3750
  render(): RenderOptions;
3636
3751
  };
3637
3752
  /** Audio processing: this plugin's filter chain and delay (`processing.audio`, `playback.read` and `events`, as for VideoFilter). */
@@ -3685,17 +3800,57 @@ interface Scope extends Disposable {
3685
3800
  readonly i18n: I18n;
3686
3801
  /** Pages in the player's options window (`ui.settings`). */
3687
3802
  readonly settings: Settings;
3688
- /** A child scope; disposing it releases only what was created through it. The child is itself owned by this scope. */
3803
+ /**
3804
+ * A child scope; disposing it releases only what was created through it. The child is itself owned by this scope.
3805
+ * @capability None.
3806
+ * @example Tie a temporary window and its timer to one feature
3807
+ * A "Live stats" feature opens a window and starts a timer; turning it off
3808
+ * releases both together. Everything made through the child scope, and
3809
+ * cleanups registered on it, go when the child is disposed; the parent
3810
+ * scope is untouched.
3811
+ * ```ts
3812
+ * let live: Scope | null = null;
3813
+ * plugin.commands.add({
3814
+ * id: "live-stats",
3815
+ * path: ["Tools", "Live stats"],
3816
+ * checked: () => live !== null,
3817
+ * run: async () => {
3818
+ * if (live) {
3819
+ * await live.dispose(); // closes the window, stops the timer
3820
+ * live = null;
3821
+ * return;
3822
+ * }
3823
+ * live = plugin.scope();
3824
+ * const window = live.windows.create("ui/stats.slint");
3825
+ * const timer = setInterval(() => live?.update(), 1000);
3826
+ * live.onDispose(() => clearInterval(timer));
3827
+ * await window.show();
3828
+ * },
3829
+ * });
3830
+ * ```
3831
+ */
3689
3832
  scope(): Scope;
3690
3833
  /**
3691
3834
  * Adopt an existing handle: it is disposed together with this scope.
3692
3835
  * @param item - Any Disposable, for example a handle created through another scope.
3693
3836
  * @returns `item` itself, for chaining.
3837
+ * @capability None.
3838
+ * @example Dispose a handle from another scope together with this one
3839
+ * A filter made through the root scope is withdrawn when a child feature
3840
+ * ends. `add` hands an existing disposable to this scope, which then
3841
+ * disposes it with its own handles; it returns the same handle for
3842
+ * chaining.
3843
+ * ```ts
3844
+ * const feature = plugin.scope();
3845
+ * const blur = feature.add(plugin.video.filter("blur")); // now ends with `feature`
3846
+ * blur.set([{ kind: "gblur", sigma: 4, steps: 3 }]);
3847
+ * ```
3694
3848
  */
3695
3849
  add<T extends Disposable>(item: T): T;
3696
3850
  /**
3697
3851
  * Run `cleanup` when this scope is disposed (for example `clearInterval` or closing a socket). It may be async; errors are logged.
3698
3852
  * @returns A handle that cancels the registration early (without running `cleanup`).
3853
+ * @capability None.
3699
3854
  * @example Stop a timer when the plugin stops
3700
3855
  * A once-a-second refresh runs as long as the plugin does. `onDispose`
3701
3856
  * runs the cleanup when the scope is disposed, which for `plugin` is
@@ -3714,6 +3869,7 @@ interface Scope extends Disposable {
3714
3869
  * Rejections of async handlers count too, and so do failures of
3715
3870
  * registrations made after activation (a command whose `ready` rejects,
3716
3871
  * a property write the player refused).
3872
+ * @capability None.
3717
3873
  * @example Show errors from handlers on the OSD
3718
3874
  * When a menu command, a window callback or a listener of the plugin
3719
3875
  * fails, the user sees why. The SDK catches what those handlers throw
@@ -3733,6 +3889,23 @@ interface Scope extends Disposable {
3733
3889
  * changing state from your own timers or between awaits of a long task.
3734
3890
  *
3735
3891
  * Several calls within one microtask are merged into one pass.
3892
+ * @capability None.
3893
+ * @example Show progress from a long task between awaits
3894
+ * A progress label moves while a long download runs, not only when it
3895
+ * ends. The SDK re-evaluates bound values after each handler and when its
3896
+ * promise settles; state changed between the awaits of a long handler
3897
+ * shows only after an explicit `update()`.
3898
+ * ```ts
3899
+ * let done = 0;
3900
+ * window.bind(() => ({ progress: done }));
3901
+ * window.on("start", async () => {
3902
+ * for (let part = 0; part < 10; part++) {
3903
+ * await downloadPart(part);
3904
+ * done = part + 1;
3905
+ * plugin.update(); // send `progress` now, not when the handler ends
3906
+ * }
3907
+ * });
3908
+ * ```
3736
3909
  */
3737
3910
  update(): void;
3738
3911
  }
@@ -3781,6 +3954,12 @@ root scope is disposed for local cleanup (timers, signals, sockets). A
3781
3954
  process that has not exited after 300 ms is killed. Media the plugin
3782
3955
  opened with Player.open keeps playing.
3783
3956
 
3957
+ The root scope: disposing it is what stopping the plugin does. Besides
3958
+ the modules of Scope, it carries the plugin's `id` and its
3959
+ `dataDirectory`.
3960
+
3961
+ - @capability That of each module (see the modules).
3962
+
3784
3963
  ```ts
3785
3964
  interface Plugin extends Scope {
3786
3965
  /** The manifest id, e.g. `"community.bookmark"`. */
@@ -5787,6 +5966,13 @@ interface DropHandler {
5787
5966
 
5788
5967
  `plugin.drops`: files dropped on the video area. Needs `ui.drop`.
5789
5968
 
5969
+ Files dropped on the video area go to providers by extension: each
5970
+ extension belongs to the provider registered for it first, and each
5971
+ provider gets one call with all of its paths. Files nobody takes are
5972
+ ignored. A panel with Panel.onDrop takes drops on itself first.
5973
+
5974
+ - @capability ui.drop
5975
+
5790
5976
  ```ts
5791
5977
  interface Drops {
5792
5978
  /**
@@ -5797,6 +5983,7 @@ interface Drops {
5797
5983
  * to the provider that registered it first) in the order the file
5798
5984
  * manager gave them, one call per provider. Folders are not expanded.
5799
5985
  * @throws invalid synchronously for malformed extensions.
5986
+ * @capability ui.drop
5800
5987
  * @example Load dropped subtitle files
5801
5988
  * Dropping .srt or .ass files on the video loads each one as a subtitle
5802
5989
  * track. Each extension belongs to the provider that registered it first;
@@ -5822,6 +6009,25 @@ interface Drops {
5822
6009
  }
5823
6010
  ```
5824
6011
 
6012
+ Example: Collect dropped CHIP-8 programs
6013
+
6014
+ `.ch8` files dropped on the player are added to the plugin's list. The
6015
+ provider claims the extension; the player calls `drop` with the dropped
6016
+ paths of that type, in the order the file manager gave them. Extensions
6017
+ go to the first provider: the built-in playlist claims every audio and
6018
+ video extension when it starts, and a built-in plugin cannot be disabled,
6019
+ so another plugin's provider for those never gets them (except a
6020
+ development load that replaces the playlist under its id).
6021
+ ```ts
6022
+ const queue: string[] = [];
6023
+ plugin.drops.provide({
6024
+ extensions: ["ch8"],
6025
+ drop: ({ paths }) => {
6026
+ queue.push(...paths); // only .ch8 files reach here
6027
+ },
6028
+ });
6029
+ ```
6030
+
5825
6031
  ### ThemeId
5826
6032
 
5827
6033
  The player's palettes: `vestra`, the dark default, and `vestra-light`
@@ -6693,10 +6899,18 @@ interface TimelineMark {
6693
6899
 
6694
6900
  This plugin's own marks; disposing it removes them.
6695
6901
 
6902
+ This plugin's marks on the seek bar, from Timeline.layer. `set`
6903
+ replaces the whole layer and never throws: the newest write wins, and a
6904
+ refused one is reported to `onError`. Disposing the layer, its scope or
6905
+ stopping the plugin removes the marks.
6906
+
6907
+ - @capability timeline.marks
6908
+
6696
6909
  ```ts
6697
6910
  interface TimelineLayer extends Disposable {
6698
6911
  /**
6699
6912
  * 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.
6913
+ * @capability timeline.marks
6700
6914
  * @example Show the file's bookmarks on the timeline
6701
6915
  * Bookmark marks appear on the seek bar at their times, titled with the
6702
6916
  * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
@@ -6721,22 +6935,76 @@ interface TimelineLayer extends Disposable {
6721
6935
  }
6722
6936
  ```
6723
6937
 
6938
+ Example: Clear the plugin's marks
6939
+
6940
+ The plugin's marks disappear from the seek bar while it keeps its
6941
+ layer for later. An empty list clears the layer; other plugins' marks
6942
+ stay.
6943
+ ```ts
6944
+ plugin.timeline.layer().set([]);
6945
+ ```
6946
+
6724
6947
  ### Timeline
6725
6948
 
6726
6949
  `plugin.timeline`: marks on the seek bar. Every plugin publishes one
6727
6950
  layer; the player merges them. A new source clears every layer.
6728
6951
 
6952
+ Marks on the player's seek bar: chapters, bookmarks, loop points. Each
6953
+ plugin publishes one layer with Timeline.layer; the player merges
6954
+ the layers by time and shows them. A new source clears every layer, so a
6955
+ plugin publishes again for each file. `marks` is the merged list, which
6956
+ any plugin can read.
6957
+
6958
+ - @capability `timeline.marks` to publish; `playback.read` to read; `events` to follow changes.
6959
+
6729
6960
  ```ts
6730
6961
  interface Timeline {
6731
6962
  /** Every layer merged, by time (`playback.read`). Empty on a new source. */
6732
6963
  readonly marks: readonly TimelineMark[];
6733
- /** The merged list changed (needs `events`; at most every 100 ms). */
6964
+ /**
6965
+ * The merged list changed (needs `events`; at most every 100 ms).
6966
+ * @capability events and playback.read
6967
+ * @example Follow the marks as they change
6968
+ * A panel lists the seek bar's marks and updates when any plugin publishes
6969
+ * or a new file clears them. The listener gets the merged list, at most
6970
+ * every 100 ms.
6971
+ * ```ts
6972
+ * plugin.timeline.onChange((marks) => {
6973
+ * console.log(marks.map((mark) => `${mark.time}s ${mark.title ?? mark.kind}`));
6974
+ * });
6975
+ * ```
6976
+ */
6734
6977
  onChange(listener: (marks: readonly TimelineMark[]) => void): Disposable;
6735
- /** This plugin's layer (`timeline.marks`); a second call throws. */
6978
+ /**
6979
+ * This plugin's layer (`timeline.marks`); a second call throws.
6980
+ * @capability timeline.marks
6981
+ * @example Publish this plugin's marks
6982
+ * The plugin gets its one layer and puts two chapter marks on the seek
6983
+ * bar. Calling `layer()` again while the first layer is not disposed
6984
+ * throws; disposing the layer removes the
6985
+ * marks.
6986
+ * ```ts
6987
+ * const layer = plugin.timeline.layer();
6988
+ * layer.set([
6989
+ * { time: 0, kind: "chapter", title: "Intro" },
6990
+ * { time: 95, kind: "chapter", title: "Part 1" }, // seconds
6991
+ * ]);
6992
+ * ```
6993
+ */
6736
6994
  layer(): TimelineLayer;
6737
6995
  }
6738
6996
  ```
6739
6997
 
6998
+ Example: Count the chapter marks other plugins published
6999
+
7000
+ The plugin reads how many chapter marks the seek bar shows, whoever
7001
+ published them. `marks` is the merged list of every layer, sorted by
7002
+ time, with each mark's `plugin`.
7003
+ ```ts
7004
+ const chapters = plugin.timeline.marks.filter((mark) => mark.kind === "chapter");
7005
+ await plugin.osd.show("{} chapters", [chapters.length]);
7006
+ ```
7007
+
6740
7008
  ### WindowState
6741
7009
 
6742
7010
  The main window as `plugin.window.state` reports it (logical points).
@@ -6772,11 +7040,28 @@ With `events`, `state` follows the window from the first read or
6772
7040
  `onChange`; without it, it updates only at start and after this plugin's
6773
7041
  `setVideoArea`.
6774
7042
 
7043
+ `plugin.window` is the player's main window: its size, fullscreen,
7044
+ picture-in-picture and the video area. Reading it needs no capability;
7045
+ changing it needs `window.control`. Changes that last only while the
7046
+ plugin runs (always on top, pointer auto-hide) revert when it stops.
7047
+
7048
+ - @capability None to read; window.control to change; events to follow outside changes.
7049
+
6775
7050
  ```ts
6776
7051
  interface MainWindow {
6777
7052
  /** Latest state; zero video area without a native window. */
6778
7053
  readonly state: WindowState;
6779
- /** The window state changed (needs `events` for outside changes). */
7054
+ /**
7055
+ * The window state changed (needs `events` for outside changes).
7056
+ * @capability events (for outside changes)
7057
+ * @example Fit a panel to fullscreen
7058
+ * The plugin's panel uses larger text while the player is fullscreen. The
7059
+ * listener runs after the SDK updated `state` from the player's events
7060
+ * (with `events`; without it only after this plugin's own changes).
7061
+ * ```ts
7062
+ * plugin.window.onChange((state) => bar.set({ large: state.fullscreen }));
7063
+ * ```
7064
+ */
6780
7065
  onChange(listener: (state: WindowState) => void): Disposable;
6781
7066
  /**
6782
7067
  * Resize the window so the video column is `width`×`height` (the bottom bar
@@ -6788,6 +7073,18 @@ interface MainWindow {
6788
7073
  * Sizes are 1–16384 logical points; the minimum video area is 318×120.
6789
7074
  * The visible area excludes the menu bar and Dock on macOS; on Windows it
6790
7075
  * is the whole display. `unavailable` without a user interface too.
7076
+ * @capability window.control
7077
+ * @throws unavailable (rejects) Fullscreen, or no user interface.
7078
+ * @throws invalid (rejects) A size outside 1–16384 logical points.
7079
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7080
+ * @example Make the video area 1280×720 points
7081
+ * The window resizes so that the video column is 1280×720 logical points,
7082
+ * the bars and a docked sidebar added around it. A request that does not
7083
+ * fit the screen is scaled down keeping the aspect, and `clamped` says so.
7084
+ * ```ts
7085
+ * const applied = await plugin.window.setVideoArea({ width: 1280, height: 720 });
7086
+ * if (applied.clamped) await plugin.osd.show("{}×{}", [applied.width, applied.height]);
7087
+ * ```
6791
7088
  */
6792
7089
  setVideoArea(size: {
6793
7090
  width: number;
@@ -6800,10 +7097,42 @@ interface MainWindow {
6800
7097
  /** The request was scaled down or raised to fit. */
6801
7098
  clamped: boolean;
6802
7099
  }>;
6803
- /** Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface. */
7100
+ /**
7101
+ * Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface.
7102
+ * @capability window.control
7103
+ * @throws unavailable (rejects) No user interface.
7104
+ * @throws invalid (rejects) `onTop` is not a boolean.
7105
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7106
+ * @example Keep the player above other windows
7107
+ * The player's windows (tool windows too) float above other applications
7108
+ * while the plugin's "Always on top" item is checked. The newest call of
7109
+ * any plugin wins, and the setting reverts when this plugin stops.
7110
+ * ```ts
7111
+ * let onTop = false;
7112
+ * plugin.commands.add({
7113
+ * id: "on-top",
7114
+ * path: ["@screen", "Always on top"],
7115
+ * checked: () => onTop,
7116
+ * run: async () => {
7117
+ * onTop = !onTop;
7118
+ * await plugin.window.setAlwaysOnTop(onTop); // reverts when this plugin stops
7119
+ * },
7120
+ * });
7121
+ * ```
7122
+ */
6804
7123
  setAlwaysOnTop(onTop: boolean): Promise<void>;
6805
7124
  /**
6806
7125
  * `"playing"`: hide the pointer over the video after 3 idle seconds while playing, in a window too (not only fullscreen). Newest call wins; reverts to `"fullscreen"` when this plugin stops.
7126
+ * @capability window.control
7127
+ * @throws invalid (rejects) A mode other than `"fullscreen"` or `"playing"`.
7128
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7129
+ * @example Hide the pointer over playing video in a window too
7130
+ * The pointer disappears after 3 idle seconds over playing video, not only
7131
+ * in fullscreen. The newest call wins; when this plugin stops the player
7132
+ * goes back to hiding it in fullscreen only.
7133
+ * ```ts
7134
+ * await plugin.window.setCursorAutoHide("playing");
7135
+ * ```
6807
7136
  */
6808
7137
  setCursorAutoHide(mode: "fullscreen" | "playing"): Promise<void>;
6809
7138
  /**
@@ -6818,6 +7147,13 @@ interface MainWindow {
6818
7147
  *
6819
7148
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
6820
7149
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
7150
+ * @capability window.control
7151
+ * @throws unsupported (rejects) The system has no picture-in-picture (`supported` is false).
7152
+ * @throws unavailable (rejects) No video, or no user interface.
7153
+ * @throws invalid (rejects) `on` is not a boolean.
7154
+ * @throws busy (rejects) The previous call is still switching.
7155
+ * @throws failed (rejects) The system refused.
7156
+ * @throws capability (rejects) The manifest does not declare `window.control`.
6821
7157
  * @example Toggle picture-in-picture where the system has it
6822
7158
  * The video moves into a floating picture-in-picture window, or back.
6823
7159
  * `state.pictureInPicture` says whether the system supports it and
@@ -6881,9 +7217,29 @@ interface AppInfo {
6881
7217
  `plugin.app`: the fixed facts plus the system, read on request. No
6882
7218
  capability.
6883
7219
 
7220
+ `plugin.app` holds facts fixed for the player's run (its version, build,
7221
+ library versions) and reads the system's on request: `system()` reads
7222
+ the displays at each call; the operating system's name and version are
7223
+ worked out once, at the first call, and reused.
7224
+
7225
+ - @capability None.
7226
+
6884
7227
  ```ts
6885
7228
  interface App extends AppInfo {
6886
- /** The operating system and the displays, read when called (displays come and go). No capability. CPU and memory: Node's `os` module. */
7229
+ /**
7230
+ * The operating system and the displays, read when called (displays come and go). No capability. CPU and memory: Node's `os` module.
7231
+ * @capability None.
7232
+ * @throws busy (rejects) The player could not start the request (no thread available).
7233
+ * @example Choose a size for the primary display
7234
+ * The plugin sizes its picture for the primary display. `system()` asks
7235
+ * the player; `displays` lists the displays present now, the primary one
7236
+ * first (empty without a user interface).
7237
+ * ```ts
7238
+ * const { displays } = await plugin.app.system();
7239
+ * const primary = displays[0];
7240
+ * if (primary) console.log(primary.width, primary.height);
7241
+ * ```
7242
+ */
6887
7243
  system(): Promise<SystemInfo>;
6888
7244
  }
6889
7245
  ```
@@ -8611,6 +8967,14 @@ one request is in flight at a time and only the newest value is sent.
8611
8967
  Values out of range fail with `invalid` (see VideoNode). The
8612
8968
  same chain applies to processed snapshots and exports.
8613
8969
 
8970
+ Members: `set(nodes)` records the desired chain (last write wins) and
8971
+ sends it, one request at a time and only the newest; `status` and
8972
+ `onStatus` report `waiting`, `applying`, `applied` or `failed`; `retry`
8973
+ applies the current value again; `dispose` withdraws the chain. `set`
8974
+ never throws: a refused chain shows as `failed` in `status`.
8975
+
8976
+ - @capability processing.video, playback.read and events
8977
+
8614
8978
  ```ts
8615
8979
  type VideoFilter = Filter<VideoNode[]>;
8616
8980
  ```
@@ -8638,6 +9002,12 @@ VideoFilter.
8638
9002
  `delayMs` is −10000–10000 in multiples of 10; the positive and the
8639
9003
  negative delays of all plugins each add up to at most 10000.
8640
9004
 
9005
+ The same members and rules as VideoFilter: `set` records the
9006
+ desired value and never throws, `status` and `onStatus` report the
9007
+ result, `retry` applies it again, `dispose` withdraws it.
9008
+
9009
+ - @capability processing.audio, playback.read and events
9010
+
8641
9011
  ```ts
8642
9012
  type AudioFilter = Filter<{
8643
9013
  /** The audio chain; `[]` for none. */
@@ -8688,13 +9058,46 @@ needed); disposing or stopping the owner restores the defaults.
8688
9058
  Out-of-range values or unknown fields fail with `invalid` without
8689
9059
  sending anything.
8690
9060
 
9061
+ Not part of snapshots or exports: it changes how the picture sits in the
9062
+ video area, not the picture. `set` merges into the current settings and
9063
+ never throws: an out-of-range value or unknown field shows as `failed`
9064
+ (`invalid`) in `status` and is reported to `onError`, without sending
9065
+ anything.
9066
+
9067
+ - @capability processing.video, playback.read and events (like VideoFilter)
9068
+
8691
9069
  ```ts
8692
9070
  interface VideoView extends Disposable {
8693
- /** Merged into the current settings; last write wins. */
9071
+ /**
9072
+ * Merged into the current settings; last write wins.
9073
+ * @capability processing.video
9074
+ * @example Stretch a 4:3 video to fill the window
9075
+ * A 4:3 video fills a 16:9 window, distorted, as the user asked. Each
9076
+ * `set` merges the given fields into this plugin's view settings; the
9077
+ * newest write wins, and the settings stay for later files until the
9078
+ * plugin disposes the view or stops.
9079
+ * ```ts
9080
+ * plugin.video.view().set({ fit: "stretch" });
9081
+ * ```
9082
+ */
8694
9083
  set(value: Partial<ViewSettings>): void;
8695
9084
  /** Current status (the same four states as filters). */
8696
9085
  readonly status: FilterStatus;
8697
- /** The status changed. */
9086
+ /**
9087
+ * The status changed.
9088
+ * @capability processing.video
9089
+ * @example Tell the user another plugin owns the view
9090
+ * When another plugin already controls the display layer, the OSD says
9091
+ * so. The first plugin to call `view()` owns it; this plugin's handle
9092
+ * then reports `failed` with `conflict`, and tries again on its next `set`.
9093
+ * ```ts
9094
+ * const view = plugin.video.view();
9095
+ * view.onStatus((status) => {
9096
+ * if (status.state === "failed" && status.error.code === "conflict")
9097
+ * void plugin.osd.show("Another plugin controls the picture size");
9098
+ * });
9099
+ * ```
9100
+ */
8698
9101
  onStatus(listener: (status: FilterStatus) => void): Disposable;
8699
9102
  }
8700
9103
  ```
@@ -8704,7 +9107,7 @@ Example: Fill the window, then zoom in
8704
9107
  The video fills the video area (cropped instead of letterboxed), then
8705
9108
  zooms to 150 % shifted a little to the right. The first plugin to call
8706
9109
  `view()` owns the display layer; each `set` merges into its settings,
8707
- which stay across sources until the plugin resets them or stops.
9110
+ which stay across sources until the plugin disposes the view or stops.
8708
9111
  ```ts
8709
9112
  const view = plugin.video.view();
8710
9113
  view.set({ fit: "fill" });
@@ -8742,13 +9145,45 @@ Platforms: only macOS renders on the GPU. Under software rendering
8742
9145
  `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
8743
9146
  is accepted but has no effect; output is always SDR.
8744
9147
 
9148
+ GPU renderer options (tone mapping, peak detection, shaders,
9149
+ interpolation). Only the GPU renderer has them, which today means macOS;
9150
+ under software rendering the handle is `failed` with `unsupported` after
9151
+ the player's first answer to `render()` (reported to `onError` too), and so is
9152
+ every `set`. Single owner, like VideoView. `set` never throws; the
9153
+ result shows in `status`.
9154
+
9155
+ - @capability processing.video, playback.read and events (like VideoFilter)
9156
+
8745
9157
  ```ts
8746
9158
  interface RenderOptions extends Disposable {
8747
- /** Merged into the current settings; last write wins. */
9159
+ /**
9160
+ * Merged into the current settings; last write wins.
9161
+ * @capability processing.video
9162
+ * @example Turn on motion interpolation
9163
+ * Motion becomes smoother on the GPU renderer. `set` merges the field into
9164
+ * the plugin's render options; under software rendering the status turns
9165
+ * `failed` with `unsupported` instead.
9166
+ * ```ts
9167
+ * plugin.video.render().set({ interpolation: true });
9168
+ * ```
9169
+ */
8748
9170
  set(value: Partial<RenderSettings>): void;
8749
9171
  /** `failed` with `unsupported` while the player renders in software; `failed` with `failed` when mpv cannot compile a shader (pushed later). */
8750
9172
  readonly status: FilterStatus;
8751
- /** The status changed (including later pushes such as shader compile errors). */
9173
+ /**
9174
+ * The status changed (including later pushes such as shader compile errors).
9175
+ * @capability processing.video
9176
+ * @example Report a shader that does not compile
9177
+ * The OSD shows mpv's message when a shader the plugin set fails to
9178
+ * compile. The player pushes that failure after the `set` was accepted, so
9179
+ * it arrives through `onStatus`, not as a rejected call.
9180
+ * ```ts
9181
+ * const render = plugin.video.render();
9182
+ * render.onStatus((status) => {
9183
+ * if (status.state === "failed") void plugin.osd.show("Render: {}", [status.error.message]);
9184
+ * });
9185
+ * ```
9186
+ */
8752
9187
  onStatus(listener: (status: FilterStatus) => void): Disposable;
8753
9188
  }
8754
9189
  ```
@@ -8850,16 +9285,79 @@ interface SubtitleConditional {
8850
9285
  `subtitles.read`; changes need `subtitles.control` and also
8851
9286
  `subtitles.read`, since they read the current selection first.
8852
9287
 
9288
+ The subtitle tracks of the current source, the primary and secondary
9289
+ selection, uploaded files and the style. `state` is the SDK's snapshot of
9290
+ the player's subtitle state (`null` until first read). The player has no
9291
+ subtitle event: the SDK reads the state again when the source, its phase
9292
+ or the backend changes (with `events`) and after this plugin's own
9293
+ changes, so a selection the user or another plugin changed shows only
9294
+ after `refresh()`. A change without `ifCurrent` reads the state first
9295
+ and applies to it; pass `ifCurrent` to make it fail with `stale` when the
9296
+ selection changed since that state. Each change is one operation the
9297
+ player confirms; `select`, `remove` and `setStyle` resolve with the new
9298
+ state, `load` with the new track.
9299
+
9300
+ - @capability `subtitles.read` to read; `subtitles.control` and `subtitles.read` to change; `events` to re-read on source changes.
9301
+
8853
9302
  ```ts
8854
9303
  interface Subtitles {
8855
9304
  /** `null` until first read. */
8856
9305
  readonly state: SubtitlesState | null;
8857
- /** The subtitle state changed. */
9306
+ /**
9307
+ * The subtitle state changed.
9308
+ * @capability events and subtitles.read
9309
+ * @example Show the subtitle language after a new file opens
9310
+ * When a new file starts, the OSD names the language of its primary
9311
+ * subtitle. The SDK reads the subtitle state again when the source or its
9312
+ * phase changes (there is no subtitle event, so a selection changed in the
9313
+ * player shows only after `refresh()`) and then calls the listener;
9314
+ * `selected` is `null` when subtitles are off.
9315
+ * ```ts
9316
+ * plugin.subtitles.onChange((state) => {
9317
+ * // A track's language is null when the file does not say.
9318
+ * void plugin.osd.show("Subtitles: {}", [state.selected?.language ?? "off"]);
9319
+ * });
9320
+ * ```
9321
+ */
8858
9322
  onChange(listener: (state: SubtitlesState) => void): Disposable;
8859
- /** Read the state from the player now. */
9323
+ /**
9324
+ * Read the state from the player now.
9325
+ * @capability subtitles.read
9326
+ * @throws capability (rejects) The manifest does not declare `subtitles.read`.
9327
+ * @example Count the subtitle tracks of the file
9328
+ * The plugin reads how many subtitle tracks the file has before building a
9329
+ * menu. `refresh` asks the player and resolves with the current state, also
9330
+ * without `events`; `state` holds it afterwards.
9331
+ * ```ts
9332
+ * const { tracks } = await plugin.subtitles.refresh();
9333
+ * await plugin.osd.show("{} subtitle tracks", [tracks.length]);
9334
+ * ```
9335
+ */
8860
9336
  refresh(): Promise<SubtitlesState>;
8861
9337
  /**
8862
9338
  * `role: "secondary"` sets the secondary subtitle: a text track that is not the primary one (`invalid` otherwise). A new source clears it. `track: null` turns the role off.
9339
+ * @capability subtitles.control and subtitles.read
9340
+ * @throws stale (rejects) With `ifCurrent`: the source or the subtitle selection changed since that state.
9341
+ * @throws invalid (rejects) An unknown track; as secondary, a bitmap track or the primary one; as primary, the current secondary.
9342
+ * @throws missing (rejects) The track is not in the current source.
9343
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading or not yet confirmed.
9344
+ * @throws busy (rejects) Too many subtitle operations at once.
9345
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9346
+ * @example Turn on the first English subtitle
9347
+ * The first English track becomes the primary subtitle, but only in the
9348
+ * file the plugin looked at. Embedded tracks are chosen by number, so
9349
+ * without `ifCurrent` a select after another file opened would pick that
9350
+ * file's track with the same number; with `ifCurrent: state` it fails with
9351
+ * `stale` instead. Without `ifCurrent`, `select` reads the newest state
9352
+ * itself and almost never fails with `stale`.
9353
+ * ```ts
9354
+ * const state = await plugin.subtitles.refresh();
9355
+ * const english = state.tracks.find((track) => track.language === "en");
9356
+ * if (english)
9357
+ * await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
9358
+ * if (error.code !== "stale") throw error; // another file or selection now
9359
+ * });
9360
+ * ```
8863
9361
  */
8864
9362
  select(track: SubtitleTrack | null, options?: SubtitleConditional & {
8865
9363
  role?: "primary" | "secondary";
@@ -8876,6 +9374,25 @@ interface Subtitles {
8876
9374
  * Limits: SRT ≤ 2 MiB and ≤ 10,000 cues; ASS ≤ 2 MiB with `[Script Info]`
8877
9375
  * and `[Events]` and 1–20,000 `Dialogue:` lines. Convert other formats
8878
9376
  * (VTT, SAMI) to SRT first.
9377
+ * @capability subtitles.control and subtitles.read
9378
+ * @throws invalid (rejects) A bad `format` or `title`, or a file over the limits.
9379
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9380
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading.
9381
+ * @throws aborted (rejects) `signal` aborted before the commit (an `AbortError`); the upload is discarded.
9382
+ * @throws busy (rejects) Too many subtitle operations at once.
9383
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9384
+ * @example Load a downloaded SRT and select it
9385
+ * A subtitle file the plugin downloaded becomes a track of the current file
9386
+ * and is selected. The plugin passes the file's bytes; the player parses
9387
+ * them, adds an `external` track for this source only and resolves with
9388
+ * it once the upload is committed.
9389
+ * ```ts
9390
+ * const response = await fetch(url);
9391
+ * if (!response.ok) throw new Error(`subtitles: HTTP ${response.status}`);
9392
+ * const bytes = new Uint8Array(await response.arrayBuffer());
9393
+ * const track = await plugin.subtitles.load(bytes, { title: "Downloaded" }); // selected
9394
+ * await plugin.osd.show("Loaded {}", [track.title]);
9395
+ * ```
8879
9396
  */
8880
9397
  load(bytes: Uint8Array, options?: SubtitleConditional & {
8881
9398
  format?: "srt" | "ass";
@@ -8883,10 +9400,40 @@ interface Subtitles {
8883
9400
  select?: "primary" | "secondary" | "none";
8884
9401
  signal?: AbortSignal;
8885
9402
  }): Promise<SubtitleTrack>;
8886
- /** Remove a track this plugin loaded. */
9403
+ /**
9404
+ * Remove a track this plugin loaded.
9405
+ * @capability subtitles.control and subtitles.read
9406
+ * @throws invalid (rejects) The track was not loaded by this plugin.
9407
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9408
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9409
+ * @throws busy (rejects) Too many subtitle operations at once.
9410
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9411
+ * @example Remove the subtitle the plugin loaded
9412
+ * The plugin's downloaded subtitle disappears from the track list. A plugin
9413
+ * can remove only tracks it loaded itself; a new source removes them
9414
+ * anyway.
9415
+ * ```ts
9416
+ * await plugin.subtitles.remove(loaded);
9417
+ * ```
9418
+ */
8887
9419
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
8888
9420
  /**
8889
9421
  * 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.
9422
+ * @capability subtitles.control and subtitles.read
9423
+ * @throws invalid (rejects) A field out of range.
9424
+ * @throws unsupported (rejects) A font that is not in `state.fonts`, or a primary subtitle that is a bitmap track.
9425
+ * @throws stale (rejects) With `ifCurrent`: the subtitle state changed since that state.
9426
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9427
+ * @throws busy (rejects) Too many subtitle operations at once.
9428
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9429
+ * @example Take the plugin's style off again
9430
+ * The plugin removes its style layer, so the subtitles go back to the
9431
+ * player's default (or to another plugin's layer). `null` removes only this
9432
+ * plugin's layer; the effective layer is the one whose manifest id sorts
9433
+ * last among the plugins that still have one.
9434
+ * ```ts
9435
+ * await plugin.subtitles.setStyle(null);
9436
+ * ```
8890
9437
  */
8891
9438
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
8892
9439
  }
@@ -8937,6 +9484,14 @@ interface CaptureOptions extends Conditional {
8937
9484
 
8938
9485
  A frozen, immutable input for exports. Dispose to release it.
8939
9486
 
9487
+ A frozen input, from MediaCapture.capture: the source, the time
9488
+ and (processed) the effects and subtitle as they were. Exports read it in
9489
+ the player's offline worker, so one snapshot can be exported several
9490
+ times and playback is unaffected. Disposing it releases the player's
9491
+ copy; an export using it after that fails with `released`.
9492
+
9493
+ - @capability media.read and media.control
9494
+
8940
9495
  ```ts
8941
9496
  interface Snapshot extends Disposable {
8942
9497
  /** Without or with effects. */
@@ -8958,11 +9513,45 @@ interface Snapshot extends Disposable {
8958
9513
  } | null;
8959
9514
  /** The frozen processing revision (processed snapshots). */
8960
9515
  readonly processingRevision: number | null;
8961
- /** Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times. */
9516
+ /**
9517
+ * Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times.
9518
+ * @capability media.read and media.control
9519
+ * @throws released (rejects) The snapshot was already released.
9520
+ * @throws invalid (rejects) Bad options (`quality` outside 1–100, a relative destination, a clip `end` not after the snapshot time or past the duration).
9521
+ * @throws busy (rejects) This plugin runs 2 exports (or the player 8).
9522
+ * @throws unavailable (rejects) No FFmpeg, or the export worker could not start.
9523
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9524
+ * @example Export a 30-second clip
9525
+ * A clip from the snapshot time to 30 seconds later is written as MP4.
9526
+ * `export` resolves once the job is accepted; the job then runs in the
9527
+ * player's offline worker, and `done` resolves with its terminal state,
9528
+ * whose `outcome` has the destination or the error.
9529
+ * ```ts
9530
+ * const job = await snapshot.export({ format: "mp4", destination: "/tmp/clip.mp4", end: snapshot.time + 30 });
9531
+ * const end = await job.done;
9532
+ * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
9533
+ * ```
9534
+ */
8962
9535
  export(options: ExportOptions): Promise<ExportJob>;
8963
9536
  }
8964
9537
  ```
8965
9538
 
9539
+ Example: Export one frame as PNG and as JPEG
9540
+
9541
+ The same frozen frame is written twice, lossless and compressed. Each
9542
+ `export` starts a job of its own; the snapshot is released after both
9543
+ are done.
9544
+ ```ts
9545
+ const snapshot = await plugin.media.capture();
9546
+ try {
9547
+ const png = await snapshot.export({ format: "png", destination: "/tmp/frame.png" });
9548
+ const jpeg = await snapshot.export({ format: "jpeg", destination: "/tmp/frame.jpg", quality: 85 });
9549
+ await Promise.all([png.done, jpeg.done]); // terminal states, not errors
9550
+ } finally {
9551
+ await snapshot.dispose();
9552
+ }
9553
+ ```
9554
+
8966
9555
  ### ExportOptions
8967
9556
 
8968
9557
  One frame as PNG, JPEG (`quality` 1–100, default 90) or BMP (24-bit), or
@@ -9015,31 +9604,121 @@ type JobState = Omit<MediaJob, "jobId">;
9015
9604
 
9016
9605
  An export, from Snapshot.export. Disposing an unfinished job cancels it.
9017
9606
 
9607
+ One export, from Snapshot.export. `state` is the SDK's last copy
9608
+ of the job's state: the SDK polls the player about every 350 ms until the
9609
+ job ends, and `refresh` reads it at once. `done` resolves
9610
+ once the job is terminal (`completed`, `failed` or `cancelled`) and never
9611
+ rejects for a failed export: the failure is in `outcome`. Disposing an
9612
+ unfinished job cancels it.
9613
+
9614
+ - @capability media.read and media.control
9615
+
9018
9616
  ```ts
9019
9617
  interface ExportJob extends Disposable {
9020
9618
  /** Latest state. */
9021
9619
  readonly state: JobState;
9022
- /** The state changed. */
9620
+ /**
9621
+ * The state changed.
9622
+ * @capability media.read
9623
+ * @example Log each state of an export
9624
+ * The plugin's log records each status the job goes through (`queued`,
9625
+ * `running`, `cancelling` after a cancel, then a terminal one). The listener runs when the SDK learns a
9626
+ * new state from its polling.
9627
+ * ```ts
9628
+ * job.onChange((state) => console.log("export", state.status));
9629
+ * ```
9630
+ */
9023
9631
  onChange(listener: (state: JobState) => void): Disposable;
9024
- /** Resolves with the terminal state (completed, failed or cancelled). */
9632
+ /**
9633
+ * Resolves with the terminal state (completed, failed or cancelled).
9634
+ * @throws disposed (rejects) The job handle was disposed while waiting.
9635
+ */
9025
9636
  readonly done: Promise<JobState>;
9026
- /** Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over). */
9637
+ /**
9638
+ * Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over).
9639
+ * @capability media.control
9640
+ * @throws rejected (rejects) The player did not accept the request.
9641
+ * @throws capability (rejects) The manifest does not declare `media.control`.
9642
+ * @example Cancel an export the user no longer wants
9643
+ * The running export stops, unless it is already writing its file.
9644
+ * `cancel` resolves `requested` when the worker will stop (the job ends
9645
+ * `cancelled`), `tooLate` when it is already publishing the file, and
9646
+ * `terminal` when the job had already ended.
9647
+ * ```ts
9648
+ * const answer = await job.cancel();
9649
+ * if (answer === "tooLate") await plugin.osd.show("Too late: the file is being written");
9650
+ * ```
9651
+ */
9027
9652
  cancel(): Promise<"requested" | "tooLate" | "terminal">;
9028
- /** Read the state from the player now. */
9653
+ /**
9654
+ * Read the state from the player now.
9655
+ * @capability media.read
9656
+ * @throws capability (rejects) The manifest does not declare `media.read`.
9657
+ * @example Read a job's state now
9658
+ * The plugin reads the newest state of an export without waiting for an
9659
+ * event. `refresh` asks the player and resolves with the state, which
9660
+ * `state` then holds.
9661
+ * ```ts
9662
+ * const state = await job.refresh();
9663
+ * console.log(state.status);
9664
+ * ```
9665
+ */
9029
9666
  refresh(): Promise<JobState>;
9030
9667
  }
9031
9668
  ```
9032
9669
 
9670
+ Example: Show export progress and cancel on request
9671
+
9672
+ The OSD shows the job's status while it runs, and a Cancel command stops
9673
+ it. `onChange` follows the state (`queued`, `running`, `cancelling`,
9674
+ then a terminal one); `cancel` asks the worker to stop. After
9675
+ `requested` the job usually ends `cancelled`, or `failed` when the
9676
+ cleanup failed.
9677
+ ```ts
9678
+ job.onChange((state) => void plugin.osd.show("Export: {}", [state.status]));
9679
+ plugin.commands.add({ id: "cancel-export", title: "Cancel export", run: () => job.cancel() });
9680
+ await job.done; // completed, failed or cancelled
9681
+ ```
9682
+
9033
9683
  ### MediaCapture
9034
9684
 
9035
9685
  `plugin.media`: snapshots and exports. Needs `media.read` and
9036
9686
  `media.control` (processed snapshots also `playback.read` and
9037
9687
  `subtitles.read`). Exports decode offline and do not disturb playback.
9038
9688
 
9689
+ Exports run in the player's offline worker from a frozen
9690
+ Snapshot: a still frame or a clip, raw or with this plugin's
9691
+ effects (and the primary subtitle). Playback is not disturbed. A plugin
9692
+ holds at most 4 snapshots at a time (16 in the player); release them
9693
+ when done.
9694
+
9695
+ - @capability media.read and media.control (processed snapshots also `playback.read` and `subtitles.read`)
9696
+
9039
9697
  ```ts
9040
9698
  interface MediaCapture {
9041
9699
  /**
9042
- * Freeze the current source (and optionally this plugin set's effects) as an input for exports. Fails `stale` when the source changed since `ifCurrent`; `unsupported` for an external ASS track with subtitles; `noSource` when nothing is playing.
9700
+ * Freeze the current source (and optionally this plugin set's effects) as an input for exports. Fails `stale` when the source changed since `ifCurrent`; `unsupported` for subtitles that cannot be flattened (only plain SRT text can) or a picture over the size limits; `noSource` when nothing is playing.
9701
+ * @capability media.read and media.control
9702
+ * @throws stale (rejects) The source changed since `ifCurrent`.
9703
+ * @throws noSource (rejects) Nothing is playing.
9704
+ * @throws invalid (rejects) `at` at or after the end of the source.
9705
+ * @throws busy (rejects) This plugin holds 4 snapshots (or the player 16); release one.
9706
+ * @throws unavailable (rejects) A processed snapshot while the picture is still being prepared.
9707
+ * @throws unsupported (rejects) A processed snapshot the player cannot freeze: subtitles that cannot be flattened (only plain SRT text can), or a picture over the size limits.
9708
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9709
+ * @example Freeze the frame at the 10-second mark
9710
+ * The plugin freezes the frame at 0:10 of the open file, raw, to export it
9711
+ * later. `capture` resolves with a `Snapshot` once the player has frozen
9712
+ * that input; the snapshot stays valid when playback moves on or the file
9713
+ * changes, until it is released.
9714
+ * ```ts
9715
+ * const snapshot = await plugin.media.capture({ at: 10 }); // seconds
9716
+ * try {
9717
+ * // … export it …
9718
+ * } finally {
9719
+ * await snapshot.dispose(); // frees one of the plugin's 4 snapshot slots
9720
+ * }
9721
+ * ```
9043
9722
  */
9044
9723
  capture(options?: CaptureOptions): Promise<Snapshot>;
9045
9724
  }
@@ -9192,25 +9871,84 @@ interface ResolvedStream {
9192
9871
 
9193
9872
  A registered resolver. Disposing it unregisters it and aborts resolutions in flight.
9194
9873
 
9874
+ A registered resolver, from Resolvers.add. Disposing it, its
9875
+ scope or stopping the plugin unregisters it and aborts resolutions in
9876
+ flight; the extensions or hosts are free again for other plugins.
9877
+
9878
+ - @capability source.resolve
9879
+
9195
9880
  ```ts
9196
9881
  interface Resolver extends Disposable {
9197
9882
  /**
9198
9883
  * Resolves when the player registered it; rejects otherwise: `conflict` when another resolver has the extension or the scheme + host (another plugin's: activation goes on; this plugin's own: activation fails), `invalid` for a malformed declaration (a player before 0.4.1 also for `extensions`).
9884
+ * @throws conflict (rejects) Another resolver has the extension or the scheme + host, or this plugin already registered this id.
9885
+ * @throws busy (rejects) The player already holds 16 resolvers.
9886
+ * @throws invalid (rejects) A malformed declaration.
9887
+ * @throws capability (rejects) The manifest does not declare `source.resolve`.
9199
9888
  */
9200
9889
  readonly ready: Promise<void>;
9201
9890
  }
9202
9891
  ```
9203
9892
 
9893
+ Example: Report when another plugin already handles the format
9894
+
9895
+ The plugin tells the user when its `.ch8` resolver could not register
9896
+ because another plugin holds that extension. `ready` rejects with
9897
+ `conflict`; for another plugin's claim that does not fail activation, so
9898
+ the plugin keeps running without the resolver.
9899
+ ```ts
9900
+ const resolver = plugin.resolvers.add({
9901
+ id: "chip8",
9902
+ extensions: ["ch8"],
9903
+ async resolve() {
9904
+ throw plugin.i18n.error("Not ready yet.");
9905
+ },
9906
+ });
9907
+ // Rejects with `conflict` when the extension is taken.
9908
+ resolver.ready.catch((error: PluginError) => {
9909
+ if (error.code === "conflict") void plugin.osd.show("Another plugin plays .ch8 files");
9910
+ });
9911
+ ```
9912
+
9204
9913
  ### Resolvers
9205
9914
 
9206
9915
  `plugin.resolvers`: turn web page URLs into playable streams, or local
9207
9916
  files into streams this plugin generates. Needs `source.resolve` (file
9208
9917
  resolvers also `producer.av`).
9209
9918
 
9919
+ A resolver turns something the player cannot play by itself into
9920
+ something it can: a web page address into stream URLs
9921
+ (WebResolverOptions), or a local file of a format the plugin
9922
+ understands into a stream the plugin generates
9923
+ (FileResolverOptions). The player asks the resolver whenever
9924
+ anyone opens a matching address or file (the Open dialog, the playlist,
9925
+ another plugin), so the plugin does not intercept opens itself.
9926
+
9927
+ - @capability source.resolve (file resolvers also `producer.av`)
9928
+
9210
9929
  ```ts
9211
9930
  interface Resolvers {
9212
9931
  /**
9213
9932
  * Register a resolver. When anyone opens a matching URL or file the player calls `resolve`; `signal` aborts when the open is cancelled or replaced. One scheme + host pair, and one extension, has one resolver (`conflict` otherwise). A rejected `resolve` fails the open with the error's message.
9933
+ * @capability source.resolve
9934
+ * @example Play a site's pages through the plugin
9935
+ * Opening a `https://video.example.com/…` address plays the page's video.
9936
+ * `add` returns at once and registers in the background (`ready` reports
9937
+ * the answer); from then on the player calls `resolve` for every matching
9938
+ * open, and the error a rejected `resolve` carries is what the user sees.
9939
+ * ```ts
9940
+ * plugin.resolvers.add({
9941
+ * id: "example",
9942
+ * schemes: ["https"],
9943
+ * hosts: ["video.example.com"], // exact host names
9944
+ * async resolve(url, signal) {
9945
+ * const response = await fetch(url, { signal });
9946
+ * if (!response.ok) throw new Error(`the page answered ${response.status}`);
9947
+ * const page = (await response.json()) as { title: string; stream: string };
9948
+ * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
9949
+ * },
9950
+ * });
9951
+ * ```
9214
9952
  */
9215
9953
  add(options: ResolverOptions): Resolver;
9216
9954
  /**
@@ -9363,6 +10101,15 @@ interface InstallOptions {
9363
10101
 
9364
10102
  A validated package directory, from Packages.validate.
9365
10103
 
10104
+ A package the player has checked, from Packages.validate or
10105
+ Packages.validateArchive: its manifest, digest and the checks
10106
+ it passed. The player copies the whole package when it checks it and
10107
+ installs that copy, so later changes to the directory do not matter.
10108
+ Installing starts a PackageChange; the player keeps only the 32
10109
+ most recent candidates.
10110
+
10111
+ - @capability plugins.manage
10112
+
9366
10113
  ```ts
9367
10114
  interface PackageCandidate {
9368
10115
  /** The package directory. */
@@ -9375,11 +10122,38 @@ interface PackageCandidate {
9375
10122
  readonly checks: string[];
9376
10123
  /**
9377
10124
  * Install it. The same source upgrades the installed plugin (its data stays); another source fails with `conflict` unless `replace` is set; a built-in id fails with `unsupported` (built-ins update with the player). A reserved id (`vestra` or `vestra.*`, which every built-in uses) fails with `reserved` (player 0.4.1; already when validated), as only the player installs built-in plugins.
10125
+ * @capability plugins.manage
10126
+ * @throws conflict (rejects) The plugin is installed from another source and `replace` is not set.
10127
+ * @throws unsupported (rejects) A built-in plugin's id.
10128
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10129
+ * @throws busy (rejects) The plugin library is being cleaned up, or the player already keeps 64 change records.
10130
+ * @throws revisionConflict (rejects) The installed revision of this plugin changed meanwhile; call `install` again (no new validation needed).
10131
+ * @throws stale (rejects) The candidate is older than the 32 the player keeps.
10132
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10133
+ * @example Replace a plugin installed from another source
10134
+ * The new package replaces the installed plugin even though it came from
10135
+ * another source. Without `replace` the player refuses with `conflict`;
10136
+ * the same source simply upgrades and keeps the plugin's data.
10137
+ * ```ts
10138
+ * const change = await candidate.install({ replace: true });
10139
+ * await change.done; // "committed" once the new plugin runs
10140
+ * ```
9378
10141
  */
9379
10142
  install(options?: InstallOptions): Promise<PackageChange>;
9380
10143
  }
9381
10144
  ```
9382
10145
 
10146
+ Example: Show what a package asks for before installing it
10147
+
10148
+ Before installing, the plugin shows the package's name and the
10149
+ capabilities it declares. The candidate's `manifest` is the manifest
10150
+ as the player read it.
10151
+ ```ts
10152
+ const candidate = await plugin.packages.validate(directory);
10153
+ const { id, version, capabilities } = candidate.manifest;
10154
+ await plugin.osd.show("{} {}: {}", [id, version, capabilities.join(", ")]);
10155
+ ```
10156
+
9383
10157
  ### PackageChangeStatus
9384
10158
 
9385
10159
  The progress of an install or uninstall.
@@ -9393,21 +10167,71 @@ type PackageChangeStatus = "preparing" | "activating" | "restoring" | "committed
9393
10167
  An install or uninstall in progress. The SDK never submits a
9394
10168
  change twice: after a lost reply it only queries the same request.
9395
10169
 
10170
+ An install or uninstall the player carries out in steps (`preparing`,
10171
+ `activating`, possibly `restoring`) until a terminal status. The SDK
10172
+ asks the player for the status about every 400 ms until then; `done`
10173
+ resolves with the terminal status and does not reject for a failed
10174
+ change: `message` says why.
10175
+
10176
+ - @capability plugins.manage
10177
+
9396
10178
  ```ts
9397
10179
  interface PackageChange {
9398
10180
  /** Current status. */
9399
10181
  readonly status: PackageChangeStatus;
9400
10182
  /** Details for failures, `null` otherwise. */
9401
10183
  readonly message: string | null;
9402
- /** The status changed. */
10184
+ /**
10185
+ * The status changed.
10186
+ * @capability plugins.manage
10187
+ * @example Close a progress window when the change ends
10188
+ * The plugin's progress window closes once the install is over, whatever
10189
+ * the outcome. The listener receives each new status; the terminal ones
10190
+ * are `committed`, `cancelled`, `failed`, `failedDegraded` and `tooLate`
10191
+ * (`tooLate` only answers a `cancel` of a change that had committed, and
10192
+ * `committed` may follow it).
10193
+ * ```ts
10194
+ * change.onChange((status) => {
10195
+ * if (["committed", "cancelled", "failed", "failedDegraded", "tooLate"].includes(status))
10196
+ * void progress.close();
10197
+ * });
10198
+ * ```
10199
+ */
9403
10200
  onChange(listener: (status: PackageChangeStatus) => void): Disposable;
9404
- /** Resolves with the final status. */
10201
+ /**
10202
+ * Resolves with the final status.
10203
+ * @throws missing (rejects) The player no longer knows the change (a `HostError` with that code); a failed change is a terminal status, not a rejection.
10204
+ */
9405
10205
  readonly done: Promise<PackageChangeStatus>;
9406
- /** Ask to cancel (it may be `tooLate`). */
10206
+ /**
10207
+ * Ask to cancel (it may be `tooLate`).
10208
+ * @capability plugins.manage
10209
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10210
+ * @example Cancel an install the user no longer wants
10211
+ * The install stops if it has not gone too far. `cancel` asks the player;
10212
+ * the change then ends `cancelled`. When the change had already committed
10213
+ * the player answers `tooLate` and `done` still resolves `committed`; a
10214
+ * change that is restoring cannot be cancelled and ends `failed` or
10215
+ * `failedDegraded`.
10216
+ * ```ts
10217
+ * await change.cancel();
10218
+ * console.log(await change.done); // "cancelled"; "committed" when it was too late
10219
+ * ```
10220
+ */
9407
10221
  cancel(): Promise<void>;
9408
10222
  }
9409
10223
  ```
9410
10224
 
10225
+ Example: Report each step of an install
10226
+
10227
+ The OSD shows each step of the install and the end result. `onChange`
10228
+ runs for every new status; `done` resolves when the change is over.
10229
+ ```ts
10230
+ change.onChange((status) => void plugin.osd.show("Install: {}", [status]));
10231
+ const end = await change.done;
10232
+ if (end !== "committed") await plugin.osd.show("Not installed: {}", [change.message ?? end]);
10233
+ ```
10234
+
9411
10235
  ### PluginInfo
9412
10236
 
9413
10237
  One plugin as the player knows it (`packages.list`).
@@ -9489,6 +10313,14 @@ interface PluginWarning {
9489
10313
  `plugin.packages`: the plugins the player knows. `list` and `onChange`
9490
10314
  need no capability; everything else needs `plugins.manage`.
9491
10315
 
10316
+ The plugins the player knows: installed, built-in and this run's
10317
+ development loads. Reading the list needs no capability; installing,
10318
+ enabling, reloading and uninstalling need `plugins.manage` and act on
10319
+ other plugins, never on the calling one. Built-in plugins update with
10320
+ the player and cannot be installed, disabled or uninstalled.
10321
+
10322
+ - @capability None for `list` and `onChange`; plugins.manage for the rest.
10323
+
9492
10324
  ```ts
9493
10325
  interface Packages {
9494
10326
  /**
@@ -9499,18 +10331,93 @@ interface Packages {
9499
10331
  * The player keeps the 32 most recent candidates of all plugins; an
9500
10332
  * older one's `install` fails with `stale` (validate again). Before
9501
10333
  * player 0.4.2 the 33rd validation of a run failed with `busy` instead.
10334
+ * @capability plugins.manage
10335
+ * @throws host (rejects) Not a package directory, or a manifest that breaks the rules (another `apiVersion` included); `message` says why.
10336
+ * @throws invalid (rejects) A bad request (a path that is not a string).
10337
+ * @throws javascript (rejects) The entry module does not parse.
10338
+ * @throws slint (rejects) A Slint file does not compile.
10339
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10340
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10341
+ * @throws disabled (rejects) The calling plugin is stopping.
10342
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10343
+ * @example Check a package directory before installing it
10344
+ * The plugin checks a folder it unpacked and installs it if the player
10345
+ * accepts it. `validate` runs the player's checks and resolves with a
10346
+ * candidate; a package that fails them rejects with the reason.
10347
+ * ```ts
10348
+ * try {
10349
+ * // Rejects with the reason when the package fails the checks.
10350
+ * const candidate = await plugin.packages.validate(directory);
10351
+ * await (await candidate.install()).done;
10352
+ * } catch (error) {
10353
+ * await plugin.osd.show("Not a valid plugin: {}", [(error as Error).message]);
10354
+ * }
10355
+ * ```
9502
10356
  */
9503
10357
  validate(directory: string): Promise<PackageCandidate>;
9504
10358
  /**
9505
10359
  * Validate a `.vxp` package file (`plugins.manage`, player 0.2.0): the same checks as Packages.validate after unpacking. Check the file's sha256 yourself before calling when you know it. A reserved id fails with `reserved`, as in Packages.validate.
10360
+ * @capability plugins.manage
10361
+ * @throws invalid (rejects) The path does not end in `.vxp`, or the archive cannot be unpacked (over 64 MB, too many files, a damaged zip, an unsafe path).
10362
+ * @throws host (rejects) The unpacked package or its manifest breaks the rules; `message` says why.
10363
+ * @throws javascript (rejects) The entry module does not parse.
10364
+ * @throws slint (rejects) A Slint file does not compile.
10365
+ * @throws reserved (rejects) A reserved id.
10366
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10367
+ * @throws disabled (rejects) The calling plugin is stopping.
10368
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10369
+ * @example Install a downloaded package file
10370
+ * A `.vxp` the plugin downloaded is checked and installed. The player
10371
+ * unpacks and checks it like a directory; checking the file's sha256
10372
+ * first, when the store publishes one, is up to the plugin.
10373
+ * ```ts
10374
+ * const candidate = await plugin.packages.validateArchive(file); // a .vxp path
10375
+ * const change = await candidate.install();
10376
+ * await plugin.osd.show("Install: {}", [await change.done]);
10377
+ * ```
9506
10378
  */
9507
10379
  validateArchive(file: string): Promise<PackageCandidate>;
9508
- /** Every plugin: running, installed, and this run's development loads. No capability. */
10380
+ /**
10381
+ * Every plugin: running, installed, and this run's development loads. No capability.
10382
+ * @capability None.
10383
+ * @throws Error (rejects) Only when the player cannot be asked; no capability is needed.
10384
+ * @example Show which plugins are running
10385
+ * The plugin lists the running plugins by name. `list` asks the player
10386
+ * and resolves with every plugin it knows, with its state, origin and
10387
+ * display texts in the interface language.
10388
+ * ```ts
10389
+ * const plugins = await plugin.packages.list();
10390
+ * const running = plugins.filter((info) => info.state === "running");
10391
+ * console.log(running.map((info) => info.display?.name ?? info.id));
10392
+ * ```
10393
+ */
9509
10394
  list(): Promise<PluginInfo[]>;
9510
- /** The new list after any start, stop, failure, install or uninstall. No capability. */
10395
+ /**
10396
+ * The new list after any start, stop, failure, install or uninstall. No capability.
10397
+ * @capability None.
10398
+ * @example Refresh a plugin list after any change
10399
+ * A store window's list updates when a plugin starts, stops, fails, is
10400
+ * installed or uninstalled. The listener receives the new list each time.
10401
+ * ```ts
10402
+ * plugin.packages.onChange((plugins) => console.log(plugins.length, "plugins"));
10403
+ * ```
10404
+ */
9511
10405
  onChange(listener: (plugins: PluginInfo[]) => void): Disposable;
9512
10406
  /**
9513
- * Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed. `unsupported` to disable a built-in (PluginInfo.builtin): users manage third-party plugins only (players released with SDK 1.5.18 on; older ones disabled built-ins too).
10407
+ * Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed or reloaded. `unsupported` to disable a built-in (PluginInfo.builtin): users manage third-party plugins only (players released with SDK 1.5.18 on; older ones disabled built-ins too).
10408
+ * @capability plugins.manage
10409
+ * @throws invalid (rejects) Disabling the calling plugin itself.
10410
+ * @throws missing (rejects) An unknown id.
10411
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10412
+ * @throws unsupported (rejects) A built-in plugin.
10413
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10414
+ * @example Turn a third-party plugin off
10415
+ * The plugin stops another plugin and keeps it disabled across launches
10416
+ * until it is enabled again. Built-in plugins cannot be disabled
10417
+ * (`unsupported`).
10418
+ * ```ts
10419
+ * await plugin.packages.setEnabled("someone.demo", false);
10420
+ * ```
9514
10421
  */
9515
10422
  setEnabled(id: string, enabled: boolean): Promise<void>;
9516
10423
  /**
@@ -9528,10 +10435,38 @@ interface Packages {
9528
10435
  * `invalid` for oneself or a disabled plugin (enable it instead),
9529
10436
  * `missing` unknown, `busy` while it is being installed or reloaded.
9530
10437
  * @player 0.5.1; older players reject it as an unknown request (`unsupported`).
10438
+ * @capability plugins.manage
10439
+ * @throws invalid (rejects) The calling plugin itself, or a disabled plugin.
10440
+ * @throws missing (rejects) An unknown id.
10441
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10442
+ * @throws unsupported (rejects) A player before 0.5.1.
10443
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10444
+ * @example Restart a plugin that misbehaves
10445
+ * The other plugin is stopped and started again from the same package and
10446
+ * data. `reload` resolves once the old process is stopped and the start is
10447
+ * scheduled; `onChange` then shows `starting` and `running` (or `failed`).
10448
+ * ```ts
10449
+ * await plugin.packages.reload("someone.demo");
10450
+ * ```
9531
10451
  */
9532
10452
  reload(id: string): Promise<void>;
9533
10453
  /**
9534
10454
  * Remove an installed plugin (`plugins.manage`); `removeData` also deletes its data directory, otherwise reinstalling it from the same source reconnects to its data. Its user bindings stay (as orphans). `unsupported` for built-ins and development loads, `missing` unknown, `invalid` for oneself.
10455
+ * @capability plugins.manage
10456
+ * @throws unsupported (rejects) A built-in plugin or a development load.
10457
+ * @throws missing (rejects) An unknown id.
10458
+ * @throws invalid (rejects) The calling plugin itself.
10459
+ * @throws busy (rejects) The plugin library is being cleaned up, or that plugin is being installed or reloaded.
10460
+ * @throws revisionConflict (rejects) The installed revision of that plugin changed meanwhile; call `uninstall` again.
10461
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10462
+ * @example Uninstall a plugin together with its data
10463
+ * The other plugin is removed and its data directory deleted, so a later
10464
+ * reinstall starts fresh. Without `removeData` a reinstall from the same
10465
+ * source finds its data again.
10466
+ * ```ts
10467
+ * const change = await plugin.packages.uninstall("someone.demo", { removeData: true });
10468
+ * await change.done;
10469
+ * ```
9535
10470
  */
9536
10471
  uninstall(id: string, options?: {
9537
10472
  removeData?: boolean;
@@ -9606,9 +10541,31 @@ interface BindingInfo extends BindingRef {
9606
10541
  `plugin.bindings`: list and override every command's bindings, including
9607
10542
  the player's. Needs `bindings.manage`.
9608
10543
 
10544
+ The player's binding table: every command's default keys (from
10545
+ CommandOptions.bindings, the player's own as `host/<action>`)
10546
+ and the user's overrides on top, saved across launches. A key-settings
10547
+ plugin lists the table, writes overrides and resets them; the player
10548
+ applies the change at once to every running plugin.
10549
+
10550
+ - @capability bindings.manage
10551
+
9609
10552
  ```ts
9610
10553
  interface Bindings {
9611
- /** Every declared command, and overrides whose command is gone. */
10554
+ /**
10555
+ * Every declared command, and overrides whose command is gone.
10556
+ * @capability bindings.manage
10557
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10558
+ * @example Find the commands that use a key
10559
+ * A key-settings window shows which commands Ctrl+K is bound to now. `list`
10560
+ * resolves with every declared command and its current bindings (the
10561
+ * defaults with the user's overrides applied) and with the overrides
10562
+ * whose command is not running (`orphans`).
10563
+ * ```ts
10564
+ * const { commands } = await plugin.bindings.list();
10565
+ * const users = commands.filter((command) => command.current.includes("Ctrl+K"));
10566
+ * console.log(users.map((command) => `${command.plugin}/${command.command}`)); // "host" for the player
10567
+ * ```
10568
+ */
9612
10569
  list(): Promise<{
9613
10570
  /** Every declared command (running plugins and the player). */
9614
10571
  commands: BindingInfo[];
@@ -9622,11 +10579,47 @@ interface Bindings {
9622
10579
  }>;
9623
10580
  /**
9624
10581
  * Override a command's bindings (`[]` unbinds it); saved across launches. `invalid` for a bad binding, `missing` for an unknown command that has no override either. At most 16 inputs, syntax as CommandOptions.bindings.
10582
+ * @capability bindings.manage
10583
+ * @throws invalid (rejects) A malformed binding, or more than 16.
10584
+ * @throws missing (rejects) An unknown command that has no override either.
10585
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10586
+ * @example Give the player's Mute command a second key
10587
+ * Mute now answers to M and to Ctrl+M. `set` replaces the command's
10588
+ * bindings with the list given (an override saved across launches), so it
10589
+ * repeats the default it keeps; `[]` would unbind it.
10590
+ * ```ts
10591
+ * await plugin.bindings.set({ plugin: "host", command: "mute" }, ["M", "Ctrl+M"]);
10592
+ * ```
9625
10593
  */
9626
10594
  set(ref: BindingRef, inputs: string[]): Promise<void>;
9627
- /** Back to the defaults: one command, or every override when omitted. */
10595
+ /**
10596
+ * Back to the defaults: one command, or every override when omitted.
10597
+ * @capability bindings.manage
10598
+ * @throws missing (rejects) A command that has no override and is not declared.
10599
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10600
+ * @example Restore every default key
10601
+ * A "Restore defaults" button removes all of the user's overrides, so
10602
+ * every command has its declared keys again. Without an argument `reset`
10603
+ * clears every override; with a reference only that command's.
10604
+ * ```ts
10605
+ * await plugin.bindings.reset(); // every override
10606
+ * ```
10607
+ */
9628
10608
  reset(ref?: BindingRef): Promise<void>;
9629
- /** Declarations or overrides changed (read `list()` again). At most every 200 ms. */
10609
+ /**
10610
+ * Declarations or overrides changed (read `list()` again). At most every 200 ms.
10611
+ * @capability bindings.manage
10612
+ * @example Refresh a key list when bindings change
10613
+ * The plugin's key list stays current when the user changes a key in the
10614
+ * player or another plugin starts. The listener carries no data and runs
10615
+ * at most every 200 ms; the plugin reads the table again with `list`.
10616
+ * ```ts
10617
+ * plugin.bindings.onChange(async () => {
10618
+ * const { commands } = await plugin.bindings.list(); // read the new table
10619
+ * render(commands);
10620
+ * });
10621
+ * ```
10622
+ */
9630
10623
  onChange(listener: () => void): Disposable;
9631
10624
  }
9632
10625
  ```
@@ -10717,6 +11710,13 @@ A tool window as the test host keeps it: the record of a
10717
11710
  `plugin.windows.create(...)` window after its first `show()`. Get one with
10718
11711
  TestHost.window.
10719
11712
 
11713
+ A record, not a window: no Slint file is compiled and nothing is drawn.
11714
+ `props` holds what the plugin wrote, by Slint name; `emit` fires a
11715
+ callback the way the user's input would; `userClose` closes it the way
11716
+ the title-bar button would.
11717
+
11718
+ - @capability None.
11719
+
10720
11720
  ```ts
10721
11721
  interface FakeWindow {
10722
11722
  /** The `.slint` file the window was created from (package-relative). */
@@ -10731,10 +11731,32 @@ interface FakeWindow {
10731
11731
  callbacks: string[];
10732
11732
  /**
10733
11733
  * Fire a Slint callback as if the user triggered it in the window. The name may be kebab-case or camelCase; `args` are the callback arguments. Follow it with `await host.settle()` before asserting.
11734
+ * @capability None.
11735
+ * @example Type into a window's search field
11736
+ * The test types "cat" into the window's search box and checks the
11737
+ * plugin's results. `emit` delivers the Slint callback `search-changed`
11738
+ * with its argument to the plugin's `on` handler; `settle()` waits for the
11739
+ * properties the handler wrote.
11740
+ * ```ts
11741
+ * const search = host.window("search");
11742
+ * search.emit("searchChanged", "cat"); // camelCase or kebab-case
11743
+ * await host.settle();
11744
+ * assert.ok((search.props["results"] as unknown[]).length > 0);
11745
+ * ```
10734
11746
  */
10735
11747
  emit(callback: string, ...args: Json[]): void;
10736
11748
  /**
10737
11749
  * The user closes the window with its title-bar button: the window closes and the plugin's `window.onClose` listeners run (unlike the plugin's own `close()`).
11750
+ * @capability None.
11751
+ * @example Close a window the way the user does
11752
+ * The test closes the plugin's window with its title-bar button and checks
11753
+ * that the plugin stopped its timer. Unlike the plugin's own `close()`,
11754
+ * `userClose` runs the plugin's `onClose` listeners.
11755
+ * ```ts
11756
+ * host.window("clock").userClose();
11757
+ * await host.settle();
11758
+ * assert.equal(host.window("clock").visible, false);
11759
+ * ```
10738
11760
  */
10739
11761
  userClose(): void;
10740
11762
  }
@@ -10777,6 +11799,13 @@ A command the plugin registered with `plugin.commands.add`, as the test
10777
11799
  host keeps it. Get one with TestHost.command (the plugin's own) or
10778
11800
  TestHost.coreAction (the player's core actions).
10779
11801
 
11802
+ The host's record of one command: what the plugin declared, the state
11803
+ it last sent, and how the menu shows it in TestHost.locale.
11804
+ Read it again after `settle()` to see changes; `run` executes it as the
11805
+ menu would.
11806
+
11807
+ - @capability None.
11808
+
10780
11809
  ```ts
10781
11810
  interface FakeCommand {
10782
11811
  /** What the plugin declared: id, menu path, title, order, group, default bindings, focus and repeat. Wire-level shape; it may gain fields in minor versions. */
@@ -10809,6 +11838,15 @@ interface FakeCommand {
10809
11838
  revision: number;
10810
11839
  /**
10811
11840
  * Execute the command the way the menu would, then wait until the host is idle. Rejects (with a HostError) when the command is disabled or busy, like the player, which does not call `run` then.
11841
+ * @capability None.
11842
+ * @throws command (rejects) A `HostError`: the command is disabled or busy (the message says which).
11843
+ * @example Check that a disabled item refuses to run
11844
+ * The test checks that Export cannot run while nothing is open. Like the
11845
+ * player, the host does not call `run` for a disabled or busy command;
11846
+ * the promise rejects with a `HostError`.
11847
+ * ```ts
11848
+ * await assert.rejects(host.command("export").run());
11849
+ * ```
10812
11850
  */
10813
11851
  run(): Promise<void>;
10814
11852
  }
@@ -10834,6 +11872,13 @@ assert.equal(host.command("toggle-loop").state.checked, true);
10834
11872
  A command's bottom-bar button (`CommandOptions.button`) as the player shows
10835
11873
  it: an entry of TestHost.barButtons.
10836
11874
 
11875
+ A bottom-bar button as the player would show it, derived from the
11876
+ command's declaration and state each time it is read: clickable while
11877
+ the command is enabled and not busy, accent-coloured while checked, with
11878
+ the command's title (and reason) as the tooltip.
11879
+
11880
+ - @capability None.
11881
+
10837
11882
  ```ts
10838
11883
  interface FakeBarButton {
10839
11884
  /** The command's local id. */
@@ -10852,17 +11897,45 @@ interface FakeBarButton {
10852
11897
  readonly tooltip: string;
10853
11898
  /**
10854
11899
  * Click the button, then wait until the host is idle: runs the command as its menu item would. Rejects (with a HostError) while the button is disabled, and once the command is gone.
11900
+ * @capability None.
11901
+ * @throws command (rejects) A `HostError`: the command is disabled or busy.
11902
+ * @throws missing (rejects) A `HostError`: the command is gone.
11903
+ * @example Click the button and wait for its effect
11904
+ * The test clicks the plugin's button and checks the OSD. `click` runs the
11905
+ * command like its menu item and resolves once the host is idle.
11906
+ * ```ts
11907
+ * await host.barButtons[0]?.click();
11908
+ * assert.ok(host.osd.length > 0);
11909
+ * ```
10855
11910
  */
10856
11911
  click(): Promise<void>;
10857
11912
  }
10858
11913
  ```
10859
11914
 
11915
+ Example: Check a bar button's tooltip while it is disabled
11916
+
11917
+ The test checks that the Export button shows why it cannot be clicked.
11918
+ The tooltip is the command's title; while the command is disabled or
11919
+ busy and has a non-empty `reason`, the reason follows on a second line.
11920
+ ```ts
11921
+ const [button] = host.barButtons;
11922
+ assert.equal(button?.enabled, false);
11923
+ assert.match(button?.tooltip ?? "", /\n/); // the command declares a reason
11924
+ ```
11925
+
10860
11926
  ### FakeProducer
10861
11927
 
10862
11928
  A generated stream (`plugin.player.openStream`) as the test host sees it:
10863
11929
  the fake player end of the frame connection. Assertion surface; see
10864
11930
  TestHost.producers.
10865
11931
 
11932
+ The player's end of a generated stream's frame connection
11933
+ (TestHost.producers): how many frames arrived and whether the
11934
+ plugin released it or the player retired it. Tests assert on it; nothing
11935
+ is played.
11936
+
11937
+ - @capability None.
11938
+
10866
11939
  ```ts
10867
11940
  interface FakeProducer {
10868
11941
  /** The producer id the host assigned (`1`, `2`, … per host). */
@@ -10884,6 +11957,18 @@ interface FakeProducer {
10884
11957
  }
10885
11958
  ```
10886
11959
 
11960
+ Example: Check that the stream delivers frames
11961
+
11962
+ The test starts the plugin's generator and checks that video and audio
11963
+ arrive. The counters grow as the SDK pulls frames and sends them over
11964
+ the fake connection.
11965
+ ```ts
11966
+ await host.command("start-generator").run();
11967
+ await host.settle(200); // let a few frames through
11968
+ const [producer] = host.producers.values();
11969
+ assert.ok((producer?.videoFrames ?? 0) > 0 && (producer?.receivedFrames ?? 0) > 0);
11970
+ ```
11971
+
10887
11972
  ### MarkdownWire
10888
11973
 
10889
11974
  A Markdown document as TestHost.markdown reads it back.
@@ -10938,6 +12023,15 @@ Fields that mirror player state at the wire level (`source`, `subtitles`,
10938
12023
  `calls`) use the protocol's shapes. They are fine for assertions but are
10939
12024
  not part of the plugin API: their shape may change in minor versions.
10940
12025
 
12026
+ An in-memory player that speaks the real wire protocol, so the SDK's own
12027
+ logic runs as it does in the player; only the player side is simulated
12028
+ (no decoding, no Slint, no windows). Make one per test, preset it if the
12029
+ plugin reads something during activation, start the plugin with
12030
+ startPlugin, drive it, `settle()`, and assert on what it
12031
+ recorded.
12032
+
12033
+ - @capability None.
12034
+
10941
12035
  ```ts
10942
12036
  class TestHost implements Transport {
10943
12037
  /** Every request the plugin sent, in order: `{ method, params }`. Wire-level; prefer TestHost.sent for one method. */
@@ -11020,6 +12114,15 @@ class TestHost implements Transport {
11020
12114
  /**
11021
12115
  * Read the plugin's translations for TestHost.locale from a package directory, as the player would (`locales/<locale>.json` of a built package, else the source `locales/<locale>.po`); `startPlugin` does this. None for English; a missing file means none; a broken `.po` throws.
11022
12116
  * @param directory - The package directory (holding `locales/`).
12117
+ * @capability None.
12118
+ * @example Load the translations after changing the language
12119
+ * The test switches the host to German before starting the plugin and
12120
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
12121
+ * shown in German.
12122
+ * ```ts
12123
+ * host.locale = "de";
12124
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
12125
+ * ```
11023
12126
  */
11024
12127
  loadCatalog(directory: string): void;
11025
12128
  /**
@@ -11027,6 +12130,7 @@ class TestHost implements Transport {
11027
12130
  * @param text - English source text or template.
11028
12131
  * @param args - Values for its placeholders.
11029
12132
  * @returns The shown text.
12133
+ * @capability None.
11030
12134
  * @example Compare with a translated message
11031
12135
  * The test expects the text a Chinese user sees for a template the plugin
11032
12136
  * shows. `translate` looks the English source up in the plugin's catalog
@@ -11041,6 +12145,19 @@ class TestHost implements Transport {
11041
12145
  /**
11042
12146
  * Simulate another plugin (or the user) saving the interface language: pushes `appearance.changed`.
11043
12147
  * @param language - `"system"` or a locale.
12148
+ * @capability None.
12149
+ * @example Simulate the user choosing French for the next start
12150
+ * The plugin hears that the saved interface language changed. The host
12151
+ * pushes `appearance.changed`, which a plugin with `events` sees in
12152
+ * `appearance.language` once it has read it and subscribed; this run's
12153
+ * language does not change.
12154
+ * ```ts
12155
+ * void plugin.appearance.language; // read once: the SDK then follows it
12156
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
12157
+ * host.setLanguage("fr");
12158
+ * await host.settle();
12159
+ * assert.equal(plugin.appearance.language, "fr");
12160
+ * ```
11044
12161
  */
11045
12162
  setLanguage(language: LanguagePreference): void;
11046
12163
  /**
@@ -11197,6 +12314,7 @@ class TestHost implements Transport {
11197
12314
  * Simulate another plugin publishing its timeline layer (or clearing it with `[]`); pushes `timeline.changed` when the plugin subscribed.
11198
12315
  * @param pluginId - The other plugin's manifest id.
11199
12316
  * @param marks - Its whole layer.
12317
+ * @capability None.
11200
12318
  * @example Let another plugin publish chapters
11201
12319
  * The plugin under test sees two chapter marks another plugin put on the
11202
12320
  * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
@@ -11230,6 +12348,7 @@ class TestHost implements Transport {
11230
12348
  }[];
11231
12349
  /**
11232
12350
  * 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.
12351
+ * @capability None.
11233
12352
  * @example Check which file a window shows as its cover
11234
12353
  * The test checks that the Cover window's image comes from the plugin's
11235
12354
  * data directory. An image property holds the SDK's wire form of
@@ -11255,6 +12374,8 @@ class TestHost implements Transport {
11255
12374
  * 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.
11256
12375
  * @param paths - The dropped paths, in the order the file manager gives.
11257
12376
  * @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`).
12377
+ * @capability None.
12378
+ * @throws Never: files nobody takes are ignored, as in the player.
11258
12379
  * @example Drop files on the video and on the panel
11259
12380
  * The first drop lands on the video area: each extension belongs to the
11260
12381
  * provider that registered it first, and each provider gets one call with
@@ -11285,7 +12406,20 @@ class TestHost implements Transport {
11285
12406
  }): Promise<void>;
11286
12407
  /** The palette (`plugin.appearance.theme`); `setAppearance` changes it elsewhere. */
11287
12408
  appearance: ThemeId;
11288
- /** Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`. */
12409
+ /**
12410
+ * Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`.
12411
+ * @capability None.
12412
+ * @example Simulate another plugin switching to the light palette
12413
+ * The plugin hears the palette change. The host updates `appearance` and
12414
+ * pushes `appearance.changed` to a plugin that listens with `events`.
12415
+ * ```ts
12416
+ * const themes: ThemeId[] = [];
12417
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
12418
+ * host.setAppearance("vestra-light");
12419
+ * await host.settle();
12420
+ * assert.deepEqual(themes, ["vestra-light"]);
12421
+ * ```
12422
+ */
11289
12423
  setAppearance(theme: ThemeId): void;
11290
12424
  /** Settings pages by id (`plugin.settings.page`), every `show` (page id or null), and the page the options window shows. */
11291
12425
  readonly settings: {
@@ -11317,6 +12451,7 @@ class TestHost implements Transport {
11317
12451
  };
11318
12452
  /**
11319
12453
  * One settings page by id. Throws when it was never registered.
12454
+ * @capability None.
11320
12455
  * @example Change a value on the plugin's settings page
11321
12456
  * The test sets the step on the General page and checks the page shows
11322
12457
  * it. `emit` fires the page's callback as the user's input would; the
@@ -11354,13 +12489,26 @@ class TestHost implements Transport {
11354
12489
  /** Whether it shows now (no video frame). `true` when mounted. */
11355
12490
  effective: boolean;
11356
12491
  } | null;
11357
- /** Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin. */
12492
+ /**
12493
+ * Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin.
12494
+ * @capability None.
12495
+ * @example Make a video picture replace the backdrop
12496
+ * The plugin's backdrop hears that a video frame now covers it. `false`
12497
+ * means a picture appeared; the change is pushed like the player's
12498
+ * `window.state`.
12499
+ * ```ts
12500
+ * host.setBackdropEffective(false);
12501
+ * await host.settle();
12502
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
12503
+ * ```
12504
+ */
11358
12505
  setBackdropEffective(effective: boolean): void;
11359
12506
  /**
11360
12507
  * Simulate another plugin mounting a panel, before or after the plugin under test, as the player handles it: in the sidebar it is another tab (after the existing ones; the first panel's tab is current); the bottom bar and the backdrop take one panel, so the plugin's own mount there then rejects with `conflict` (not an activation failure, as in the player). Pushed as a `window.state` change.
11361
12508
  * @param slot - The position.
11362
12509
  * @param plugin - The other plugin's manifest id.
11363
12510
  * @param options - `title`: its tab label (default: `plugin`).
12511
+ * @capability None.
11364
12512
  * @example Put the playlist next to this plugin's sidebar panel
11365
12513
  * The sidebar ends up with two tabs: the playlist, mounted first, and the
11366
12514
  * plugin's own panel after it. `mountOther` before `startPlugin` mounts
@@ -11377,10 +12525,31 @@ class TestHost implements Transport {
11377
12525
  mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
11378
12526
  title?: string;
11379
12527
  }): void;
11380
- /** Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is. */
12528
+ /**
12529
+ * Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is.
12530
+ * @capability None.
12531
+ * @example Let the playlist's tab go away
12532
+ * The other plugin's sidebar tab disappears and the plugin's own tab
12533
+ * becomes current. When the removed tab was current, the next tab (else
12534
+ * the previous one) becomes current, as in the player.
12535
+ * ```ts
12536
+ * host.unmountOther("vestra.playlist");
12537
+ * await host.settle();
12538
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
12539
+ * ```
12540
+ */
11381
12541
  unmountOther(plugin: string): void;
11382
12542
  /**
11383
12543
  * Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin under test's `manifestId`, or one of TestHost.mountOther) becomes the current tab. Pushed as a `window.state` change.
12544
+ * @capability None.
12545
+ * @example Click another plugin's sidebar tab
12546
+ * The user switches the sidebar to the playlist; the plugin's panel is in
12547
+ * a background tab now. The change is pushed as `window.state`.
12548
+ * ```ts
12549
+ * host.selectTab("vestra.playlist");
12550
+ * await host.settle();
12551
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
12552
+ * ```
11384
12553
  */
11385
12554
  selectTab(plugin: string): void;
11386
12555
  /** The display layer (`plugin.video.view()`): who owns it (this plugin is `manifestId`) and the values the player holds. */
@@ -11392,6 +12561,17 @@ class TestHost implements Transport {
11392
12561
  };
11393
12562
  /**
11394
12563
  * Simulate another plugin taking the display layer first (the plugin's `video.view()` then fails with `conflict`), or, with `null`, releasing it (values reset to the defaults).
12564
+ * @capability None.
12565
+ * @example Let another plugin own the display layer
12566
+ * The plugin's `video.view()` fails with `conflict` because another plugin
12567
+ * took the display layer first. `null` releases it again.
12568
+ * ```ts
12569
+ * host.setVideoViewOwner("someone.zoom");
12570
+ * const view = plugin.video.view();
12571
+ * view.set({ zoom: 2 });
12572
+ * await host.settle();
12573
+ * assert.equal(view.status.state, "failed");
12574
+ * ```
11395
12575
  */
11396
12576
  setVideoViewOwner(pluginId: string | null): void;
11397
12577
  /**
@@ -11407,10 +12587,22 @@ class TestHost implements Transport {
11407
12587
  };
11408
12588
  /**
11409
12589
  * Simulate another plugin owning the render options (the plugin's `video.render()` then fails with `conflict`), or, with `null`, releasing them (values reset to the defaults).
12590
+ * @capability None.
12591
+ * @example Let another plugin own the render options
12592
+ * The plugin's render options report `conflict` while another plugin owns
12593
+ * them; `null` releases them and resets the values to the defaults.
12594
+ * ```ts
12595
+ * host.setVideoRenderOwner("someone.hdr");
12596
+ * const render = plugin.video.render();
12597
+ * render.set({ toneMapping: "bt.2390" });
12598
+ * await host.settle();
12599
+ * assert.equal(render.status.state, "failed");
12600
+ * ```
11410
12601
  */
11411
12602
  setVideoRenderOwner(pluginId: string | null): void;
11412
12603
  /**
11413
12604
  * 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.
12605
+ * @capability None.
11414
12606
  * @example Make a shader fail to compile
11415
12607
  * The plugin's render options report `failed`, as when mpv rejects a
11416
12608
  * shader. The host pushes the failure only while this plugin owns the
@@ -11531,10 +12723,21 @@ class TestHost implements Transport {
11531
12723
  /** Exact host names it resolves (a web resolver), with `schemes`. */
11532
12724
  hosts?: string[];
11533
12725
  }[];
11534
- /** What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once. */
12726
+ /**
12727
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once.
12728
+ * @capability None.
12729
+ * @example Check the file types a resolver adds
12730
+ * The test checks the list the plugin will read as
12731
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
12732
+ * `otherResolvers`, sorted, each once.
12733
+ * ```ts
12734
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
12735
+ * ```
12736
+ */
11535
12737
  fileTypes(): string[];
11536
12738
  /**
11537
12739
  * 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).
12740
+ * @capability None.
11538
12741
  * @example Let another plugin's file type appear while running
11539
12742
  * Another plugin starts and adds `.ch8`; the plugin under test then sees
11540
12743
  * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
@@ -11652,6 +12855,9 @@ class TestHost implements Transport {
11652
12855
  readonly dataDirectory: string;
11653
12856
  /** The manifest id of the plugin under test (set by startPlugin; `"test.plugin"` by default). */
11654
12857
  manifestId: string;
12858
+ /**
12859
+ * A fresh in-memory player: nothing open, no plugin, the player's core actions registered. Preset what the plugin reads during activation, then pass it to startPlugin as `host`.
12860
+ */
11655
12861
  constructor();
11656
12862
  /** The `player.setStepSeek` value in effect (default 5). */
11657
12863
  stepSeek: number;
@@ -11707,7 +12913,8 @@ class TestHost implements Transport {
11707
12913
  /** Every core action run (`host.run`, a core command, a pressed core binding), newest last. */
11708
12914
  readonly coreRuns: string[];
11709
12915
  /**
11710
- * 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.
12916
+ * 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 like its menu item: a disabled action rejects with `command`, as in the player (`plugin.host.run` rejects with `unavailable` instead); otherwise it runs with the player's checks and writes its on-screen text to TestHost.osd.
12917
+ * @capability None.
11711
12918
  * @example Pause through the player's own action
11712
12919
  * The test pauses playback the way the Space key does and checks that the
11713
12920
  * host recorded it. Core actions belong to the player (`host/<id>`);
@@ -11734,6 +12941,7 @@ class TestHost implements Transport {
11734
12941
  * find the one a call uses.
11735
12942
  * @param method - The wire method name.
11736
12943
  * @param handler - Produces the reply from the request parameters.
12944
+ * @capability None.
11737
12945
  * @example Make opening a folder fail
11738
12946
  * The plugin's Open Folder command meets a missing folder and must tell
11739
12947
  * the user. `handle` replaces the host's answer to one wire method; the
@@ -11750,17 +12958,43 @@ class TestHost implements Transport {
11750
12958
  * ```
11751
12959
  */
11752
12960
  handle(method: string, handler: Handler): void;
11753
- /** Restore the default behaviour of a method replaced with TestHost.handle. */
12961
+ /**
12962
+ * Restore the default behaviour of a method replaced with TestHost.handle.
12963
+ * @capability None.
12964
+ * @example Restore the default answer after a simulated failure
12965
+ * The first write fails, the second succeeds. `reset` removes the handler
12966
+ * that `handle` installed, so the method is answered by the test host
12967
+ * again.
12968
+ * ```ts
12969
+ * host.handle("clipboard.writeText", () => {
12970
+ * throw new HostError("failed", "denied");
12971
+ * });
12972
+ * await assert.rejects(plugin.clipboard.writeText("x"));
12973
+ * host.reset("clipboard.writeText");
12974
+ * await plugin.clipboard.writeText("x"); // the default handler again
12975
+ * ```
12976
+ */
11754
12977
  reset(method: string): void;
11755
12978
  /**
11756
12979
  * Deliver a host event to the plugin. Like the player, subscription-based events (`source.state`, `playback.backend`, `window.state`, `timeline.changed`, `appearance.changed`) are delivered only when the plugin subscribed (it declared `events` and listens). Most tests use the higher-level helpers (`setSource`, `setMainWindow`, ...) instead.
11757
12980
  * @param event - The wire event name.
11758
12981
  * @param data - The event payload (wire-level shape).
12982
+ * @capability None.
12983
+ * @example Deliver a raw event the helpers do not cover
12984
+ * The test pushes a `window.state` event after changing `layout` by hand.
12985
+ * Like the player, the host delivers it only when the plugin subscribed;
12986
+ * most tests use `setSource`, `setMainWindow` and the other helpers
12987
+ * instead.
12988
+ * ```ts
12989
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
12990
+ * await host.settle();
12991
+ * ```
11759
12992
  */
11760
12993
  emit(event: string, data: unknown): void;
11761
12994
  /**
11762
12995
  * 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.
11763
12996
  * @param patch - Wire-level source fields, e.g. `phase`, `position`, `duration`, `paused`, `audioTracks`.
12997
+ * @capability None.
11764
12998
  * @example Load a file, then let it play to the end
11765
12999
  * The plugin sees a one-minute source become ready, then end on its own.
11766
13000
  * `setSource` changes the host's source facts and broadcasts them like
@@ -11777,6 +13011,7 @@ class TestHost implements Transport {
11777
13011
  setSource(patch: Partial<SourceState>): void;
11778
13012
  /**
11779
13013
  * 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.
13014
+ * @capability None.
11780
13015
  * @example Go fullscreen, then report picture-in-picture support
11781
13016
  * The plugin sees the main window fullscreen at 1920×1080, then learns
11782
13017
  * that picture-in-picture is available. `setMainWindow` changes the
@@ -11803,12 +13038,23 @@ class TestHost implements Transport {
11803
13038
  }>): void;
11804
13039
  /**
11805
13040
  * The pointer reached the right edge in fullscreen (`true`) or left the popped-out sidebar (`false`): the attached sidebar pops out or collapses (reference §6.3). No effect outside fullscreen, when hidden or floating.
13041
+ * @capability None.
13042
+ * @example Pop the sidebar out in fullscreen
13043
+ * The pointer reaches the right edge in fullscreen and the attached
13044
+ * sidebar pops out over the video. Outside fullscreen, or with the
13045
+ * sidebar hidden or floating, nothing happens.
13046
+ * ```ts
13047
+ * host.setMainWindow({ fullscreen: true });
13048
+ * host.popSidebar(true);
13049
+ * await host.settle();
13050
+ * ```
11806
13051
  */
11807
13052
  popSidebar(popped: boolean): void;
11808
13053
  /**
11809
13054
  * 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`.
11810
13055
  * @param source - The path or URL.
11811
13056
  * @param phase - The phase to end in (default `"ready"`).
13057
+ * @capability None.
11812
13058
  * @example Let the user open another file meanwhile
11813
13059
  * A state the plugin captured before stops being current once another
11814
13060
  * file opens. `openElsewhere` gives the source a new `sourceId`, so
@@ -11827,6 +13073,7 @@ class TestHost implements Transport {
11827
13073
  * 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`.
11828
13074
  * @param producerId - The producer, from TestHost.producers.
11829
13075
  * @param message - The player's failure text.
13076
+ * @capability None.
11830
13077
  * @example Break the plugin's generated stream
11831
13078
  * The plugin's stream stops with an error, as when the player loses its
11832
13079
  * audio device. `failStream` puts the source into `error` with the
@@ -11839,8 +13086,11 @@ class TestHost implements Transport {
11839
13086
  */
11840
13087
  failStream(producerId: number, message: string): void;
11841
13088
  /**
11842
- * 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.
13089
+ * 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"`). When only disabled commands hold the key, the first of them decides, as in the player: with a `reason` it shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without one the key goes on to the focused control (`"passed"`, nothing runs). Unlike the player, which only logs a key that reaches a busy command, `press` then rejects, so a test notices. Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
11843
13090
  * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
13091
+ * @capability None.
13092
+ * @throws command (rejects) A `HostError`: the command that would run is busy (the player only logs this).
13093
+ * @throws Error (rejects) Nothing is bound to the key.
11844
13094
  * @example Press a global key and a panel key
11845
13095
  * Ctrl+B runs whatever is bound globally; Delete is pressed while the
11846
13096
  * plugin's panel has focus, so panel bindings are tried first. `press`
@@ -11931,6 +13181,7 @@ class TestHost implements Transport {
11931
13181
  readonly remoteImages: Map<string, string | null>;
11932
13182
  /**
11933
13183
  * 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.
13184
+ * @capability None.
11934
13185
  * @example Read back a Markdown message in a chat window
11935
13186
  * The test checks the Markdown source of the first chat message. Rows
11936
13187
  * hold the document in its wire form, as `plugin.ui.markdown` produced
@@ -11948,13 +13199,47 @@ class TestHost implements Transport {
11948
13199
  /** The system's name for it. */
11949
13200
  name: string;
11950
13201
  }[];
11951
- /** Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed). */
13202
+ /**
13203
+ * Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed).
13204
+ * @capability None.
13205
+ * @example Simulate another plugin starting
13206
+ * The plugin's store window hears that a plugin is running now. The list
13207
+ * replaces `packages` and is pushed as `packages.changed`, which the
13208
+ * plugin's `packages.onChange` listeners receive.
13209
+ * ```ts
13210
+ * host.setPackages([...host.packages]); // edit a copy, then push it
13211
+ * await host.settle();
13212
+ * ```
13213
+ */
11952
13214
  setPackages(list: TestHost["packages"]): void;
11953
13215
  /**
11954
13216
  * Simulate another plugin calling `player.next()` / `previous()`: the host asks this plugin's sequence provider. Rejects with `unavailable` when there is no provider or its flag is false.
13217
+ * @capability None.
13218
+ * @throws unavailable (rejects) A `HostError`: there is no sequence provider, or its flag is false.
13219
+ * @example Ask the plugin's provider for the next item
13220
+ * Another plugin calls `player.next()`; the host asks the plugin under
13221
+ * test, which is the sequence provider, and the test checks what it
13222
+ * opened.
13223
+ * ```ts
13224
+ * await host.step("next");
13225
+ * await host.settle();
13226
+ * assert.equal(host.source.source, "/media/b.mp4");
13227
+ * ```
11955
13228
  */
11956
13229
  step(direction: "next" | "previous"): Promise<unknown>;
11957
- /** The plugin's command with this local id. Throws when it is not registered (never added, or disposed). */
13230
+ /**
13231
+ * The plugin's command with this local id. Throws when it is not registered (never added, or disposed).
13232
+ * @capability None.
13233
+ * @example Read a command's state after a change
13234
+ * The test checks that Export becomes enabled once a file is ready. The
13235
+ * record is updated as the plugin sends new state; read it after
13236
+ * `settle()`.
13237
+ * ```ts
13238
+ * host.setSource({ phase: "ready", duration: 60 });
13239
+ * await host.settle();
13240
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
13241
+ * ```
13242
+ */
11958
13243
  command(id: string): FakeCommand;
11959
13244
  /**
11960
13245
  * 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`.
@@ -11974,6 +13259,7 @@ class TestHost implements Transport {
11974
13259
  get barButtons(): readonly FakeBarButton[];
11975
13260
  /**
11976
13261
  * 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()`.
13262
+ * @capability None.
11977
13263
  * @example Press a window's Reset button from a test
11978
13264
  * The Picture window's Reset callback runs and the brightness returns to
11979
13265
  * 0. The window is found by its id (the `.slint` file name by default);
@@ -11988,6 +13274,7 @@ class TestHost implements Transport {
11988
13274
  window(id: string): FakeWindow;
11989
13275
  /**
11990
13276
  * 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.
13277
+ * @capability None.
11991
13278
  * @example Select a row in the plugin's sidebar panel
11992
13279
  * The test checks the panel sits in the sidebar and selects its third
11993
13280
  * row. `emit` fires a callback on the panel's global as a click would;
@@ -12009,6 +13296,7 @@ class TestHost implements Transport {
12009
13296
  };
12010
13297
  /**
12011
13298
  * The parameters of every request of one wire method, oldest first.
13299
+ * @capability None.
12012
13300
  * @example Check the request behind an OSD message
12013
13301
  * The test checks the last OSD request the plugin sent, before
12014
13302
  * translation. `sent` lists the parameters of every request of one wire
@@ -12029,6 +13317,8 @@ class TestHost implements Transport {
12029
13317
  * for: on a loaded machine it can finish after `settle()` returns. Poll
12030
13318
  * the result you assert on instead (second example).
12031
13319
  * @param ms - Extra time to wait, for debounces and timers (default 0).
13320
+ * @capability None.
13321
+ * @throws Never.
12032
13322
  * @example Wait past a debounced save
12033
13323
  * The plugin's store writes its file 300 ms after the last `save`, so
12034
13324
  * the test waits 400 ms before it checks what the plugin did next.
@@ -12054,7 +13344,22 @@ class TestHost implements Transport {
12054
13344
  * ```
12055
13345
  */
12056
13346
  settle(ms?: number): Promise<void>;
12057
- /** Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it. */
13347
+ /**
13348
+ * Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it.
13349
+ * @capability None.
13350
+ * @example Clean up a host that was never started
13351
+ * A test that made a host but failed before `startPlugin` removes the
13352
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
13353
+ * `close` for you otherwise.
13354
+ * ```ts
13355
+ * const host = new TestHost();
13356
+ * try {
13357
+ * // … preset the host; the test fails here …
13358
+ * } finally {
13359
+ * host.close(); // deletes the host's temporary data
13360
+ * }
13361
+ * ```
13362
+ */
12058
13363
  close(): void;
12059
13364
  }
12060
13365
  ```
@@ -12104,9 +13409,14 @@ throws, or when a registration made during activation fails (for example
12104
13409
  `limit`, `conflict`, `invalid`), which is when the player would fail the
12105
13410
  activation.
12106
13411
 
13412
+ The way into a plugin's tests: one call starts the plugin the way the
13413
+ player's runner does and returns the host to drive, the `plugin` object
13414
+ to call directly, and `stop` to end it like the player.
13415
+
12107
13416
  - @param activate - The plugin's default-exported `activate` function.
12108
13417
  - @param options - Where the plugin lives and how to start it.
12109
13418
  - @returns `host`: the TestHost; `plugin`: the object passed to `activate` (call the plugin API on it directly); `stop()`: dispose the plugin like the player stopping it (scopes released, stores flushed), wait until the host is idle and remove the host's temporary files.
13419
+ - @capability None.
12110
13420
 
12111
13421
  ```ts
12112
13422
  function startPlugin(activate: (plugin: Plugin) => unknown, options?: {
@@ -12851,11 +14161,17 @@ Common codes: `stale` (the condition no longer holds; nothing was done),
12851
14161
  `timeout`, `aborted`, `limit` (a player limit such as the command count),
12852
14162
  `disabled` (the plugin is stopping).
12853
14163
 
14164
+ A definite refusal, as opposed to a lost reply: the SDK does not retry
14165
+ it, and the plugin sees an `Error` with `code`.
14166
+
14167
+ - @capability None.
14168
+
12854
14169
  ```ts
12855
14170
  class HostError extends Error {
12856
14171
  /** The stable error code, e.g. `"invalid"`, `"stale"`, `"unavailable"`. */
12857
14172
  code: string;
12858
14173
  /**
14174
+ * A refusal with this code, as the player would send it.
12859
14175
  * @param code - The stable error code the caller will see as `error.code`.
12860
14176
  * @param message - Human-readable detail (not for branching on).
12861
14177
  */