@vestra-player/sdk 1.5.31 → 1.5.34

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.34: 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
 
@@ -740,7 +740,7 @@ plugin.video.view() returns the VideoView: aspect override, fit (keep with bars,
740
740
 
741
741
  plugin.video.render() returns the RenderOptions of the GPU renderer: HDR to SDR tone mapping and the display's brightness, user shaders (`.glsl` files) and motion interpolation. Like the display layer they act at display time only, have one owner (independent of the display layer's owner) and merge writes.
742
742
 
743
- - **Only the GPU renderer has them.** Under software rendering every `set` fails with `unsupported`; see Reference: platforms for where the player renders on the GPU. Treat `unsupported` as "hide these settings", not as an error.
743
+ - **Only the GPU renderer has them.** Under software rendering every `set` fails with `unsupported`; see Reference: platforms for where the player renders on the GPU. Treat `unsupported` as "hide these settings", not as an error. To know before creating the handle, read rendering from `plugin.app.system()` (player 0.5.12, no capability): `"gpu"` or `"software"`.
744
744
  - A shader that does not compile is reported later: the status first becomes `applied`, then `failed` with code `failed` and the compiler's first error line once mpv first uses the shader.
745
745
 
746
746
  ## Concept: Windows, panels, settings pages, dialogs and the OSD
@@ -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. A plugin has at most 4 such tracks on a source at once and loads at most 16 files into it (removed ones count); past that `load` fails with `busy` (Limits).
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.
@@ -2488,6 +2567,7 @@ The table lists the API whose documentation names a player version, newest last:
2488
2567
  | 0.5.3 | Scrub.seek |
2489
2568
  | 0.5.5 | SessionStorage |
2490
2569
  | 0.5.10 | CommandButton, CommandOptions.button |
2570
+ | 0.5.12 | SystemInfo.rendering |
2491
2571
 
2492
2572
  The Slint library carries no version tags: `Theme.danger` needs player 0.3.0; `@vestra/icons.slint` and the palette `vestra-light` need 0.4.0 (Theme).
2493
2573
 
@@ -2645,6 +2725,21 @@ plugin.storage.session (player 0.5.5). A key out of its rule, or a value that is
2645
2725
  | `SESSION_STORAGE_LIMITS.bytes` | 1048576 (1 MiB) | Bytes one plugin may hold at the same time: for every key, its UTF-8 bytes, its value written as compact JSON (`JSON.stringify(value)`) and `entryBytes`. |
2646
2726
  | `SESSION_STORAGE_LIMITS.entryBytes` | 128 | What each key costs on top of its text and value (128): the player's own bookkeeping, so that many tiny entries stay within `bytes` of memory. |
2647
2727
 
2728
+ #### Subtitle files
2729
+
2730
+ Subtitles.load uploads a file as a track of the current source. A file over `fileBytes` fails with `invalid`; every other limit fails with `busy` and nothing is loaded. The counts are per source, so another file starts from zero. Removing a track frees its place among the tracks and their bytes, but a load stays counted until the source changes.
2731
+
2732
+ | Limit | Value | Meaning |
2733
+ | --- | --- | --- |
2734
+ | `SUBTITLE_LIMITS.fileBytes` | 2097152 (2 MiB) | UTF-8 bytes of one file; a larger one fails with `invalid`. |
2735
+ | `SUBTITLE_LIMITS.tracksPerPlugin` | 4 | Tracks one plugin has loaded into the current source at the same time. |
2736
+ | `SUBTITLE_LIMITS.tracks` | 16 | Loaded tracks of every plugin in the current source at the same time. |
2737
+ | `SUBTITLE_LIMITS.totalBytes` | 16777216 (16 MiB) | Bytes of the loaded tracks of every plugin in the current source together. |
2738
+ | `SUBTITLE_LIMITS.loadsPerPlugin` | 16 | Loads by one plugin into the current source, removed tracks included. |
2739
+ | `SUBTITLE_LIMITS.loads` | 128 | Loads by every plugin into the current source, removed tracks included. |
2740
+ | `SUBTITLE_LIMITS.uploadsPerPlugin` | 2 | Loads of one plugin in progress at the same time. |
2741
+ | `SUBTITLE_LIMITS.uploads` | 8 | Loads of every plugin in progress at the same time. |
2742
+
2648
2743
  #### Messages
2649
2744
 
2650
2745
  Every request a plugin sends travels as one protocol line. The plugin's side refuses a longer one with code `limit` before sending it; property values meet their own limits first.
@@ -3165,7 +3260,7 @@ A plugin uses the same API on macOS and Windows; the player does the system-spec
3165
3260
  | DropModifiers | The pointer and ⌥ ⇧ ⌘ (`cmd` is ⌘). | The pointer and Alt, Shift, Ctrl (`cmd` is Ctrl). |
3166
3261
  | plugin.system (open, reveal) | Opened by the system; revealing several files selects all of them in one Finder window. | Opened by the shell; one Explorer window per folder. |
3167
3262
  | plugin.secrets | The login keychain. | The Credential Manager. |
3168
- | App.system() | Version, build, model and displays. | Version, build, model and displays. |
3263
+ | App.system() | Version, build, model and displays; `rendering` is `"gpu"`, or `"software"` after a fallback (a window that cannot show GPU frames). | Version, build, model and displays; `rendering` is `"software"`. |
3169
3264
  | Snapshots and exports (MediaCapture) | Available. | Available; needs FFmpeg 6.1 or newer. |
3170
3265
  | Generated streams (Player.openStream) | A Unix socket. | Loopback TCP with a token. |
3171
3266
  | Rendering | On the GPU (OpenGL), with hardware decoding. | In software, with hardware decoding copied back (`d3d11va-copy` and similar). |
@@ -3494,9 +3589,32 @@ flushes, a running `tools.run` process is killed and a listener is
3494
3589
  unsubscribed. A Media is the exception: disposing its scope does
3495
3590
  **not** close the media; call Media.close for that.
3496
3591
 
3592
+ Every handle a plugin creates is a `Disposable` owned by the scope that
3593
+ created it; disposing the scope (or stopping the plugin) disposes them in
3594
+ reverse creation order, so a plugin rarely calls `dispose` itself.
3595
+
3596
+ - @capability None of its own: that of the handle.
3597
+
3497
3598
  ```ts
3498
3599
  interface Disposable {
3499
- /** Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects. */
3600
+ /**
3601
+ * Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects.
3602
+ * @capability None of its own: that of the handle.
3603
+ * @throws Never.
3604
+ * @example Remove a command when its feature is turned off
3605
+ * A "Turn feature off" item removes the feature's own menu item at once,
3606
+ * before the plugin stops. `dispose` takes the command out of the menu and
3607
+ * the binding table and resolves once the player confirmed it; the id can
3608
+ * be added again later.
3609
+ * ```ts
3610
+ * const extra = plugin.commands.add({ id: "extra", path: ["Tools", "Extra"], run() {} });
3611
+ * plugin.commands.add({
3612
+ * id: "extra-off",
3613
+ * path: ["Tools", "Turn extra off"],
3614
+ * run: () => extra.dispose(), // never rejects
3615
+ * });
3616
+ * ```
3617
+ */
3500
3618
  dispose(): Promise<void>;
3501
3619
  }
3502
3620
  ```
@@ -3522,7 +3640,6 @@ change).
3522
3640
  | code | meaning |
3523
3641
  | --- | --- |
3524
3642
  | `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
3643
  | `capability` | The manifest does not declare the capability the call needs. |
3527
3644
  | `invalid` | A bad argument (out of range, malformed, unknown id). |
3528
3645
  | `missing` | The target does not exist (an empty panel position, a missing file, an unknown command). |
@@ -3542,7 +3659,7 @@ change).
3542
3659
  | `backend-uncertain` | The player's backend blocked a filter stage; call `retry()`. |
3543
3660
  | `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
3661
  | `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`. |
3662
+ | `versionMismatch` | The package needs a newer player (`minPlayerVersion`). |
3546
3663
  | `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
3664
  | `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
3665
  | `host` | Any other failure inside the player; `message` is the player's reason. |
@@ -3605,6 +3722,14 @@ Reading Scope.window, Scope.app, Scope.appearance
3605
3722
  `.theme`, Scope.ui, Scope.tools, Scope.storage and
3606
3723
  Scope.system needs none.
3607
3724
 
3725
+ Every module of the API (`commands`, `windows`, `player`…) is reachable
3726
+ from a scope, and every handle made through it belongs to it. A child
3727
+ scope from Scope.scope is the unit for features that come and
3728
+ go: dispose the child and everything it created is released, while the
3729
+ rest of the plugin keeps running.
3730
+
3731
+ - @capability That of each module (see the modules).
3732
+
3608
3733
  ```ts
3609
3734
  interface Scope extends Disposable {
3610
3735
  /** Menu commands and their default bindings (`commands`). */
@@ -3628,10 +3753,16 @@ interface Scope extends Disposable {
3628
3753
  */
3629
3754
  filter(id: string): VideoFilter;
3630
3755
  /**
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.
3756
+ * 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.
3757
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3758
+ * @throws Error without a code (throws) A second call in the same plugin while the first view is not disposed.
3632
3759
  */
3633
3760
  view(): VideoView;
3634
- /** The GPU renderer's options (needs `processing.video`), owned like the display layer. `failed` with `unsupported` under software rendering. */
3761
+ /**
3762
+ * 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.
3763
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3764
+ * @throws Error without a code (throws) A second call in the same plugin while the first handle is not disposed.
3765
+ */
3635
3766
  render(): RenderOptions;
3636
3767
  };
3637
3768
  /** Audio processing: this plugin's filter chain and delay (`processing.audio`, `playback.read` and `events`, as for VideoFilter). */
@@ -3685,17 +3816,57 @@ interface Scope extends Disposable {
3685
3816
  readonly i18n: I18n;
3686
3817
  /** Pages in the player's options window (`ui.settings`). */
3687
3818
  readonly settings: Settings;
3688
- /** A child scope; disposing it releases only what was created through it. The child is itself owned by this scope. */
3819
+ /**
3820
+ * A child scope; disposing it releases only what was created through it. The child is itself owned by this scope.
3821
+ * @capability None.
3822
+ * @example Tie a temporary window and its timer to one feature
3823
+ * A "Live stats" feature opens a window and starts a timer; turning it off
3824
+ * releases both together. Everything made through the child scope, and
3825
+ * cleanups registered on it, go when the child is disposed; the parent
3826
+ * scope is untouched.
3827
+ * ```ts
3828
+ * let live: Scope | null = null;
3829
+ * plugin.commands.add({
3830
+ * id: "live-stats",
3831
+ * path: ["Tools", "Live stats"],
3832
+ * checked: () => live !== null,
3833
+ * run: async () => {
3834
+ * if (live) {
3835
+ * await live.dispose(); // closes the window, stops the timer
3836
+ * live = null;
3837
+ * return;
3838
+ * }
3839
+ * live = plugin.scope();
3840
+ * const window = live.windows.create("ui/stats.slint");
3841
+ * const timer = setInterval(() => live?.update(), 1000);
3842
+ * live.onDispose(() => clearInterval(timer));
3843
+ * await window.show();
3844
+ * },
3845
+ * });
3846
+ * ```
3847
+ */
3689
3848
  scope(): Scope;
3690
3849
  /**
3691
3850
  * Adopt an existing handle: it is disposed together with this scope.
3692
3851
  * @param item - Any Disposable, for example a handle created through another scope.
3693
3852
  * @returns `item` itself, for chaining.
3853
+ * @capability None.
3854
+ * @example Dispose a handle from another scope together with this one
3855
+ * A filter made through the root scope is withdrawn when a child feature
3856
+ * ends. `add` hands an existing disposable to this scope, which then
3857
+ * disposes it with its own handles; it returns the same handle for
3858
+ * chaining.
3859
+ * ```ts
3860
+ * const feature = plugin.scope();
3861
+ * const blur = feature.add(plugin.video.filter("blur")); // now ends with `feature`
3862
+ * blur.set([{ kind: "gblur", sigma: 4, steps: 3 }]);
3863
+ * ```
3694
3864
  */
3695
3865
  add<T extends Disposable>(item: T): T;
3696
3866
  /**
3697
3867
  * Run `cleanup` when this scope is disposed (for example `clearInterval` or closing a socket). It may be async; errors are logged.
3698
3868
  * @returns A handle that cancels the registration early (without running `cleanup`).
3869
+ * @capability None.
3699
3870
  * @example Stop a timer when the plugin stops
3700
3871
  * A once-a-second refresh runs as long as the plugin does. `onDispose`
3701
3872
  * runs the cleanup when the scope is disposed, which for `plugin` is
@@ -3714,6 +3885,7 @@ interface Scope extends Disposable {
3714
3885
  * Rejections of async handlers count too, and so do failures of
3715
3886
  * registrations made after activation (a command whose `ready` rejects,
3716
3887
  * a property write the player refused).
3888
+ * @capability None.
3717
3889
  * @example Show errors from handlers on the OSD
3718
3890
  * When a menu command, a window callback or a listener of the plugin
3719
3891
  * fails, the user sees why. The SDK catches what those handlers throw
@@ -3733,6 +3905,23 @@ interface Scope extends Disposable {
3733
3905
  * changing state from your own timers or between awaits of a long task.
3734
3906
  *
3735
3907
  * Several calls within one microtask are merged into one pass.
3908
+ * @capability None.
3909
+ * @example Show progress from a long task between awaits
3910
+ * A progress label moves while a long download runs, not only when it
3911
+ * ends. The SDK re-evaluates bound values after each handler and when its
3912
+ * promise settles; state changed between the awaits of a long handler
3913
+ * shows only after an explicit `update()`.
3914
+ * ```ts
3915
+ * let done = 0;
3916
+ * window.bind(() => ({ progress: done }));
3917
+ * window.on("start", async () => {
3918
+ * for (let part = 0; part < 10; part++) {
3919
+ * await downloadPart(part);
3920
+ * done = part + 1;
3921
+ * plugin.update(); // send `progress` now, not when the handler ends
3922
+ * }
3923
+ * });
3924
+ * ```
3736
3925
  */
3737
3926
  update(): void;
3738
3927
  }
@@ -3781,6 +3970,12 @@ root scope is disposed for local cleanup (timers, signals, sockets). A
3781
3970
  process that has not exited after 300 ms is killed. Media the plugin
3782
3971
  opened with Player.open keeps playing.
3783
3972
 
3973
+ The root scope: disposing it is what stopping the plugin does. Besides
3974
+ the modules of Scope, it carries the plugin's `id` and its
3975
+ `dataDirectory`.
3976
+
3977
+ - @capability That of each module (see the modules).
3978
+
3784
3979
  ```ts
3785
3980
  interface Plugin extends Scope {
3786
3981
  /** The manifest id, e.g. `"community.bookmark"`. */
@@ -5787,6 +5982,13 @@ interface DropHandler {
5787
5982
 
5788
5983
  `plugin.drops`: files dropped on the video area. Needs `ui.drop`.
5789
5984
 
5985
+ Files dropped on the video area go to providers by extension: each
5986
+ extension belongs to the provider registered for it first, and each
5987
+ provider gets one call with all of its paths. Files nobody takes are
5988
+ ignored. A panel with Panel.onDrop takes drops on itself first.
5989
+
5990
+ - @capability ui.drop
5991
+
5790
5992
  ```ts
5791
5993
  interface Drops {
5792
5994
  /**
@@ -5797,6 +5999,7 @@ interface Drops {
5797
5999
  * to the provider that registered it first) in the order the file
5798
6000
  * manager gave them, one call per provider. Folders are not expanded.
5799
6001
  * @throws invalid synchronously for malformed extensions.
6002
+ * @capability ui.drop
5800
6003
  * @example Load dropped subtitle files
5801
6004
  * Dropping .srt or .ass files on the video loads each one as a subtitle
5802
6005
  * track. Each extension belongs to the provider that registered it first;
@@ -5822,6 +6025,25 @@ interface Drops {
5822
6025
  }
5823
6026
  ```
5824
6027
 
6028
+ Example: Collect dropped CHIP-8 programs
6029
+
6030
+ `.ch8` files dropped on the player are added to the plugin's list. The
6031
+ provider claims the extension; the player calls `drop` with the dropped
6032
+ paths of that type, in the order the file manager gave them. Extensions
6033
+ go to the first provider: the built-in playlist claims every audio and
6034
+ video extension when it starts, and a built-in plugin cannot be disabled,
6035
+ so another plugin's provider for those never gets them (except a
6036
+ development load that replaces the playlist under its id).
6037
+ ```ts
6038
+ const queue: string[] = [];
6039
+ plugin.drops.provide({
6040
+ extensions: ["ch8"],
6041
+ drop: ({ paths }) => {
6042
+ queue.push(...paths); // only .ch8 files reach here
6043
+ },
6044
+ });
6045
+ ```
6046
+
5825
6047
  ### ThemeId
5826
6048
 
5827
6049
  The player's palettes: `vestra`, the dark default, and `vestra-light`
@@ -6693,10 +6915,18 @@ interface TimelineMark {
6693
6915
 
6694
6916
  This plugin's own marks; disposing it removes them.
6695
6917
 
6918
+ This plugin's marks on the seek bar, from Timeline.layer. `set`
6919
+ replaces the whole layer and never throws: the newest write wins, and a
6920
+ refused one is reported to `onError`. Disposing the layer, its scope or
6921
+ stopping the plugin removes the marks.
6922
+
6923
+ - @capability timeline.marks
6924
+
6696
6925
  ```ts
6697
6926
  interface TimelineLayer extends Disposable {
6698
6927
  /**
6699
6928
  * 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.
6929
+ * @capability timeline.marks
6700
6930
  * @example Show the file's bookmarks on the timeline
6701
6931
  * Bookmark marks appear on the seek bar at their times, titled with the
6702
6932
  * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
@@ -6721,22 +6951,76 @@ interface TimelineLayer extends Disposable {
6721
6951
  }
6722
6952
  ```
6723
6953
 
6954
+ Example: Clear the plugin's marks
6955
+
6956
+ The plugin's marks disappear from the seek bar while it keeps its
6957
+ layer for later. An empty list clears the layer; other plugins' marks
6958
+ stay.
6959
+ ```ts
6960
+ plugin.timeline.layer().set([]);
6961
+ ```
6962
+
6724
6963
  ### Timeline
6725
6964
 
6726
6965
  `plugin.timeline`: marks on the seek bar. Every plugin publishes one
6727
6966
  layer; the player merges them. A new source clears every layer.
6728
6967
 
6968
+ Marks on the player's seek bar: chapters, bookmarks, loop points. Each
6969
+ plugin publishes one layer with Timeline.layer; the player merges
6970
+ the layers by time and shows them. A new source clears every layer, so a
6971
+ plugin publishes again for each file. `marks` is the merged list, which
6972
+ any plugin can read.
6973
+
6974
+ - @capability `timeline.marks` to publish; `playback.read` to read; `events` to follow changes.
6975
+
6729
6976
  ```ts
6730
6977
  interface Timeline {
6731
6978
  /** Every layer merged, by time (`playback.read`). Empty on a new source. */
6732
6979
  readonly marks: readonly TimelineMark[];
6733
- /** The merged list changed (needs `events`; at most every 100 ms). */
6980
+ /**
6981
+ * The merged list changed (needs `events`; at most every 100 ms).
6982
+ * @capability events and playback.read
6983
+ * @example Follow the marks as they change
6984
+ * A panel lists the seek bar's marks and updates when any plugin publishes
6985
+ * or a new file clears them. The listener gets the merged list, at most
6986
+ * every 100 ms.
6987
+ * ```ts
6988
+ * plugin.timeline.onChange((marks) => {
6989
+ * console.log(marks.map((mark) => `${mark.time}s ${mark.title ?? mark.kind}`));
6990
+ * });
6991
+ * ```
6992
+ */
6734
6993
  onChange(listener: (marks: readonly TimelineMark[]) => void): Disposable;
6735
- /** This plugin's layer (`timeline.marks`); a second call throws. */
6994
+ /**
6995
+ * This plugin's layer (`timeline.marks`); a second call throws.
6996
+ * @capability timeline.marks
6997
+ * @example Publish this plugin's marks
6998
+ * The plugin gets its one layer and puts two chapter marks on the seek
6999
+ * bar. Calling `layer()` again while the first layer is not disposed
7000
+ * throws; disposing the layer removes the
7001
+ * marks.
7002
+ * ```ts
7003
+ * const layer = plugin.timeline.layer();
7004
+ * layer.set([
7005
+ * { time: 0, kind: "chapter", title: "Intro" },
7006
+ * { time: 95, kind: "chapter", title: "Part 1" }, // seconds
7007
+ * ]);
7008
+ * ```
7009
+ */
6736
7010
  layer(): TimelineLayer;
6737
7011
  }
6738
7012
  ```
6739
7013
 
7014
+ Example: Count the chapter marks other plugins published
7015
+
7016
+ The plugin reads how many chapter marks the seek bar shows, whoever
7017
+ published them. `marks` is the merged list of every layer, sorted by
7018
+ time, with each mark's `plugin`.
7019
+ ```ts
7020
+ const chapters = plugin.timeline.marks.filter((mark) => mark.kind === "chapter");
7021
+ await plugin.osd.show("{} chapters", [chapters.length]);
7022
+ ```
7023
+
6740
7024
  ### WindowState
6741
7025
 
6742
7026
  The main window as `plugin.window.state` reports it (logical points).
@@ -6772,11 +7056,28 @@ With `events`, `state` follows the window from the first read or
6772
7056
  `onChange`; without it, it updates only at start and after this plugin's
6773
7057
  `setVideoArea`.
6774
7058
 
7059
+ `plugin.window` is the player's main window: its size, fullscreen,
7060
+ picture-in-picture and the video area. Reading it needs no capability;
7061
+ changing it needs `window.control`. Changes that last only while the
7062
+ plugin runs (always on top, pointer auto-hide) revert when it stops.
7063
+
7064
+ - @capability None to read; window.control to change; events to follow outside changes.
7065
+
6775
7066
  ```ts
6776
7067
  interface MainWindow {
6777
7068
  /** Latest state; zero video area without a native window. */
6778
7069
  readonly state: WindowState;
6779
- /** The window state changed (needs `events` for outside changes). */
7070
+ /**
7071
+ * The window state changed (needs `events` for outside changes).
7072
+ * @capability events (for outside changes)
7073
+ * @example Fit a panel to fullscreen
7074
+ * The plugin's panel uses larger text while the player is fullscreen. The
7075
+ * listener runs after the SDK updated `state` from the player's events
7076
+ * (with `events`; without it only after this plugin's own changes).
7077
+ * ```ts
7078
+ * plugin.window.onChange((state) => bar.set({ large: state.fullscreen }));
7079
+ * ```
7080
+ */
6780
7081
  onChange(listener: (state: WindowState) => void): Disposable;
6781
7082
  /**
6782
7083
  * Resize the window so the video column is `width`×`height` (the bottom bar
@@ -6788,6 +7089,18 @@ interface MainWindow {
6788
7089
  * Sizes are 1–16384 logical points; the minimum video area is 318×120.
6789
7090
  * The visible area excludes the menu bar and Dock on macOS; on Windows it
6790
7091
  * is the whole display. `unavailable` without a user interface too.
7092
+ * @capability window.control
7093
+ * @throws unavailable (rejects) Fullscreen, or no user interface.
7094
+ * @throws invalid (rejects) A size outside 1–16384 logical points.
7095
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7096
+ * @example Make the video area 1280×720 points
7097
+ * The window resizes so that the video column is 1280×720 logical points,
7098
+ * the bars and a docked sidebar added around it. A request that does not
7099
+ * fit the screen is scaled down keeping the aspect, and `clamped` says so.
7100
+ * ```ts
7101
+ * const applied = await plugin.window.setVideoArea({ width: 1280, height: 720 });
7102
+ * if (applied.clamped) await plugin.osd.show("{}×{}", [applied.width, applied.height]);
7103
+ * ```
6791
7104
  */
6792
7105
  setVideoArea(size: {
6793
7106
  width: number;
@@ -6800,10 +7113,42 @@ interface MainWindow {
6800
7113
  /** The request was scaled down or raised to fit. */
6801
7114
  clamped: boolean;
6802
7115
  }>;
6803
- /** Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface. */
7116
+ /**
7117
+ * Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface.
7118
+ * @capability window.control
7119
+ * @throws unavailable (rejects) No user interface.
7120
+ * @throws invalid (rejects) `onTop` is not a boolean.
7121
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7122
+ * @example Keep the player above other windows
7123
+ * The player's windows (tool windows too) float above other applications
7124
+ * while the plugin's "Always on top" item is checked. The newest call of
7125
+ * any plugin wins, and the setting reverts when this plugin stops.
7126
+ * ```ts
7127
+ * let onTop = false;
7128
+ * plugin.commands.add({
7129
+ * id: "on-top",
7130
+ * path: ["@screen", "Always on top"],
7131
+ * checked: () => onTop,
7132
+ * run: async () => {
7133
+ * onTop = !onTop;
7134
+ * await plugin.window.setAlwaysOnTop(onTop); // reverts when this plugin stops
7135
+ * },
7136
+ * });
7137
+ * ```
7138
+ */
6804
7139
  setAlwaysOnTop(onTop: boolean): Promise<void>;
6805
7140
  /**
6806
7141
  * `"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.
7142
+ * @capability window.control
7143
+ * @throws invalid (rejects) A mode other than `"fullscreen"` or `"playing"`.
7144
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7145
+ * @example Hide the pointer over playing video in a window too
7146
+ * The pointer disappears after 3 idle seconds over playing video, not only
7147
+ * in fullscreen. The newest call wins; when this plugin stops the player
7148
+ * goes back to hiding it in fullscreen only.
7149
+ * ```ts
7150
+ * await plugin.window.setCursorAutoHide("playing");
7151
+ * ```
6807
7152
  */
6808
7153
  setCursorAutoHide(mode: "fullscreen" | "playing"): Promise<void>;
6809
7154
  /**
@@ -6818,6 +7163,13 @@ interface MainWindow {
6818
7163
  *
6819
7164
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
6820
7165
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
7166
+ * @capability window.control
7167
+ * @throws unsupported (rejects) The system has no picture-in-picture (`supported` is false).
7168
+ * @throws unavailable (rejects) No video, or no user interface.
7169
+ * @throws invalid (rejects) `on` is not a boolean.
7170
+ * @throws busy (rejects) The previous call is still switching.
7171
+ * @throws failed (rejects) The system refused.
7172
+ * @throws capability (rejects) The manifest does not declare `window.control`.
6821
7173
  * @example Toggle picture-in-picture where the system has it
6822
7174
  * The video moves into a floating picture-in-picture window, or back.
6823
7175
  * `state.pictureInPicture` says whether the system supports it and
@@ -6881,9 +7233,29 @@ interface AppInfo {
6881
7233
  `plugin.app`: the fixed facts plus the system, read on request. No
6882
7234
  capability.
6883
7235
 
7236
+ `plugin.app` holds facts fixed for the player's run (its version, build,
7237
+ library versions) and reads the system's on request: `system()` reads
7238
+ the displays at each call; the operating system's name and version are
7239
+ worked out once, at the first call, and reused.
7240
+
7241
+ - @capability None.
7242
+
6884
7243
  ```ts
6885
7244
  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. */
7245
+ /**
7246
+ * The operating system, the displays and the renderer, read when called (displays come and go; the renderer can fall back from GPU to software once). No capability. CPU and memory: Node's `os` module.
7247
+ * @capability None.
7248
+ * @throws busy (rejects) The player could not start the request (no thread available).
7249
+ * @example Choose a size for the primary display
7250
+ * The plugin sizes its picture for the primary display. `system()` asks
7251
+ * the player; `displays` lists the displays present now, the primary one
7252
+ * first (empty without a user interface).
7253
+ * ```ts
7254
+ * const { displays } = await plugin.app.system();
7255
+ * const primary = displays[0];
7256
+ * if (primary) console.log(primary.width, primary.height);
7257
+ * ```
7258
+ */
6887
7259
  system(): Promise<SystemInfo>;
6888
7260
  }
6889
7261
  ```
@@ -6920,6 +7292,42 @@ interface SystemInfo {
6920
7292
  readonly arch: string;
6921
7293
  /** Primary display first; empty without a user interface. */
6922
7294
  readonly displays: readonly DisplayInfo[];
7295
+ /**
7296
+ * Which renderer draws the video now: `"gpu"` (the GPU renderer, so
7297
+ * render options apply) or `"software"` (they
7298
+ * fail with `unsupported`). Read at each call. It changes at most once
7299
+ * while the player runs, from `"gpu"` to `"software"` when a window
7300
+ * cannot show GPU frames; the owner of the render options hears that
7301
+ * switch through RenderOptions.onStatus.
7302
+ *
7303
+ * Absent (`undefined`) on players before 0.5.12, which do not report it:
7304
+ * the renderer is then unknown, not software.
7305
+ *
7306
+ * Today the player renders on the GPU on macOS and in software on
7307
+ * Windows, Linux and without a user interface (see
7308
+ * Platforms); test this field
7309
+ * rather than SystemInfo.os.
7310
+ * @player 0.5.12; older players leave it out (declare `"minPlayerVersion": "0.5.12"` when the plugin relies on it).
7311
+ * @example Offer the HDR menu only where render options work
7312
+ * The plugin adds its HDR command only when the player renders on the
7313
+ * GPU, instead of creating the render options just to find out from a
7314
+ * failed status. `system()` asks the player, which reports the renderer
7315
+ * drawing at that moment; no capability is needed to ask. A player before
7316
+ * 0.5.12 leaves the field out, so the test is for `"gpu"` exactly (an
7317
+ * older player gets no HDR command).
7318
+ * ```ts
7319
+ * const { rendering } = await plugin.app.system();
7320
+ * if (rendering === "gpu") {
7321
+ * const render = plugin.video.render(); // one handle, kept
7322
+ * plugin.commands.add({
7323
+ * id: "hdr",
7324
+ * path: ["@video", "HDR Tone Mapping"],
7325
+ * run: () => render.set({ toneMapping: "bt.2390", targetPeak: 250 }),
7326
+ * });
7327
+ * }
7328
+ * ```
7329
+ */
7330
+ readonly rendering?: "gpu" | "software";
6923
7331
  }
6924
7332
  ```
6925
7333
 
@@ -8611,6 +9019,14 @@ one request is in flight at a time and only the newest value is sent.
8611
9019
  Values out of range fail with `invalid` (see VideoNode). The
8612
9020
  same chain applies to processed snapshots and exports.
8613
9021
 
9022
+ Members: `set(nodes)` records the desired chain (last write wins) and
9023
+ sends it, one request at a time and only the newest; `status` and
9024
+ `onStatus` report `waiting`, `applying`, `applied` or `failed`; `retry`
9025
+ applies the current value again; `dispose` withdraws the chain. `set`
9026
+ never throws: a refused chain shows as `failed` in `status`.
9027
+
9028
+ - @capability processing.video, playback.read and events
9029
+
8614
9030
  ```ts
8615
9031
  type VideoFilter = Filter<VideoNode[]>;
8616
9032
  ```
@@ -8638,6 +9054,12 @@ VideoFilter.
8638
9054
  `delayMs` is −10000–10000 in multiples of 10; the positive and the
8639
9055
  negative delays of all plugins each add up to at most 10000.
8640
9056
 
9057
+ The same members and rules as VideoFilter: `set` records the
9058
+ desired value and never throws, `status` and `onStatus` report the
9059
+ result, `retry` applies it again, `dispose` withdraws it.
9060
+
9061
+ - @capability processing.audio, playback.read and events
9062
+
8641
9063
  ```ts
8642
9064
  type AudioFilter = Filter<{
8643
9065
  /** The audio chain; `[]` for none. */
@@ -8688,13 +9110,46 @@ needed); disposing or stopping the owner restores the defaults.
8688
9110
  Out-of-range values or unknown fields fail with `invalid` without
8689
9111
  sending anything.
8690
9112
 
9113
+ Not part of snapshots or exports: it changes how the picture sits in the
9114
+ video area, not the picture. `set` merges into the current settings and
9115
+ never throws: an out-of-range value or unknown field shows as `failed`
9116
+ (`invalid`) in `status` and is reported to `onError`, without sending
9117
+ anything.
9118
+
9119
+ - @capability processing.video, playback.read and events (like VideoFilter)
9120
+
8691
9121
  ```ts
8692
9122
  interface VideoView extends Disposable {
8693
- /** Merged into the current settings; last write wins. */
9123
+ /**
9124
+ * Merged into the current settings; last write wins.
9125
+ * @capability processing.video
9126
+ * @example Stretch a 4:3 video to fill the window
9127
+ * A 4:3 video fills a 16:9 window, distorted, as the user asked. Each
9128
+ * `set` merges the given fields into this plugin's view settings; the
9129
+ * newest write wins, and the settings stay for later files until the
9130
+ * plugin disposes the view or stops.
9131
+ * ```ts
9132
+ * plugin.video.view().set({ fit: "stretch" });
9133
+ * ```
9134
+ */
8694
9135
  set(value: Partial<ViewSettings>): void;
8695
9136
  /** Current status (the same four states as filters). */
8696
9137
  readonly status: FilterStatus;
8697
- /** The status changed. */
9138
+ /**
9139
+ * The status changed.
9140
+ * @capability processing.video
9141
+ * @example Tell the user another plugin owns the view
9142
+ * When another plugin already controls the display layer, the OSD says
9143
+ * so. The first plugin to call `view()` owns it; this plugin's handle
9144
+ * then reports `failed` with `conflict`, and tries again on its next `set`.
9145
+ * ```ts
9146
+ * const view = plugin.video.view();
9147
+ * view.onStatus((status) => {
9148
+ * if (status.state === "failed" && status.error.code === "conflict")
9149
+ * void plugin.osd.show("Another plugin controls the picture size");
9150
+ * });
9151
+ * ```
9152
+ */
8698
9153
  onStatus(listener: (status: FilterStatus) => void): Disposable;
8699
9154
  }
8700
9155
  ```
@@ -8704,7 +9159,7 @@ Example: Fill the window, then zoom in
8704
9159
  The video fills the video area (cropped instead of letterboxed), then
8705
9160
  zooms to 150 % shifted a little to the right. The first plugin to call
8706
9161
  `view()` owns the display layer; each `set` merges into its settings,
8707
- which stay across sources until the plugin resets them or stops.
9162
+ which stay across sources until the plugin disposes the view or stops.
8708
9163
  ```ts
8709
9164
  const view = plugin.video.view();
8710
9165
  view.set({ fit: "fill" });
@@ -8739,16 +9194,50 @@ independent handles.
8739
9194
 
8740
9195
  Platforms: only macOS renders on the GPU. Under software rendering
8741
9196
  (Windows, Linux, headless, fallback) every `set` fails with
8742
- `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
8743
- is accepted but has no effect; output is always SDR.
9197
+ `unsupported`; SystemInfo.rendering tells which renderer draws
9198
+ without creating the handle. macOS OpenGL 4.1 has no
9199
+ compute shaders, so `peakDetect` is accepted but has no effect; output
9200
+ is always SDR.
9201
+
9202
+ GPU renderer options (tone mapping, peak detection, shaders,
9203
+ interpolation). Only the GPU renderer has them, which today means macOS;
9204
+ under software rendering the handle is `failed` with `unsupported` after
9205
+ the player's first answer to `render()` (reported to `onError` too), and so is
9206
+ every `set`. Single owner, like VideoView. `set` never throws; the
9207
+ result shows in `status`.
9208
+
9209
+ - @capability processing.video, playback.read and events (like VideoFilter)
8744
9210
 
8745
9211
  ```ts
8746
9212
  interface RenderOptions extends Disposable {
8747
- /** Merged into the current settings; last write wins. */
9213
+ /**
9214
+ * Merged into the current settings; last write wins.
9215
+ * @capability processing.video
9216
+ * @example Turn on motion interpolation
9217
+ * Motion becomes smoother on the GPU renderer. `set` merges the field into
9218
+ * the plugin's render options; under software rendering the status turns
9219
+ * `failed` with `unsupported` instead.
9220
+ * ```ts
9221
+ * plugin.video.render().set({ interpolation: true });
9222
+ * ```
9223
+ */
8748
9224
  set(value: Partial<RenderSettings>): void;
8749
9225
  /** `failed` with `unsupported` while the player renders in software; `failed` with `failed` when mpv cannot compile a shader (pushed later). */
8750
9226
  readonly status: FilterStatus;
8751
- /** The status changed (including later pushes such as shader compile errors). */
9227
+ /**
9228
+ * The status changed (including later pushes such as shader compile errors).
9229
+ * @capability processing.video
9230
+ * @example Report a shader that does not compile
9231
+ * The OSD shows mpv's message when a shader the plugin set fails to
9232
+ * compile. The player pushes that failure after the `set` was accepted, so
9233
+ * it arrives through `onStatus`, not as a rejected call.
9234
+ * ```ts
9235
+ * const render = plugin.video.render();
9236
+ * render.onStatus((status) => {
9237
+ * if (status.state === "failed") void plugin.osd.show("Render: {}", [status.error.message]);
9238
+ * });
9239
+ * ```
9240
+ */
8752
9241
  onStatus(listener: (status: FilterStatus) => void): Disposable;
8753
9242
  }
8754
9243
  ```
@@ -8850,16 +9339,79 @@ interface SubtitleConditional {
8850
9339
  `subtitles.read`; changes need `subtitles.control` and also
8851
9340
  `subtitles.read`, since they read the current selection first.
8852
9341
 
9342
+ The subtitle tracks of the current source, the primary and secondary
9343
+ selection, uploaded files and the style. `state` is the SDK's snapshot of
9344
+ the player's subtitle state (`null` until first read). The player has no
9345
+ subtitle event: the SDK reads the state again when the source, its phase
9346
+ or the backend changes (with `events`) and after this plugin's own
9347
+ changes, so a selection the user or another plugin changed shows only
9348
+ after `refresh()`. A change without `ifCurrent` reads the state first
9349
+ and applies to it; pass `ifCurrent` to make it fail with `stale` when the
9350
+ selection changed since that state. Each change is one operation the
9351
+ player confirms; `select`, `remove` and `setStyle` resolve with the new
9352
+ state, `load` with the new track.
9353
+
9354
+ - @capability `subtitles.read` to read; `subtitles.control` and `subtitles.read` to change; `events` to re-read on source changes.
9355
+
8853
9356
  ```ts
8854
9357
  interface Subtitles {
8855
9358
  /** `null` until first read. */
8856
9359
  readonly state: SubtitlesState | null;
8857
- /** The subtitle state changed. */
9360
+ /**
9361
+ * The subtitle state changed.
9362
+ * @capability events and subtitles.read
9363
+ * @example Show the subtitle language after a new file opens
9364
+ * When a new file starts, the OSD names the language of its primary
9365
+ * subtitle. The SDK reads the subtitle state again when the source or its
9366
+ * phase changes (there is no subtitle event, so a selection changed in the
9367
+ * player shows only after `refresh()`) and then calls the listener;
9368
+ * `selected` is `null` when subtitles are off.
9369
+ * ```ts
9370
+ * plugin.subtitles.onChange((state) => {
9371
+ * // A track's language is null when the file does not say.
9372
+ * void plugin.osd.show("Subtitles: {}", [state.selected?.language ?? "off"]);
9373
+ * });
9374
+ * ```
9375
+ */
8858
9376
  onChange(listener: (state: SubtitlesState) => void): Disposable;
8859
- /** Read the state from the player now. */
9377
+ /**
9378
+ * Read the state from the player now.
9379
+ * @capability subtitles.read
9380
+ * @throws capability (rejects) The manifest does not declare `subtitles.read`.
9381
+ * @example Count the subtitle tracks of the file
9382
+ * The plugin reads how many subtitle tracks the file has before building a
9383
+ * menu. `refresh` asks the player and resolves with the current state, also
9384
+ * without `events`; `state` holds it afterwards.
9385
+ * ```ts
9386
+ * const { tracks } = await plugin.subtitles.refresh();
9387
+ * await plugin.osd.show("{} subtitle tracks", [tracks.length]);
9388
+ * ```
9389
+ */
8860
9390
  refresh(): Promise<SubtitlesState>;
8861
9391
  /**
8862
9392
  * `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.
9393
+ * @capability subtitles.control and subtitles.read
9394
+ * @throws stale (rejects) With `ifCurrent`: the source or the subtitle selection changed since that state.
9395
+ * @throws invalid (rejects) An unknown track; as secondary, a bitmap track or the primary one; as primary, the current secondary.
9396
+ * @throws missing (rejects) The track is not in the current source.
9397
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading or not yet confirmed.
9398
+ * @throws busy (rejects) Too many subtitle operations at once.
9399
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9400
+ * @example Turn on the first English subtitle
9401
+ * The first English track becomes the primary subtitle, but only in the
9402
+ * file the plugin looked at. Embedded tracks are chosen by number, so
9403
+ * without `ifCurrent` a select after another file opened would pick that
9404
+ * file's track with the same number; with `ifCurrent: state` it fails with
9405
+ * `stale` instead. Without `ifCurrent`, `select` reads the newest state
9406
+ * itself and almost never fails with `stale`.
9407
+ * ```ts
9408
+ * const state = await plugin.subtitles.refresh();
9409
+ * const english = state.tracks.find((track) => track.language === "en");
9410
+ * if (english)
9411
+ * await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
9412
+ * if (error.code !== "stale") throw error; // another file or selection now
9413
+ * });
9414
+ * ```
8863
9415
  */
8864
9416
  select(track: SubtitleTrack | null, options?: SubtitleConditional & {
8865
9417
  role?: "primary" | "secondary";
@@ -8875,7 +9427,29 @@ interface Subtitles {
8875
9427
  *
8876
9428
  * Limits: SRT ≤ 2 MiB and ≤ 10,000 cues; ASS ≤ 2 MiB with `[Script Info]`
8877
9429
  * and `[Events]` and 1–20,000 `Dialogue:` lines. Convert other formats
8878
- * (VTT, SAMI) to SRT first.
9430
+ * (VTT, SAMI) to SRT first. Per source, a plugin has at most 4 loaded
9431
+ * tracks at once and loads at most 16 files (removed ones count), and
9432
+ * every plugin together at most 16 tracks; see `SUBTITLE_LIMITS` in
9433
+ * `@vestra-player/sdk/testing`.
9434
+ * @capability subtitles.control and subtitles.read
9435
+ * @throws invalid (rejects) A bad `format` or `title`, or a file over the limits.
9436
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9437
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading.
9438
+ * @throws aborted (rejects) `signal` aborted before the commit (an `AbortError`); the upload is discarded.
9439
+ * @throws busy (rejects) Too many subtitle operations at once, or a limit of `SUBTITLE_LIMITS` is reached (4 tracks of this plugin, 16 loads of this plugin or 16 tracks in the source, or another of its limits); nothing is loaded.
9440
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9441
+ * @example Load a downloaded SRT and select it
9442
+ * A subtitle file the plugin downloaded becomes a track of the current file
9443
+ * and is selected. The plugin passes the file's bytes; the player parses
9444
+ * them, adds an `external` track for this source only and resolves with
9445
+ * it once the upload is committed.
9446
+ * ```ts
9447
+ * const response = await fetch(url);
9448
+ * if (!response.ok) throw new Error(`subtitles: HTTP ${response.status}`);
9449
+ * const bytes = new Uint8Array(await response.arrayBuffer());
9450
+ * const track = await plugin.subtitles.load(bytes, { title: "Downloaded" }); // selected
9451
+ * await plugin.osd.show("Loaded {}", [track.title]);
9452
+ * ```
8879
9453
  */
8880
9454
  load(bytes: Uint8Array, options?: SubtitleConditional & {
8881
9455
  format?: "srt" | "ass";
@@ -8883,10 +9457,40 @@ interface Subtitles {
8883
9457
  select?: "primary" | "secondary" | "none";
8884
9458
  signal?: AbortSignal;
8885
9459
  }): Promise<SubtitleTrack>;
8886
- /** Remove a track this plugin loaded. */
9460
+ /**
9461
+ * Remove a track this plugin loaded.
9462
+ * @capability subtitles.control and subtitles.read
9463
+ * @throws invalid (rejects) The track was not loaded by this plugin.
9464
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9465
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9466
+ * @throws busy (rejects) Too many subtitle operations at once.
9467
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9468
+ * @example Remove the subtitle the plugin loaded
9469
+ * The plugin's downloaded subtitle disappears from the track list. A plugin
9470
+ * can remove only tracks it loaded itself; a new source removes them
9471
+ * anyway.
9472
+ * ```ts
9473
+ * await plugin.subtitles.remove(loaded);
9474
+ * ```
9475
+ */
8887
9476
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
8888
9477
  /**
8889
9478
  * 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.
9479
+ * @capability subtitles.control and subtitles.read
9480
+ * @throws invalid (rejects) A field out of range.
9481
+ * @throws unsupported (rejects) A font that is not in `state.fonts`, or a primary subtitle that is a bitmap track.
9482
+ * @throws stale (rejects) With `ifCurrent`: the subtitle state changed since that state.
9483
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9484
+ * @throws busy (rejects) Too many subtitle operations at once.
9485
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9486
+ * @example Take the plugin's style off again
9487
+ * The plugin removes its style layer, so the subtitles go back to the
9488
+ * player's default (or to another plugin's layer). `null` removes only this
9489
+ * plugin's layer; the effective layer is the one whose manifest id sorts
9490
+ * last among the plugins that still have one.
9491
+ * ```ts
9492
+ * await plugin.subtitles.setStyle(null);
9493
+ * ```
8890
9494
  */
8891
9495
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
8892
9496
  }
@@ -8937,6 +9541,14 @@ interface CaptureOptions extends Conditional {
8937
9541
 
8938
9542
  A frozen, immutable input for exports. Dispose to release it.
8939
9543
 
9544
+ A frozen input, from MediaCapture.capture: the source, the time
9545
+ and (processed) the effects and subtitle as they were. Exports read it in
9546
+ the player's offline worker, so one snapshot can be exported several
9547
+ times and playback is unaffected. Disposing it releases the player's
9548
+ copy; an export using it after that fails with `released`.
9549
+
9550
+ - @capability media.read and media.control
9551
+
8940
9552
  ```ts
8941
9553
  interface Snapshot extends Disposable {
8942
9554
  /** Without or with effects. */
@@ -8958,11 +9570,45 @@ interface Snapshot extends Disposable {
8958
9570
  } | null;
8959
9571
  /** The frozen processing revision (processed snapshots). */
8960
9572
  readonly processingRevision: number | null;
8961
- /** Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times. */
9573
+ /**
9574
+ * Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times.
9575
+ * @capability media.read and media.control
9576
+ * @throws released (rejects) The snapshot was already released.
9577
+ * @throws invalid (rejects) Bad options (`quality` outside 1–100, a relative destination, a clip `end` not after the snapshot time or past the duration).
9578
+ * @throws busy (rejects) This plugin runs 2 exports (or the player 8).
9579
+ * @throws unavailable (rejects) No FFmpeg, or the export worker could not start.
9580
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9581
+ * @example Export a 30-second clip
9582
+ * A clip from the snapshot time to 30 seconds later is written as MP4.
9583
+ * `export` resolves once the job is accepted; the job then runs in the
9584
+ * player's offline worker, and `done` resolves with its terminal state,
9585
+ * whose `outcome` has the destination or the error.
9586
+ * ```ts
9587
+ * const job = await snapshot.export({ format: "mp4", destination: "/tmp/clip.mp4", end: snapshot.time + 30 });
9588
+ * const end = await job.done;
9589
+ * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
9590
+ * ```
9591
+ */
8962
9592
  export(options: ExportOptions): Promise<ExportJob>;
8963
9593
  }
8964
9594
  ```
8965
9595
 
9596
+ Example: Export one frame as PNG and as JPEG
9597
+
9598
+ The same frozen frame is written twice, lossless and compressed. Each
9599
+ `export` starts a job of its own; the snapshot is released after both
9600
+ are done.
9601
+ ```ts
9602
+ const snapshot = await plugin.media.capture();
9603
+ try {
9604
+ const png = await snapshot.export({ format: "png", destination: "/tmp/frame.png" });
9605
+ const jpeg = await snapshot.export({ format: "jpeg", destination: "/tmp/frame.jpg", quality: 85 });
9606
+ await Promise.all([png.done, jpeg.done]); // terminal states, not errors
9607
+ } finally {
9608
+ await snapshot.dispose();
9609
+ }
9610
+ ```
9611
+
8966
9612
  ### ExportOptions
8967
9613
 
8968
9614
  One frame as PNG, JPEG (`quality` 1–100, default 90) or BMP (24-bit), or
@@ -9015,31 +9661,121 @@ type JobState = Omit<MediaJob, "jobId">;
9015
9661
 
9016
9662
  An export, from Snapshot.export. Disposing an unfinished job cancels it.
9017
9663
 
9664
+ One export, from Snapshot.export. `state` is the SDK's last copy
9665
+ of the job's state: the SDK polls the player about every 350 ms until the
9666
+ job ends, and `refresh` reads it at once. `done` resolves
9667
+ once the job is terminal (`completed`, `failed` or `cancelled`) and never
9668
+ rejects for a failed export: the failure is in `outcome`. Disposing an
9669
+ unfinished job cancels it.
9670
+
9671
+ - @capability media.read and media.control
9672
+
9018
9673
  ```ts
9019
9674
  interface ExportJob extends Disposable {
9020
9675
  /** Latest state. */
9021
9676
  readonly state: JobState;
9022
- /** The state changed. */
9677
+ /**
9678
+ * The state changed.
9679
+ * @capability media.read
9680
+ * @example Log each state of an export
9681
+ * The plugin's log records each status the job goes through (`queued`,
9682
+ * `running`, `cancelling` after a cancel, then a terminal one). The listener runs when the SDK learns a
9683
+ * new state from its polling.
9684
+ * ```ts
9685
+ * job.onChange((state) => console.log("export", state.status));
9686
+ * ```
9687
+ */
9023
9688
  onChange(listener: (state: JobState) => void): Disposable;
9024
- /** Resolves with the terminal state (completed, failed or cancelled). */
9689
+ /**
9690
+ * Resolves with the terminal state (completed, failed or cancelled).
9691
+ * @throws disposed (rejects) The job handle was disposed while waiting.
9692
+ */
9025
9693
  readonly done: Promise<JobState>;
9026
- /** Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over). */
9694
+ /**
9695
+ * Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over).
9696
+ * @capability media.control
9697
+ * @throws rejected (rejects) The player did not accept the request.
9698
+ * @throws capability (rejects) The manifest does not declare `media.control`.
9699
+ * @example Cancel an export the user no longer wants
9700
+ * The running export stops, unless it is already writing its file.
9701
+ * `cancel` resolves `requested` when the worker will stop (the job ends
9702
+ * `cancelled`), `tooLate` when it is already publishing the file, and
9703
+ * `terminal` when the job had already ended.
9704
+ * ```ts
9705
+ * const answer = await job.cancel();
9706
+ * if (answer === "tooLate") await plugin.osd.show("Too late: the file is being written");
9707
+ * ```
9708
+ */
9027
9709
  cancel(): Promise<"requested" | "tooLate" | "terminal">;
9028
- /** Read the state from the player now. */
9710
+ /**
9711
+ * Read the state from the player now.
9712
+ * @capability media.read
9713
+ * @throws capability (rejects) The manifest does not declare `media.read`.
9714
+ * @example Read a job's state now
9715
+ * The plugin reads the newest state of an export without waiting for an
9716
+ * event. `refresh` asks the player and resolves with the state, which
9717
+ * `state` then holds.
9718
+ * ```ts
9719
+ * const state = await job.refresh();
9720
+ * console.log(state.status);
9721
+ * ```
9722
+ */
9029
9723
  refresh(): Promise<JobState>;
9030
9724
  }
9031
9725
  ```
9032
9726
 
9727
+ Example: Show export progress and cancel on request
9728
+
9729
+ The OSD shows the job's status while it runs, and a Cancel command stops
9730
+ it. `onChange` follows the state (`queued`, `running`, `cancelling`,
9731
+ then a terminal one); `cancel` asks the worker to stop. After
9732
+ `requested` the job usually ends `cancelled`, or `failed` when the
9733
+ cleanup failed.
9734
+ ```ts
9735
+ job.onChange((state) => void plugin.osd.show("Export: {}", [state.status]));
9736
+ plugin.commands.add({ id: "cancel-export", title: "Cancel export", run: () => job.cancel() });
9737
+ await job.done; // completed, failed or cancelled
9738
+ ```
9739
+
9033
9740
  ### MediaCapture
9034
9741
 
9035
9742
  `plugin.media`: snapshots and exports. Needs `media.read` and
9036
9743
  `media.control` (processed snapshots also `playback.read` and
9037
9744
  `subtitles.read`). Exports decode offline and do not disturb playback.
9038
9745
 
9746
+ Exports run in the player's offline worker from a frozen
9747
+ Snapshot: a still frame or a clip, raw or with this plugin's
9748
+ effects (and the primary subtitle). Playback is not disturbed. A plugin
9749
+ holds at most 4 snapshots at a time (16 in the player); release them
9750
+ when done.
9751
+
9752
+ - @capability media.read and media.control (processed snapshots also `playback.read` and `subtitles.read`)
9753
+
9039
9754
  ```ts
9040
9755
  interface MediaCapture {
9041
9756
  /**
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.
9757
+ * 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.
9758
+ * @capability media.read and media.control
9759
+ * @throws stale (rejects) The source changed since `ifCurrent`.
9760
+ * @throws noSource (rejects) Nothing is playing.
9761
+ * @throws invalid (rejects) `at` at or after the end of the source.
9762
+ * @throws busy (rejects) This plugin holds 4 snapshots (or the player 16); release one.
9763
+ * @throws unavailable (rejects) A processed snapshot while the picture is still being prepared.
9764
+ * @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.
9765
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9766
+ * @example Freeze the frame at the 10-second mark
9767
+ * The plugin freezes the frame at 0:10 of the open file, raw, to export it
9768
+ * later. `capture` resolves with a `Snapshot` once the player has frozen
9769
+ * that input; the snapshot stays valid when playback moves on or the file
9770
+ * changes, until it is released.
9771
+ * ```ts
9772
+ * const snapshot = await plugin.media.capture({ at: 10 }); // seconds
9773
+ * try {
9774
+ * // … export it …
9775
+ * } finally {
9776
+ * await snapshot.dispose(); // frees one of the plugin's 4 snapshot slots
9777
+ * }
9778
+ * ```
9043
9779
  */
9044
9780
  capture(options?: CaptureOptions): Promise<Snapshot>;
9045
9781
  }
@@ -9192,25 +9928,84 @@ interface ResolvedStream {
9192
9928
 
9193
9929
  A registered resolver. Disposing it unregisters it and aborts resolutions in flight.
9194
9930
 
9931
+ A registered resolver, from Resolvers.add. Disposing it, its
9932
+ scope or stopping the plugin unregisters it and aborts resolutions in
9933
+ flight; the extensions or hosts are free again for other plugins.
9934
+
9935
+ - @capability source.resolve
9936
+
9195
9937
  ```ts
9196
9938
  interface Resolver extends Disposable {
9197
9939
  /**
9198
9940
  * 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`).
9941
+ * @throws conflict (rejects) Another resolver has the extension or the scheme + host, or this plugin already registered this id.
9942
+ * @throws busy (rejects) The player already holds 16 resolvers.
9943
+ * @throws invalid (rejects) A malformed declaration.
9944
+ * @throws capability (rejects) The manifest does not declare `source.resolve`.
9199
9945
  */
9200
9946
  readonly ready: Promise<void>;
9201
9947
  }
9202
9948
  ```
9203
9949
 
9950
+ Example: Report when another plugin already handles the format
9951
+
9952
+ The plugin tells the user when its `.ch8` resolver could not register
9953
+ because another plugin holds that extension. `ready` rejects with
9954
+ `conflict`; for another plugin's claim that does not fail activation, so
9955
+ the plugin keeps running without the resolver.
9956
+ ```ts
9957
+ const resolver = plugin.resolvers.add({
9958
+ id: "chip8",
9959
+ extensions: ["ch8"],
9960
+ async resolve() {
9961
+ throw plugin.i18n.error("Not ready yet.");
9962
+ },
9963
+ });
9964
+ // Rejects with `conflict` when the extension is taken.
9965
+ resolver.ready.catch((error: PluginError) => {
9966
+ if (error.code === "conflict") void plugin.osd.show("Another plugin plays .ch8 files");
9967
+ });
9968
+ ```
9969
+
9204
9970
  ### Resolvers
9205
9971
 
9206
9972
  `plugin.resolvers`: turn web page URLs into playable streams, or local
9207
9973
  files into streams this plugin generates. Needs `source.resolve` (file
9208
9974
  resolvers also `producer.av`).
9209
9975
 
9976
+ A resolver turns something the player cannot play by itself into
9977
+ something it can: a web page address into stream URLs
9978
+ (WebResolverOptions), or a local file of a format the plugin
9979
+ understands into a stream the plugin generates
9980
+ (FileResolverOptions). The player asks the resolver whenever
9981
+ anyone opens a matching address or file (the Open dialog, the playlist,
9982
+ another plugin), so the plugin does not intercept opens itself.
9983
+
9984
+ - @capability source.resolve (file resolvers also `producer.av`)
9985
+
9210
9986
  ```ts
9211
9987
  interface Resolvers {
9212
9988
  /**
9213
9989
  * 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.
9990
+ * @capability source.resolve
9991
+ * @example Play a site's pages through the plugin
9992
+ * Opening a `https://video.example.com/…` address plays the page's video.
9993
+ * `add` returns at once and registers in the background (`ready` reports
9994
+ * the answer); from then on the player calls `resolve` for every matching
9995
+ * open, and the error a rejected `resolve` carries is what the user sees.
9996
+ * ```ts
9997
+ * plugin.resolvers.add({
9998
+ * id: "example",
9999
+ * schemes: ["https"],
10000
+ * hosts: ["video.example.com"], // exact host names
10001
+ * async resolve(url, signal) {
10002
+ * const response = await fetch(url, { signal });
10003
+ * if (!response.ok) throw new Error(`the page answered ${response.status}`);
10004
+ * const page = (await response.json()) as { title: string; stream: string };
10005
+ * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
10006
+ * },
10007
+ * });
10008
+ * ```
9214
10009
  */
9215
10010
  add(options: ResolverOptions): Resolver;
9216
10011
  /**
@@ -9363,6 +10158,15 @@ interface InstallOptions {
9363
10158
 
9364
10159
  A validated package directory, from Packages.validate.
9365
10160
 
10161
+ A package the player has checked, from Packages.validate or
10162
+ Packages.validateArchive: its manifest, digest and the checks
10163
+ it passed. The player copies the whole package when it checks it and
10164
+ installs that copy, so later changes to the directory do not matter.
10165
+ Installing starts a PackageChange; the player keeps only the 32
10166
+ most recent candidates.
10167
+
10168
+ - @capability plugins.manage
10169
+
9366
10170
  ```ts
9367
10171
  interface PackageCandidate {
9368
10172
  /** The package directory. */
@@ -9375,11 +10179,38 @@ interface PackageCandidate {
9375
10179
  readonly checks: string[];
9376
10180
  /**
9377
10181
  * 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.
10182
+ * @capability plugins.manage
10183
+ * @throws conflict (rejects) The plugin is installed from another source and `replace` is not set.
10184
+ * @throws unsupported (rejects) A built-in plugin's id.
10185
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10186
+ * @throws busy (rejects) The plugin library is being cleaned up, or the player already keeps 64 change records.
10187
+ * @throws revisionConflict (rejects) The installed revision of this plugin changed meanwhile; call `install` again (no new validation needed).
10188
+ * @throws stale (rejects) The candidate is older than the 32 the player keeps.
10189
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10190
+ * @example Replace a plugin installed from another source
10191
+ * The new package replaces the installed plugin even though it came from
10192
+ * another source. Without `replace` the player refuses with `conflict`;
10193
+ * the same source simply upgrades and keeps the plugin's data.
10194
+ * ```ts
10195
+ * const change = await candidate.install({ replace: true });
10196
+ * await change.done; // "committed" once the new plugin runs
10197
+ * ```
9378
10198
  */
9379
10199
  install(options?: InstallOptions): Promise<PackageChange>;
9380
10200
  }
9381
10201
  ```
9382
10202
 
10203
+ Example: Show what a package asks for before installing it
10204
+
10205
+ Before installing, the plugin shows the package's name and the
10206
+ capabilities it declares. The candidate's `manifest` is the manifest
10207
+ as the player read it.
10208
+ ```ts
10209
+ const candidate = await plugin.packages.validate(directory);
10210
+ const { id, version, capabilities } = candidate.manifest;
10211
+ await plugin.osd.show("{} {}: {}", [id, version, capabilities.join(", ")]);
10212
+ ```
10213
+
9383
10214
  ### PackageChangeStatus
9384
10215
 
9385
10216
  The progress of an install or uninstall.
@@ -9393,21 +10224,71 @@ type PackageChangeStatus = "preparing" | "activating" | "restoring" | "committed
9393
10224
  An install or uninstall in progress. The SDK never submits a
9394
10225
  change twice: after a lost reply it only queries the same request.
9395
10226
 
10227
+ An install or uninstall the player carries out in steps (`preparing`,
10228
+ `activating`, possibly `restoring`) until a terminal status. The SDK
10229
+ asks the player for the status about every 400 ms until then; `done`
10230
+ resolves with the terminal status and does not reject for a failed
10231
+ change: `message` says why.
10232
+
10233
+ - @capability plugins.manage
10234
+
9396
10235
  ```ts
9397
10236
  interface PackageChange {
9398
10237
  /** Current status. */
9399
10238
  readonly status: PackageChangeStatus;
9400
10239
  /** Details for failures, `null` otherwise. */
9401
10240
  readonly message: string | null;
9402
- /** The status changed. */
10241
+ /**
10242
+ * The status changed.
10243
+ * @capability plugins.manage
10244
+ * @example Close a progress window when the change ends
10245
+ * The plugin's progress window closes once the install is over, whatever
10246
+ * the outcome. The listener receives each new status; the terminal ones
10247
+ * are `committed`, `cancelled`, `failed`, `failedDegraded` and `tooLate`
10248
+ * (`tooLate` only answers a `cancel` of a change that had committed, and
10249
+ * `committed` may follow it).
10250
+ * ```ts
10251
+ * change.onChange((status) => {
10252
+ * if (["committed", "cancelled", "failed", "failedDegraded", "tooLate"].includes(status))
10253
+ * void progress.close();
10254
+ * });
10255
+ * ```
10256
+ */
9403
10257
  onChange(listener: (status: PackageChangeStatus) => void): Disposable;
9404
- /** Resolves with the final status. */
10258
+ /**
10259
+ * Resolves with the final status.
10260
+ * @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.
10261
+ */
9405
10262
  readonly done: Promise<PackageChangeStatus>;
9406
- /** Ask to cancel (it may be `tooLate`). */
10263
+ /**
10264
+ * Ask to cancel (it may be `tooLate`).
10265
+ * @capability plugins.manage
10266
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10267
+ * @example Cancel an install the user no longer wants
10268
+ * The install stops if it has not gone too far. `cancel` asks the player;
10269
+ * the change then ends `cancelled`. When the change had already committed
10270
+ * the player answers `tooLate` and `done` still resolves `committed`; a
10271
+ * change that is restoring cannot be cancelled and ends `failed` or
10272
+ * `failedDegraded`.
10273
+ * ```ts
10274
+ * await change.cancel();
10275
+ * console.log(await change.done); // "cancelled"; "committed" when it was too late
10276
+ * ```
10277
+ */
9407
10278
  cancel(): Promise<void>;
9408
10279
  }
9409
10280
  ```
9410
10281
 
10282
+ Example: Report each step of an install
10283
+
10284
+ The OSD shows each step of the install and the end result. `onChange`
10285
+ runs for every new status; `done` resolves when the change is over.
10286
+ ```ts
10287
+ change.onChange((status) => void plugin.osd.show("Install: {}", [status]));
10288
+ const end = await change.done;
10289
+ if (end !== "committed") await plugin.osd.show("Not installed: {}", [change.message ?? end]);
10290
+ ```
10291
+
9411
10292
  ### PluginInfo
9412
10293
 
9413
10294
  One plugin as the player knows it (`packages.list`).
@@ -9489,6 +10370,14 @@ interface PluginWarning {
9489
10370
  `plugin.packages`: the plugins the player knows. `list` and `onChange`
9490
10371
  need no capability; everything else needs `plugins.manage`.
9491
10372
 
10373
+ The plugins the player knows: installed, built-in and this run's
10374
+ development loads. Reading the list needs no capability; installing,
10375
+ enabling, reloading and uninstalling need `plugins.manage` and act on
10376
+ other plugins, never on the calling one. Built-in plugins update with
10377
+ the player and cannot be installed, disabled or uninstalled.
10378
+
10379
+ - @capability None for `list` and `onChange`; plugins.manage for the rest.
10380
+
9492
10381
  ```ts
9493
10382
  interface Packages {
9494
10383
  /**
@@ -9499,18 +10388,93 @@ interface Packages {
9499
10388
  * The player keeps the 32 most recent candidates of all plugins; an
9500
10389
  * older one's `install` fails with `stale` (validate again). Before
9501
10390
  * player 0.4.2 the 33rd validation of a run failed with `busy` instead.
10391
+ * @capability plugins.manage
10392
+ * @throws host (rejects) Not a package directory, or a manifest that breaks the rules (another `apiVersion` included); `message` says why.
10393
+ * @throws invalid (rejects) A bad request (a path that is not a string).
10394
+ * @throws javascript (rejects) The entry module does not parse.
10395
+ * @throws slint (rejects) A Slint file does not compile.
10396
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10397
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10398
+ * @throws disabled (rejects) The calling plugin is stopping.
10399
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10400
+ * @example Check a package directory before installing it
10401
+ * The plugin checks a folder it unpacked and installs it if the player
10402
+ * accepts it. `validate` runs the player's checks and resolves with a
10403
+ * candidate; a package that fails them rejects with the reason.
10404
+ * ```ts
10405
+ * try {
10406
+ * // Rejects with the reason when the package fails the checks.
10407
+ * const candidate = await plugin.packages.validate(directory);
10408
+ * await (await candidate.install()).done;
10409
+ * } catch (error) {
10410
+ * await plugin.osd.show("Not a valid plugin: {}", [(error as Error).message]);
10411
+ * }
10412
+ * ```
9502
10413
  */
9503
10414
  validate(directory: string): Promise<PackageCandidate>;
9504
10415
  /**
9505
10416
  * 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.
10417
+ * @capability plugins.manage
10418
+ * @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).
10419
+ * @throws host (rejects) The unpacked package or its manifest breaks the rules; `message` says why.
10420
+ * @throws javascript (rejects) The entry module does not parse.
10421
+ * @throws slint (rejects) A Slint file does not compile.
10422
+ * @throws reserved (rejects) A reserved id.
10423
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10424
+ * @throws disabled (rejects) The calling plugin is stopping.
10425
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10426
+ * @example Install a downloaded package file
10427
+ * A `.vxp` the plugin downloaded is checked and installed. The player
10428
+ * unpacks and checks it like a directory; checking the file's sha256
10429
+ * first, when the store publishes one, is up to the plugin.
10430
+ * ```ts
10431
+ * const candidate = await plugin.packages.validateArchive(file); // a .vxp path
10432
+ * const change = await candidate.install();
10433
+ * await plugin.osd.show("Install: {}", [await change.done]);
10434
+ * ```
9506
10435
  */
9507
10436
  validateArchive(file: string): Promise<PackageCandidate>;
9508
- /** Every plugin: running, installed, and this run's development loads. No capability. */
10437
+ /**
10438
+ * Every plugin: running, installed, and this run's development loads. No capability.
10439
+ * @capability None.
10440
+ * @throws Error (rejects) Only when the player cannot be asked; no capability is needed.
10441
+ * @example Show which plugins are running
10442
+ * The plugin lists the running plugins by name. `list` asks the player
10443
+ * and resolves with every plugin it knows, with its state, origin and
10444
+ * display texts in the interface language.
10445
+ * ```ts
10446
+ * const plugins = await plugin.packages.list();
10447
+ * const running = plugins.filter((info) => info.state === "running");
10448
+ * console.log(running.map((info) => info.display?.name ?? info.id));
10449
+ * ```
10450
+ */
9509
10451
  list(): Promise<PluginInfo[]>;
9510
- /** The new list after any start, stop, failure, install or uninstall. No capability. */
10452
+ /**
10453
+ * The new list after any start, stop, failure, install or uninstall. No capability.
10454
+ * @capability None.
10455
+ * @example Refresh a plugin list after any change
10456
+ * A store window's list updates when a plugin starts, stops, fails, is
10457
+ * installed or uninstalled. The listener receives the new list each time.
10458
+ * ```ts
10459
+ * plugin.packages.onChange((plugins) => console.log(plugins.length, "plugins"));
10460
+ * ```
10461
+ */
9511
10462
  onChange(listener: (plugins: PluginInfo[]) => void): Disposable;
9512
10463
  /**
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).
10464
+ * 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).
10465
+ * @capability plugins.manage
10466
+ * @throws invalid (rejects) Disabling the calling plugin itself.
10467
+ * @throws missing (rejects) An unknown id.
10468
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10469
+ * @throws unsupported (rejects) A built-in plugin.
10470
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10471
+ * @example Turn a third-party plugin off
10472
+ * The plugin stops another plugin and keeps it disabled across launches
10473
+ * until it is enabled again. Built-in plugins cannot be disabled
10474
+ * (`unsupported`).
10475
+ * ```ts
10476
+ * await plugin.packages.setEnabled("someone.demo", false);
10477
+ * ```
9514
10478
  */
9515
10479
  setEnabled(id: string, enabled: boolean): Promise<void>;
9516
10480
  /**
@@ -9528,10 +10492,38 @@ interface Packages {
9528
10492
  * `invalid` for oneself or a disabled plugin (enable it instead),
9529
10493
  * `missing` unknown, `busy` while it is being installed or reloaded.
9530
10494
  * @player 0.5.1; older players reject it as an unknown request (`unsupported`).
10495
+ * @capability plugins.manage
10496
+ * @throws invalid (rejects) The calling plugin itself, or a disabled plugin.
10497
+ * @throws missing (rejects) An unknown id.
10498
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10499
+ * @throws unsupported (rejects) A player before 0.5.1.
10500
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10501
+ * @example Restart a plugin that misbehaves
10502
+ * The other plugin is stopped and started again from the same package and
10503
+ * data. `reload` resolves once the old process is stopped and the start is
10504
+ * scheduled; `onChange` then shows `starting` and `running` (or `failed`).
10505
+ * ```ts
10506
+ * await plugin.packages.reload("someone.demo");
10507
+ * ```
9531
10508
  */
9532
10509
  reload(id: string): Promise<void>;
9533
10510
  /**
9534
10511
  * 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.
10512
+ * @capability plugins.manage
10513
+ * @throws unsupported (rejects) A built-in plugin or a development load.
10514
+ * @throws missing (rejects) An unknown id.
10515
+ * @throws invalid (rejects) The calling plugin itself.
10516
+ * @throws busy (rejects) The plugin library is being cleaned up, or that plugin is being installed or reloaded.
10517
+ * @throws revisionConflict (rejects) The installed revision of that plugin changed meanwhile; call `uninstall` again.
10518
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10519
+ * @example Uninstall a plugin together with its data
10520
+ * The other plugin is removed and its data directory deleted, so a later
10521
+ * reinstall starts fresh. Without `removeData` a reinstall from the same
10522
+ * source finds its data again.
10523
+ * ```ts
10524
+ * const change = await plugin.packages.uninstall("someone.demo", { removeData: true });
10525
+ * await change.done;
10526
+ * ```
9535
10527
  */
9536
10528
  uninstall(id: string, options?: {
9537
10529
  removeData?: boolean;
@@ -9606,9 +10598,31 @@ interface BindingInfo extends BindingRef {
9606
10598
  `plugin.bindings`: list and override every command's bindings, including
9607
10599
  the player's. Needs `bindings.manage`.
9608
10600
 
10601
+ The player's binding table: every command's default keys (from
10602
+ CommandOptions.bindings, the player's own as `host/<action>`)
10603
+ and the user's overrides on top, saved across launches. A key-settings
10604
+ plugin lists the table, writes overrides and resets them; the player
10605
+ applies the change at once to every running plugin.
10606
+
10607
+ - @capability bindings.manage
10608
+
9609
10609
  ```ts
9610
10610
  interface Bindings {
9611
- /** Every declared command, and overrides whose command is gone. */
10611
+ /**
10612
+ * Every declared command, and overrides whose command is gone.
10613
+ * @capability bindings.manage
10614
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10615
+ * @example Find the commands that use a key
10616
+ * A key-settings window shows which commands Ctrl+K is bound to now. `list`
10617
+ * resolves with every declared command and its current bindings (the
10618
+ * defaults with the user's overrides applied) and with the overrides
10619
+ * whose command is not running (`orphans`).
10620
+ * ```ts
10621
+ * const { commands } = await plugin.bindings.list();
10622
+ * const users = commands.filter((command) => command.current.includes("Ctrl+K"));
10623
+ * console.log(users.map((command) => `${command.plugin}/${command.command}`)); // "host" for the player
10624
+ * ```
10625
+ */
9612
10626
  list(): Promise<{
9613
10627
  /** Every declared command (running plugins and the player). */
9614
10628
  commands: BindingInfo[];
@@ -9622,11 +10636,47 @@ interface Bindings {
9622
10636
  }>;
9623
10637
  /**
9624
10638
  * 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.
10639
+ * @capability bindings.manage
10640
+ * @throws invalid (rejects) A malformed binding, or more than 16.
10641
+ * @throws missing (rejects) An unknown command that has no override either.
10642
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10643
+ * @example Give the player's Mute command a second key
10644
+ * Mute now answers to M and to Ctrl+M. `set` replaces the command's
10645
+ * bindings with the list given (an override saved across launches), so it
10646
+ * repeats the default it keeps; `[]` would unbind it.
10647
+ * ```ts
10648
+ * await plugin.bindings.set({ plugin: "host", command: "mute" }, ["M", "Ctrl+M"]);
10649
+ * ```
9625
10650
  */
9626
10651
  set(ref: BindingRef, inputs: string[]): Promise<void>;
9627
- /** Back to the defaults: one command, or every override when omitted. */
10652
+ /**
10653
+ * Back to the defaults: one command, or every override when omitted.
10654
+ * @capability bindings.manage
10655
+ * @throws missing (rejects) A command that has no override and is not declared.
10656
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10657
+ * @example Restore every default key
10658
+ * A "Restore defaults" button removes all of the user's overrides, so
10659
+ * every command has its declared keys again. Without an argument `reset`
10660
+ * clears every override; with a reference only that command's.
10661
+ * ```ts
10662
+ * await plugin.bindings.reset(); // every override
10663
+ * ```
10664
+ */
9628
10665
  reset(ref?: BindingRef): Promise<void>;
9629
- /** Declarations or overrides changed (read `list()` again). At most every 200 ms. */
10666
+ /**
10667
+ * Declarations or overrides changed (read `list()` again). At most every 200 ms.
10668
+ * @capability bindings.manage
10669
+ * @example Refresh a key list when bindings change
10670
+ * The plugin's key list stays current when the user changes a key in the
10671
+ * player or another plugin starts. The listener carries no data and runs
10672
+ * at most every 200 ms; the plugin reads the table again with `list`.
10673
+ * ```ts
10674
+ * plugin.bindings.onChange(async () => {
10675
+ * const { commands } = await plugin.bindings.list(); // read the new table
10676
+ * render(commands);
10677
+ * });
10678
+ * ```
10679
+ */
9630
10680
  onChange(listener: () => void): Disposable;
9631
10681
  }
9632
10682
  ```
@@ -10717,6 +11767,13 @@ A tool window as the test host keeps it: the record of a
10717
11767
  `plugin.windows.create(...)` window after its first `show()`. Get one with
10718
11768
  TestHost.window.
10719
11769
 
11770
+ A record, not a window: no Slint file is compiled and nothing is drawn.
11771
+ `props` holds what the plugin wrote, by Slint name; `emit` fires a
11772
+ callback the way the user's input would; `userClose` closes it the way
11773
+ the title-bar button would.
11774
+
11775
+ - @capability None.
11776
+
10720
11777
  ```ts
10721
11778
  interface FakeWindow {
10722
11779
  /** The `.slint` file the window was created from (package-relative). */
@@ -10731,10 +11788,32 @@ interface FakeWindow {
10731
11788
  callbacks: string[];
10732
11789
  /**
10733
11790
  * 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.
11791
+ * @capability None.
11792
+ * @example Type into a window's search field
11793
+ * The test types "cat" into the window's search box and checks the
11794
+ * plugin's results. `emit` delivers the Slint callback `search-changed`
11795
+ * with its argument to the plugin's `on` handler; `settle()` waits for the
11796
+ * properties the handler wrote.
11797
+ * ```ts
11798
+ * const search = host.window("search");
11799
+ * search.emit("searchChanged", "cat"); // camelCase or kebab-case
11800
+ * await host.settle();
11801
+ * assert.ok((search.props["results"] as unknown[]).length > 0);
11802
+ * ```
10734
11803
  */
10735
11804
  emit(callback: string, ...args: Json[]): void;
10736
11805
  /**
10737
11806
  * 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()`).
11807
+ * @capability None.
11808
+ * @example Close a window the way the user does
11809
+ * The test closes the plugin's window with its title-bar button and checks
11810
+ * that the plugin stopped its timer. Unlike the plugin's own `close()`,
11811
+ * `userClose` runs the plugin's `onClose` listeners.
11812
+ * ```ts
11813
+ * host.window("clock").userClose();
11814
+ * await host.settle();
11815
+ * assert.equal(host.window("clock").visible, false);
11816
+ * ```
10738
11817
  */
10739
11818
  userClose(): void;
10740
11819
  }
@@ -10777,6 +11856,13 @@ A command the plugin registered with `plugin.commands.add`, as the test
10777
11856
  host keeps it. Get one with TestHost.command (the plugin's own) or
10778
11857
  TestHost.coreAction (the player's core actions).
10779
11858
 
11859
+ The host's record of one command: what the plugin declared, the state
11860
+ it last sent, and how the menu shows it in TestHost.locale.
11861
+ Read it again after `settle()` to see changes; `run` executes it as the
11862
+ menu would.
11863
+
11864
+ - @capability None.
11865
+
10780
11866
  ```ts
10781
11867
  interface FakeCommand {
10782
11868
  /** 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 +11895,15 @@ interface FakeCommand {
10809
11895
  revision: number;
10810
11896
  /**
10811
11897
  * 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.
11898
+ * @capability None.
11899
+ * @throws command (rejects) A `HostError`: the command is disabled or busy (the message says which).
11900
+ * @example Check that a disabled item refuses to run
11901
+ * The test checks that Export cannot run while nothing is open. Like the
11902
+ * player, the host does not call `run` for a disabled or busy command;
11903
+ * the promise rejects with a `HostError`.
11904
+ * ```ts
11905
+ * await assert.rejects(host.command("export").run());
11906
+ * ```
10812
11907
  */
10813
11908
  run(): Promise<void>;
10814
11909
  }
@@ -10834,6 +11929,13 @@ assert.equal(host.command("toggle-loop").state.checked, true);
10834
11929
  A command's bottom-bar button (`CommandOptions.button`) as the player shows
10835
11930
  it: an entry of TestHost.barButtons.
10836
11931
 
11932
+ A bottom-bar button as the player would show it, derived from the
11933
+ command's declaration and state each time it is read: clickable while
11934
+ the command is enabled and not busy, accent-coloured while checked, with
11935
+ the command's title (and reason) as the tooltip.
11936
+
11937
+ - @capability None.
11938
+
10837
11939
  ```ts
10838
11940
  interface FakeBarButton {
10839
11941
  /** The command's local id. */
@@ -10852,17 +11954,45 @@ interface FakeBarButton {
10852
11954
  readonly tooltip: string;
10853
11955
  /**
10854
11956
  * 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.
11957
+ * @capability None.
11958
+ * @throws command (rejects) A `HostError`: the command is disabled or busy.
11959
+ * @throws missing (rejects) A `HostError`: the command is gone.
11960
+ * @example Click the button and wait for its effect
11961
+ * The test clicks the plugin's button and checks the OSD. `click` runs the
11962
+ * command like its menu item and resolves once the host is idle.
11963
+ * ```ts
11964
+ * await host.barButtons[0]?.click();
11965
+ * assert.ok(host.osd.length > 0);
11966
+ * ```
10855
11967
  */
10856
11968
  click(): Promise<void>;
10857
11969
  }
10858
11970
  ```
10859
11971
 
11972
+ Example: Check a bar button's tooltip while it is disabled
11973
+
11974
+ The test checks that the Export button shows why it cannot be clicked.
11975
+ The tooltip is the command's title; while the command is disabled or
11976
+ busy and has a non-empty `reason`, the reason follows on a second line.
11977
+ ```ts
11978
+ const [button] = host.barButtons;
11979
+ assert.equal(button?.enabled, false);
11980
+ assert.match(button?.tooltip ?? "", /\n/); // the command declares a reason
11981
+ ```
11982
+
10860
11983
  ### FakeProducer
10861
11984
 
10862
11985
  A generated stream (`plugin.player.openStream`) as the test host sees it:
10863
11986
  the fake player end of the frame connection. Assertion surface; see
10864
11987
  TestHost.producers.
10865
11988
 
11989
+ The player's end of a generated stream's frame connection
11990
+ (TestHost.producers): how many frames arrived and whether the
11991
+ plugin released it or the player retired it. Tests assert on it; nothing
11992
+ is played.
11993
+
11994
+ - @capability None.
11995
+
10866
11996
  ```ts
10867
11997
  interface FakeProducer {
10868
11998
  /** The producer id the host assigned (`1`, `2`, … per host). */
@@ -10884,6 +12014,18 @@ interface FakeProducer {
10884
12014
  }
10885
12015
  ```
10886
12016
 
12017
+ Example: Check that the stream delivers frames
12018
+
12019
+ The test starts the plugin's generator and checks that video and audio
12020
+ arrive. The counters grow as the SDK pulls frames and sends them over
12021
+ the fake connection.
12022
+ ```ts
12023
+ await host.command("start-generator").run();
12024
+ await host.settle(200); // let a few frames through
12025
+ const [producer] = host.producers.values();
12026
+ assert.ok((producer?.videoFrames ?? 0) > 0 && (producer?.receivedFrames ?? 0) > 0);
12027
+ ```
12028
+
10887
12029
  ### MarkdownWire
10888
12030
 
10889
12031
  A Markdown document as TestHost.markdown reads it back.
@@ -10938,6 +12080,15 @@ Fields that mirror player state at the wire level (`source`, `subtitles`,
10938
12080
  `calls`) use the protocol's shapes. They are fine for assertions but are
10939
12081
  not part of the plugin API: their shape may change in minor versions.
10940
12082
 
12083
+ An in-memory player that speaks the real wire protocol, so the SDK's own
12084
+ logic runs as it does in the player; only the player side is simulated
12085
+ (no decoding, no Slint, no windows). Make one per test, preset it if the
12086
+ plugin reads something during activation, start the plugin with
12087
+ startPlugin, drive it, `settle()`, and assert on what it
12088
+ recorded.
12089
+
12090
+ - @capability None.
12091
+
10941
12092
  ```ts
10942
12093
  class TestHost implements Transport {
10943
12094
  /** Every request the plugin sent, in order: `{ method, params }`. Wire-level; prefer TestHost.sent for one method. */
@@ -11020,6 +12171,15 @@ class TestHost implements Transport {
11020
12171
  /**
11021
12172
  * 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
12173
  * @param directory - The package directory (holding `locales/`).
12174
+ * @capability None.
12175
+ * @example Load the translations after changing the language
12176
+ * The test switches the host to German before starting the plugin and
12177
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
12178
+ * shown in German.
12179
+ * ```ts
12180
+ * host.locale = "de";
12181
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
12182
+ * ```
11023
12183
  */
11024
12184
  loadCatalog(directory: string): void;
11025
12185
  /**
@@ -11027,6 +12187,7 @@ class TestHost implements Transport {
11027
12187
  * @param text - English source text or template.
11028
12188
  * @param args - Values for its placeholders.
11029
12189
  * @returns The shown text.
12190
+ * @capability None.
11030
12191
  * @example Compare with a translated message
11031
12192
  * The test expects the text a Chinese user sees for a template the plugin
11032
12193
  * shows. `translate` looks the English source up in the plugin's catalog
@@ -11041,6 +12202,19 @@ class TestHost implements Transport {
11041
12202
  /**
11042
12203
  * Simulate another plugin (or the user) saving the interface language: pushes `appearance.changed`.
11043
12204
  * @param language - `"system"` or a locale.
12205
+ * @capability None.
12206
+ * @example Simulate the user choosing French for the next start
12207
+ * The plugin hears that the saved interface language changed. The host
12208
+ * pushes `appearance.changed`, which a plugin with `events` sees in
12209
+ * `appearance.language` once it has read it and subscribed; this run's
12210
+ * language does not change.
12211
+ * ```ts
12212
+ * void plugin.appearance.language; // read once: the SDK then follows it
12213
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
12214
+ * host.setLanguage("fr");
12215
+ * await host.settle();
12216
+ * assert.equal(plugin.appearance.language, "fr");
12217
+ * ```
11044
12218
  */
11045
12219
  setLanguage(language: LanguagePreference): void;
11046
12220
  /**
@@ -11197,6 +12371,7 @@ class TestHost implements Transport {
11197
12371
  * Simulate another plugin publishing its timeline layer (or clearing it with `[]`); pushes `timeline.changed` when the plugin subscribed.
11198
12372
  * @param pluginId - The other plugin's manifest id.
11199
12373
  * @param marks - Its whole layer.
12374
+ * @capability None.
11200
12375
  * @example Let another plugin publish chapters
11201
12376
  * The plugin under test sees two chapter marks another plugin put on the
11202
12377
  * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
@@ -11230,6 +12405,7 @@ class TestHost implements Transport {
11230
12405
  }[];
11231
12406
  /**
11232
12407
  * 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.
12408
+ * @capability None.
11233
12409
  * @example Check which file a window shows as its cover
11234
12410
  * The test checks that the Cover window's image comes from the plugin's
11235
12411
  * data directory. An image property holds the SDK's wire form of
@@ -11255,6 +12431,8 @@ class TestHost implements Transport {
11255
12431
  * 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
12432
  * @param paths - The dropped paths, in the order the file manager gives.
11257
12433
  * @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`).
12434
+ * @capability None.
12435
+ * @throws Never: files nobody takes are ignored, as in the player.
11258
12436
  * @example Drop files on the video and on the panel
11259
12437
  * The first drop lands on the video area: each extension belongs to the
11260
12438
  * provider that registered it first, and each provider gets one call with
@@ -11285,7 +12463,20 @@ class TestHost implements Transport {
11285
12463
  }): Promise<void>;
11286
12464
  /** The palette (`plugin.appearance.theme`); `setAppearance` changes it elsewhere. */
11287
12465
  appearance: ThemeId;
11288
- /** Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`. */
12466
+ /**
12467
+ * Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`.
12468
+ * @capability None.
12469
+ * @example Simulate another plugin switching to the light palette
12470
+ * The plugin hears the palette change. The host updates `appearance` and
12471
+ * pushes `appearance.changed` to a plugin that listens with `events`.
12472
+ * ```ts
12473
+ * const themes: ThemeId[] = [];
12474
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
12475
+ * host.setAppearance("vestra-light");
12476
+ * await host.settle();
12477
+ * assert.deepEqual(themes, ["vestra-light"]);
12478
+ * ```
12479
+ */
11289
12480
  setAppearance(theme: ThemeId): void;
11290
12481
  /** Settings pages by id (`plugin.settings.page`), every `show` (page id or null), and the page the options window shows. */
11291
12482
  readonly settings: {
@@ -11317,6 +12508,7 @@ class TestHost implements Transport {
11317
12508
  };
11318
12509
  /**
11319
12510
  * One settings page by id. Throws when it was never registered.
12511
+ * @capability None.
11320
12512
  * @example Change a value on the plugin's settings page
11321
12513
  * The test sets the step on the General page and checks the page shows
11322
12514
  * it. `emit` fires the page's callback as the user's input would; the
@@ -11354,13 +12546,26 @@ class TestHost implements Transport {
11354
12546
  /** Whether it shows now (no video frame). `true` when mounted. */
11355
12547
  effective: boolean;
11356
12548
  } | null;
11357
- /** Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin. */
12549
+ /**
12550
+ * Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin.
12551
+ * @capability None.
12552
+ * @example Make a video picture replace the backdrop
12553
+ * The plugin's backdrop hears that a video frame now covers it. `false`
12554
+ * means a picture appeared; the change is pushed like the player's
12555
+ * `window.state`.
12556
+ * ```ts
12557
+ * host.setBackdropEffective(false);
12558
+ * await host.settle();
12559
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
12560
+ * ```
12561
+ */
11358
12562
  setBackdropEffective(effective: boolean): void;
11359
12563
  /**
11360
12564
  * 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
12565
  * @param slot - The position.
11362
12566
  * @param plugin - The other plugin's manifest id.
11363
12567
  * @param options - `title`: its tab label (default: `plugin`).
12568
+ * @capability None.
11364
12569
  * @example Put the playlist next to this plugin's sidebar panel
11365
12570
  * The sidebar ends up with two tabs: the playlist, mounted first, and the
11366
12571
  * plugin's own panel after it. `mountOther` before `startPlugin` mounts
@@ -11377,10 +12582,31 @@ class TestHost implements Transport {
11377
12582
  mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
11378
12583
  title?: string;
11379
12584
  }): void;
11380
- /** Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is. */
12585
+ /**
12586
+ * Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is.
12587
+ * @capability None.
12588
+ * @example Let the playlist's tab go away
12589
+ * The other plugin's sidebar tab disappears and the plugin's own tab
12590
+ * becomes current. When the removed tab was current, the next tab (else
12591
+ * the previous one) becomes current, as in the player.
12592
+ * ```ts
12593
+ * host.unmountOther("vestra.playlist");
12594
+ * await host.settle();
12595
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
12596
+ * ```
12597
+ */
11381
12598
  unmountOther(plugin: string): void;
11382
12599
  /**
11383
12600
  * 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.
12601
+ * @capability None.
12602
+ * @example Click another plugin's sidebar tab
12603
+ * The user switches the sidebar to the playlist; the plugin's panel is in
12604
+ * a background tab now. The change is pushed as `window.state`.
12605
+ * ```ts
12606
+ * host.selectTab("vestra.playlist");
12607
+ * await host.settle();
12608
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
12609
+ * ```
11384
12610
  */
11385
12611
  selectTab(plugin: string): void;
11386
12612
  /** The display layer (`plugin.video.view()`): who owns it (this plugin is `manifestId`) and the values the player holds. */
@@ -11392,6 +12618,17 @@ class TestHost implements Transport {
11392
12618
  };
11393
12619
  /**
11394
12620
  * 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).
12621
+ * @capability None.
12622
+ * @example Let another plugin own the display layer
12623
+ * The plugin's `video.view()` fails with `conflict` because another plugin
12624
+ * took the display layer first. `null` releases it again.
12625
+ * ```ts
12626
+ * host.setVideoViewOwner("someone.zoom");
12627
+ * const view = plugin.video.view();
12628
+ * view.set({ zoom: 2 });
12629
+ * await host.settle();
12630
+ * assert.equal(view.status.state, "failed");
12631
+ * ```
11395
12632
  */
11396
12633
  setVideoViewOwner(pluginId: string | null): void;
11397
12634
  /**
@@ -11402,15 +12639,27 @@ class TestHost implements Transport {
11402
12639
  owner: string | null;
11403
12640
  /** The values the player holds. */
11404
12641
  values: RenderSettings;
11405
- /** `true`: GPU rendering; `false`: software rendering (`unsupported`). */
12642
+ /** `true`: GPU rendering; `false`: software rendering (`unsupported`). `plugin.app.system()` reports it as `rendering`. */
11406
12643
  gpu: boolean;
11407
12644
  };
11408
12645
  /**
11409
12646
  * 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).
12647
+ * @capability None.
12648
+ * @example Let another plugin own the render options
12649
+ * The plugin's render options report `conflict` while another plugin owns
12650
+ * them; `null` releases them and resets the values to the defaults.
12651
+ * ```ts
12652
+ * host.setVideoRenderOwner("someone.hdr");
12653
+ * const render = plugin.video.render();
12654
+ * render.set({ toneMapping: "bt.2390" });
12655
+ * await host.settle();
12656
+ * assert.equal(render.status.state, "failed");
12657
+ * ```
11410
12658
  */
11411
12659
  setVideoRenderOwner(pluginId: string | null): void;
11412
12660
  /**
11413
12661
  * 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.
12662
+ * @capability None.
11414
12663
  * @example Make a shader fail to compile
11415
12664
  * The plugin's render options report `failed`, as when mpv rejects a
11416
12665
  * shader. The host pushes the failure only while this plugin owns the
@@ -11479,6 +12728,8 @@ class TestHost implements Transport {
11479
12728
  bytes: Buffer[];
11480
12729
  /** The announced total size in bytes. */
11481
12730
  expected: number;
12731
+ /** The source generation it was begun for; like the player, a new source drops the uploads of the old one. */
12732
+ generation?: number;
11482
12733
  }>;
11483
12734
  /** Snapshots taken with `media.capture`, by snapshot id. Wire-level. */
11484
12735
  readonly snapshots: Map<number, Json>;
@@ -11531,10 +12782,21 @@ class TestHost implements Transport {
11531
12782
  /** Exact host names it resolves (a web resolver), with `schemes`. */
11532
12783
  hosts?: string[];
11533
12784
  }[];
11534
- /** What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once. */
12785
+ /**
12786
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once.
12787
+ * @capability None.
12788
+ * @example Check the file types a resolver adds
12789
+ * The test checks the list the plugin will read as
12790
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
12791
+ * `otherResolvers`, sorted, each once.
12792
+ * ```ts
12793
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
12794
+ * ```
12795
+ */
11535
12796
  fileTypes(): string[];
11536
12797
  /**
11537
12798
  * 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).
12799
+ * @capability None.
11538
12800
  * @example Let another plugin's file type appear while running
11539
12801
  * Another plugin starts and adds `.ch8`; the plugin under test then sees
11540
12802
  * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
@@ -11621,8 +12883,10 @@ class TestHost implements Transport {
11621
12883
  * `plugin.storage.session`: key → value, as the player keeps them. Preset entries to simulate values an earlier run of the plugin stored in the same player session, or pass `startPlugin(..., { sessionStorage })`. Like the player, a key is 1–1024 bytes, a `set` that would make the entries larger than 1 MiB (keys, values as compact JSON and `SESSION_STORAGE_LIMITS.entryBytes` per key) fails `limit`, and an object's properties are stored sorted, as the player hands them back.
11622
12884
  */
11623
12885
  readonly sessionStorage: Map<string, Json>;
11624
- /** What `plugin.app.system()` reports. */
11625
- systemInfo: WireSystemInfo;
12886
+ /**
12887
+ * What `plugin.app.system()` reports. Its `rendering` follows videoRender.gpu (`"gpu"` or `"software"`), so one switch covers the render options and the fact.
12888
+ */
12889
+ systemInfo: Omit<WireSystemInfo, "rendering">;
11626
12890
  /** What `plugin.app` reports. Set it before startPlugin: the plugin receives it once at start-up. */
11627
12891
  app: {
11628
12892
  /** The player version. */
@@ -11652,6 +12916,9 @@ class TestHost implements Transport {
11652
12916
  readonly dataDirectory: string;
11653
12917
  /** The manifest id of the plugin under test (set by startPlugin; `"test.plugin"` by default). */
11654
12918
  manifestId: string;
12919
+ /**
12920
+ * 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`.
12921
+ */
11655
12922
  constructor();
11656
12923
  /** The `player.setStepSeek` value in effect (default 5). */
11657
12924
  stepSeek: number;
@@ -11707,7 +12974,8 @@ class TestHost implements Transport {
11707
12974
  /** Every core action run (`host.run`, a core command, a pressed core binding), newest last. */
11708
12975
  readonly coreRuns: string[];
11709
12976
  /**
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.
12977
+ * 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.
12978
+ * @capability None.
11711
12979
  * @example Pause through the player's own action
11712
12980
  * The test pauses playback the way the Space key does and checks that the
11713
12981
  * host recorded it. Core actions belong to the player (`host/<id>`);
@@ -11734,6 +13002,7 @@ class TestHost implements Transport {
11734
13002
  * find the one a call uses.
11735
13003
  * @param method - The wire method name.
11736
13004
  * @param handler - Produces the reply from the request parameters.
13005
+ * @capability None.
11737
13006
  * @example Make opening a folder fail
11738
13007
  * The plugin's Open Folder command meets a missing folder and must tell
11739
13008
  * the user. `handle` replaces the host's answer to one wire method; the
@@ -11750,17 +13019,43 @@ class TestHost implements Transport {
11750
13019
  * ```
11751
13020
  */
11752
13021
  handle(method: string, handler: Handler): void;
11753
- /** Restore the default behaviour of a method replaced with TestHost.handle. */
13022
+ /**
13023
+ * Restore the default behaviour of a method replaced with TestHost.handle.
13024
+ * @capability None.
13025
+ * @example Restore the default answer after a simulated failure
13026
+ * The first write fails, the second succeeds. `reset` removes the handler
13027
+ * that `handle` installed, so the method is answered by the test host
13028
+ * again.
13029
+ * ```ts
13030
+ * host.handle("clipboard.writeText", () => {
13031
+ * throw new HostError("failed", "denied");
13032
+ * });
13033
+ * await assert.rejects(plugin.clipboard.writeText("x"));
13034
+ * host.reset("clipboard.writeText");
13035
+ * await plugin.clipboard.writeText("x"); // the default handler again
13036
+ * ```
13037
+ */
11754
13038
  reset(method: string): void;
11755
13039
  /**
11756
13040
  * 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
13041
  * @param event - The wire event name.
11758
13042
  * @param data - The event payload (wire-level shape).
13043
+ * @capability None.
13044
+ * @example Deliver a raw event the helpers do not cover
13045
+ * The test pushes a `window.state` event after changing `layout` by hand.
13046
+ * Like the player, the host delivers it only when the plugin subscribed;
13047
+ * most tests use `setSource`, `setMainWindow` and the other helpers
13048
+ * instead.
13049
+ * ```ts
13050
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
13051
+ * await host.settle();
13052
+ * ```
11759
13053
  */
11760
13054
  emit(event: string, data: unknown): void;
11761
13055
  /**
11762
13056
  * 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
13057
  * @param patch - Wire-level source fields, e.g. `phase`, `position`, `duration`, `paused`, `audioTracks`.
13058
+ * @capability None.
11764
13059
  * @example Load a file, then let it play to the end
11765
13060
  * The plugin sees a one-minute source become ready, then end on its own.
11766
13061
  * `setSource` changes the host's source facts and broadcasts them like
@@ -11777,6 +13072,7 @@ class TestHost implements Transport {
11777
13072
  setSource(patch: Partial<SourceState>): void;
11778
13073
  /**
11779
13074
  * 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.
13075
+ * @capability None.
11780
13076
  * @example Go fullscreen, then report picture-in-picture support
11781
13077
  * The plugin sees the main window fullscreen at 1920×1080, then learns
11782
13078
  * that picture-in-picture is available. `setMainWindow` changes the
@@ -11803,12 +13099,23 @@ class TestHost implements Transport {
11803
13099
  }>): void;
11804
13100
  /**
11805
13101
  * 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.
13102
+ * @capability None.
13103
+ * @example Pop the sidebar out in fullscreen
13104
+ * The pointer reaches the right edge in fullscreen and the attached
13105
+ * sidebar pops out over the video. Outside fullscreen, or with the
13106
+ * sidebar hidden or floating, nothing happens.
13107
+ * ```ts
13108
+ * host.setMainWindow({ fullscreen: true });
13109
+ * host.popSidebar(true);
13110
+ * await host.settle();
13111
+ * ```
11806
13112
  */
11807
13113
  popSidebar(popped: boolean): void;
11808
13114
  /**
11809
13115
  * 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
13116
  * @param source - The path or URL.
11811
13117
  * @param phase - The phase to end in (default `"ready"`).
13118
+ * @capability None.
11812
13119
  * @example Let the user open another file meanwhile
11813
13120
  * A state the plugin captured before stops being current once another
11814
13121
  * file opens. `openElsewhere` gives the source a new `sourceId`, so
@@ -11827,6 +13134,7 @@ class TestHost implements Transport {
11827
13134
  * 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
13135
  * @param producerId - The producer, from TestHost.producers.
11829
13136
  * @param message - The player's failure text.
13137
+ * @capability None.
11830
13138
  * @example Break the plugin's generated stream
11831
13139
  * The plugin's stream stops with an error, as when the player loses its
11832
13140
  * audio device. `failStream` puts the source into `error` with the
@@ -11839,8 +13147,11 @@ class TestHost implements Transport {
11839
13147
  */
11840
13148
  failStream(producerId: number, message: string): void;
11841
13149
  /**
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.
13150
+ * 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
13151
  * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
13152
+ * @capability None.
13153
+ * @throws command (rejects) A `HostError`: the command that would run is busy (the player only logs this).
13154
+ * @throws Error (rejects) Nothing is bound to the key.
11844
13155
  * @example Press a global key and a panel key
11845
13156
  * Ctrl+B runs whatever is bound globally; Delete is pressed while the
11846
13157
  * plugin's panel has focus, so panel bindings are tried first. `press`
@@ -11931,6 +13242,7 @@ class TestHost implements Transport {
11931
13242
  readonly remoteImages: Map<string, string | null>;
11932
13243
  /**
11933
13244
  * 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.
13245
+ * @capability None.
11934
13246
  * @example Read back a Markdown message in a chat window
11935
13247
  * The test checks the Markdown source of the first chat message. Rows
11936
13248
  * hold the document in its wire form, as `plugin.ui.markdown` produced
@@ -11948,13 +13260,47 @@ class TestHost implements Transport {
11948
13260
  /** The system's name for it. */
11949
13261
  name: string;
11950
13262
  }[];
11951
- /** Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed). */
13263
+ /**
13264
+ * Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed).
13265
+ * @capability None.
13266
+ * @example Simulate another plugin starting
13267
+ * The plugin's store window hears that a plugin is running now. The list
13268
+ * replaces `packages` and is pushed as `packages.changed`, which the
13269
+ * plugin's `packages.onChange` listeners receive.
13270
+ * ```ts
13271
+ * host.setPackages([...host.packages]); // edit a copy, then push it
13272
+ * await host.settle();
13273
+ * ```
13274
+ */
11952
13275
  setPackages(list: TestHost["packages"]): void;
11953
13276
  /**
11954
13277
  * 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.
13278
+ * @capability None.
13279
+ * @throws unavailable (rejects) A `HostError`: there is no sequence provider, or its flag is false.
13280
+ * @example Ask the plugin's provider for the next item
13281
+ * Another plugin calls `player.next()`; the host asks the plugin under
13282
+ * test, which is the sequence provider, and the test checks what it
13283
+ * opened.
13284
+ * ```ts
13285
+ * await host.step("next");
13286
+ * await host.settle();
13287
+ * assert.equal(host.source.source, "/media/b.mp4");
13288
+ * ```
11955
13289
  */
11956
13290
  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). */
13291
+ /**
13292
+ * The plugin's command with this local id. Throws when it is not registered (never added, or disposed).
13293
+ * @capability None.
13294
+ * @example Read a command's state after a change
13295
+ * The test checks that Export becomes enabled once a file is ready. The
13296
+ * record is updated as the plugin sends new state; read it after
13297
+ * `settle()`.
13298
+ * ```ts
13299
+ * host.setSource({ phase: "ready", duration: 60 });
13300
+ * await host.settle();
13301
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
13302
+ * ```
13303
+ */
11958
13304
  command(id: string): FakeCommand;
11959
13305
  /**
11960
13306
  * 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 +13320,7 @@ class TestHost implements Transport {
11974
13320
  get barButtons(): readonly FakeBarButton[];
11975
13321
  /**
11976
13322
  * 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()`.
13323
+ * @capability None.
11977
13324
  * @example Press a window's Reset button from a test
11978
13325
  * The Picture window's Reset callback runs and the brightness returns to
11979
13326
  * 0. The window is found by its id (the `.slint` file name by default);
@@ -11988,6 +13335,7 @@ class TestHost implements Transport {
11988
13335
  window(id: string): FakeWindow;
11989
13336
  /**
11990
13337
  * 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.
13338
+ * @capability None.
11991
13339
  * @example Select a row in the plugin's sidebar panel
11992
13340
  * The test checks the panel sits in the sidebar and selects its third
11993
13341
  * row. `emit` fires a callback on the panel's global as a click would;
@@ -12009,6 +13357,7 @@ class TestHost implements Transport {
12009
13357
  };
12010
13358
  /**
12011
13359
  * The parameters of every request of one wire method, oldest first.
13360
+ * @capability None.
12012
13361
  * @example Check the request behind an OSD message
12013
13362
  * The test checks the last OSD request the plugin sent, before
12014
13363
  * translation. `sent` lists the parameters of every request of one wire
@@ -12029,6 +13378,8 @@ class TestHost implements Transport {
12029
13378
  * for: on a loaded machine it can finish after `settle()` returns. Poll
12030
13379
  * the result you assert on instead (second example).
12031
13380
  * @param ms - Extra time to wait, for debounces and timers (default 0).
13381
+ * @capability None.
13382
+ * @throws Never.
12032
13383
  * @example Wait past a debounced save
12033
13384
  * The plugin's store writes its file 300 ms after the last `save`, so
12034
13385
  * the test waits 400 ms before it checks what the plugin did next.
@@ -12054,7 +13405,22 @@ class TestHost implements Transport {
12054
13405
  * ```
12055
13406
  */
12056
13407
  settle(ms?: number): Promise<void>;
12057
- /** Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it. */
13408
+ /**
13409
+ * Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it.
13410
+ * @capability None.
13411
+ * @example Clean up a host that was never started
13412
+ * A test that made a host but failed before `startPlugin` removes the
13413
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
13414
+ * `close` for you otherwise.
13415
+ * ```ts
13416
+ * const host = new TestHost();
13417
+ * try {
13418
+ * // … preset the host; the test fails here …
13419
+ * } finally {
13420
+ * host.close(); // deletes the host's temporary data
13421
+ * }
13422
+ * ```
13423
+ */
12058
13424
  close(): void;
12059
13425
  }
12060
13426
  ```
@@ -12104,9 +13470,14 @@ throws, or when a registration made during activation fails (for example
12104
13470
  `limit`, `conflict`, `invalid`), which is when the player would fail the
12105
13471
  activation.
12106
13472
 
13473
+ The way into a plugin's tests: one call starts the plugin the way the
13474
+ player's runner does and returns the host to drive, the `plugin` object
13475
+ to call directly, and `stop` to end it like the player.
13476
+
12107
13477
  - @param activate - The plugin's default-exported `activate` function.
12108
13478
  - @param options - Where the plugin lives and how to start it.
12109
13479
  - @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.
13480
+ - @capability None.
12110
13481
 
12111
13482
  ```ts
12112
13483
  function startPlugin(activate: (plugin: Plugin) => unknown, options?: {
@@ -12751,6 +14122,61 @@ if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
12751
14122
  }
12752
14123
  ```
12753
14124
 
14125
+ ### SUBTITLE_LIMITS
14126
+
14127
+ The player's limits on subtitle files a plugin loads with
14128
+ Subtitles.load, which `TestHost` enforces with the player's codes.
14129
+ The track counts are per source: opening another file starts from zero.
14130
+ A file over `fileBytes` fails with `invalid`; every other limit fails with
14131
+ `busy`, and nothing is loaded.
14132
+
14133
+ Each `load` uses a new resource on the source, and removing the track
14134
+ frees its place among the live tracks (`tracksPerPlugin`, `tracks`,
14135
+ `totalBytes`) but not among the loads (`loadsPerPlugin`, `loads`). So one
14136
+ plugin loads at most 16 files into one source, with at most 4 of them
14137
+ present at once.
14138
+
14139
+ ```ts
14140
+ const SUBTITLE_LIMITS: Readonly<{
14141
+ /** UTF-8 bytes of one file; a larger one fails with `invalid` (2 MiB). */
14142
+ readonly fileBytes: number;
14143
+ /** Tracks one plugin has loaded into the current source at the same time (4). */
14144
+ readonly tracksPerPlugin: 4;
14145
+ /** Loaded tracks of every plugin in the current source at the same time (16). */
14146
+ readonly tracks: 16;
14147
+ /** Bytes of the loaded tracks of every plugin in the current source together (16 MiB). */
14148
+ readonly totalBytes: number;
14149
+ /** Loads by one plugin into the current source, removed tracks included (16). */
14150
+ readonly loadsPerPlugin: 16;
14151
+ /** Loads by every plugin into the current source, removed tracks included (128). */
14152
+ readonly loads: 128;
14153
+ /** Loads of one plugin in progress at the same time (2). */
14154
+ readonly uploadsPerPlugin: 2;
14155
+ /** Loads of every plugin in progress at the same time (8). */
14156
+ readonly uploads: 8;
14157
+ }>;
14158
+ ```
14159
+
14160
+ Example: Replace the plugin's oldest subtitle when it has four
14161
+
14162
+ The plugin keeps the files it loaded, newest last, and removes the oldest
14163
+ before a fifth would be refused with `busy`. A load the player still
14164
+ refuses (the source holds 16 tracks of every plugin, or this plugin
14165
+ already loaded 16 files into it) is reported on the OSD.
14166
+ ```ts
14167
+ import { SUBTITLE_LIMITS } from "@vestra-player/sdk/testing";
14168
+
14169
+ const loaded: SubtitleTrack[] = []; // this source's, newest last
14170
+ if (loaded.length >= SUBTITLE_LIMITS.tracksPerPlugin)
14171
+ await plugin.subtitles.remove(loaded.shift()!);
14172
+ try {
14173
+ loaded.push(await plugin.subtitles.load(bytes));
14174
+ } catch (error) {
14175
+ if ((error as PluginError).code !== "busy") throw error;
14176
+ await plugin.osd.show("No room for another subtitle on this file.");
14177
+ }
14178
+ ```
14179
+
12754
14180
  ### UI_VALUE_LIMITS
12755
14181
 
12756
14182
  The player's limits on one property value (a `PropValue` as written
@@ -12851,11 +14277,17 @@ Common codes: `stale` (the condition no longer holds; nothing was done),
12851
14277
  `timeout`, `aborted`, `limit` (a player limit such as the command count),
12852
14278
  `disabled` (the plugin is stopping).
12853
14279
 
14280
+ A definite refusal, as opposed to a lost reply: the SDK does not retry
14281
+ it, and the plugin sees an `Error` with `code`.
14282
+
14283
+ - @capability None.
14284
+
12854
14285
  ```ts
12855
14286
  class HostError extends Error {
12856
14287
  /** The stable error code, e.g. `"invalid"`, `"stale"`, `"unavailable"`. */
12857
14288
  code: string;
12858
14289
  /**
14290
+ * A refusal with this code, as the player would send it.
12859
14291
  * @param code - The stable error code the caller will see as `error.code`.
12860
14292
  * @param message - Human-readable detail (not for branching on).
12861
14293
  */
@@ -13233,7 +14665,7 @@ interface SubtitleState {
13233
14665
 
13234
14666
  ### WireSystemInfo
13235
14667
 
13236
- `app.system`: the operating system and the displays.
14668
+ `app.system`: the operating system, the displays and the renderer.
13237
14669
 
13238
14670
  ```ts
13239
14671
  interface WireSystemInfo {
@@ -13251,6 +14683,8 @@ interface WireSystemInfo {
13251
14683
  refreshRate: number | null;
13252
14684
  primary: boolean;
13253
14685
  }[];
14686
+ /** Player 0.5.12; older players leave it out. */
14687
+ rendering?: "gpu" | "software";
13254
14688
  }
13255
14689
  ```
13256
14690