@vestra-player/sdk 1.5.30 → 1.5.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.30: full reference
1
+ # @vestra-player/sdk 1.5.33: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
@@ -1032,6 +1032,282 @@ The API is the same on macOS and Windows; what differs is the system's own part:
1032
1032
 
1033
1033
  `TestHost` from `@vestra-player/sdk/testing` runs a plugin without the player and records its interface: windows with their properties and callbacks, the mounted panel, settings pages, the OSD and dialogs. It does not compile Slint files; check them by running the plugin in the player. How to write such tests is in Test a plugin.
1034
1034
 
1035
+ ## How-to: Control playback
1036
+
1037
+ **Goal:** your plugin opens files and controls playback (pause, seek, speed, next and previous), shows what is playing, and never acts on a file the user has already left.
1038
+
1039
+ The player has one source. The user, other plugins and yours all change it, so every control in `plugin.player` is **conditional**: it acts on the state your plugin last saw and fails with `stale` (doing nothing) when someone else changed the source in between. Most of this guide is about using that well.
1040
+
1041
+ #### Capabilities
1042
+
1043
+ ```json
1044
+ {
1045
+ "capabilities": ["commands", "playback.read", "playback.control", "events"]
1046
+ }
1047
+ ```
1048
+
1049
+ `playback.read` reads the state, `playback.control` changes it, `events` keeps `player.state` current when the change comes from elsewhere (without it, the state only moves after your own calls and `refresh()`), and `commands` gives your actions menu items and keys.
1050
+
1051
+ #### Read the state
1052
+
1053
+ `plugin.player.state` is a snapshot the SDK keeps current from the player's events. `onChange` runs after it changed; read the new snapshot there instead of asking the player.
1054
+
1055
+ ```ts
1056
+ const info = plugin.osd.panel();
1057
+ plugin.player.onChange((state) => {
1058
+ // `position` and `duration` are seconds, null while unknown.
1059
+ if (state.phase !== "ready") return info.set(null);
1060
+ info.set("{} / {} s", [state.position?.toFixed(0) ?? "–", state.duration?.toFixed(0) ?? "–"]);
1061
+ });
1062
+ ```
1063
+
1064
+ `state.can` says which controls the current source allows (`pause`, `seek`, `rate`, `restart`, `close`, `next`, `previous`). Bind menu items to it so they grey out by themselves:
1065
+
1066
+ ```ts
1067
+ plugin.commands.add({
1068
+ id: "pause",
1069
+ path: ["@playback", "Pause"],
1070
+ enabled: () => plugin.player.state.can.pause, // re-evaluated after every change
1071
+ checked: () => plugin.player.state.paused,
1072
+ run: () => plugin.player.setPaused(!plugin.player.state.paused),
1073
+ });
1074
+ ```
1075
+
1076
+ #### Control it, and handle `stale`
1077
+
1078
+ A control without options is conditional on `player.state` as it is when you call. `stale` means the source changed after that: another file, a close, or any pause, seek or speed change by the user or another plugin. Nothing happened, and usually that is the right outcome:
1079
+
1080
+ ```ts
1081
+ try {
1082
+ await plugin.player.seek(30);
1083
+ } catch (error) {
1084
+ if ((error as PluginError).code !== "stale") throw error; // another file now
1085
+ }
1086
+ ```
1087
+
1088
+ For work that takes time (a timer, a dialog, a network request), keep the snapshot from when it started and pass it as `ifCurrent`, so the call applies only to that source:
1089
+
1090
+ ```ts
1091
+ const seen = plugin.player.state;
1092
+ const answer = await plugin.dialogs.confirm({ message: "Close this file?", confirm: "Close" }); // ui.dialog
1093
+ if (answer.confirmed)
1094
+ await plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
1095
+ if (error.code !== "stale") throw error; // the user opened something else meanwhile
1096
+ });
1097
+ ```
1098
+
1099
+ `player.isCurrent(seen)` answers the same question without a call. Your own consecutive calls do not make each other stale: the SDK refreshes the state after each one.
1100
+
1101
+ Other codes: `invalid` for a value out of range (a negative seek, a speed outside 0.05–10) or a control while nothing is open, `unsupported` for something the source cannot do (a loop on a source that cannot seek).
1102
+
1103
+ #### Open a file and follow it
1104
+
1105
+ `open` resolves once the player accepted the load (for an address a resolver plugin handles, once the resolver has answered) and returns a `Media` handle for that source. `ready()` waits until it plays; `onEnd` fires when it ends on its own.
1106
+
1107
+ ```ts
1108
+ const media = await plugin.player.open("/Movies/clip.mp4");
1109
+ try {
1110
+ await media.ready({ signal: AbortSignal.timeout(10_000) }); // closes it on timeout
1111
+ } catch (error) {
1112
+ await plugin.osd.show("Cannot play: {}", [(error as Error).message]);
1113
+ }
1114
+ media.onEnd((ended) =>
1115
+ // Play on only if nobody opened something else at the end.
1116
+ plugin.player.open("/Movies/next.mp4", { ifCurrent: ended }).catch(() => {}),
1117
+ );
1118
+ ```
1119
+
1120
+ `ready()` rejects with `failed` (the file cannot be loaded), `replaced` (another source took over) or `ended`. Disposing your scope does not close the media: playback belongs to the user, so call `media.close()` when you mean it.
1121
+
1122
+ #### Behave exactly like the player's own keys
1123
+
1124
+ For buttons in your own bar or window, prefer the player's core actions to calling the controls yourself. `plugin.host.run` (capability `commands`) runs them with the player's checks, accumulation of repeated presses and on-screen text:
1125
+
1126
+ ```ts
1127
+ declare const bar: Panel;
1128
+ // ---cut---
1129
+ bar.on("playPause", () => plugin.host.run("play-pause"));
1130
+ bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 })); // adds up with →
1131
+ ```
1132
+
1133
+ `run` rejects with `unavailable` while the action is disabled, for example with nothing open.
1134
+
1135
+ #### Next and previous
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 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
+
1139
+ ```ts
1140
+ declare const items: string[];
1141
+ let index = 0;
1142
+ // ---cut---
1143
+ const playAt = async (at: number) => {
1144
+ index = at;
1145
+ await plugin.player.open(items[at]!);
1146
+ };
1147
+ plugin.player.provideSequence({
1148
+ next: () => playAt(index + 1),
1149
+ previous: () => playAt(index - 1),
1150
+ canNext: () => index + 1 < items.length, // shown as state.can.next
1151
+ canPrevious: () => index > 0,
1152
+ });
1153
+ ```
1154
+
1155
+ A second provider is refused with `conflict` (`sequence-taken`). During activation that does not fail the plugin: it runs without Next and Previous and the player shows a warning; a refusal after activation goes to `onError`.
1156
+
1157
+ #### Sliders, loops and the output
1158
+
1159
+ - A seek slider: start a `player.scrub()` gesture on press and `end()` it on release; see Keep a slider responsive while dragging.
1160
+ - An A-B loop: `setLoop({ a, b, count })`; `null` stops it, a new source clears it.
1161
+ - Volume and mute: `setOutput({ volume, muted })` (volume 0–200). The newest write wins; the promise resolves `false` when a newer one replaced it.
1162
+ - Your own step size for ← and →: `setStepSeek({ seconds, keyframes })`, withdrawn when your plugin stops.
1163
+
1164
+ ## How-to: Store a plugin's data
1165
+
1166
+ **Goal:** your plugin keeps its settings across restarts, keeps work in progress across a reload, and keeps an API token safe, each in the right place.
1167
+
1168
+ | What | Where | Lasts | Capability |
1169
+ | --- | --- | --- | --- |
1170
+ | Settings, history, small state | `plugin.storage.json(name)`: a JSON file in the data directory | Across player restarts and plugin upgrades | none |
1171
+ | Work in progress for this run | `plugin.storage.session`: values in the player's memory | Across plugin restarts (reload, crash, upgrade); cleared when the player quits or the plugin is disabled or uninstalled | none |
1172
+ | Tokens, passwords | `plugin.secrets`: the system's keychain or Credential Manager | Until deleted, or until the plugin is uninstalled with its data | `secrets` |
1173
+ | Anything else (caches, downloads) | Your own files under `plugin.dataDirectory` | Until you delete them | none |
1174
+
1175
+ All four are private to the plugin; another plugin never reads them.
1176
+
1177
+ #### Settings: a JSON file
1178
+
1179
+ `storage.json` reads `<dataDirectory>/<name>.json` when you call it and gives you `value` at once (the defaults when the file is missing, broken or fails `validate`; a broken file is kept aside as `<name>.json.bad-<time>`). `save` replaces the value in memory and writes the file 300 ms later, through a temporary file and a rename.
1180
+
1181
+ ```ts
1182
+ type Prefs = { step: number; recent: string[] };
1183
+ const prefs = plugin.storage.json<Prefs>("prefs", {
1184
+ defaults: { step: 5, recent: [] },
1185
+ // Checked when the file is read: an older version may have written it.
1186
+ validate: (v): v is Prefs => typeof (v as Prefs)?.step === "number" && Array.isArray((v as Prefs)?.recent),
1187
+ });
1188
+ prefs.save({ ...prefs.value, step: 10 }); // never change prefs.value in place
1189
+ ```
1190
+
1191
+ Stopping the plugin writes a pending save first, but only within the short time the player gives a stopping plugin (about 300 ms for a plugin in its own process), and a crash loses it; call `await prefs.flush()` after a save that must not be lost. Also flush before you read or copy the file yourself. See Remember a window's state between runs for a complete window built on a store.
1192
+
1193
+ #### Work in progress: session values
1194
+
1195
+ `storage.session` keeps JSON values in the player for this run: a reload of the plugin (an upgrade, a crash, the user's Reload) finds them again; the next player start does not.
1196
+
1197
+ ```ts
1198
+ const session = plugin.storage.session;
1199
+ const queue = (await session.get<string[]>("queue")) ?? []; // undefined: nothing stored
1200
+ queue.push("https://example.com/a.mkv");
1201
+ try {
1202
+ await session.set("queue", queue); // kept once this resolves
1203
+ } catch (error) {
1204
+ // `limit`: the plugin's values would pass 1 MiB; nothing changed.
1205
+ if ((error as PluginError).code !== "limit") throw error;
1206
+ }
1207
+ ```
1208
+
1209
+ Each call is one request, answered in order, with no debounce. Keys are 1–1024 bytes; values come back as `JSON.stringify` wrote them (object properties may come back in another order, a `Date` as a string), and are not checked, so check their shape.
1210
+
1211
+ #### Secrets: the system's store
1212
+
1213
+ `plugin.secrets` stores short text values in the macOS keychain or the Windows Credential Manager. Never write a token to a settings file.
1214
+
1215
+ ```ts
1216
+ let token = await plugin.secrets.get("api-token").catch(() => null); // no store: ask
1217
+ if (token === null) {
1218
+ token = "…"; // ask the user, for example in a tool window
1219
+ await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
1220
+ // `unsupported` (no store), `unavailable` (store unreachable), `failed` (access denied).
1221
+ void plugin.osd.show("The token is not kept for next time ({})", [error.code]);
1222
+ });
1223
+ }
1224
+ ```
1225
+
1226
+ Names are 1–64 of `a-z 0-9 . _ -`, values 1–1024 bytes, at most 256 names. Have a fallback: some systems have no store, a store can be unreachable (no keychain, read-only, no logged-in session on Windows), and the user can deny access.
1227
+
1228
+ #### Your own files
1229
+
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
+
1232
+ ## How-to: Load subtitles from a plugin
1233
+
1234
+ **Goal:** your plugin finds subtitles for the playing file (on disk, on a website, by converting another format), adds them as a track, selects them, and optionally styles them.
1235
+
1236
+ #### Capabilities
1237
+
1238
+ ```json
1239
+ {
1240
+ "capabilities": ["subtitles.read", "subtitles.control", "events"]
1241
+ }
1242
+ ```
1243
+
1244
+ `subtitles.read` reads the tracks and the selection; `subtitles.control` changes them (and needs `subtitles.read` too, because every change reads the current selection first); `events` makes the SDK read `plugin.subtitles.state` again when the source or its phase changes. The player has no subtitle event: a selection the user or another plugin changes shows only after `refresh()`.
1245
+
1246
+ #### Read the tracks
1247
+
1248
+ `plugin.subtitles.state` is `null` until first read; `refresh()` reads it from the player. Each track has an `id` (compare tracks by id), a `kind` (`embedded` in the file or `external`, loaded from a file), a `title` and a `language`.
1249
+
1250
+ ```ts
1251
+ const state = plugin.subtitles.state ?? (await plugin.subtitles.refresh());
1252
+ for (const track of state.tracks)
1253
+ console.log(track.id, track.kind, track.language ?? "?", track.title);
1254
+ // The primary and the secondary subtitle; null when off.
1255
+ console.log(state.selected?.id, state.secondary?.id);
1256
+ ```
1257
+
1258
+ #### Select a track, and handle `stale`
1259
+
1260
+ Without options a change reads the newest state itself and applies to it, so it almost never fails. Embedded tracks are chosen by number, though: a `select` after another file opened would pick that file's track with the same number. Pass the state you looked at as `ifCurrent`; then the change fails with `stale`, and does nothing, when the user picked another track or another file opened in between:
1261
+
1262
+ ```ts
1263
+ const state = await plugin.subtitles.refresh();
1264
+ const english = state.tracks.find((track) => track.language === "en");
1265
+ if (english)
1266
+ await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
1267
+ if (error.code !== "stale") throw error; // the user changed it meanwhile
1268
+ });
1269
+ ```
1270
+
1271
+ `select(track, { role: "secondary" })` sets the second line shown together with the primary one (a text track other than the primary); `select(null)` turns a role off. A new source clears the secondary subtitle.
1272
+
1273
+ #### Upload a file
1274
+
1275
+ `load` takes the file's bytes: UTF-8 SRT, or ASS/SSA with `format: "ass"`, at most 2 MiB (SRT up to 10,000 cues, ASS up to 20,000 `Dialogue:` lines). Convert other formats (WebVTT, SAMI) to SRT first. The player adds an `external` track to the current source only, selects it unless `select: "none"`, and resolves with the track once the upload is committed.
1276
+
1277
+ ```ts
1278
+ import { readFile } from "node:fs/promises";
1279
+ import { basename } from "node:path";
1280
+ declare const path: string;
1281
+ // ---cut---
1282
+ const bytes = await readFile(path);
1283
+ const track = await plugin.subtitles.load(bytes, {
1284
+ format: path.endsWith(".ass") ? "ass" : "srt",
1285
+ title: basename(path), // data: shown as it is
1286
+ select: "primary",
1287
+ signal: AbortSignal.timeout(30_000), // discards the upload if not committed in time
1288
+ });
1289
+ await plugin.osd.show("Loaded {}", [track.title]);
1290
+ ```
1291
+
1292
+ A title that mixes words with data is a text value the player translates: `title: plugin.i18n.text("{} · {}", [plugin.i18n.text("Downloaded"), name])`. `remove(track)` takes away a track this plugin loaded; the next source removes it anyway.
1293
+
1294
+ #### Style the subtitles
1295
+
1296
+ `setStyle` sets this plugin's style layer: font (one of `state.fonts`), size at the 1080p reference height, colour, outline, margin, timing offset, box. Each plugin has one layer and its newest call replaces it. When several plugins set a layer, the player shows the one whose **manifest id sorts last**, whatever the order of the calls; `setStyle(null)` takes yours off.
1297
+
1298
+ ```ts
1299
+ const { fonts } = await plugin.subtitles.refresh();
1300
+ if (fonts.length)
1301
+ await plugin.subtitles.setStyle({
1302
+ fontId: fonts[0]!.id,
1303
+ fontSize: 40,
1304
+ color: "white",
1305
+ outline: 2,
1306
+ marginPercent: 5,
1307
+ offsetMs: 0, // positive shows them later
1308
+ });
1309
+ ```
1310
+
1035
1311
  ## How-to: Keep a slider responsive while dragging
1036
1312
 
1037
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.
@@ -3297,9 +3573,32 @@ flushes, a running `tools.run` process is killed and a listener is
3297
3573
  unsubscribed. A Media is the exception: disposing its scope does
3298
3574
  **not** close the media; call Media.close for that.
3299
3575
 
3576
+ Every handle a plugin creates is a `Disposable` owned by the scope that
3577
+ created it; disposing the scope (or stopping the plugin) disposes them in
3578
+ reverse creation order, so a plugin rarely calls `dispose` itself.
3579
+
3580
+ - @capability None of its own: that of the handle.
3581
+
3300
3582
  ```ts
3301
3583
  interface Disposable {
3302
- /** Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects. */
3584
+ /**
3585
+ * Release the resource. Resolves once the player confirmed the release (or immediately for purely local resources). Never rejects.
3586
+ * @capability None of its own: that of the handle.
3587
+ * @throws Never.
3588
+ * @example Remove a command when its feature is turned off
3589
+ * A "Turn feature off" item removes the feature's own menu item at once,
3590
+ * before the plugin stops. `dispose` takes the command out of the menu and
3591
+ * the binding table and resolves once the player confirmed it; the id can
3592
+ * be added again later.
3593
+ * ```ts
3594
+ * const extra = plugin.commands.add({ id: "extra", path: ["Tools", "Extra"], run() {} });
3595
+ * plugin.commands.add({
3596
+ * id: "extra-off",
3597
+ * path: ["Tools", "Turn extra off"],
3598
+ * run: () => extra.dispose(), // never rejects
3599
+ * });
3600
+ * ```
3601
+ */
3303
3602
  dispose(): Promise<void>;
3304
3603
  }
3305
3604
  ```
@@ -3325,7 +3624,6 @@ change).
3325
3624
  | code | meaning |
3326
3625
  | --- | --- |
3327
3626
  | `stale` | A condition no longer held (the source or the subtitle selection changed); nothing was done. See Conditional. |
3328
- | `staleDigest` | PackageCandidate.install: the package's files changed after it was validated; validate it again. |
3329
3627
  | `capability` | The manifest does not declare the capability the call needs. |
3330
3628
  | `invalid` | A bad argument (out of range, malformed, unknown id). |
3331
3629
  | `missing` | The target does not exist (an empty panel position, a missing file, an unknown command). |
@@ -3345,7 +3643,7 @@ change).
3345
3643
  | `backend-uncertain` | The player's backend blocked a filter stage; call `retry()`. |
3346
3644
  | `limit` | A player-wide limit was reached (for example the number of commands), a property value is over the limits of PropValue, or a request is longer than the player's 1 MiB message. |
3347
3645
  | `reserved` | Packages.validate, PackageCandidate.install: the id is `vestra` or starts with `vestra.` (any case), which only the player's built-in plugins use (player 0.4.1). |
3348
- | `versionMismatch` | The package needs a newer player (`minPlayerVersion`) or another `apiVersion`. |
3646
+ | `versionMismatch` | The package needs a newer player (`minPlayerVersion`). |
3349
3647
  | `ui-prepare` | The player could not prepare a mounted panel or a settings page (its Slint file does not compile, a bound property is not declared, or another plugin's panel holds the slot); `message` has the diagnostics. |
3350
3648
  | `ui` | The player could not apply a property to a tool window or a settings page, or could not build the Options window (a property the Slint file does not declare, a value of the wrong type); `message` has Slint's reason. Also reported to Scope.onError without a request: a callback whose arguments the player could not convert, a property it could not show again when it rebuilt a view (an image file removed since). |
3351
3649
  | `host` | Any other failure inside the player; `message` is the player's reason. |
@@ -3408,6 +3706,14 @@ Reading Scope.window, Scope.app, Scope.appearance
3408
3706
  `.theme`, Scope.ui, Scope.tools, Scope.storage and
3409
3707
  Scope.system needs none.
3410
3708
 
3709
+ Every module of the API (`commands`, `windows`, `player`…) is reachable
3710
+ from a scope, and every handle made through it belongs to it. A child
3711
+ scope from Scope.scope is the unit for features that come and
3712
+ go: dispose the child and everything it created is released, while the
3713
+ rest of the plugin keeps running.
3714
+
3715
+ - @capability That of each module (see the modules).
3716
+
3411
3717
  ```ts
3412
3718
  interface Scope extends Disposable {
3413
3719
  /** Menu commands and their default bindings (`commands`). */
@@ -3431,10 +3737,16 @@ interface Scope extends Disposable {
3431
3737
  */
3432
3738
  filter(id: string): VideoFilter;
3433
3739
  /**
3434
- * The display layer (needs `processing.video`). The first plugin to call it owns the layer; another plugin's view fails with `conflict` until the owner disposes or stops. A second call in the same plugin throws.
3740
+ * The display layer (needs `processing.video`). The first plugin to call it owns the layer; another plugin's view fails with `conflict` until the owner disposes or stops.
3741
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3742
+ * @throws Error without a code (throws) A second call in the same plugin while the first view is not disposed.
3435
3743
  */
3436
3744
  view(): VideoView;
3437
- /** The GPU renderer's options (needs `processing.video`), owned like the display layer. `failed` with `unsupported` under software rendering. */
3745
+ /**
3746
+ * The GPU renderer's options (needs `processing.video`), owned like the display layer. Under software rendering the handle is `failed` with `unsupported` after the player's first answer, and `onError` hears it.
3747
+ * @throws capability (throws) The manifest does not declare `processing.video`.
3748
+ * @throws Error without a code (throws) A second call in the same plugin while the first handle is not disposed.
3749
+ */
3438
3750
  render(): RenderOptions;
3439
3751
  };
3440
3752
  /** Audio processing: this plugin's filter chain and delay (`processing.audio`, `playback.read` and `events`, as for VideoFilter). */
@@ -3488,17 +3800,57 @@ interface Scope extends Disposable {
3488
3800
  readonly i18n: I18n;
3489
3801
  /** Pages in the player's options window (`ui.settings`). */
3490
3802
  readonly settings: Settings;
3491
- /** A child scope; disposing it releases only what was created through it. The child is itself owned by this scope. */
3803
+ /**
3804
+ * A child scope; disposing it releases only what was created through it. The child is itself owned by this scope.
3805
+ * @capability None.
3806
+ * @example Tie a temporary window and its timer to one feature
3807
+ * A "Live stats" feature opens a window and starts a timer; turning it off
3808
+ * releases both together. Everything made through the child scope, and
3809
+ * cleanups registered on it, go when the child is disposed; the parent
3810
+ * scope is untouched.
3811
+ * ```ts
3812
+ * let live: Scope | null = null;
3813
+ * plugin.commands.add({
3814
+ * id: "live-stats",
3815
+ * path: ["Tools", "Live stats"],
3816
+ * checked: () => live !== null,
3817
+ * run: async () => {
3818
+ * if (live) {
3819
+ * await live.dispose(); // closes the window, stops the timer
3820
+ * live = null;
3821
+ * return;
3822
+ * }
3823
+ * live = plugin.scope();
3824
+ * const window = live.windows.create("ui/stats.slint");
3825
+ * const timer = setInterval(() => live?.update(), 1000);
3826
+ * live.onDispose(() => clearInterval(timer));
3827
+ * await window.show();
3828
+ * },
3829
+ * });
3830
+ * ```
3831
+ */
3492
3832
  scope(): Scope;
3493
3833
  /**
3494
3834
  * Adopt an existing handle: it is disposed together with this scope.
3495
3835
  * @param item - Any Disposable, for example a handle created through another scope.
3496
3836
  * @returns `item` itself, for chaining.
3837
+ * @capability None.
3838
+ * @example Dispose a handle from another scope together with this one
3839
+ * A filter made through the root scope is withdrawn when a child feature
3840
+ * ends. `add` hands an existing disposable to this scope, which then
3841
+ * disposes it with its own handles; it returns the same handle for
3842
+ * chaining.
3843
+ * ```ts
3844
+ * const feature = plugin.scope();
3845
+ * const blur = feature.add(plugin.video.filter("blur")); // now ends with `feature`
3846
+ * blur.set([{ kind: "gblur", sigma: 4, steps: 3 }]);
3847
+ * ```
3497
3848
  */
3498
3849
  add<T extends Disposable>(item: T): T;
3499
3850
  /**
3500
3851
  * Run `cleanup` when this scope is disposed (for example `clearInterval` or closing a socket). It may be async; errors are logged.
3501
3852
  * @returns A handle that cancels the registration early (without running `cleanup`).
3853
+ * @capability None.
3502
3854
  * @example Stop a timer when the plugin stops
3503
3855
  * A once-a-second refresh runs as long as the plugin does. `onDispose`
3504
3856
  * runs the cleanup when the scope is disposed, which for `plugin` is
@@ -3517,6 +3869,7 @@ interface Scope extends Disposable {
3517
3869
  * Rejections of async handlers count too, and so do failures of
3518
3870
  * registrations made after activation (a command whose `ready` rejects,
3519
3871
  * a property write the player refused).
3872
+ * @capability None.
3520
3873
  * @example Show errors from handlers on the OSD
3521
3874
  * When a menu command, a window callback or a listener of the plugin
3522
3875
  * fails, the user sees why. The SDK catches what those handlers throw
@@ -3536,6 +3889,23 @@ interface Scope extends Disposable {
3536
3889
  * changing state from your own timers or between awaits of a long task.
3537
3890
  *
3538
3891
  * Several calls within one microtask are merged into one pass.
3892
+ * @capability None.
3893
+ * @example Show progress from a long task between awaits
3894
+ * A progress label moves while a long download runs, not only when it
3895
+ * ends. The SDK re-evaluates bound values after each handler and when its
3896
+ * promise settles; state changed between the awaits of a long handler
3897
+ * shows only after an explicit `update()`.
3898
+ * ```ts
3899
+ * let done = 0;
3900
+ * window.bind(() => ({ progress: done }));
3901
+ * window.on("start", async () => {
3902
+ * for (let part = 0; part < 10; part++) {
3903
+ * await downloadPart(part);
3904
+ * done = part + 1;
3905
+ * plugin.update(); // send `progress` now, not when the handler ends
3906
+ * }
3907
+ * });
3908
+ * ```
3539
3909
  */
3540
3910
  update(): void;
3541
3911
  }
@@ -3584,6 +3954,12 @@ root scope is disposed for local cleanup (timers, signals, sockets). A
3584
3954
  process that has not exited after 300 ms is killed. Media the plugin
3585
3955
  opened with Player.open keeps playing.
3586
3956
 
3957
+ The root scope: disposing it is what stopping the plugin does. Besides
3958
+ the modules of Scope, it carries the plugin's `id` and its
3959
+ `dataDirectory`.
3960
+
3961
+ - @capability That of each module (see the modules).
3962
+
3587
3963
  ```ts
3588
3964
  interface Plugin extends Scope {
3589
3965
  /** The manifest id, e.g. `"community.bookmark"`. */
@@ -3889,12 +4265,23 @@ interface CommandOptions {
3889
4265
  A registered command, from Commands.add. Disposing it removes it
3890
4266
  from the menu and the binding table; the id can be added again right away.
3891
4267
 
4268
+ The handle of one menu item and its keys. Its state (`enabled`, `checked`,
4269
+ `busy`, `reason`) is bound: functions given to Commands.add or
4270
+ Command.set are evaluated again in every update pass and only
4271
+ changes reach the player. Its title, path, bindings and button cannot
4272
+ change in place; dispose the command (or a child Scope) and add it
4273
+ again. Disposing it, its scope or stopping the plugin removes the menu
4274
+ item, its keys and its bar button.
4275
+
4276
+ - @capability commands
4277
+
3892
4278
  ```ts
3893
4279
  interface Command extends Disposable {
3894
4280
  /** The local id given in CommandOptions.id. */
3895
4281
  readonly id: string;
3896
4282
  /**
3897
4283
  * Replace some state fields (values or functions). Last write wins.
4284
+ * @capability commands
3898
4285
  * @example Show a command as busy while it works
3899
4286
  * The Export item shows as busy with "Exporting…" as its reason while the
3900
4287
  * export runs, then returns to normal. `set` replaces only the fields it
@@ -3920,15 +4307,47 @@ interface Command extends Disposable {
3920
4307
  }>): void;
3921
4308
  /**
3922
4309
  * Resolves when the host has registered the command. Rejects with `conflict` (path already taken), `invalid` or `limit` (more than 160 commands in this plugin, or 1024 in the whole player). You rarely need to await it: during activation a failure fails activation, later it goes to Scope.onError.
4310
+ * @throws conflict (rejects) Another command already has this menu path, or the path would be both a menu item and a submenu.
4311
+ * @throws capability (rejects) The manifest does not declare `commands`.
4312
+ * @throws invalid (rejects) The player refused the declaration.
4313
+ * @throws limit (rejects) More than 160 commands in this plugin, or 1024 in the player.
3923
4314
  */
3924
4315
  readonly ready: Promise<void>;
3925
4316
  }
3926
4317
  ```
3927
4318
 
4319
+ Example: Enable an item only while a file is open
4320
+
4321
+ Video ▸ Export… is greyed out until a file is open. `set` replaces the
4322
+ `enabled` field with a function; the SDK evaluates it again after every
4323
+ player state change and sends the new state when it changes, so the
4324
+ item follows the player without further code.
4325
+ ```ts
4326
+ const exportCommand = plugin.commands.add({
4327
+ id: "export",
4328
+ path: ["@video", "Export…"],
4329
+ run: runExport,
4330
+ });
4331
+ // A function: re-evaluated in every update pass.
4332
+ exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
4333
+ ```
4334
+
3928
4335
  ### Commands
3929
4336
 
3930
4337
  `plugin.commands`: menu commands with default bindings. Needs `commands`.
3931
4338
 
4339
+ A command is one action of the plugin with a menu item (`path`), default
4340
+ keys (`bindings`), optionally a bottom-bar button (`button`) and a state
4341
+ the menu shows (`enabled`, `checked`, `busy`, `reason`). The player owns
4342
+ the menu, the binding table and the user's key overrides: it calls `run`
4343
+ when the user picks the item, presses a key or clicks the button.
4344
+ Commands of all plugins merge into the player's menus by their English
4345
+ path keys. Registration is asynchronous: `add` returns at once, and
4346
+ Command.ready reports the player's answer (during activation a
4347
+ refusal fails activation).
4348
+
4349
+ - @capability commands
4350
+
3932
4351
  ```ts
3933
4352
  interface Commands {
3934
4353
  /**
@@ -3938,6 +4357,7 @@ interface Commands {
3938
4357
  * Titles and paths cannot change in place: put changing items (track
3939
4358
  * lists, presets) in a child Scope and rebuild it.
3940
4359
  * @throws invalid synchronously for a malformed path (including an unknown `@` menu id), order, group or binding; `conflict` for an id this plugin has already declared and not disposed.
4360
+ * @capability commands
3941
4361
  * @example Pause playback from a menu item
3942
4362
  * Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
3943
4363
  * while paused and is greyed out when nothing can be paused. The player
@@ -3960,6 +4380,20 @@ interface Commands {
3960
4380
  }
3961
4381
  ```
3962
4382
 
4383
+ Example: Add a menu item with a default key
4384
+
4385
+ Tools ▸ Say hello, or Ctrl+H, shows a greeting. `add` declares the item;
4386
+ the player calls `run` when the user picks it or presses its key, and the
4387
+ user can change the key in the player's key settings.
4388
+ ```ts
4389
+ plugin.commands.add({
4390
+ id: "hello",
4391
+ path: ["Tools", "Say hello"],
4392
+ bindings: ["Ctrl+H"], // a default; the user may override it
4393
+ run: () => plugin.osd.show("Hello"),
4394
+ });
4395
+ ```
4396
+
3963
4397
  ### ImageRef
3964
4398
 
3965
4399
  An image file for a Slint `image` property, made by Ui.image.
@@ -5532,6 +5966,13 @@ interface DropHandler {
5532
5966
 
5533
5967
  `plugin.drops`: files dropped on the video area. Needs `ui.drop`.
5534
5968
 
5969
+ Files dropped on the video area go to providers by extension: each
5970
+ extension belongs to the provider registered for it first, and each
5971
+ provider gets one call with all of its paths. Files nobody takes are
5972
+ ignored. A panel with Panel.onDrop takes drops on itself first.
5973
+
5974
+ - @capability ui.drop
5975
+
5535
5976
  ```ts
5536
5977
  interface Drops {
5537
5978
  /**
@@ -5542,6 +5983,7 @@ interface Drops {
5542
5983
  * to the provider that registered it first) in the order the file
5543
5984
  * manager gave them, one call per provider. Folders are not expanded.
5544
5985
  * @throws invalid synchronously for malformed extensions.
5986
+ * @capability ui.drop
5545
5987
  * @example Load dropped subtitle files
5546
5988
  * Dropping .srt or .ass files on the video loads each one as a subtitle
5547
5989
  * track. Each extension belongs to the provider that registered it first;
@@ -5567,6 +6009,25 @@ interface Drops {
5567
6009
  }
5568
6010
  ```
5569
6011
 
6012
+ Example: Collect dropped CHIP-8 programs
6013
+
6014
+ `.ch8` files dropped on the player are added to the plugin's list. The
6015
+ provider claims the extension; the player calls `drop` with the dropped
6016
+ paths of that type, in the order the file manager gave them. Extensions
6017
+ go to the first provider: the built-in playlist claims every audio and
6018
+ video extension when it starts, and a built-in plugin cannot be disabled,
6019
+ so another plugin's provider for those never gets them (except a
6020
+ development load that replaces the playlist under its id).
6021
+ ```ts
6022
+ const queue: string[] = [];
6023
+ plugin.drops.provide({
6024
+ extensions: ["ch8"],
6025
+ drop: ({ paths }) => {
6026
+ queue.push(...paths); // only .ch8 files reach here
6027
+ },
6028
+ });
6029
+ ```
6030
+
5570
6031
  ### ThemeId
5571
6032
 
5572
6033
  The player's palettes: `vestra`, the dark default, and `vestra-light`
@@ -6438,10 +6899,18 @@ interface TimelineMark {
6438
6899
 
6439
6900
  This plugin's own marks; disposing it removes them.
6440
6901
 
6902
+ This plugin's marks on the seek bar, from Timeline.layer. `set`
6903
+ replaces the whole layer and never throws: the newest write wins, and a
6904
+ refused one is reported to `onError`. Disposing the layer, its scope or
6905
+ stopping the plugin removes the marks.
6906
+
6907
+ - @capability timeline.marks
6908
+
6441
6909
  ```ts
6442
6910
  interface TimelineLayer extends Disposable {
6443
6911
  /**
6444
6912
  * Replace the layer (last write wins; `[]` clears it). At most 1000 marks, titles ≤ 100 characters, times within the media. Failures go to `onError`; with `ifCurrent`, a write for another source is dropped silently. A title is data (a chapter's or bookmark's own name), or a Text the player translates (`text("Chapter {}", [n])`, SDK 1.5.11); other plugins read the shown string.
6913
+ * @capability timeline.marks
6445
6914
  * @example Show the file's bookmarks on the timeline
6446
6915
  * Bookmark marks appear on the seek bar at their times, titled with the
6447
6916
  * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
@@ -6466,22 +6935,76 @@ interface TimelineLayer extends Disposable {
6466
6935
  }
6467
6936
  ```
6468
6937
 
6938
+ Example: Clear the plugin's marks
6939
+
6940
+ The plugin's marks disappear from the seek bar while it keeps its
6941
+ layer for later. An empty list clears the layer; other plugins' marks
6942
+ stay.
6943
+ ```ts
6944
+ plugin.timeline.layer().set([]);
6945
+ ```
6946
+
6469
6947
  ### Timeline
6470
6948
 
6471
6949
  `plugin.timeline`: marks on the seek bar. Every plugin publishes one
6472
6950
  layer; the player merges them. A new source clears every layer.
6473
6951
 
6952
+ Marks on the player's seek bar: chapters, bookmarks, loop points. Each
6953
+ plugin publishes one layer with Timeline.layer; the player merges
6954
+ the layers by time and shows them. A new source clears every layer, so a
6955
+ plugin publishes again for each file. `marks` is the merged list, which
6956
+ any plugin can read.
6957
+
6958
+ - @capability `timeline.marks` to publish; `playback.read` to read; `events` to follow changes.
6959
+
6474
6960
  ```ts
6475
6961
  interface Timeline {
6476
6962
  /** Every layer merged, by time (`playback.read`). Empty on a new source. */
6477
6963
  readonly marks: readonly TimelineMark[];
6478
- /** The merged list changed (needs `events`; at most every 100 ms). */
6964
+ /**
6965
+ * The merged list changed (needs `events`; at most every 100 ms).
6966
+ * @capability events and playback.read
6967
+ * @example Follow the marks as they change
6968
+ * A panel lists the seek bar's marks and updates when any plugin publishes
6969
+ * or a new file clears them. The listener gets the merged list, at most
6970
+ * every 100 ms.
6971
+ * ```ts
6972
+ * plugin.timeline.onChange((marks) => {
6973
+ * console.log(marks.map((mark) => `${mark.time}s ${mark.title ?? mark.kind}`));
6974
+ * });
6975
+ * ```
6976
+ */
6479
6977
  onChange(listener: (marks: readonly TimelineMark[]) => void): Disposable;
6480
- /** This plugin's layer (`timeline.marks`); a second call throws. */
6978
+ /**
6979
+ * This plugin's layer (`timeline.marks`); a second call throws.
6980
+ * @capability timeline.marks
6981
+ * @example Publish this plugin's marks
6982
+ * The plugin gets its one layer and puts two chapter marks on the seek
6983
+ * bar. Calling `layer()` again while the first layer is not disposed
6984
+ * throws; disposing the layer removes the
6985
+ * marks.
6986
+ * ```ts
6987
+ * const layer = plugin.timeline.layer();
6988
+ * layer.set([
6989
+ * { time: 0, kind: "chapter", title: "Intro" },
6990
+ * { time: 95, kind: "chapter", title: "Part 1" }, // seconds
6991
+ * ]);
6992
+ * ```
6993
+ */
6481
6994
  layer(): TimelineLayer;
6482
6995
  }
6483
6996
  ```
6484
6997
 
6998
+ Example: Count the chapter marks other plugins published
6999
+
7000
+ The plugin reads how many chapter marks the seek bar shows, whoever
7001
+ published them. `marks` is the merged list of every layer, sorted by
7002
+ time, with each mark's `plugin`.
7003
+ ```ts
7004
+ const chapters = plugin.timeline.marks.filter((mark) => mark.kind === "chapter");
7005
+ await plugin.osd.show("{} chapters", [chapters.length]);
7006
+ ```
7007
+
6485
7008
  ### WindowState
6486
7009
 
6487
7010
  The main window as `plugin.window.state` reports it (logical points).
@@ -6517,11 +7040,28 @@ With `events`, `state` follows the window from the first read or
6517
7040
  `onChange`; without it, it updates only at start and after this plugin's
6518
7041
  `setVideoArea`.
6519
7042
 
7043
+ `plugin.window` is the player's main window: its size, fullscreen,
7044
+ picture-in-picture and the video area. Reading it needs no capability;
7045
+ changing it needs `window.control`. Changes that last only while the
7046
+ plugin runs (always on top, pointer auto-hide) revert when it stops.
7047
+
7048
+ - @capability None to read; window.control to change; events to follow outside changes.
7049
+
6520
7050
  ```ts
6521
7051
  interface MainWindow {
6522
7052
  /** Latest state; zero video area without a native window. */
6523
7053
  readonly state: WindowState;
6524
- /** The window state changed (needs `events` for outside changes). */
7054
+ /**
7055
+ * The window state changed (needs `events` for outside changes).
7056
+ * @capability events (for outside changes)
7057
+ * @example Fit a panel to fullscreen
7058
+ * The plugin's panel uses larger text while the player is fullscreen. The
7059
+ * listener runs after the SDK updated `state` from the player's events
7060
+ * (with `events`; without it only after this plugin's own changes).
7061
+ * ```ts
7062
+ * plugin.window.onChange((state) => bar.set({ large: state.fullscreen }));
7063
+ * ```
7064
+ */
6525
7065
  onChange(listener: (state: WindowState) => void): Disposable;
6526
7066
  /**
6527
7067
  * Resize the window so the video column is `width`×`height` (the bottom bar
@@ -6533,6 +7073,18 @@ interface MainWindow {
6533
7073
  * Sizes are 1–16384 logical points; the minimum video area is 318×120.
6534
7074
  * The visible area excludes the menu bar and Dock on macOS; on Windows it
6535
7075
  * is the whole display. `unavailable` without a user interface too.
7076
+ * @capability window.control
7077
+ * @throws unavailable (rejects) Fullscreen, or no user interface.
7078
+ * @throws invalid (rejects) A size outside 1–16384 logical points.
7079
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7080
+ * @example Make the video area 1280×720 points
7081
+ * The window resizes so that the video column is 1280×720 logical points,
7082
+ * the bars and a docked sidebar added around it. A request that does not
7083
+ * fit the screen is scaled down keeping the aspect, and `clamped` says so.
7084
+ * ```ts
7085
+ * const applied = await plugin.window.setVideoArea({ width: 1280, height: 720 });
7086
+ * if (applied.clamped) await plugin.osd.show("{}×{}", [applied.width, applied.height]);
7087
+ * ```
6536
7088
  */
6537
7089
  setVideoArea(size: {
6538
7090
  width: number;
@@ -6545,10 +7097,42 @@ interface MainWindow {
6545
7097
  /** The request was scaled down or raised to fit. */
6546
7098
  clamped: boolean;
6547
7099
  }>;
6548
- /** Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface. */
7100
+ /**
7101
+ * Float the player's windows above other applications. Newest call wins; reverts when this plugin stops. `unavailable` without a user interface.
7102
+ * @capability window.control
7103
+ * @throws unavailable (rejects) No user interface.
7104
+ * @throws invalid (rejects) `onTop` is not a boolean.
7105
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7106
+ * @example Keep the player above other windows
7107
+ * The player's windows (tool windows too) float above other applications
7108
+ * while the plugin's "Always on top" item is checked. The newest call of
7109
+ * any plugin wins, and the setting reverts when this plugin stops.
7110
+ * ```ts
7111
+ * let onTop = false;
7112
+ * plugin.commands.add({
7113
+ * id: "on-top",
7114
+ * path: ["@screen", "Always on top"],
7115
+ * checked: () => onTop,
7116
+ * run: async () => {
7117
+ * onTop = !onTop;
7118
+ * await plugin.window.setAlwaysOnTop(onTop); // reverts when this plugin stops
7119
+ * },
7120
+ * });
7121
+ * ```
7122
+ */
6549
7123
  setAlwaysOnTop(onTop: boolean): Promise<void>;
6550
7124
  /**
6551
7125
  * `"playing"`: hide the pointer over the video after 3 idle seconds while playing, in a window too (not only fullscreen). Newest call wins; reverts to `"fullscreen"` when this plugin stops.
7126
+ * @capability window.control
7127
+ * @throws invalid (rejects) A mode other than `"fullscreen"` or `"playing"`.
7128
+ * @throws capability (rejects) The manifest does not declare `window.control`.
7129
+ * @example Hide the pointer over playing video in a window too
7130
+ * The pointer disappears after 3 idle seconds over playing video, not only
7131
+ * in fullscreen. The newest call wins; when this plugin stops the player
7132
+ * goes back to hiding it in fullscreen only.
7133
+ * ```ts
7134
+ * await plugin.window.setCursorAutoHide("playing");
7135
+ * ```
6552
7136
  */
6553
7137
  setCursorAutoHide(mode: "fullscreen" | "playing"): Promise<void>;
6554
7138
  /**
@@ -6563,6 +7147,13 @@ interface MainWindow {
6563
7147
  *
6564
7148
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
6565
7149
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
7150
+ * @capability window.control
7151
+ * @throws unsupported (rejects) The system has no picture-in-picture (`supported` is false).
7152
+ * @throws unavailable (rejects) No video, or no user interface.
7153
+ * @throws invalid (rejects) `on` is not a boolean.
7154
+ * @throws busy (rejects) The previous call is still switching.
7155
+ * @throws failed (rejects) The system refused.
7156
+ * @throws capability (rejects) The manifest does not declare `window.control`.
6566
7157
  * @example Toggle picture-in-picture where the system has it
6567
7158
  * The video moves into a floating picture-in-picture window, or back.
6568
7159
  * `state.pictureInPicture` says whether the system supports it and
@@ -6626,9 +7217,29 @@ interface AppInfo {
6626
7217
  `plugin.app`: the fixed facts plus the system, read on request. No
6627
7218
  capability.
6628
7219
 
7220
+ `plugin.app` holds facts fixed for the player's run (its version, build,
7221
+ library versions) and reads the system's on request: `system()` reads
7222
+ the displays at each call; the operating system's name and version are
7223
+ worked out once, at the first call, and reused.
7224
+
7225
+ - @capability None.
7226
+
6629
7227
  ```ts
6630
7228
  interface App extends AppInfo {
6631
- /** The operating system and the displays, read when called (displays come and go). No capability. CPU and memory: Node's `os` module. */
7229
+ /**
7230
+ * The operating system and the displays, read when called (displays come and go). No capability. CPU and memory: Node's `os` module.
7231
+ * @capability None.
7232
+ * @throws busy (rejects) The player could not start the request (no thread available).
7233
+ * @example Choose a size for the primary display
7234
+ * The plugin sizes its picture for the primary display. `system()` asks
7235
+ * the player; `displays` lists the displays present now, the primary one
7236
+ * first (empty without a user interface).
7237
+ * ```ts
7238
+ * const { displays } = await plugin.app.system();
7239
+ * const primary = displays[0];
7240
+ * if (primary) console.log(primary.width, primary.height);
7241
+ * ```
7242
+ */
6632
7243
  system(): Promise<SystemInfo>;
6633
7244
  }
6634
7245
  ```
@@ -7071,29 +7682,100 @@ media.onEnd(async (ended) => {
7071
7682
 
7072
7683
  A source this plugin opened.
7073
7684
 
7685
+ A source this plugin opened with Player.open. It does not own
7686
+ the source: the user or another plugin can replace it at any time, and
7687
+ `current` then turns false. `ready` and `onEnd` follow the player state:
7688
+ with `events` they react to the player's events, without it `ready`
7689
+ polls the state every 100 ms (which needs `playback.read`). Disposing the
7690
+ scope that opened it does not close the media; call `close` for that.
7691
+
7692
+ - @capability That of Player.open (`playback.control`); `ready` and `onEnd` follow the state through `events` and `playback.read`.
7693
+
7074
7694
  ```ts
7075
7695
  interface Media {
7076
7696
  /** Still the player's source (a restart keeps it current). */
7077
7697
  readonly current: boolean;
7078
7698
  /**
7079
7699
  * Resolves when it is playing. Rejects with code `failed` (load error), `replaced` (another source took over) or `ended`; with `AbortError` when the signal aborts, after closing the media.
7700
+ * @capability That of Player.open; `playback.read` or `events` to see the state change.
7701
+ * @throws failed (rejects) The media could not be loaded.
7702
+ * @throws replaced (rejects) Another source took over first.
7703
+ * @throws ended (rejects) The media already played to the end.
7704
+ * @throws aborted (rejects) `signal` aborted (an `AbortError`); the media is closed.
7705
+ * @example Wait at most ten seconds for a stream to start
7706
+ * A network stream gets ten seconds to start playing; otherwise it is
7707
+ * closed and the user is told. Aborting the signal makes `ready` reject
7708
+ * with an `AbortError` after closing the media.
7709
+ * ```ts
7710
+ * const media = await plugin.player.open("https://example.com/live.m3u8");
7711
+ * // The timeout's abort closes the media and rejects with an AbortError.
7712
+ * try {
7713
+ * await media.ready({ signal: AbortSignal.timeout(10_000) });
7714
+ * } catch (error) {
7715
+ * await plugin.osd.show("Not playing: {}", [(error as Error).message]);
7716
+ * }
7717
+ * ```
7080
7718
  */
7081
7719
  ready(options?: {
7082
7720
  signal?: AbortSignal;
7083
7721
  }): Promise<void>;
7084
- /** Called once each time a playthrough of this media ends naturally. */
7722
+ /**
7723
+ * Called once each time a playthrough of this media ends naturally.
7724
+ * @capability That of Player.open; `events` to hear the end.
7725
+ * @example Play the next file when this one ends
7726
+ * When the plugin's file ends naturally, the next one opens. The listener
7727
+ * gets the state at the end; passing it as `ifCurrent` makes the next
7728
+ * `open` fail with `stale` (and do nothing) if the user opened something
7729
+ * else in the meantime.
7730
+ * ```ts
7731
+ * media.onEnd((ended) =>
7732
+ * plugin.player.open(next, { ifCurrent: ended }).catch((error: PluginError) => {
7733
+ * if (error.code !== "stale") throw error; // the user opened something else
7734
+ * }),
7735
+ * );
7736
+ * ```
7737
+ */
7085
7738
  onEnd(listener: (state: PlayerState) => void): Disposable;
7086
- /** Close it if it is still the player's source; otherwise do nothing. */
7739
+ /**
7740
+ * Close it if it is still the player's source; otherwise do nothing.
7741
+ * @capability That of Player.open (`playback.control`).
7742
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7743
+ * @example Close the preview the plugin opened
7744
+ * The plugin closes the preview it opened, but leaves alone anything the
7745
+ * user opened since. `close` acts only while this media is still the
7746
+ * player's source and does nothing otherwise.
7747
+ * ```ts
7748
+ * await preview.close(); // a no-op when something else is playing now
7749
+ * ```
7750
+ */
7087
7751
  close(): Promise<void>;
7088
7752
  }
7089
7753
  ```
7090
7754
 
7755
+ Example: Open a file and say when it ends
7756
+
7757
+ The file opens and plays, and the OSD says "Finished" when it reaches its
7758
+ end on its own. The handle belongs to this source: `onEnd` fires only
7759
+ for a natural end of this media, not when someone opens another file.
7760
+ ```ts
7761
+ const media = await plugin.player.open("/Movies/clip.mp4");
7762
+ media.onEnd(() => void plugin.osd.show("Finished")); // natural ends only
7763
+ ```
7764
+
7091
7765
  ### Scrub
7092
7766
 
7093
7767
  A drag gesture: each seek is conditional on the previous one, latest value wins.
7094
7768
  Any outside change (another plugin opens a file, the user pauses) ends
7095
7769
  the gesture.
7096
7770
 
7771
+ A drag gesture on a seek slider. Each `seek` is conditional on the
7772
+ previous one and only the newest target is sent while a seek is
7773
+ running, so a fast drag never queues seeks. Any outside change (another
7774
+ file, the user pausing) ends the gesture: `active` turns false and later
7775
+ seeks resolve `false`.
7776
+
7777
+ - @capability playback.control
7778
+
7097
7779
  ```ts
7098
7780
  interface Scrub {
7099
7781
  /**
@@ -7113,6 +7795,11 @@ interface Scrub {
7113
7795
  * the target of a keyframe seek is always sent. Omitted or `false`: exact,
7114
7796
  * as before. Older players ignore the option and seek exactly: set
7115
7797
  * `"minPlayerVersion": "0.5.3"` when you rely on it.
7798
+ * @capability playback.control
7799
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7800
+ * @throws stale (rejects) The source changed from outside while this seek was being sent (a superseded target or an ended gesture resolves `false` instead).
7801
+ * @throws unsupported (rejects) The source is a generated stream.
7802
+ * @throws backend (rejects) The player failed the seek.
7116
7803
  * @example Scrub on keyframes for a fast picture
7117
7804
  * While dragging, the picture jumps to the nearest earlier keyframe at
7118
7805
  * once instead of decoding up to the exact frame. With `keyframes: true`
@@ -7128,7 +7815,17 @@ interface Scrub {
7128
7815
  }): Promise<boolean>;
7129
7816
  /** False once anyone else changed the source; later seeks are ignored. */
7130
7817
  readonly active: boolean;
7131
- /** End the gesture; later seeks resolve `false`. */
7818
+ /**
7819
+ * End the gesture; later seeks resolve `false`.
7820
+ * @capability playback.control
7821
+ * @example End the gesture when the user lets go
7822
+ * Releasing the slider ends the gesture, so a late `dragMove` cannot move
7823
+ * playback any more. After `end`, `seek` resolves `false` without doing
7824
+ * anything.
7825
+ * ```ts
7826
+ * bar.on("dragEnd", () => scrub.end()); // later seeks resolve false
7827
+ * ```
7828
+ */
7132
7829
  end(): void;
7133
7830
  }
7134
7831
  ```
@@ -7206,6 +7903,15 @@ type StreamStartError = PluginError & {
7206
7903
 
7207
7904
  A generated source, from Player.openStream. Disposing it stops it.
7208
7905
 
7906
+ A source whose frames this plugin generates, from
7907
+ Player.openStream. The SDK pulls frames on the audio clock over
7908
+ a local connection and handles prefill, activation and pausing; the plugin
7909
+ only supplies `frame()`. A stream ends when the plugin stops it, when
7910
+ another source replaces it or the user closes it (`onStop` with `null`),
7911
+ or when it fails (`onStop` with the error).
7912
+
7913
+ - @capability producer.av and playback.control
7914
+
7209
7915
  ```ts
7210
7916
  interface Stream extends Disposable {
7211
7917
  /** The stream is producing frames. */
@@ -7222,15 +7928,51 @@ interface Stream extends Disposable {
7222
7928
  * (before 1.5.7 a replaced or closed stream could get `unavailable`
7223
7929
  * "producer absent"). Without `playback.read` a failure the SDK has not
7224
7930
  * seen yet is reported as null.
7931
+ * @capability producer.av
7932
+ * @example Tell the user why the stream stopped
7933
+ * The OSD explains a failure, and nothing is said when the user simply
7934
+ * opened another file. The listener runs only for stops from outside the
7935
+ * plugin: `null` for a replacement or the user's close, an error (such as
7936
+ * `stream` with the player's text) for a failure.
7937
+ * ```ts
7938
+ * stream.onStop((error) => {
7939
+ * if (error) void plugin.osd.show("Generator stopped: {}", [error.message]);
7940
+ * });
7941
+ * ```
7225
7942
  */
7226
7943
  onStop(listener: (error: PluginError | null) => void): Disposable;
7227
7944
  /**
7228
7945
  * Stop producing and close the stream if it is still the player's source. The source closes first, then the frame connection, then the producer, so stopping never shows as a playback failure. `dispose()` and deactivation do the same.
7946
+ * @capability producer.av and playback.control
7947
+ * @throws Whatever code closing the player's source failed with (the frame connection and the producer are released either way). A stream that is no longer the source just resolves.
7948
+ * @example Stop the stream when the plugin's window closes
7949
+ * Closing the generator window stops the stream it feeds. `stop` closes
7950
+ * the player's source if it is still this stream and then the frame
7951
+ * connection; it resolves once both are closed.
7952
+ * ```ts
7953
+ * window.onClose(() => stream.stop());
7954
+ * ```
7229
7955
  */
7230
7956
  stop(): Promise<void>;
7231
7957
  }
7232
7958
  ```
7233
7959
 
7960
+ Example: Stop the stream from a menu item
7961
+
7962
+ A menu item ends the plugin's generated stream and closes it as the
7963
+ player's source. `stop` closes the source first and then the frame
7964
+ connection, so the user never sees a playback error; `onStop` is not
7965
+ called for the plugin's own stop.
7966
+ ```ts
7967
+ // Enabled while the stream produces frames.
7968
+ plugin.commands.add({
7969
+ id: "stop-generator",
7970
+ path: ["@playback", "Stop generator"],
7971
+ enabled: () => stream.running,
7972
+ run: () => stream.stop(),
7973
+ });
7974
+ ```
7975
+
7234
7976
  ### SequenceProvider
7235
7977
 
7236
7978
  The plugin that owns "next / previous" (a playlist).
@@ -7337,15 +8079,67 @@ Capabilities: reading (`state` at start, `refresh`, `stats`, `chapters`,
7337
8079
  Controls are conditional (see Conditional): they fail with
7338
8080
  `stale` and do nothing when the source changed since the state you saw.
7339
8081
 
8082
+ `plugin.player` is the player's one source: what is open, its phase and
8083
+ position, and the controls. `state` is a snapshot the SDK keeps current
8084
+ from the player's events (with `events`) and refreshes after this
8085
+ plugin's own calls; `onChange` runs after it changed. Every control is
8086
+ conditional: it acts on the state the plugin last saw and fails with
8087
+ `stale` when the source changed meanwhile: another file, a close, or any
8088
+ pause, seek or speed change by the user or another plugin (see
8089
+ Conditional). The player keeps the source when a plugin stops;
8090
+ only the plugin's own handles (a stream, its sequence provider, its
8091
+ output device) are withdrawn.
8092
+
8093
+ - @capability `playback.read` to read; `playback.control` to control; `events` to follow changes made elsewhere; `producer.av` for Player.openStream.
8094
+
7340
8095
  ```ts
7341
8096
  interface Player {
7342
8097
  /** Latest state; always newer than anything delivered before. */
7343
8098
  readonly state: PlayerState;
7344
- /** The state changed (needs `events` for outside changes). */
8099
+ /**
8100
+ * The state changed (needs `events` for outside changes).
8101
+ * @capability events (for changes made elsewhere; without it the listener runs only after this plugin's own calls)
8102
+ * @example Show the position while it plays
8103
+ * The OSD block shows the position, updated as playback moves. The SDK
8104
+ * updates `player.state` from the player's events and then calls the
8105
+ * listener with the new snapshot; the handler only reads it.
8106
+ * ```ts
8107
+ * const info = plugin.osd.panel();
8108
+ * plugin.player.onChange((state) => {
8109
+ * // `position` is null while nothing is open.
8110
+ * info.set(state.position === null ? null : "{} s", [state.position?.toFixed(0) ?? ""]);
8111
+ * });
8112
+ * ```
8113
+ */
7345
8114
  onChange(listener: (state: PlayerState) => void): Disposable;
7346
- /** Read the state from the player now (`playback.read`). */
8115
+ /**
8116
+ * Read the state from the player now (`playback.read`).
8117
+ * @capability playback.read
8118
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8119
+ * @example Read the state before deciding
8120
+ * A command reads the newest state from the player before acting, even
8121
+ * without `events`. `refresh` asks the player and resolves with the new
8122
+ * snapshot; `player.state` holds it afterwards.
8123
+ * ```ts
8124
+ * const state = await plugin.player.refresh();
8125
+ * if (state.phase === "ready") await plugin.osd.show("{} s left", [((state.duration ?? 0) - (state.position ?? 0)).toFixed(0)]);
8126
+ * ```
8127
+ */
7347
8128
  refresh(): Promise<PlayerState>;
7348
- /** True if nothing changed the source since `state` was observed. */
8129
+ /**
8130
+ * True if nothing changed the source since `state` was observed.
8131
+ * @capability None.
8132
+ * @example Drop a result that arrives after the file changed
8133
+ * A slow lookup for the current file is shown only if that file is still
8134
+ * open. `isCurrent` compares the snapshot taken before the work with the
8135
+ * SDK's newest state; it is false once the source changed in any way:
8136
+ * another file, a close, or a pause, seek or speed change by anyone.
8137
+ * ```ts
8138
+ * const seen = plugin.player.state;
8139
+ * const title = await lookUpTitle(seen.source ?? "");
8140
+ * if (plugin.player.isCurrent(seen)) await plugin.osd.show("{}", [title]); // still the same file
8141
+ * ```
8142
+ */
7349
8143
  isCurrent(state: PlayerState): boolean;
7350
8144
  /**
7351
8145
  * Open a path or URL, replacing the current source (unless `ifCurrent` no
@@ -7356,6 +8150,25 @@ interface Player {
7356
8150
  * Resolver are resolved first, and so are local files of an
7357
8151
  * extension a file resolver claims (the source is then a stream that
7358
8152
  * plugin generates; `paused` reaches it).
8153
+ * @capability playback.control
8154
+ * @throws stale (rejects) `ifCurrent` no longer holds; nothing was opened.
8155
+ * @throws replaced (rejects) Accepted, then another source took over before it finished (for example while a URL was still being resolved).
8156
+ * @throws invalid (rejects) An empty `source`.
8157
+ * @throws aborted (rejects) `signal` aborted (an `AbortError`); the source is closed if it was opened.
8158
+ * @throws busy (rejects) The address needs a resolver plugin and the player is already resolving 32 addresses.
8159
+ * @throws backend (rejects) A resolver plugin handled the address or file and failed (its message), or the resolution was cancelled or expired.
8160
+ * @throws unsupported (rejects) The player refused what a resolver returned (for example split streams with different headers).
8161
+ * @throws unavailable (rejects) The stream a file resolver prepared was not ready to play.
8162
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8163
+ * @example Open a file paused
8164
+ * The file opens at its start without playing. `open` resolves once the
8165
+ * player accepted the load and returns a Media handle for that
8166
+ * source; `ready` then waits until it can play (here: until it is loaded
8167
+ * and paused).
8168
+ * ```ts
8169
+ * const media = await plugin.player.open("/Movies/clip.mp4", { paused: true });
8170
+ * await media.ready(); // rejects `failed` when the file cannot be loaded
8171
+ * ```
7359
8172
  */
7360
8173
  open(source: string, options?: {
7361
8174
  paused?: boolean;
@@ -7372,6 +8185,13 @@ interface Player {
7372
8185
  * TCP with a token on Windows. Rejects with a StreamStartError
7373
8186
  * (`timeout`), `stream`, `device`, or `replaced` when another source
7374
8187
  * replaced it (or it was closed) before it ran.
8188
+ * @capability producer.av and playback.control
8189
+ * @throws timeout (rejects) A StreamStartError: the stream did not start in time.
8190
+ * @throws stream (rejects) The player failed to start the stream.
8191
+ * @throws device (rejects) The audio device was not ready.
8192
+ * @throws replaced (rejects) Another source replaced it, or it was closed, before it ran.
8193
+ * @throws invalid (rejects) Bad options (a size or a frame function).
8194
+ * @throws aborted (rejects) `signal` aborted, or the scope was disposed, while the stream was starting (an `AbortError`).
7375
8195
  * @example Play pictures the plugin draws itself
7376
8196
  * The player shows a 320×240 picture the plugin renders, with silence as
7377
8197
  * its sound, as the current source. The SDK pulls frames on the audio
@@ -7392,25 +8212,143 @@ interface Player {
7392
8212
  * ```
7393
8213
  */
7394
8214
  openStream(options: StreamOptions): Promise<Stream>;
7395
- /** Pause or resume. */
8215
+ /**
8216
+ * Pause or resume.
8217
+ * @capability playback.control
8218
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin); nothing was done.
8219
+ * @throws invalid (rejects) Nothing is open.
8220
+ * @throws busy (rejects) A generated stream is still handling a previous control.
8221
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8222
+ * @example Pause, but only the file the user was watching
8223
+ * A "pause on lock" helper pauses the current file and does nothing when
8224
+ * another file was opened in between. The call is conditional on
8225
+ * `player.state` by default; `stale` means the source changed and the
8226
+ * pause was not applied, which is the intended outcome here.
8227
+ * ```ts
8228
+ * try {
8229
+ * await plugin.player.setPaused(true);
8230
+ * } catch (error) {
8231
+ * if ((error as PluginError).code !== "stale") throw error; // another file now
8232
+ * }
8233
+ * ```
8234
+ */
7396
8235
  setPaused(paused: boolean, options?: Conditional): Promise<void>;
7397
- /** Jump to `seconds`. */
8236
+ /**
8237
+ * Jump to `seconds`.
8238
+ * @capability playback.control
8239
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin); nothing was done.
8240
+ * @throws invalid (rejects) Nothing is open, or `seconds` is negative or not finite.
8241
+ * @throws unsupported (rejects) The source is a generated stream, which cannot seek.
8242
+ * @throws busy (rejects) A generated stream is still handling a previous control.
8243
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8244
+ * @example Jump back ten seconds
8245
+ * Playback jumps ten seconds back, never before the start. The target is
8246
+ * computed from the snapshot the call is conditional on, so if the user
8247
+ * opened another file meanwhile the seek fails with `stale` instead of
8248
+ * landing in the wrong file.
8249
+ * ```ts
8250
+ * const { position } = plugin.player.state;
8251
+ * if (position !== null && plugin.player.state.can.seek)
8252
+ * await plugin.player.seek(Math.max(0, position - 10)); // seconds
8253
+ * ```
8254
+ */
7398
8255
  seek(seconds: number, options?: Conditional): Promise<void>;
7399
- /** Set the playback speed. */
8256
+ /**
8257
+ * Set the playback speed.
8258
+ * @capability playback.control
8259
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin); nothing was done.
8260
+ * @throws invalid (rejects) Nothing is open, or `rate` is outside 0.05–10.
8261
+ * @throws unsupported (rejects) The source is a generated stream, which cannot change speed.
8262
+ * @throws busy (rejects) A generated stream is still handling a previous control.
8263
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8264
+ * @example Play at one and a half times the speed
8265
+ * Playback speeds up to 1.5×, with the speed on the OSD. `setRate` sets
8266
+ * the speed directly (the player's own keys step it by 0.1 within
8267
+ * 0.25–4); `state.can.rate` is false for sources that cannot change speed.
8268
+ * ```ts
8269
+ * if (plugin.player.state.can.rate) {
8270
+ * await plugin.player.setRate(1.5);
8271
+ * await plugin.osd.show("Speed {}×", [1.5]);
8272
+ * }
8273
+ * ```
8274
+ */
7400
8275
  setRate(rate: number, options?: Conditional): Promise<void>;
7401
- /** Play from the start (the "replay" after the end). */
8276
+ /**
8277
+ * Play from the start (the "replay" after the end).
8278
+ * @capability playback.control
8279
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin); nothing was done.
8280
+ * @throws invalid (rejects) Nothing is open.
8281
+ * @throws unsupported (rejects) The source is a generated stream, which cannot restart.
8282
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8283
+ * @example Replay a file after it ended
8284
+ * A Replay button starts the file again from its start once it has ended.
8285
+ * `restart` plays the current source from the beginning and keeps it the
8286
+ * same source, so a Media handle stays current.
8287
+ * ```ts
8288
+ * if (plugin.player.state.phase === "ended") await plugin.player.restart();
8289
+ * ```
8290
+ */
7402
8291
  restart(options?: Conditional): Promise<void>;
7403
- /** Close the current source. */
8292
+ /**
8293
+ * Close the current source.
8294
+ * @capability playback.control
8295
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin); nothing was done.
8296
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8297
+ * @example Close the file when the sleep timer runs out
8298
+ * After 30 minutes the open file is closed, unless the user opened another
8299
+ * one meanwhile. The snapshot taken when the timer started is passed as
8300
+ * `ifCurrent`, so the close applies only to that source; `stale` means it
8301
+ * was replaced.
8302
+ * ```ts
8303
+ * const seen = plugin.player.state;
8304
+ * setTimeout(() => {
8305
+ * // Only the source that was open when the timer started.
8306
+ * plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
8307
+ * if (error.code !== "stale") plugin.osd.show("Could not close: {}", [error.code]);
8308
+ * });
8309
+ * }, 30 * 60 * 1000);
8310
+ * ```
8311
+ */
7404
8312
  close(options?: Conditional): Promise<void>;
7405
- /** Start a drag gesture from the current state. */
8313
+ /**
8314
+ * Start a drag gesture from the current state.
8315
+ * @capability playback.control
8316
+ * @example Start a gesture when the user grabs the slider
8317
+ * A drag on the plugin's seek slider becomes one gesture whose seeks never
8318
+ * pile up. `scrub()` captures the current state; the gesture's seeks are
8319
+ * conditional on it and on each other, and it ends on its own when
8320
+ * someone else changes the source.
8321
+ * ```ts
8322
+ * let scrub: Scrub | null = null;
8323
+ * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
8324
+ * bar.on("dragEnd", () => scrub?.end());
8325
+ * ```
8326
+ */
7406
8327
  scrub(): Scrub;
7407
- /** Device output; last write wins and volume is applied before mute. Resolves `true` when this request was applied, `false` if superseded. `volume` is 0–200. */
8328
+ /**
8329
+ * Device output; last write wins and volume is applied before mute. Resolves `true` when this request was applied, `false` if superseded. `volume` is 0–200.
8330
+ * @capability playback.control
8331
+ * @throws invalid (rejects) `volume` outside 0–200, or `muted` not a boolean.
8332
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8333
+ * @example Duck the volume during an announcement
8334
+ * The volume drops to 30 while the plugin's announcement plays, then
8335
+ * returns. Writes are "latest wins": the promise resolves `true` when this
8336
+ * write was applied and `false` when a newer one replaced it first, which
8337
+ * is not an error.
8338
+ * ```ts
8339
+ * const before = plugin.player.state.volume; // 0–200
8340
+ * await plugin.player.setOutput({ volume: 30 });
8341
+ * // … the announcement …
8342
+ * await plugin.player.setOutput({ volume: before });
8343
+ * ```
8344
+ */
7408
8345
  setOutput(output: {
7409
8346
  volume?: number;
7410
8347
  muted?: boolean;
7411
8348
  }): Promise<boolean>;
7412
8349
  /**
7413
- * Become the sequence provider (one at a time; another provider rejects with `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
8350
+ * Become the sequence provider, first come first served: while another plugin holds it (the built-in playlist registers at activation), this one is refused with `conflict` (`sequence-taken`). That does not fail activation: the player shows a warning (PluginInfo.warnings); a refusal after activation goes to the scope's `onError`. Dispose to stop.
8351
+ * @capability playback.control
7414
8352
  * @example Drive the player's Next and Previous from a playlist
7415
8353
  * The player's Next and Previous items and keys play the plugin's list.
7416
8354
  * The player calls `next` and `previous` when the user asks; `canNext`
@@ -7432,19 +8370,99 @@ interface Player {
7432
8370
  * ```
7433
8371
  */
7434
8372
  provideSequence(provider: SequenceProvider): Disposable;
7435
- /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
8373
+ /**
8374
+ * Ask the provider to go forward / back. Rejects `unavailable` when it cannot.
8375
+ * @capability playback.control
8376
+ * @throws unavailable (rejects) There is no sequence provider, or it has no next item.
8377
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8378
+ * @example Skip to the next item from a plugin button
8379
+ * The plugin's own Next button plays the next item of whichever plugin
8380
+ * provides the sequence (the playlist, usually). `next` asks the
8381
+ * provider, the same as the player's Next key; `unavailable` means there
8382
+ * is no next item, which the button treats as the end of the list.
8383
+ * ```ts
8384
+ * try {
8385
+ * await plugin.player.next(); // as the player's Next key does
8386
+ * } catch (error) {
8387
+ * if ((error as PluginError).code !== "unavailable") throw error;
8388
+ * await plugin.osd.show("End of the list");
8389
+ * }
8390
+ * ```
8391
+ */
7436
8392
  next(): Promise<void>;
7437
- /** Ask the provider to go back. Rejects `unavailable` when it cannot. */
8393
+ /**
8394
+ * Ask the provider to go back. Rejects `unavailable` when it cannot.
8395
+ * @capability playback.control
8396
+ * @throws unavailable (rejects) There is no sequence provider, or it has no previous item.
8397
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8398
+ * @example Go back to the previous item
8399
+ * The plugin's Previous button asks the sequence provider for the item
8400
+ * before the current one, like the player's PageUp. It is disabled while
8401
+ * `state.can.previous` is false, which the provider's `canPrevious`
8402
+ * decides.
8403
+ * ```ts
8404
+ * plugin.commands.add({
8405
+ * id: "previous",
8406
+ * title: "Previous item",
8407
+ * // The provider's `canPrevious`, as the player reports it.
8408
+ * enabled: () => plugin.player.state.can.previous,
8409
+ * run: () => plugin.player.previous(),
8410
+ * });
8411
+ * ```
8412
+ */
7438
8413
  previous(): Promise<void>;
7439
- /** Codec, size, frame counts and backend of the current playback. `playback.read`; `video` and `audio` are `null` without a source. */
8414
+ /**
8415
+ * Codec, size, frame counts and backend of the current playback. `playback.read`; `video` and `audio` are `null` without a source.
8416
+ * @capability playback.read
8417
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8418
+ * @example Show the video codec and size
8419
+ * The OSD shows something like "h264 1920×1080". `stats` asks the player
8420
+ * for the backend's own values at that moment; `video` is `null` without
8421
+ * a video stream (nothing open, an audio file).
8422
+ * ```ts
8423
+ * const { video } = await plugin.player.stats();
8424
+ * if (video) await plugin.osd.show("{} {}×{}", [video.codec ?? "?", video.width ?? 0, video.height ?? 0]);
8425
+ * ```
8426
+ */
7440
8427
  stats(): Promise<PlaybackStats>;
7441
8428
  /**
7442
8429
  * Add an external audio file (absolute local path) to the current media and, unless `select: false`, select it. Resolves with the new track (`external`, not exportable). It stays until the source changes. Rejects `invalid` for a bad path, `unavailable` without ready media, `backend` when it cannot load.
8430
+ * @capability playback.control
8431
+ * @throws invalid (rejects) Not an absolute path to a file.
8432
+ * @throws unavailable (rejects) No ready media.
8433
+ * @throws backend (rejects) The player could not load the file.
8434
+ * @throws stale (rejects) `ifCurrent` no longer holds.
8435
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8436
+ * @example Add a dubbed audio file and switch to it
8437
+ * A separate audio file plays with the film instead of its own sound.
8438
+ * `addAudioTrack` attaches the file to the current media and selects it;
8439
+ * the track stays until another source opens.
8440
+ * ```ts
8441
+ * const track = await plugin.player.addAudioTrack(dubPath); // selected too
8442
+ * await plugin.osd.show("Audio: {}", [track.title ?? dubPath]);
8443
+ * ```
7443
8444
  */
7444
8445
  addAudioTrack(path: string, options?: {
7445
8446
  select?: boolean;
7446
8447
  } & Conditional): Promise<AudioTrack>;
7447
- /** Select one of `state.audioTracks` by id; `invalid` for an unknown id. */
8448
+ /**
8449
+ * Select one of `state.audioTracks` by id; `invalid` for an unknown id.
8450
+ * @capability playback.control
8451
+ * @throws invalid (rejects) No track with this id.
8452
+ * @throws unavailable (rejects) No ready media.
8453
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin).
8454
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8455
+ * @example Switch to the next audio track
8456
+ * Each press of the plugin's key moves to the next audio track of the
8457
+ * file. The tracks and the selected one are in `state.audioTracks`;
8458
+ * `selectAudioTrack` resolves with the new state.
8459
+ * ```ts
8460
+ * const audioTracks = plugin.player.state.audioTracks ?? []; // null: not known yet
8461
+ * const at = audioTracks.findIndex((track) => track.selected);
8462
+ * const next = audioTracks[(at + 1) % audioTracks.length];
8463
+ * if (next) await plugin.player.selectAudioTrack(next.id);
8464
+ * ```
8465
+ */
7448
8466
  selectAudioTrack(id: number, options?: Conditional): Promise<PlayerState>;
7449
8467
  /**
7450
8468
  * One frame or to the next / previous keyframe (pauses for frames). Rejects
@@ -7454,6 +8472,19 @@ interface Player {
7454
8472
  * Resolves once the new position is in effect, so `player.state.position`
7455
8473
  * is current afterwards. One step at a time: a second call while one is
7456
8474
  * in flight rejects `busy`.
8475
+ * @capability playback.control
8476
+ * @throws unsupported (rejects) Nothing playable to step through (no moving video: audio only, cover art, a generated stream), a backward frame step on a source that cannot seek, or a keyframe step in either direction on a source that cannot seek.
8477
+ * @throws busy (rejects) A step is still in flight.
8478
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin).
8479
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8480
+ * @example Step one frame forward for a close look
8481
+ * The picture advances by one frame and playback pauses. `step` resolves
8482
+ * once the new position is in effect, so `state.position` is current
8483
+ * afterwards; a second step while one is running rejects with `busy`.
8484
+ * ```ts
8485
+ * await plugin.player.step("frame", "forward");
8486
+ * await plugin.osd.show("{} s", [plugin.player.state.position?.toFixed(3) ?? "?"]);
8487
+ * ```
7457
8488
  */
7458
8489
  step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
7459
8490
  /**
@@ -7476,12 +8507,37 @@ interface Player {
7476
8507
  *
7477
8508
  * Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
7478
8509
  * the manifest when you rely on it.
8510
+ * @capability playback.control
8511
+ * @throws invalid (rejects) `seconds` outside 0.1–3600 or not in tenths, or `keyframes` not a boolean.
8512
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8513
+ * @example Make ← and → jump 10 seconds to keyframes
8514
+ * The arrow keys now jump 10 seconds and land on keyframes, which is fast
8515
+ * on large files. The newest call of any plugin wins and is withdrawn
8516
+ * when that plugin stops, so a plugin offering this as a setting applies
8517
+ * it on activation.
8518
+ * ```ts
8519
+ * await plugin.player.setStepSeek({ seconds: 10, keyframes: true });
8520
+ * ```
7479
8521
  */
7480
8522
  setStepSeek(options: {
7481
8523
  seconds: number;
7482
8524
  keyframes?: boolean;
7483
8525
  }): Promise<void>;
7484
- /** The chapters of the current media (empty without media or chapters), read on demand; `sourceId` tells which source they belong to. `playback.read`. */
8526
+ /**
8527
+ * The chapters of the current media (empty without media or chapters), read on demand; `sourceId` tells which source they belong to. `playback.read`.
8528
+ * @capability playback.read
8529
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8530
+ * @example List the chapters of the open file
8531
+ * The plugin reads the chapter titles of the current file to show them in
8532
+ * its panel. `chapters` asks the player when called; `sourceId` says which
8533
+ * source they belong to, so a reply for a file that was replaced meanwhile
8534
+ * can be dropped.
8535
+ * ```ts
8536
+ * const { sourceId, chapters } = await plugin.player.chapters();
8537
+ * if (sourceId === plugin.player.state.sourceId) // still the same file
8538
+ * console.log(chapters.map((chapter) => chapter.title));
8539
+ * ```
8540
+ */
7485
8541
  chapters(): Promise<{
7486
8542
  /** The source the chapters belong to (compare with `state.sourceId`). */
7487
8543
  sourceId: number;
@@ -7490,13 +8546,45 @@ interface Player {
7490
8546
  }>;
7491
8547
  /**
7492
8548
  * A-B loop: on reaching `b`, jump back to `a` (seconds, `0 ≤ a < b ≤ duration`), `count` times (positive integer; omitted = endless); `null` stops it. After the last jump back `state.loop` becomes null. `invalid` out of range, `unsupported` for a source that cannot seek, `unavailable` without ready media. A new source clears it.
8549
+ * @capability playback.control
8550
+ * @throws invalid (rejects) `a`/`b` out of range or `count` not a positive integer.
8551
+ * @throws unsupported (rejects) The source cannot seek, or is a generated stream.
8552
+ * @throws unavailable (rejects) No ready media.
8553
+ * @throws stale (rejects) The source changed since the state the call is based on (another file, a close, or any pause, seek or speed change by the user or another plugin).
8554
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8555
+ * @example Loop a passage three times
8556
+ * Playback repeats 1:00–1:30 three times, then goes on. The player jumps
8557
+ * back to `a` each time it reaches `b`; after the last repeat
8558
+ * `state.loop` becomes `null`, and a new source clears the loop.
8559
+ * ```ts
8560
+ * await plugin.player.setLoop({ a: 60, b: 90, count: 3 }); // seconds
8561
+ * ```
7493
8562
  */
7494
8563
  setLoop(loop: {
7495
8564
  a: number;
7496
8565
  b: number;
7497
8566
  count?: number;
7498
8567
  } | null, options?: Conditional): Promise<void>;
7499
- /** The output devices present now (not the system default entry). `playback.read`. */
8568
+ /**
8569
+ * The output devices present now (not the system default entry). `playback.read`.
8570
+ * @capability playback.read
8571
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8572
+ * @example Offer the audio output devices in a menu
8573
+ * A submenu lists the output devices present now, with a radio mark on the
8574
+ * one in use. `audioDevices` asks the player when called (devices come and
8575
+ * go), and `state.audioOutput` tells which one is active.
8576
+ * ```ts
8577
+ * // The devices present now; build the menu again when they change.
8578
+ * for (const device of await plugin.player.audioDevices())
8579
+ * plugin.commands.add({
8580
+ * id: `device.${device.id}`,
8581
+ * path: ["@audio", "Output", device.name],
8582
+ * radio: true,
8583
+ * checked: () => plugin.player.state.audioOutput?.active === device.id,
8584
+ * run: () => plugin.player.setAudioDevice(device.id),
8585
+ * });
8586
+ * ```
8587
+ */
7500
8588
  audioDevices(): Promise<AudioDevice[]>;
7501
8589
  /**
7502
8590
  * Play on this device while it is present, else on the system default
@@ -7506,6 +8594,18 @@ interface Player {
7506
8594
  * Unknown ids are accepted (`state.audioOutput.active` stays `null`); a
7507
8595
  * malformed id is `invalid`. Reverts to the default when this plugin
7508
8596
  * stops; save the choice yourself to restore it on activation.
8597
+ * @capability playback.control
8598
+ * @throws invalid (rejects) A malformed id.
8599
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8600
+ * @example Restore the user's output device on start
8601
+ * The plugin plays on the device the user picked last time, or on the
8602
+ * system default when it is not connected. The choice lasts only for
8603
+ * this run and is withdrawn when the plugin stops, so the plugin saves
8604
+ * it itself and sets it again on activation.
8605
+ * ```ts
8606
+ * const store = plugin.storage.json("output", { defaults: { device: null as string | null } });
8607
+ * await plugin.player.setAudioDevice(store.value.device); // null: the system default
8608
+ * ```
7509
8609
  */
7510
8610
  setAudioDevice(id: string | null): Promise<void>;
7511
8611
  }
@@ -7558,6 +8658,15 @@ type HostAction = "play-pause" | "stop" | "previous" | "next" | "frame-back" | "
7558
8658
 
7559
8659
  `plugin.host`: the player's own core actions.
7560
8660
 
8661
+ The player's core actions (play/pause, stop, step seeks, speed, volume,
8662
+ fullscreen…) are commands of the player itself, `host/<action>` in the
8663
+ binding table. Running them through `host.run` gives a plugin's own
8664
+ buttons exactly the player's behaviour: the same checks, the same
8665
+ accumulation of repeated presses and the same on-screen text, instead of
8666
+ reimplementing them with Player calls.
8667
+
8668
+ - @capability commands
8669
+
7561
8670
  ```ts
7562
8671
  interface Host {
7563
8672
  /**
@@ -7567,6 +8676,10 @@ interface Host {
7567
8676
  * `invalid` for an unknown id. Needs `commands`.
7568
8677
  *
7569
8678
  * Prefer this over reimplementing playback buttons.
8679
+ * @capability commands
8680
+ * @throws unavailable (rejects) The action is disabled right now (nothing open, no window for `fullscreen`).
8681
+ * @throws invalid (rejects) An unknown action, or `seconds` out of range.
8682
+ * @throws capability (rejects) The manifest does not declare `commands`.
7570
8683
  * @example Make a bar button play and pause like Space
7571
8684
  * The plugin's own Play button behaves exactly like the player's Space
7572
8685
  * key, including its on-screen text. `host.run` runs the core action
@@ -7597,6 +8710,10 @@ interface Host {
7597
8710
  * once the seek is submitted, as before. `invalid` for `seconds` out of
7598
8711
  * range.
7599
8712
  * @player 0.5.1 (set `"minPlayerVersion": "0.5.1"`: older players ignore `seconds` and seek by the `setStepSeek` step)
8713
+ * @capability commands
8714
+ * @throws unavailable (rejects) The action is disabled right now (nothing open, no window for `fullscreen`).
8715
+ * @throws invalid (rejects) An unknown action, or `seconds` out of range.
8716
+ * @throws capability (rejects) The manifest does not declare `commands`.
7600
8717
  * @example Add a 30-second skip that adds up with the arrow keys
7601
8718
  * The plugin's "+30 s" button seeks forward 30 seconds and shows the same
7602
8719
  * text as →. Passing `seconds` makes the core step seek use that step
@@ -7612,6 +8729,21 @@ interface Host {
7612
8729
  }
7613
8730
  ```
7614
8731
 
8732
+ Example: Give a custom bar the player's own Stop and Mute
8733
+
8734
+ The plugin's bar buttons stop playback and mute exactly as the player's
8735
+ own keys do, with the same on-screen text. Each callback runs a core
8736
+ action; the player applies its checks and rejects with `unavailable`
8737
+ while the action is disabled, which the bar ignores.
8738
+ ```ts
8739
+ const run = (action: HostAction) =>
8740
+ plugin.host.run(action).catch((error: PluginError) => {
8741
+ if (error.code !== "unavailable") throw error; // disabled right now
8742
+ });
8743
+ bar.on("stop", () => run("stop"));
8744
+ bar.on("mute", () => run("mute"));
8745
+ ```
8746
+
7615
8747
  ### OsdPanel
7616
8748
 
7617
8749
  This plugin's information block on the OSD: multi-line text over the
@@ -7835,6 +8967,14 @@ one request is in flight at a time and only the newest value is sent.
7835
8967
  Values out of range fail with `invalid` (see VideoNode). The
7836
8968
  same chain applies to processed snapshots and exports.
7837
8969
 
8970
+ Members: `set(nodes)` records the desired chain (last write wins) and
8971
+ sends it, one request at a time and only the newest; `status` and
8972
+ `onStatus` report `waiting`, `applying`, `applied` or `failed`; `retry`
8973
+ applies the current value again; `dispose` withdraws the chain. `set`
8974
+ never throws: a refused chain shows as `failed` in `status`.
8975
+
8976
+ - @capability processing.video, playback.read and events
8977
+
7838
8978
  ```ts
7839
8979
  type VideoFilter = Filter<VideoNode[]>;
7840
8980
  ```
@@ -7862,6 +9002,12 @@ VideoFilter.
7862
9002
  `delayMs` is −10000–10000 in multiples of 10; the positive and the
7863
9003
  negative delays of all plugins each add up to at most 10000.
7864
9004
 
9005
+ The same members and rules as VideoFilter: `set` records the
9006
+ desired value and never throws, `status` and `onStatus` report the
9007
+ result, `retry` applies it again, `dispose` withdraws it.
9008
+
9009
+ - @capability processing.audio, playback.read and events
9010
+
7865
9011
  ```ts
7866
9012
  type AudioFilter = Filter<{
7867
9013
  /** The audio chain; `[]` for none. */
@@ -7912,13 +9058,46 @@ needed); disposing or stopping the owner restores the defaults.
7912
9058
  Out-of-range values or unknown fields fail with `invalid` without
7913
9059
  sending anything.
7914
9060
 
9061
+ Not part of snapshots or exports: it changes how the picture sits in the
9062
+ video area, not the picture. `set` merges into the current settings and
9063
+ never throws: an out-of-range value or unknown field shows as `failed`
9064
+ (`invalid`) in `status` and is reported to `onError`, without sending
9065
+ anything.
9066
+
9067
+ - @capability processing.video, playback.read and events (like VideoFilter)
9068
+
7915
9069
  ```ts
7916
9070
  interface VideoView extends Disposable {
7917
- /** Merged into the current settings; last write wins. */
9071
+ /**
9072
+ * Merged into the current settings; last write wins.
9073
+ * @capability processing.video
9074
+ * @example Stretch a 4:3 video to fill the window
9075
+ * A 4:3 video fills a 16:9 window, distorted, as the user asked. Each
9076
+ * `set` merges the given fields into this plugin's view settings; the
9077
+ * newest write wins, and the settings stay for later files until the
9078
+ * plugin disposes the view or stops.
9079
+ * ```ts
9080
+ * plugin.video.view().set({ fit: "stretch" });
9081
+ * ```
9082
+ */
7918
9083
  set(value: Partial<ViewSettings>): void;
7919
9084
  /** Current status (the same four states as filters). */
7920
9085
  readonly status: FilterStatus;
7921
- /** The status changed. */
9086
+ /**
9087
+ * The status changed.
9088
+ * @capability processing.video
9089
+ * @example Tell the user another plugin owns the view
9090
+ * When another plugin already controls the display layer, the OSD says
9091
+ * so. The first plugin to call `view()` owns it; this plugin's handle
9092
+ * then reports `failed` with `conflict`, and tries again on its next `set`.
9093
+ * ```ts
9094
+ * const view = plugin.video.view();
9095
+ * view.onStatus((status) => {
9096
+ * if (status.state === "failed" && status.error.code === "conflict")
9097
+ * void plugin.osd.show("Another plugin controls the picture size");
9098
+ * });
9099
+ * ```
9100
+ */
7922
9101
  onStatus(listener: (status: FilterStatus) => void): Disposable;
7923
9102
  }
7924
9103
  ```
@@ -7928,7 +9107,7 @@ Example: Fill the window, then zoom in
7928
9107
  The video fills the video area (cropped instead of letterboxed), then
7929
9108
  zooms to 150 % shifted a little to the right. The first plugin to call
7930
9109
  `view()` owns the display layer; each `set` merges into its settings,
7931
- which stay across sources until the plugin resets them or stops.
9110
+ which stay across sources until the plugin disposes the view or stops.
7932
9111
  ```ts
7933
9112
  const view = plugin.video.view();
7934
9113
  view.set({ fit: "fill" });
@@ -7966,13 +9145,45 @@ Platforms: only macOS renders on the GPU. Under software rendering
7966
9145
  `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
7967
9146
  is accepted but has no effect; output is always SDR.
7968
9147
 
9148
+ GPU renderer options (tone mapping, peak detection, shaders,
9149
+ interpolation). Only the GPU renderer has them, which today means macOS;
9150
+ under software rendering the handle is `failed` with `unsupported` after
9151
+ the player's first answer to `render()` (reported to `onError` too), and so is
9152
+ every `set`. Single owner, like VideoView. `set` never throws; the
9153
+ result shows in `status`.
9154
+
9155
+ - @capability processing.video, playback.read and events (like VideoFilter)
9156
+
7969
9157
  ```ts
7970
9158
  interface RenderOptions extends Disposable {
7971
- /** Merged into the current settings; last write wins. */
9159
+ /**
9160
+ * Merged into the current settings; last write wins.
9161
+ * @capability processing.video
9162
+ * @example Turn on motion interpolation
9163
+ * Motion becomes smoother on the GPU renderer. `set` merges the field into
9164
+ * the plugin's render options; under software rendering the status turns
9165
+ * `failed` with `unsupported` instead.
9166
+ * ```ts
9167
+ * plugin.video.render().set({ interpolation: true });
9168
+ * ```
9169
+ */
7972
9170
  set(value: Partial<RenderSettings>): void;
7973
9171
  /** `failed` with `unsupported` while the player renders in software; `failed` with `failed` when mpv cannot compile a shader (pushed later). */
7974
9172
  readonly status: FilterStatus;
7975
- /** The status changed (including later pushes such as shader compile errors). */
9173
+ /**
9174
+ * The status changed (including later pushes such as shader compile errors).
9175
+ * @capability processing.video
9176
+ * @example Report a shader that does not compile
9177
+ * The OSD shows mpv's message when a shader the plugin set fails to
9178
+ * compile. The player pushes that failure after the `set` was accepted, so
9179
+ * it arrives through `onStatus`, not as a rejected call.
9180
+ * ```ts
9181
+ * const render = plugin.video.render();
9182
+ * render.onStatus((status) => {
9183
+ * if (status.state === "failed") void plugin.osd.show("Render: {}", [status.error.message]);
9184
+ * });
9185
+ * ```
9186
+ */
7976
9187
  onStatus(listener: (status: FilterStatus) => void): Disposable;
7977
9188
  }
7978
9189
  ```
@@ -8074,16 +9285,79 @@ interface SubtitleConditional {
8074
9285
  `subtitles.read`; changes need `subtitles.control` and also
8075
9286
  `subtitles.read`, since they read the current selection first.
8076
9287
 
9288
+ The subtitle tracks of the current source, the primary and secondary
9289
+ selection, uploaded files and the style. `state` is the SDK's snapshot of
9290
+ the player's subtitle state (`null` until first read). The player has no
9291
+ subtitle event: the SDK reads the state again when the source, its phase
9292
+ or the backend changes (with `events`) and after this plugin's own
9293
+ changes, so a selection the user or another plugin changed shows only
9294
+ after `refresh()`. A change without `ifCurrent` reads the state first
9295
+ and applies to it; pass `ifCurrent` to make it fail with `stale` when the
9296
+ selection changed since that state. Each change is one operation the
9297
+ player confirms; `select`, `remove` and `setStyle` resolve with the new
9298
+ state, `load` with the new track.
9299
+
9300
+ - @capability `subtitles.read` to read; `subtitles.control` and `subtitles.read` to change; `events` to re-read on source changes.
9301
+
8077
9302
  ```ts
8078
9303
  interface Subtitles {
8079
9304
  /** `null` until first read. */
8080
9305
  readonly state: SubtitlesState | null;
8081
- /** The subtitle state changed. */
9306
+ /**
9307
+ * The subtitle state changed.
9308
+ * @capability events and subtitles.read
9309
+ * @example Show the subtitle language after a new file opens
9310
+ * When a new file starts, the OSD names the language of its primary
9311
+ * subtitle. The SDK reads the subtitle state again when the source or its
9312
+ * phase changes (there is no subtitle event, so a selection changed in the
9313
+ * player shows only after `refresh()`) and then calls the listener;
9314
+ * `selected` is `null` when subtitles are off.
9315
+ * ```ts
9316
+ * plugin.subtitles.onChange((state) => {
9317
+ * // A track's language is null when the file does not say.
9318
+ * void plugin.osd.show("Subtitles: {}", [state.selected?.language ?? "off"]);
9319
+ * });
9320
+ * ```
9321
+ */
8082
9322
  onChange(listener: (state: SubtitlesState) => void): Disposable;
8083
- /** Read the state from the player now. */
9323
+ /**
9324
+ * Read the state from the player now.
9325
+ * @capability subtitles.read
9326
+ * @throws capability (rejects) The manifest does not declare `subtitles.read`.
9327
+ * @example Count the subtitle tracks of the file
9328
+ * The plugin reads how many subtitle tracks the file has before building a
9329
+ * menu. `refresh` asks the player and resolves with the current state, also
9330
+ * without `events`; `state` holds it afterwards.
9331
+ * ```ts
9332
+ * const { tracks } = await plugin.subtitles.refresh();
9333
+ * await plugin.osd.show("{} subtitle tracks", [tracks.length]);
9334
+ * ```
9335
+ */
8084
9336
  refresh(): Promise<SubtitlesState>;
8085
9337
  /**
8086
9338
  * `role: "secondary"` sets the secondary subtitle: a text track that is not the primary one (`invalid` otherwise). A new source clears it. `track: null` turns the role off.
9339
+ * @capability subtitles.control and subtitles.read
9340
+ * @throws stale (rejects) With `ifCurrent`: the source or the subtitle selection changed since that state.
9341
+ * @throws invalid (rejects) An unknown track; as secondary, a bitmap track or the primary one; as primary, the current secondary.
9342
+ * @throws missing (rejects) The track is not in the current source.
9343
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading or not yet confirmed.
9344
+ * @throws busy (rejects) Too many subtitle operations at once.
9345
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9346
+ * @example Turn on the first English subtitle
9347
+ * The first English track becomes the primary subtitle, but only in the
9348
+ * file the plugin looked at. Embedded tracks are chosen by number, so
9349
+ * without `ifCurrent` a select after another file opened would pick that
9350
+ * file's track with the same number; with `ifCurrent: state` it fails with
9351
+ * `stale` instead. Without `ifCurrent`, `select` reads the newest state
9352
+ * itself and almost never fails with `stale`.
9353
+ * ```ts
9354
+ * const state = await plugin.subtitles.refresh();
9355
+ * const english = state.tracks.find((track) => track.language === "en");
9356
+ * if (english)
9357
+ * await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
9358
+ * if (error.code !== "stale") throw error; // another file or selection now
9359
+ * });
9360
+ * ```
8087
9361
  */
8088
9362
  select(track: SubtitleTrack | null, options?: SubtitleConditional & {
8089
9363
  role?: "primary" | "secondary";
@@ -8100,6 +9374,25 @@ interface Subtitles {
8100
9374
  * Limits: SRT ≤ 2 MiB and ≤ 10,000 cues; ASS ≤ 2 MiB with `[Script Info]`
8101
9375
  * and `[Events]` and 1–20,000 `Dialogue:` lines. Convert other formats
8102
9376
  * (VTT, SAMI) to SRT first.
9377
+ * @capability subtitles.control and subtitles.read
9378
+ * @throws invalid (rejects) A bad `format` or `title`, or a file over the limits.
9379
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9380
+ * @throws unavailable (rejects) Nothing loaded, or the source is still loading.
9381
+ * @throws aborted (rejects) `signal` aborted before the commit (an `AbortError`); the upload is discarded.
9382
+ * @throws busy (rejects) Too many subtitle operations at once.
9383
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9384
+ * @example Load a downloaded SRT and select it
9385
+ * A subtitle file the plugin downloaded becomes a track of the current file
9386
+ * and is selected. The plugin passes the file's bytes; the player parses
9387
+ * them, adds an `external` track for this source only and resolves with
9388
+ * it once the upload is committed.
9389
+ * ```ts
9390
+ * const response = await fetch(url);
9391
+ * if (!response.ok) throw new Error(`subtitles: HTTP ${response.status}`);
9392
+ * const bytes = new Uint8Array(await response.arrayBuffer());
9393
+ * const track = await plugin.subtitles.load(bytes, { title: "Downloaded" }); // selected
9394
+ * await plugin.osd.show("Loaded {}", [track.title]);
9395
+ * ```
8103
9396
  */
8104
9397
  load(bytes: Uint8Array, options?: SubtitleConditional & {
8105
9398
  format?: "srt" | "ass";
@@ -8107,10 +9400,40 @@ interface Subtitles {
8107
9400
  select?: "primary" | "secondary" | "none";
8108
9401
  signal?: AbortSignal;
8109
9402
  }): Promise<SubtitleTrack>;
8110
- /** Remove a track this plugin loaded. */
9403
+ /**
9404
+ * Remove a track this plugin loaded.
9405
+ * @capability subtitles.control and subtitles.read
9406
+ * @throws invalid (rejects) The track was not loaded by this plugin.
9407
+ * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9408
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9409
+ * @throws busy (rejects) Too many subtitle operations at once.
9410
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9411
+ * @example Remove the subtitle the plugin loaded
9412
+ * The plugin's downloaded subtitle disappears from the track list. A plugin
9413
+ * can remove only tracks it loaded itself; a new source removes them
9414
+ * anyway.
9415
+ * ```ts
9416
+ * await plugin.subtitles.remove(loaded);
9417
+ * ```
9418
+ */
8111
9419
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
8112
9420
  /**
8113
9421
  * Set this plugin's style layer; `null` removes it. The newest call of this plugin replaces its earlier layer. When several plugins have a layer, the player uses the one whose manifest id sorts last, whatever the order of the calls.
9422
+ * @capability subtitles.control and subtitles.read
9423
+ * @throws invalid (rejects) A field out of range.
9424
+ * @throws unsupported (rejects) A font that is not in `state.fonts`, or a primary subtitle that is a bitmap track.
9425
+ * @throws stale (rejects) With `ifCurrent`: the subtitle state changed since that state.
9426
+ * @throws unavailable (rejects) Nothing is loaded, or the source is still loading.
9427
+ * @throws busy (rejects) Too many subtitle operations at once.
9428
+ * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9429
+ * @example Take the plugin's style off again
9430
+ * The plugin removes its style layer, so the subtitles go back to the
9431
+ * player's default (or to another plugin's layer). `null` removes only this
9432
+ * plugin's layer; the effective layer is the one whose manifest id sorts
9433
+ * last among the plugins that still have one.
9434
+ * ```ts
9435
+ * await plugin.subtitles.setStyle(null);
9436
+ * ```
8114
9437
  */
8115
9438
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
8116
9439
  }
@@ -8161,6 +9484,14 @@ interface CaptureOptions extends Conditional {
8161
9484
 
8162
9485
  A frozen, immutable input for exports. Dispose to release it.
8163
9486
 
9487
+ A frozen input, from MediaCapture.capture: the source, the time
9488
+ and (processed) the effects and subtitle as they were. Exports read it in
9489
+ the player's offline worker, so one snapshot can be exported several
9490
+ times and playback is unaffected. Disposing it releases the player's
9491
+ copy; an export using it after that fails with `released`.
9492
+
9493
+ - @capability media.read and media.control
9494
+
8164
9495
  ```ts
8165
9496
  interface Snapshot extends Disposable {
8166
9497
  /** Without or with effects. */
@@ -8182,11 +9513,45 @@ interface Snapshot extends Disposable {
8182
9513
  } | null;
8183
9514
  /** The frozen processing revision (processed snapshots). */
8184
9515
  readonly processingRevision: number | null;
8185
- /** Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times. */
9516
+ /**
9517
+ * Start exporting; the job runs in the host's offline worker. One snapshot can be exported several times.
9518
+ * @capability media.read and media.control
9519
+ * @throws released (rejects) The snapshot was already released.
9520
+ * @throws invalid (rejects) Bad options (`quality` outside 1–100, a relative destination, a clip `end` not after the snapshot time or past the duration).
9521
+ * @throws busy (rejects) This plugin runs 2 exports (or the player 8).
9522
+ * @throws unavailable (rejects) No FFmpeg, or the export worker could not start.
9523
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9524
+ * @example Export a 30-second clip
9525
+ * A clip from the snapshot time to 30 seconds later is written as MP4.
9526
+ * `export` resolves once the job is accepted; the job then runs in the
9527
+ * player's offline worker, and `done` resolves with its terminal state,
9528
+ * whose `outcome` has the destination or the error.
9529
+ * ```ts
9530
+ * const job = await snapshot.export({ format: "mp4", destination: "/tmp/clip.mp4", end: snapshot.time + 30 });
9531
+ * const end = await job.done;
9532
+ * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
9533
+ * ```
9534
+ */
8186
9535
  export(options: ExportOptions): Promise<ExportJob>;
8187
9536
  }
8188
9537
  ```
8189
9538
 
9539
+ Example: Export one frame as PNG and as JPEG
9540
+
9541
+ The same frozen frame is written twice, lossless and compressed. Each
9542
+ `export` starts a job of its own; the snapshot is released after both
9543
+ are done.
9544
+ ```ts
9545
+ const snapshot = await plugin.media.capture();
9546
+ try {
9547
+ const png = await snapshot.export({ format: "png", destination: "/tmp/frame.png" });
9548
+ const jpeg = await snapshot.export({ format: "jpeg", destination: "/tmp/frame.jpg", quality: 85 });
9549
+ await Promise.all([png.done, jpeg.done]); // terminal states, not errors
9550
+ } finally {
9551
+ await snapshot.dispose();
9552
+ }
9553
+ ```
9554
+
8190
9555
  ### ExportOptions
8191
9556
 
8192
9557
  One frame as PNG, JPEG (`quality` 1–100, default 90) or BMP (24-bit), or
@@ -8239,31 +9604,121 @@ type JobState = Omit<MediaJob, "jobId">;
8239
9604
 
8240
9605
  An export, from Snapshot.export. Disposing an unfinished job cancels it.
8241
9606
 
9607
+ One export, from Snapshot.export. `state` is the SDK's last copy
9608
+ of the job's state: the SDK polls the player about every 350 ms until the
9609
+ job ends, and `refresh` reads it at once. `done` resolves
9610
+ once the job is terminal (`completed`, `failed` or `cancelled`) and never
9611
+ rejects for a failed export: the failure is in `outcome`. Disposing an
9612
+ unfinished job cancels it.
9613
+
9614
+ - @capability media.read and media.control
9615
+
8242
9616
  ```ts
8243
9617
  interface ExportJob extends Disposable {
8244
9618
  /** Latest state. */
8245
9619
  readonly state: JobState;
8246
- /** The state changed. */
9620
+ /**
9621
+ * The state changed.
9622
+ * @capability media.read
9623
+ * @example Log each state of an export
9624
+ * The plugin's log records each status the job goes through (`queued`,
9625
+ * `running`, `cancelling` after a cancel, then a terminal one). The listener runs when the SDK learns a
9626
+ * new state from its polling.
9627
+ * ```ts
9628
+ * job.onChange((state) => console.log("export", state.status));
9629
+ * ```
9630
+ */
8247
9631
  onChange(listener: (state: JobState) => void): Disposable;
8248
- /** Resolves with the terminal state (completed, failed or cancelled). */
9632
+ /**
9633
+ * Resolves with the terminal state (completed, failed or cancelled).
9634
+ * @throws disposed (rejects) The job handle was disposed while waiting.
9635
+ */
8249
9636
  readonly done: Promise<JobState>;
8250
- /** Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over). */
9637
+ /**
9638
+ * Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over).
9639
+ * @capability media.control
9640
+ * @throws rejected (rejects) The player did not accept the request.
9641
+ * @throws capability (rejects) The manifest does not declare `media.control`.
9642
+ * @example Cancel an export the user no longer wants
9643
+ * The running export stops, unless it is already writing its file.
9644
+ * `cancel` resolves `requested` when the worker will stop (the job ends
9645
+ * `cancelled`), `tooLate` when it is already publishing the file, and
9646
+ * `terminal` when the job had already ended.
9647
+ * ```ts
9648
+ * const answer = await job.cancel();
9649
+ * if (answer === "tooLate") await plugin.osd.show("Too late: the file is being written");
9650
+ * ```
9651
+ */
8251
9652
  cancel(): Promise<"requested" | "tooLate" | "terminal">;
8252
- /** Read the state from the player now. */
9653
+ /**
9654
+ * Read the state from the player now.
9655
+ * @capability media.read
9656
+ * @throws capability (rejects) The manifest does not declare `media.read`.
9657
+ * @example Read a job's state now
9658
+ * The plugin reads the newest state of an export without waiting for an
9659
+ * event. `refresh` asks the player and resolves with the state, which
9660
+ * `state` then holds.
9661
+ * ```ts
9662
+ * const state = await job.refresh();
9663
+ * console.log(state.status);
9664
+ * ```
9665
+ */
8253
9666
  refresh(): Promise<JobState>;
8254
9667
  }
8255
9668
  ```
8256
9669
 
9670
+ Example: Show export progress and cancel on request
9671
+
9672
+ The OSD shows the job's status while it runs, and a Cancel command stops
9673
+ it. `onChange` follows the state (`queued`, `running`, `cancelling`,
9674
+ then a terminal one); `cancel` asks the worker to stop. After
9675
+ `requested` the job usually ends `cancelled`, or `failed` when the
9676
+ cleanup failed.
9677
+ ```ts
9678
+ job.onChange((state) => void plugin.osd.show("Export: {}", [state.status]));
9679
+ plugin.commands.add({ id: "cancel-export", title: "Cancel export", run: () => job.cancel() });
9680
+ await job.done; // completed, failed or cancelled
9681
+ ```
9682
+
8257
9683
  ### MediaCapture
8258
9684
 
8259
9685
  `plugin.media`: snapshots and exports. Needs `media.read` and
8260
9686
  `media.control` (processed snapshots also `playback.read` and
8261
9687
  `subtitles.read`). Exports decode offline and do not disturb playback.
8262
9688
 
9689
+ Exports run in the player's offline worker from a frozen
9690
+ Snapshot: a still frame or a clip, raw or with this plugin's
9691
+ effects (and the primary subtitle). Playback is not disturbed. A plugin
9692
+ holds at most 4 snapshots at a time (16 in the player); release them
9693
+ when done.
9694
+
9695
+ - @capability media.read and media.control (processed snapshots also `playback.read` and `subtitles.read`)
9696
+
8263
9697
  ```ts
8264
9698
  interface MediaCapture {
8265
9699
  /**
8266
- * Freeze the current source (and optionally this plugin set's effects) as an input for exports. Fails `stale` when the source changed since `ifCurrent`; `unsupported` for an external ASS track with subtitles; `noSource` when nothing is playing.
9700
+ * Freeze the current source (and optionally this plugin set's effects) as an input for exports. Fails `stale` when the source changed since `ifCurrent`; `unsupported` for subtitles that cannot be flattened (only plain SRT text can) or a picture over the size limits; `noSource` when nothing is playing.
9701
+ * @capability media.read and media.control
9702
+ * @throws stale (rejects) The source changed since `ifCurrent`.
9703
+ * @throws noSource (rejects) Nothing is playing.
9704
+ * @throws invalid (rejects) `at` at or after the end of the source.
9705
+ * @throws busy (rejects) This plugin holds 4 snapshots (or the player 16); release one.
9706
+ * @throws unavailable (rejects) A processed snapshot while the picture is still being prepared.
9707
+ * @throws unsupported (rejects) A processed snapshot the player cannot freeze: subtitles that cannot be flattened (only plain SRT text can), or a picture over the size limits.
9708
+ * @throws capability (rejects) The manifest does not declare `media.read` and `media.control`.
9709
+ * @example Freeze the frame at the 10-second mark
9710
+ * The plugin freezes the frame at 0:10 of the open file, raw, to export it
9711
+ * later. `capture` resolves with a `Snapshot` once the player has frozen
9712
+ * that input; the snapshot stays valid when playback moves on or the file
9713
+ * changes, until it is released.
9714
+ * ```ts
9715
+ * const snapshot = await plugin.media.capture({ at: 10 }); // seconds
9716
+ * try {
9717
+ * // … export it …
9718
+ * } finally {
9719
+ * await snapshot.dispose(); // frees one of the plugin's 4 snapshot slots
9720
+ * }
9721
+ * ```
8267
9722
  */
8268
9723
  capture(options?: CaptureOptions): Promise<Snapshot>;
8269
9724
  }
@@ -8416,25 +9871,84 @@ interface ResolvedStream {
8416
9871
 
8417
9872
  A registered resolver. Disposing it unregisters it and aborts resolutions in flight.
8418
9873
 
9874
+ A registered resolver, from Resolvers.add. Disposing it, its
9875
+ scope or stopping the plugin unregisters it and aborts resolutions in
9876
+ flight; the extensions or hosts are free again for other plugins.
9877
+
9878
+ - @capability source.resolve
9879
+
8419
9880
  ```ts
8420
9881
  interface Resolver extends Disposable {
8421
9882
  /**
8422
9883
  * Resolves when the player registered it; rejects otherwise: `conflict` when another resolver has the extension or the scheme + host (another plugin's: activation goes on; this plugin's own: activation fails), `invalid` for a malformed declaration (a player before 0.4.1 also for `extensions`).
9884
+ * @throws conflict (rejects) Another resolver has the extension or the scheme + host, or this plugin already registered this id.
9885
+ * @throws busy (rejects) The player already holds 16 resolvers.
9886
+ * @throws invalid (rejects) A malformed declaration.
9887
+ * @throws capability (rejects) The manifest does not declare `source.resolve`.
8423
9888
  */
8424
9889
  readonly ready: Promise<void>;
8425
9890
  }
8426
9891
  ```
8427
9892
 
9893
+ Example: Report when another plugin already handles the format
9894
+
9895
+ The plugin tells the user when its `.ch8` resolver could not register
9896
+ because another plugin holds that extension. `ready` rejects with
9897
+ `conflict`; for another plugin's claim that does not fail activation, so
9898
+ the plugin keeps running without the resolver.
9899
+ ```ts
9900
+ const resolver = plugin.resolvers.add({
9901
+ id: "chip8",
9902
+ extensions: ["ch8"],
9903
+ async resolve() {
9904
+ throw plugin.i18n.error("Not ready yet.");
9905
+ },
9906
+ });
9907
+ // Rejects with `conflict` when the extension is taken.
9908
+ resolver.ready.catch((error: PluginError) => {
9909
+ if (error.code === "conflict") void plugin.osd.show("Another plugin plays .ch8 files");
9910
+ });
9911
+ ```
9912
+
8428
9913
  ### Resolvers
8429
9914
 
8430
9915
  `plugin.resolvers`: turn web page URLs into playable streams, or local
8431
9916
  files into streams this plugin generates. Needs `source.resolve` (file
8432
9917
  resolvers also `producer.av`).
8433
9918
 
9919
+ A resolver turns something the player cannot play by itself into
9920
+ something it can: a web page address into stream URLs
9921
+ (WebResolverOptions), or a local file of a format the plugin
9922
+ understands into a stream the plugin generates
9923
+ (FileResolverOptions). The player asks the resolver whenever
9924
+ anyone opens a matching address or file (the Open dialog, the playlist,
9925
+ another plugin), so the plugin does not intercept opens itself.
9926
+
9927
+ - @capability source.resolve (file resolvers also `producer.av`)
9928
+
8434
9929
  ```ts
8435
9930
  interface Resolvers {
8436
9931
  /**
8437
9932
  * Register a resolver. When anyone opens a matching URL or file the player calls `resolve`; `signal` aborts when the open is cancelled or replaced. One scheme + host pair, and one extension, has one resolver (`conflict` otherwise). A rejected `resolve` fails the open with the error's message.
9933
+ * @capability source.resolve
9934
+ * @example Play a site's pages through the plugin
9935
+ * Opening a `https://video.example.com/…` address plays the page's video.
9936
+ * `add` returns at once and registers in the background (`ready` reports
9937
+ * the answer); from then on the player calls `resolve` for every matching
9938
+ * open, and the error a rejected `resolve` carries is what the user sees.
9939
+ * ```ts
9940
+ * plugin.resolvers.add({
9941
+ * id: "example",
9942
+ * schemes: ["https"],
9943
+ * hosts: ["video.example.com"], // exact host names
9944
+ * async resolve(url, signal) {
9945
+ * const response = await fetch(url, { signal });
9946
+ * if (!response.ok) throw new Error(`the page answered ${response.status}`);
9947
+ * const page = (await response.json()) as { title: string; stream: string };
9948
+ * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
9949
+ * },
9950
+ * });
9951
+ * ```
8438
9952
  */
8439
9953
  add(options: ResolverOptions): Resolver;
8440
9954
  /**
@@ -8587,6 +10101,15 @@ interface InstallOptions {
8587
10101
 
8588
10102
  A validated package directory, from Packages.validate.
8589
10103
 
10104
+ A package the player has checked, from Packages.validate or
10105
+ Packages.validateArchive: its manifest, digest and the checks
10106
+ it passed. The player copies the whole package when it checks it and
10107
+ installs that copy, so later changes to the directory do not matter.
10108
+ Installing starts a PackageChange; the player keeps only the 32
10109
+ most recent candidates.
10110
+
10111
+ - @capability plugins.manage
10112
+
8590
10113
  ```ts
8591
10114
  interface PackageCandidate {
8592
10115
  /** The package directory. */
@@ -8599,11 +10122,38 @@ interface PackageCandidate {
8599
10122
  readonly checks: string[];
8600
10123
  /**
8601
10124
  * Install it. The same source upgrades the installed plugin (its data stays); another source fails with `conflict` unless `replace` is set; a built-in id fails with `unsupported` (built-ins update with the player). A reserved id (`vestra` or `vestra.*`, which every built-in uses) fails with `reserved` (player 0.4.1; already when validated), as only the player installs built-in plugins.
10125
+ * @capability plugins.manage
10126
+ * @throws conflict (rejects) The plugin is installed from another source and `replace` is not set.
10127
+ * @throws unsupported (rejects) A built-in plugin's id.
10128
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10129
+ * @throws busy (rejects) The plugin library is being cleaned up, or the player already keeps 64 change records.
10130
+ * @throws revisionConflict (rejects) The installed revision of this plugin changed meanwhile; call `install` again (no new validation needed).
10131
+ * @throws stale (rejects) The candidate is older than the 32 the player keeps.
10132
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10133
+ * @example Replace a plugin installed from another source
10134
+ * The new package replaces the installed plugin even though it came from
10135
+ * another source. Without `replace` the player refuses with `conflict`;
10136
+ * the same source simply upgrades and keeps the plugin's data.
10137
+ * ```ts
10138
+ * const change = await candidate.install({ replace: true });
10139
+ * await change.done; // "committed" once the new plugin runs
10140
+ * ```
8602
10141
  */
8603
10142
  install(options?: InstallOptions): Promise<PackageChange>;
8604
10143
  }
8605
10144
  ```
8606
10145
 
10146
+ Example: Show what a package asks for before installing it
10147
+
10148
+ Before installing, the plugin shows the package's name and the
10149
+ capabilities it declares. The candidate's `manifest` is the manifest
10150
+ as the player read it.
10151
+ ```ts
10152
+ const candidate = await plugin.packages.validate(directory);
10153
+ const { id, version, capabilities } = candidate.manifest;
10154
+ await plugin.osd.show("{} {}: {}", [id, version, capabilities.join(", ")]);
10155
+ ```
10156
+
8607
10157
  ### PackageChangeStatus
8608
10158
 
8609
10159
  The progress of an install or uninstall.
@@ -8617,21 +10167,71 @@ type PackageChangeStatus = "preparing" | "activating" | "restoring" | "committed
8617
10167
  An install or uninstall in progress. The SDK never submits a
8618
10168
  change twice: after a lost reply it only queries the same request.
8619
10169
 
10170
+ An install or uninstall the player carries out in steps (`preparing`,
10171
+ `activating`, possibly `restoring`) until a terminal status. The SDK
10172
+ asks the player for the status about every 400 ms until then; `done`
10173
+ resolves with the terminal status and does not reject for a failed
10174
+ change: `message` says why.
10175
+
10176
+ - @capability plugins.manage
10177
+
8620
10178
  ```ts
8621
10179
  interface PackageChange {
8622
10180
  /** Current status. */
8623
10181
  readonly status: PackageChangeStatus;
8624
10182
  /** Details for failures, `null` otherwise. */
8625
10183
  readonly message: string | null;
8626
- /** The status changed. */
10184
+ /**
10185
+ * The status changed.
10186
+ * @capability plugins.manage
10187
+ * @example Close a progress window when the change ends
10188
+ * The plugin's progress window closes once the install is over, whatever
10189
+ * the outcome. The listener receives each new status; the terminal ones
10190
+ * are `committed`, `cancelled`, `failed`, `failedDegraded` and `tooLate`
10191
+ * (`tooLate` only answers a `cancel` of a change that had committed, and
10192
+ * `committed` may follow it).
10193
+ * ```ts
10194
+ * change.onChange((status) => {
10195
+ * if (["committed", "cancelled", "failed", "failedDegraded", "tooLate"].includes(status))
10196
+ * void progress.close();
10197
+ * });
10198
+ * ```
10199
+ */
8627
10200
  onChange(listener: (status: PackageChangeStatus) => void): Disposable;
8628
- /** Resolves with the final status. */
10201
+ /**
10202
+ * Resolves with the final status.
10203
+ * @throws missing (rejects) The player no longer knows the change (a `HostError` with that code); a failed change is a terminal status, not a rejection.
10204
+ */
8629
10205
  readonly done: Promise<PackageChangeStatus>;
8630
- /** Ask to cancel (it may be `tooLate`). */
10206
+ /**
10207
+ * Ask to cancel (it may be `tooLate`).
10208
+ * @capability plugins.manage
10209
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10210
+ * @example Cancel an install the user no longer wants
10211
+ * The install stops if it has not gone too far. `cancel` asks the player;
10212
+ * the change then ends `cancelled`. When the change had already committed
10213
+ * the player answers `tooLate` and `done` still resolves `committed`; a
10214
+ * change that is restoring cannot be cancelled and ends `failed` or
10215
+ * `failedDegraded`.
10216
+ * ```ts
10217
+ * await change.cancel();
10218
+ * console.log(await change.done); // "cancelled"; "committed" when it was too late
10219
+ * ```
10220
+ */
8631
10221
  cancel(): Promise<void>;
8632
10222
  }
8633
10223
  ```
8634
10224
 
10225
+ Example: Report each step of an install
10226
+
10227
+ The OSD shows each step of the install and the end result. `onChange`
10228
+ runs for every new status; `done` resolves when the change is over.
10229
+ ```ts
10230
+ change.onChange((status) => void plugin.osd.show("Install: {}", [status]));
10231
+ const end = await change.done;
10232
+ if (end !== "committed") await plugin.osd.show("Not installed: {}", [change.message ?? end]);
10233
+ ```
10234
+
8635
10235
  ### PluginInfo
8636
10236
 
8637
10237
  One plugin as the player knows it (`packages.list`).
@@ -8713,6 +10313,14 @@ interface PluginWarning {
8713
10313
  `plugin.packages`: the plugins the player knows. `list` and `onChange`
8714
10314
  need no capability; everything else needs `plugins.manage`.
8715
10315
 
10316
+ The plugins the player knows: installed, built-in and this run's
10317
+ development loads. Reading the list needs no capability; installing,
10318
+ enabling, reloading and uninstalling need `plugins.manage` and act on
10319
+ other plugins, never on the calling one. Built-in plugins update with
10320
+ the player and cannot be installed, disabled or uninstalled.
10321
+
10322
+ - @capability None for `list` and `onChange`; plugins.manage for the rest.
10323
+
8716
10324
  ```ts
8717
10325
  interface Packages {
8718
10326
  /**
@@ -8723,18 +10331,93 @@ interface Packages {
8723
10331
  * The player keeps the 32 most recent candidates of all plugins; an
8724
10332
  * older one's `install` fails with `stale` (validate again). Before
8725
10333
  * player 0.4.2 the 33rd validation of a run failed with `busy` instead.
10334
+ * @capability plugins.manage
10335
+ * @throws host (rejects) Not a package directory, or a manifest that breaks the rules (another `apiVersion` included); `message` says why.
10336
+ * @throws invalid (rejects) A bad request (a path that is not a string).
10337
+ * @throws javascript (rejects) The entry module does not parse.
10338
+ * @throws slint (rejects) A Slint file does not compile.
10339
+ * @throws reserved (rejects) A reserved id (`vestra`, `vestra.*`).
10340
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10341
+ * @throws disabled (rejects) The calling plugin is stopping.
10342
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10343
+ * @example Check a package directory before installing it
10344
+ * The plugin checks a folder it unpacked and installs it if the player
10345
+ * accepts it. `validate` runs the player's checks and resolves with a
10346
+ * candidate; a package that fails them rejects with the reason.
10347
+ * ```ts
10348
+ * try {
10349
+ * // Rejects with the reason when the package fails the checks.
10350
+ * const candidate = await plugin.packages.validate(directory);
10351
+ * await (await candidate.install()).done;
10352
+ * } catch (error) {
10353
+ * await plugin.osd.show("Not a valid plugin: {}", [(error as Error).message]);
10354
+ * }
10355
+ * ```
8726
10356
  */
8727
10357
  validate(directory: string): Promise<PackageCandidate>;
8728
10358
  /**
8729
10359
  * Validate a `.vxp` package file (`plugins.manage`, player 0.2.0): the same checks as Packages.validate after unpacking. Check the file's sha256 yourself before calling when you know it. A reserved id fails with `reserved`, as in Packages.validate.
10360
+ * @capability plugins.manage
10361
+ * @throws invalid (rejects) The path does not end in `.vxp`, or the archive cannot be unpacked (over 64 MB, too many files, a damaged zip, an unsafe path).
10362
+ * @throws host (rejects) The unpacked package or its manifest breaks the rules; `message` says why.
10363
+ * @throws javascript (rejects) The entry module does not parse.
10364
+ * @throws slint (rejects) A Slint file does not compile.
10365
+ * @throws reserved (rejects) A reserved id.
10366
+ * @throws versionMismatch (rejects) The package's `minPlayerVersion` is newer than the player.
10367
+ * @throws disabled (rejects) The calling plugin is stopping.
10368
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10369
+ * @example Install a downloaded package file
10370
+ * A `.vxp` the plugin downloaded is checked and installed. The player
10371
+ * unpacks and checks it like a directory; checking the file's sha256
10372
+ * first, when the store publishes one, is up to the plugin.
10373
+ * ```ts
10374
+ * const candidate = await plugin.packages.validateArchive(file); // a .vxp path
10375
+ * const change = await candidate.install();
10376
+ * await plugin.osd.show("Install: {}", [await change.done]);
10377
+ * ```
8730
10378
  */
8731
10379
  validateArchive(file: string): Promise<PackageCandidate>;
8732
- /** Every plugin: running, installed, and this run's development loads. No capability. */
10380
+ /**
10381
+ * Every plugin: running, installed, and this run's development loads. No capability.
10382
+ * @capability None.
10383
+ * @throws Error (rejects) Only when the player cannot be asked; no capability is needed.
10384
+ * @example Show which plugins are running
10385
+ * The plugin lists the running plugins by name. `list` asks the player
10386
+ * and resolves with every plugin it knows, with its state, origin and
10387
+ * display texts in the interface language.
10388
+ * ```ts
10389
+ * const plugins = await plugin.packages.list();
10390
+ * const running = plugins.filter((info) => info.state === "running");
10391
+ * console.log(running.map((info) => info.display?.name ?? info.id));
10392
+ * ```
10393
+ */
8733
10394
  list(): Promise<PluginInfo[]>;
8734
- /** The new list after any start, stop, failure, install or uninstall. No capability. */
10395
+ /**
10396
+ * The new list after any start, stop, failure, install or uninstall. No capability.
10397
+ * @capability None.
10398
+ * @example Refresh a plugin list after any change
10399
+ * A store window's list updates when a plugin starts, stops, fails, is
10400
+ * installed or uninstalled. The listener receives the new list each time.
10401
+ * ```ts
10402
+ * plugin.packages.onChange((plugins) => console.log(plugins.length, "plugins"));
10403
+ * ```
10404
+ */
8735
10405
  onChange(listener: (plugins: PluginInfo[]) => void): Disposable;
8736
10406
  /**
8737
- * Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed. `unsupported` to disable a built-in (PluginInfo.builtin): users manage third-party plugins only (players released with SDK 1.5.18 on; older ones disabled built-ins too).
10407
+ * Stop or start a plugin (`plugins.manage`). An installed plugin stays disabled across launches; a development load only for this run. `invalid` to disable oneself, `missing` unknown. `busy` while it is being installed or reloaded. `unsupported` to disable a built-in (PluginInfo.builtin): users manage third-party plugins only (players released with SDK 1.5.18 on; older ones disabled built-ins too).
10408
+ * @capability plugins.manage
10409
+ * @throws invalid (rejects) Disabling the calling plugin itself.
10410
+ * @throws missing (rejects) An unknown id.
10411
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10412
+ * @throws unsupported (rejects) A built-in plugin.
10413
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10414
+ * @example Turn a third-party plugin off
10415
+ * The plugin stops another plugin and keeps it disabled across launches
10416
+ * until it is enabled again. Built-in plugins cannot be disabled
10417
+ * (`unsupported`).
10418
+ * ```ts
10419
+ * await plugin.packages.setEnabled("someone.demo", false);
10420
+ * ```
8738
10421
  */
8739
10422
  setEnabled(id: string, enabled: boolean): Promise<void>;
8740
10423
  /**
@@ -8752,10 +10435,38 @@ interface Packages {
8752
10435
  * `invalid` for oneself or a disabled plugin (enable it instead),
8753
10436
  * `missing` unknown, `busy` while it is being installed or reloaded.
8754
10437
  * @player 0.5.1; older players reject it as an unknown request (`unsupported`).
10438
+ * @capability plugins.manage
10439
+ * @throws invalid (rejects) The calling plugin itself, or a disabled plugin.
10440
+ * @throws missing (rejects) An unknown id.
10441
+ * @throws busy (rejects) The plugin is being installed or reloaded.
10442
+ * @throws unsupported (rejects) A player before 0.5.1.
10443
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10444
+ * @example Restart a plugin that misbehaves
10445
+ * The other plugin is stopped and started again from the same package and
10446
+ * data. `reload` resolves once the old process is stopped and the start is
10447
+ * scheduled; `onChange` then shows `starting` and `running` (or `failed`).
10448
+ * ```ts
10449
+ * await plugin.packages.reload("someone.demo");
10450
+ * ```
8755
10451
  */
8756
10452
  reload(id: string): Promise<void>;
8757
10453
  /**
8758
10454
  * Remove an installed plugin (`plugins.manage`); `removeData` also deletes its data directory, otherwise reinstalling it from the same source reconnects to its data. Its user bindings stay (as orphans). `unsupported` for built-ins and development loads, `missing` unknown, `invalid` for oneself.
10455
+ * @capability plugins.manage
10456
+ * @throws unsupported (rejects) A built-in plugin or a development load.
10457
+ * @throws missing (rejects) An unknown id.
10458
+ * @throws invalid (rejects) The calling plugin itself.
10459
+ * @throws busy (rejects) The plugin library is being cleaned up, or that plugin is being installed or reloaded.
10460
+ * @throws revisionConflict (rejects) The installed revision of that plugin changed meanwhile; call `uninstall` again.
10461
+ * @throws capability (rejects) The manifest does not declare `plugins.manage`.
10462
+ * @example Uninstall a plugin together with its data
10463
+ * The other plugin is removed and its data directory deleted, so a later
10464
+ * reinstall starts fresh. Without `removeData` a reinstall from the same
10465
+ * source finds its data again.
10466
+ * ```ts
10467
+ * const change = await plugin.packages.uninstall("someone.demo", { removeData: true });
10468
+ * await change.done;
10469
+ * ```
8759
10470
  */
8760
10471
  uninstall(id: string, options?: {
8761
10472
  removeData?: boolean;
@@ -8830,9 +10541,31 @@ interface BindingInfo extends BindingRef {
8830
10541
  `plugin.bindings`: list and override every command's bindings, including
8831
10542
  the player's. Needs `bindings.manage`.
8832
10543
 
10544
+ The player's binding table: every command's default keys (from
10545
+ CommandOptions.bindings, the player's own as `host/<action>`)
10546
+ and the user's overrides on top, saved across launches. A key-settings
10547
+ plugin lists the table, writes overrides and resets them; the player
10548
+ applies the change at once to every running plugin.
10549
+
10550
+ - @capability bindings.manage
10551
+
8833
10552
  ```ts
8834
10553
  interface Bindings {
8835
- /** Every declared command, and overrides whose command is gone. */
10554
+ /**
10555
+ * Every declared command, and overrides whose command is gone.
10556
+ * @capability bindings.manage
10557
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10558
+ * @example Find the commands that use a key
10559
+ * A key-settings window shows which commands Ctrl+K is bound to now. `list`
10560
+ * resolves with every declared command and its current bindings (the
10561
+ * defaults with the user's overrides applied) and with the overrides
10562
+ * whose command is not running (`orphans`).
10563
+ * ```ts
10564
+ * const { commands } = await plugin.bindings.list();
10565
+ * const users = commands.filter((command) => command.current.includes("Ctrl+K"));
10566
+ * console.log(users.map((command) => `${command.plugin}/${command.command}`)); // "host" for the player
10567
+ * ```
10568
+ */
8836
10569
  list(): Promise<{
8837
10570
  /** Every declared command (running plugins and the player). */
8838
10571
  commands: BindingInfo[];
@@ -8846,11 +10579,47 @@ interface Bindings {
8846
10579
  }>;
8847
10580
  /**
8848
10581
  * Override a command's bindings (`[]` unbinds it); saved across launches. `invalid` for a bad binding, `missing` for an unknown command that has no override either. At most 16 inputs, syntax as CommandOptions.bindings.
10582
+ * @capability bindings.manage
10583
+ * @throws invalid (rejects) A malformed binding, or more than 16.
10584
+ * @throws missing (rejects) An unknown command that has no override either.
10585
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10586
+ * @example Give the player's Mute command a second key
10587
+ * Mute now answers to M and to Ctrl+M. `set` replaces the command's
10588
+ * bindings with the list given (an override saved across launches), so it
10589
+ * repeats the default it keeps; `[]` would unbind it.
10590
+ * ```ts
10591
+ * await plugin.bindings.set({ plugin: "host", command: "mute" }, ["M", "Ctrl+M"]);
10592
+ * ```
8849
10593
  */
8850
10594
  set(ref: BindingRef, inputs: string[]): Promise<void>;
8851
- /** Back to the defaults: one command, or every override when omitted. */
10595
+ /**
10596
+ * Back to the defaults: one command, or every override when omitted.
10597
+ * @capability bindings.manage
10598
+ * @throws missing (rejects) A command that has no override and is not declared.
10599
+ * @throws capability (rejects) The manifest does not declare `bindings.manage`.
10600
+ * @example Restore every default key
10601
+ * A "Restore defaults" button removes all of the user's overrides, so
10602
+ * every command has its declared keys again. Without an argument `reset`
10603
+ * clears every override; with a reference only that command's.
10604
+ * ```ts
10605
+ * await plugin.bindings.reset(); // every override
10606
+ * ```
10607
+ */
8852
10608
  reset(ref?: BindingRef): Promise<void>;
8853
- /** Declarations or overrides changed (read `list()` again). At most every 200 ms. */
10609
+ /**
10610
+ * Declarations or overrides changed (read `list()` again). At most every 200 ms.
10611
+ * @capability bindings.manage
10612
+ * @example Refresh a key list when bindings change
10613
+ * The plugin's key list stays current when the user changes a key in the
10614
+ * player or another plugin starts. The listener carries no data and runs
10615
+ * at most every 200 ms; the plugin reads the table again with `list`.
10616
+ * ```ts
10617
+ * plugin.bindings.onChange(async () => {
10618
+ * const { commands } = await plugin.bindings.list(); // read the new table
10619
+ * render(commands);
10620
+ * });
10621
+ * ```
10622
+ */
8854
10623
  onChange(listener: () => void): Disposable;
8855
10624
  }
8856
10625
  ```
@@ -8876,13 +10645,65 @@ PNG/JPEG bytes; Windows writes Unicode text and images as a bitmap plus
8876
10645
  PNG. Other systems: `unsupported`. Failures: `invalid`, `missing`,
8877
10646
  `unavailable`, `failed` (the system refused).
8878
10647
 
10648
+ The player reads and writes the system clipboard on the plugin's behalf.
10649
+ Text is plain text; images are written from a PNG or JPEG file.
10650
+
10651
+ - @capability clipboard
10652
+
8879
10653
  ```ts
8880
10654
  interface Clipboard {
8881
- /** Plain text on the clipboard; `null` when it holds none. */
10655
+ /**
10656
+ * Plain text on the clipboard; `null` when it holds none.
10657
+ * @capability clipboard
10658
+ * @throws unavailable (rejects) The player runs without a user interface.
10659
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10660
+ * @throws failed (rejects) The system refused (Windows).
10661
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
10662
+ * @example Open a URL the user copied
10663
+ * A "Paste and play" command opens the address on the clipboard. `readText`
10664
+ * resolves with the clipboard's text, or `null` when it holds none (an
10665
+ * image, nothing).
10666
+ * ```ts
10667
+ * const text = await plugin.clipboard.readText();
10668
+ * if (text?.startsWith("https://")) await plugin.player.open(text.trim());
10669
+ * ```
10670
+ */
8882
10671
  readText(): Promise<string | null>;
8883
- /** Replace the clipboard with text (at most 1 MiB of UTF-8). */
10672
+ /**
10673
+ * Replace the clipboard with text (at most 1 MiB of UTF-8).
10674
+ * @capability clipboard
10675
+ * @throws invalid (rejects) More than 1 MiB of UTF-8.
10676
+ * @throws unavailable (rejects) The player runs without a user interface.
10677
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10678
+ * @throws failed (rejects) The system refused.
10679
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
10680
+ * @example Copy the playing file's path
10681
+ * The path of the playing file is put on the clipboard, replacing what was
10682
+ * there. It resolves once the system has it.
10683
+ * ```ts
10684
+ * const { source } = plugin.player.state;
10685
+ * if (source) await plugin.clipboard.writeText(source);
10686
+ * ```
10687
+ */
8884
10688
  writeText(text: string): Promise<void>;
8885
- /** Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB). */
10689
+ /**
10690
+ * Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB).
10691
+ * @capability clipboard
10692
+ * @throws missing (rejects) The file does not exist.
10693
+ * @throws invalid (rejects) Not an absolute path, not PNG or JPEG, over 16 MB, or (Windows) an image the system cannot decode.
10694
+ * @throws unavailable (rejects) The player runs without a user interface.
10695
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10696
+ * @throws failed (rejects) The system refused.
10697
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
10698
+ * @example Copy a snapshot to the clipboard
10699
+ * A frame the plugin saved as PNG is put on the clipboard, ready to paste
10700
+ * into another application. The player reads the file and writes the
10701
+ * image (macOS: its original bytes; Windows: a bitmap plus PNG).
10702
+ * ```ts
10703
+ * await plugin.clipboard.writeImage(pngPath); // absolute path, at most 16 MB
10704
+ * await plugin.osd.show("Snapshot copied");
10705
+ * ```
10706
+ */
8886
10707
  writeImage(path: string): Promise<void>;
8887
10708
  }
8888
10709
  ```
@@ -8906,6 +10727,12 @@ try {
8906
10727
 
8907
10728
  Keeping the computer awake (`playback.control`).
8908
10729
 
10730
+ A request, not a command: the player keeps the system awake while any
10731
+ plugin asks for it and a source plays, and withdraws a plugin's request
10732
+ when it stops.
10733
+
10734
+ - @capability playback.control
10735
+
8909
10736
  ```ts
8910
10737
  interface Power {
8911
10738
  /**
@@ -8917,11 +10744,31 @@ interface Power {
8917
10744
  * take the system assertion are logged by the player, not reported.
8918
10745
  * macOS uses `caffeinate`, Windows the thread execution state; elsewhere
8919
10746
  * it has no effect.
10747
+ * @capability playback.control
10748
+ * @throws invalid (rejects) A mode other than `"off"` or `"playing"`.
10749
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
10750
+ * @example Follow the plugin's own "Keep awake" setting
10751
+ * The plugin keeps the display awake during playback only while its
10752
+ * setting is on. Each call replaces this plugin's request; `"off"`
10753
+ * withdraws it, and other plugins' requests still count.
10754
+ * ```ts
10755
+ * const store = plugin.storage.json("power", { defaults: { keepAwake: true } });
10756
+ * await plugin.power.preventSleep(store.value.keepAwake ? "playing" : "off");
10757
+ * ```
8920
10758
  */
8921
10759
  preventSleep(mode: "off" | "playing"): Promise<void>;
8922
10760
  }
8923
10761
  ```
8924
10762
 
10763
+ Example: Keep the display on while playing
10764
+
10765
+ The screen does not dim or sleep while a video plays, and may again when
10766
+ it is paused or ends. The player takes the system's assertion only while
10767
+ a source is ready and not paused.
10768
+ ```ts
10769
+ await plugin.power.preventSleep("playing"); // "off" withdraws the request
10770
+ ```
10771
+
8925
10772
  ### ToolResult
8926
10773
 
8927
10774
  The output of Tools.run.
@@ -8940,11 +10787,20 @@ interface ToolResult {
8940
10787
  `plugin.tools`: ffmpeg and ffprobe, run in this plugin's process. No
8941
10788
  capability.
8942
10789
 
10790
+ ffmpeg and ffprobe run as child processes of the plugin. When
10791
+ `VESTRA_FFMPEG` / `VESTRA_FFPROBE` is set, only that path is used (an
10792
+ invalid one means the tool is not found); otherwise the tool is searched
10793
+ on `PATH`. A found path is kept for the plugin's run, a miss is looked up
10794
+ again next time. Disposing the
10795
+ scope ends a running process.
10796
+
10797
+ - @capability None.
10798
+
8943
10799
  ```ts
8944
10800
  interface Tools {
8945
10801
  /**
8946
- * Run ffmpeg or ffprobe (found through `VESTRA_FFMPEG` / `VESTRA_FFPROBE`,
8947
- * then `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
10802
+ * Run ffmpeg or ffprobe (`VESTRA_FFMPEG` / `VESTRA_FFPROBE` when set, else
10803
+ * found on `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
8948
10804
  * `failed` (non-zero exit; `message` is the last stderr line), `invalid`
8949
10805
  * (output over `maxOutput`, default 16 MiB), `aborted` (`signal`). Timeout,
8950
10806
  * `signal` and disposing the scope end the process.
@@ -8952,13 +10808,50 @@ interface Tools {
8952
10808
  * Arguments are passed as-is (no quoting needed). On Windows `PATHEXT` is
8953
10809
  * honoured. Ending sends SIGTERM, then SIGKILL after 300 ms (immediate on
8954
10810
  * Windows).
10811
+ * @capability None.
10812
+ * @throws unavailable (rejects) The tool was not found.
10813
+ * @throws timeout (rejects) It ran longer than `timeoutMs`; the process is ended.
10814
+ * @throws failed (rejects) A non-zero exit; `message` is the last stderr line.
10815
+ * @throws invalid (rejects) Bad arguments or options, or output over `maxOutput`.
10816
+ * @throws aborted (rejects) `signal` aborted, or the scope was disposed.
10817
+ * @example Cut a clip without re-encoding
10818
+ * The plugin writes the first ten seconds of the file to a new file. `run`
10819
+ * starts ffmpeg without a shell (arguments pass as they are, no quoting)
10820
+ * and resolves with its output once it exits with 0; a failure rejects
10821
+ * with the last line ffmpeg printed.
10822
+ * ```ts
10823
+ * await plugin.tools.run(
10824
+ * "ffmpeg",
10825
+ * ["-y", "-i", input, "-t", "10", "-c", "copy", output],
10826
+ * { timeoutMs: 60_000 }, // give up after a minute
10827
+ * );
10828
+ * ```
8955
10829
  */
8956
10830
  run(tool: "ffmpeg" | "ffprobe", args: string[], options: {
8957
10831
  timeoutMs: number;
8958
10832
  maxOutput?: number;
8959
10833
  signal?: AbortSignal;
8960
10834
  }): Promise<ToolResult>;
8961
- /** Whether the tool can be found. */
10835
+ /**
10836
+ * Whether the tool can be found.
10837
+ * @capability None.
10838
+ * @throws Never; it resolves `false` when the tool cannot be found.
10839
+ * @example Grey out a command when ffmpeg is missing
10840
+ * The Export item is disabled with a reason when ffmpeg is not installed.
10841
+ * `available` looks the tool up (a found path is kept, a miss is looked
10842
+ * up again on the next call, so installing it later is noticed).
10843
+ * ```ts
10844
+ * const hasFfmpeg = await plugin.tools.available("ffmpeg");
10845
+ * plugin.commands.add({
10846
+ * id: "export",
10847
+ * path: ["@video", "Export…"],
10848
+ * enabled: hasFfmpeg,
10849
+ * // Shown with the greyed-out item.
10850
+ * reason: hasFfmpeg ? null : "Install ffmpeg to export",
10851
+ * run() {},
10852
+ * });
10853
+ * ```
10854
+ */
8962
10855
  available(tool: "ffmpeg" | "ffprobe"): Promise<boolean>;
8963
10856
  }
8964
10857
  ```
@@ -8986,21 +10879,75 @@ if (await plugin.tools.available("ffprobe")) {
8986
10879
 
8987
10880
  A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
8988
10881
 
10882
+ One JSON file, `<dataDirectory>/<name>.json`, made by
10883
+ Storage.json. `value` is read once when the store is made;
10884
+ `save` replaces it in memory at once and writes the file `debounceMs`
10885
+ later (300 by default) through a temporary file and a rename, so a
10886
+ crash never leaves half a file. Writes to one file run one at a time,
10887
+ newest last. Disposing the store, its scope or stopping the plugin
10888
+ writes a pending save first.
10889
+
10890
+ - @capability None.
10891
+
8989
10892
  ```ts
8990
10893
  interface JsonStore<T> extends Disposable {
8991
10894
  /** The loaded value, or `defaults` when the file was missing or broken. */
8992
10895
  readonly value: T;
8993
- /** Replace the value now and write it after `debounceMs`. Only `save` writes: mutating `value` in place is not saved. */
10896
+ /**
10897
+ * Replace the value now and write it after `debounceMs`. Only `save` writes: mutating `value` in place is not saved.
10898
+ * @capability None.
10899
+ * @example Save a slider value without writing on every move
10900
+ * Dragging the plugin's volume slider updates the stored value at once but
10901
+ * writes the file only once the user stops for 300 ms. Each `save`
10902
+ * replaces the value and restarts the debounce timer.
10903
+ * ```ts
10904
+ * const store = plugin.storage.json("prefs", { defaults: { volume: 100 } });
10905
+ * window.on("volumeChanged", (volume: number) => store.save({ ...store.value, volume }));
10906
+ * ```
10907
+ */
8994
10908
  save(next: T): void;
8995
- /** Write a pending save now. */
10909
+ /**
10910
+ * Write a pending save now.
10911
+ * @capability None.
10912
+ * @throws Error (rejects) Node's file system error when the file cannot be written; its `code` is Node's (`ENOSPC`, `EACCES` and so on), not a player error code.
10913
+ * @example Write the file now before copying it
10914
+ * Before exporting its settings, the plugin makes sure the file on disk has
10915
+ * the latest value. `flush` writes a pending save at once (or waits for
10916
+ * the write in progress) and resolves when the file is written.
10917
+ * ```ts
10918
+ * const store = plugin.storage.json("settings", { defaults: { step: 5 } });
10919
+ * await store.flush(); // the file now has the newest value
10920
+ * await copyFile(join(plugin.dataDirectory, "settings.json"), exportPath);
10921
+ * ```
10922
+ */
8996
10923
  flush(): Promise<void>;
8997
10924
  }
8998
10925
  ```
8999
10926
 
10927
+ Example: Count how often the plugin was started
10928
+
10929
+ The counter grows by one at every start and is kept in `stats.json`. The
10930
+ store reads the file when it is made (the defaults when there is none),
10931
+ and `save` writes the new count a moment later.
10932
+ ```ts
10933
+ const stats = plugin.storage.json("stats", { defaults: { starts: 0 } });
10934
+ stats.save({ starts: stats.value.starts + 1 }); // written 300 ms later
10935
+ ```
10936
+
9000
10937
  ### Storage
9001
10938
 
9002
10939
  `plugin.storage`: JSON settings files. No capability.
9003
10940
 
10941
+ Two kinds of storage, both private to the plugin and needing no
10942
+ capability. Storage.json keeps settings in JSON files in the
10943
+ plugin's data directory: they last across player restarts and upgrades,
10944
+ and are written by the plugin's own process. Storage.session
10945
+ keeps values in the player's memory for one player run: they survive a
10946
+ restart of the plugin's process but not of the player. Secrets belong in
10947
+ Secrets, never in either.
10948
+
10949
+ - @capability None.
10950
+
9004
10951
  ```ts
9005
10952
  interface Storage {
9006
10953
  /**
@@ -9011,6 +10958,8 @@ interface Storage {
9011
10958
  *
9012
10959
  * The file is read synchronously on creation; `debounceMs` defaults to
9013
10960
  * 300. Files are created with mode 0600. `invalid` for a bad name.
10961
+ * @capability None.
10962
+ * @throws invalid (throws) A bad `name`, or a `debounceMs` that is not a non-negative number.
9014
10963
  * @example Keep settings and a recent list in a JSON file
9015
10964
  * The plugin remembers whether to resume and the last ten files across
9016
10965
  * restarts. `json` reads `settings.json` from the data directory at once
@@ -9040,6 +10989,19 @@ interface Storage {
9040
10989
  }
9041
10990
  ```
9042
10991
 
10992
+ Example: Keep a setting across restarts and a queue for this run
10993
+
10994
+ The plugin's step size survives a restart of the player; its work queue
10995
+ survives a reload of the plugin but starts empty with the next player
10996
+ run. The JSON store reads its file at once and writes it shortly after
10997
+ each `save`; session values live in the player process.
10998
+ ```ts
10999
+ const settings = plugin.storage.json("settings", { defaults: { step: 5 } });
11000
+ const queue = (await plugin.storage.session.get<string[]>("queue")) ?? [];
11001
+ void settings.value.step; // read synchronously, from the file or the defaults
11002
+ void queue;
11003
+ ```
11004
+
9043
11005
  ### SessionStorage
9044
11006
 
9045
11007
  `plugin.storage.session`: JSON values that live as long as the **player**,
@@ -9085,17 +11047,74 @@ quitting).
9085
11047
  resolves, so a crash right after loses nothing; there is no debounce.
9086
11048
  Calls answer in order.
9087
11049
 
11050
+ The player keeps the values in its own memory, per plugin, until it
11051
+ quits; the plugin's process may restart in between. Each call is one
11052
+ request to the player, answered in order, with no debounce.
11053
+
11054
+ - @capability None.
11055
+
9088
11056
  ```ts
9089
11057
  interface SessionStorage {
9090
- /** The value stored under `key`, or `undefined` when there is none. */
11058
+ /**
11059
+ * The value stored under `key`, or `undefined` when there is none.
11060
+ * @capability None.
11061
+ * @throws invalid (rejects) A bad key (empty, over 1024 bytes, with NUL or a lone surrogate).
11062
+ * @throws disabled (rejects) The plugin is stopping.
11063
+ * @throws unsupported (rejects) A player before 0.5.5.
11064
+ * @example Pick up where the plugin stopped after a reload
11065
+ * After a reload the plugin resumes the item it was working on. `get`
11066
+ * asks the player and resolves with the value parsed from JSON, or
11067
+ * `undefined` when nothing is stored; the value is not checked, so its
11068
+ * shape is checked here.
11069
+ * ```ts
11070
+ * const current = await plugin.storage.session.get<{ url: string }>("current");
11071
+ * if (typeof current?.url === "string") await plugin.osd.show("Resuming {}", [current.url]);
11072
+ * ```
11073
+ */
9091
11074
  get<T = unknown>(key: string): Promise<T | undefined>;
9092
11075
  /**
9093
11076
  * Store or replace `value` under `key`. `invalid` for a bad key or a value that is not JSON, `limit` when the plugin's values would be over 1 MiB or the request over the 1 MiB message limit.
11077
+ * @capability None.
11078
+ * @throws invalid (rejects) A bad key, or a value JSON cannot hold (or a string with a lone surrogate).
11079
+ * @throws disabled (rejects) The plugin is stopping.
11080
+ * @throws unsupported (rejects) A player before 0.5.5.
11081
+ * @throws limit (rejects) The plugin's values would pass 1 MiB, or the request the 1 MiB message limit; nothing changes.
11082
+ * @example Remember the item being worked on
11083
+ * The plugin records the URL it is processing, so a reload can continue
11084
+ * with it. The value is stored as `JSON.stringify` writes it, and is kept
11085
+ * once the promise resolves.
11086
+ * ```ts
11087
+ * await plugin.storage.session.set("current", { url: "https://example.com/a.mkv" });
11088
+ * ```
9094
11089
  */
9095
11090
  set(key: string, value: unknown): Promise<void>;
9096
- /** Remove a key; removing a missing one is fine. */
11091
+ /**
11092
+ * Remove a key; removing a missing one is fine.
11093
+ * @capability None.
11094
+ * @throws invalid (rejects) A bad key.
11095
+ * @throws disabled (rejects) The plugin is stopping.
11096
+ * @throws unsupported (rejects) A player before 0.5.5.
11097
+ * @example Forget the item once it is done
11098
+ * When the work finishes, the plugin removes the entry so that a later
11099
+ * reload does not resume it. Removing a key that is not there is fine.
11100
+ * ```ts
11101
+ * await plugin.storage.session.delete("current");
11102
+ * ```
11103
+ */
9097
11104
  delete(key: string): Promise<void>;
9098
- /** The keys stored, in code point order. */
11105
+ /**
11106
+ * The keys stored, in code point order.
11107
+ * @capability None.
11108
+ * @throws disabled (rejects) The plugin is stopping.
11109
+ * @throws unsupported (rejects) A player before 0.5.5.
11110
+ * @example Clear everything the plugin stored for this run
11111
+ * The plugin starts its session state over. `keys` lists the plugin's own
11112
+ * keys in code point order; each `delete` is a request of its own.
11113
+ * ```ts
11114
+ * const session = plugin.storage.session;
11115
+ * for (const key of await session.keys()) await session.delete(key);
11116
+ * ```
11117
+ */
9099
11118
  keys(): Promise<string[]>;
9100
11119
  }
9101
11120
  ```
@@ -9119,15 +11138,61 @@ await plugin.storage.session.set("queue", pending);
9119
11138
  `plugin.system`: files and links, executed by the player. No capability.
9120
11139
  Other systems than macOS and Windows: `unsupported`.
9121
11140
 
11141
+ The player opens files, folders and links with the system's own
11142
+ applications for the plugin, so the plugin never runs `open`,
11143
+ `explorer` or a shell itself.
11144
+
11145
+ - @capability None.
11146
+
9122
11147
  ```ts
9123
11148
  interface System {
9124
11149
  /**
9125
11150
  * Select files or folders in the file manager (macOS: one Finder window for all, so several files of one folder are all highlighted; Windows: one Explorer window per folder, selecting its first path). Paths that do not exist are skipped; `missing` when none exists; at most 256.
11151
+ * @capability None.
11152
+ * @throws missing (rejects) None of the paths exists.
11153
+ * @throws invalid (rejects) No paths, or more than 256.
11154
+ * @throws failed (rejects) The system refused.
11155
+ * @throws timeout (rejects) macOS did not answer within 10 s.
11156
+ * @throws unsupported (rejects) A system other than macOS and Windows.
11157
+ * @example Show the exported files in the file manager
11158
+ * After an export, the file manager opens with the new files selected.
11159
+ * Paths that no longer exist are skipped; the call rejects with `missing`
11160
+ * only when none of them exists.
11161
+ * ```ts
11162
+ * await plugin.system.reveal(exported); // macOS: one Finder window for all
11163
+ * ```
9126
11164
  */
9127
11165
  reveal(paths: string | string[]): Promise<void>;
9128
- /** Open the file or folder with its default application. `missing` if absent. */
11166
+ /**
11167
+ * Open the file or folder with its default application. `missing` if absent.
11168
+ * @capability None.
11169
+ * @throws missing (rejects) The path does not exist.
11170
+ * @throws invalid (rejects) An empty path.
11171
+ * @throws failed (rejects) The system refused.
11172
+ * @throws timeout (rejects) macOS did not answer within 10 s.
11173
+ * @throws unsupported (rejects) A system other than macOS and Windows.
11174
+ * @example Open the plugin's data folder
11175
+ * A "Show data folder" command opens the plugin's data directory in the
11176
+ * file manager, which is the default application for a folder.
11177
+ * ```ts
11178
+ * await plugin.system.openPath(plugin.dataDirectory);
11179
+ * ```
11180
+ */
9129
11181
  openPath(path: string): Promise<void>;
9130
- /** Open an http(s) address in the default browser. `invalid` for another protocol or a malformed address; `timeout` after 10 s, `failed` otherwise. */
11182
+ /**
11183
+ * Open an http(s) address in the default browser. `invalid` for another protocol or a malformed address; `timeout` after 10 s, `failed` otherwise.
11184
+ * @capability None.
11185
+ * @throws invalid (rejects) Not an `http:` or `https:` address.
11186
+ * @throws timeout (rejects) macOS did not hand it to the browser within 10 s.
11187
+ * @throws failed (rejects) The system refused.
11188
+ * @throws unsupported (rejects) A system other than macOS and Windows.
11189
+ * @example Open the plugin's help page
11190
+ * A Help command opens the plugin's documentation in the default browser.
11191
+ * Only `http:` and `https:` addresses are opened.
11192
+ * ```ts
11193
+ * await plugin.system.openUrl("https://example.com/plugin/help");
11194
+ * ```
11195
+ */
9131
11196
  openUrl(url: string): Promise<void>;
9132
11197
  }
9133
11198
  ```
@@ -9153,20 +11218,74 @@ try {
9153
11218
  Per-plugin secrets (API keys, passwords) in the system's secret store:
9154
11219
  the login keychain on macOS, the Credential Manager on Windows. Needs
9155
11220
  `secrets`. `unsupported` on systems without a store, `unavailable` when
9156
- the store refuses access: keep a fallback (or ask again).
11221
+ the store cannot be reached (no keychain, a read-only store, no logged-in
11222
+ session on Windows): keep a fallback (or ask again).
9157
11223
 
9158
11224
  The user denying access and other system errors reject `failed`. Never
9159
11225
  write secrets to settings files.
9160
11226
 
11227
+ Values are stored by the player in the system's secret store under this
11228
+ plugin's id: other plugins cannot read them. Names are 1–64 of
11229
+ `a-z 0-9 . _ -`, values 1–1024 bytes of text, at most 256 names per
11230
+ plugin (`SECRET_LIMITS`).
11231
+
11232
+ - @capability secrets
11233
+
9161
11234
  ```ts
9162
11235
  interface Secrets {
9163
- /** The stored value, `null` when there is none. */
11236
+ /**
11237
+ * The stored value, `null` when there is none.
11238
+ * @capability secrets
11239
+ * @throws unsupported (rejects) The system has no secret store.
11240
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
11241
+ * @throws failed (rejects) The user denied access, or another system error.
11242
+ * @throws capability (rejects) The manifest does not declare `secrets`.
11243
+ * @throws invalid (rejects) A bad name.
11244
+ * @example Read a stored token
11245
+ * The plugin uses the token the user saved earlier, or asks for one when
11246
+ * there is none. `get` resolves with the stored text or `null`.
11247
+ * ```ts
11248
+ * const token = await plugin.secrets.get("api-token");
11249
+ * if (token === null) await plugin.osd.show("Sign in first");
11250
+ * ```
11251
+ */
9164
11252
  get(name: string): Promise<string | null>;
9165
11253
  /**
9166
11254
  * Store or replace (`name`: 1–64 of `a-z 0-9 . _ -`; value 1–1024 bytes). At most 256 different names per plugin: a new name beyond that rejects `limit`. Uninstalling with `removeData` deletes them.
11255
+ * @capability secrets
11256
+ * @throws invalid (rejects) A bad name, or a value that is empty, has NUL or is over 1024 bytes.
11257
+ * @throws limit (rejects) A new name beyond 256.
11258
+ * @throws unsupported (rejects) The system has no secret store.
11259
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
11260
+ * @throws failed (rejects) The user denied access, or another system error.
11261
+ * @throws capability (rejects) The manifest does not declare `secrets`.
11262
+ * @example Save the token the user entered
11263
+ * The token the user typed is kept in the system's secret store for later
11264
+ * runs. `set` stores or replaces it; the store can refuse, in which case
11265
+ * the plugin keeps the token for this run only.
11266
+ * ```ts
11267
+ * await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
11268
+ * // No store, or access denied: the token is not kept for next time.
11269
+ * void plugin.osd.show("The token could not be saved ({})", [error.code]);
11270
+ * });
11271
+ * ```
9167
11272
  */
9168
11273
  set(name: string, value: string): Promise<void>;
9169
- /** Remove; removing a missing secret is fine. */
11274
+ /**
11275
+ * Remove; removing a missing secret is fine.
11276
+ * @capability secrets
11277
+ * @throws invalid (rejects) A bad name.
11278
+ * @throws unsupported (rejects) The system has no secret store.
11279
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
11280
+ * @throws failed (rejects) The user denied access, or another system error.
11281
+ * @throws capability (rejects) The manifest does not declare `secrets`.
11282
+ * @example Sign out by removing the token
11283
+ * Signing out removes the stored token, so the next start asks again.
11284
+ * Removing a name that is not stored is fine.
11285
+ * ```ts
11286
+ * await plugin.secrets.delete("api-token");
11287
+ * ```
11288
+ */
9170
11289
  delete(name: string): Promise<void>;
9171
11290
  }
9172
11291
  ```
@@ -9591,6 +11710,13 @@ A tool window as the test host keeps it: the record of a
9591
11710
  `plugin.windows.create(...)` window after its first `show()`. Get one with
9592
11711
  TestHost.window.
9593
11712
 
11713
+ A record, not a window: no Slint file is compiled and nothing is drawn.
11714
+ `props` holds what the plugin wrote, by Slint name; `emit` fires a
11715
+ callback the way the user's input would; `userClose` closes it the way
11716
+ the title-bar button would.
11717
+
11718
+ - @capability None.
11719
+
9594
11720
  ```ts
9595
11721
  interface FakeWindow {
9596
11722
  /** The `.slint` file the window was created from (package-relative). */
@@ -9605,10 +11731,32 @@ interface FakeWindow {
9605
11731
  callbacks: string[];
9606
11732
  /**
9607
11733
  * Fire a Slint callback as if the user triggered it in the window. The name may be kebab-case or camelCase; `args` are the callback arguments. Follow it with `await host.settle()` before asserting.
11734
+ * @capability None.
11735
+ * @example Type into a window's search field
11736
+ * The test types "cat" into the window's search box and checks the
11737
+ * plugin's results. `emit` delivers the Slint callback `search-changed`
11738
+ * with its argument to the plugin's `on` handler; `settle()` waits for the
11739
+ * properties the handler wrote.
11740
+ * ```ts
11741
+ * const search = host.window("search");
11742
+ * search.emit("searchChanged", "cat"); // camelCase or kebab-case
11743
+ * await host.settle();
11744
+ * assert.ok((search.props["results"] as unknown[]).length > 0);
11745
+ * ```
9608
11746
  */
9609
11747
  emit(callback: string, ...args: Json[]): void;
9610
11748
  /**
9611
11749
  * The user closes the window with its title-bar button: the window closes and the plugin's `window.onClose` listeners run (unlike the plugin's own `close()`).
11750
+ * @capability None.
11751
+ * @example Close a window the way the user does
11752
+ * The test closes the plugin's window with its title-bar button and checks
11753
+ * that the plugin stopped its timer. Unlike the plugin's own `close()`,
11754
+ * `userClose` runs the plugin's `onClose` listeners.
11755
+ * ```ts
11756
+ * host.window("clock").userClose();
11757
+ * await host.settle();
11758
+ * assert.equal(host.window("clock").visible, false);
11759
+ * ```
9612
11760
  */
9613
11761
  userClose(): void;
9614
11762
  }
@@ -9651,6 +11799,13 @@ A command the plugin registered with `plugin.commands.add`, as the test
9651
11799
  host keeps it. Get one with TestHost.command (the plugin's own) or
9652
11800
  TestHost.coreAction (the player's core actions).
9653
11801
 
11802
+ The host's record of one command: what the plugin declared, the state
11803
+ it last sent, and how the menu shows it in TestHost.locale.
11804
+ Read it again after `settle()` to see changes; `run` executes it as the
11805
+ menu would.
11806
+
11807
+ - @capability None.
11808
+
9654
11809
  ```ts
9655
11810
  interface FakeCommand {
9656
11811
  /** What the plugin declared: id, menu path, title, order, group, default bindings, focus and repeat. Wire-level shape; it may gain fields in minor versions. */
@@ -9683,6 +11838,15 @@ interface FakeCommand {
9683
11838
  revision: number;
9684
11839
  /**
9685
11840
  * Execute the command the way the menu would, then wait until the host is idle. Rejects (with a HostError) when the command is disabled or busy, like the player, which does not call `run` then.
11841
+ * @capability None.
11842
+ * @throws command (rejects) A `HostError`: the command is disabled or busy (the message says which).
11843
+ * @example Check that a disabled item refuses to run
11844
+ * The test checks that Export cannot run while nothing is open. Like the
11845
+ * player, the host does not call `run` for a disabled or busy command;
11846
+ * the promise rejects with a `HostError`.
11847
+ * ```ts
11848
+ * await assert.rejects(host.command("export").run());
11849
+ * ```
9686
11850
  */
9687
11851
  run(): Promise<void>;
9688
11852
  }
@@ -9708,6 +11872,13 @@ assert.equal(host.command("toggle-loop").state.checked, true);
9708
11872
  A command's bottom-bar button (`CommandOptions.button`) as the player shows
9709
11873
  it: an entry of TestHost.barButtons.
9710
11874
 
11875
+ A bottom-bar button as the player would show it, derived from the
11876
+ command's declaration and state each time it is read: clickable while
11877
+ the command is enabled and not busy, accent-coloured while checked, with
11878
+ the command's title (and reason) as the tooltip.
11879
+
11880
+ - @capability None.
11881
+
9711
11882
  ```ts
9712
11883
  interface FakeBarButton {
9713
11884
  /** The command's local id. */
@@ -9726,17 +11897,45 @@ interface FakeBarButton {
9726
11897
  readonly tooltip: string;
9727
11898
  /**
9728
11899
  * Click the button, then wait until the host is idle: runs the command as its menu item would. Rejects (with a HostError) while the button is disabled, and once the command is gone.
11900
+ * @capability None.
11901
+ * @throws command (rejects) A `HostError`: the command is disabled or busy.
11902
+ * @throws missing (rejects) A `HostError`: the command is gone.
11903
+ * @example Click the button and wait for its effect
11904
+ * The test clicks the plugin's button and checks the OSD. `click` runs the
11905
+ * command like its menu item and resolves once the host is idle.
11906
+ * ```ts
11907
+ * await host.barButtons[0]?.click();
11908
+ * assert.ok(host.osd.length > 0);
11909
+ * ```
9729
11910
  */
9730
11911
  click(): Promise<void>;
9731
11912
  }
9732
11913
  ```
9733
11914
 
11915
+ Example: Check a bar button's tooltip while it is disabled
11916
+
11917
+ The test checks that the Export button shows why it cannot be clicked.
11918
+ The tooltip is the command's title; while the command is disabled or
11919
+ busy and has a non-empty `reason`, the reason follows on a second line.
11920
+ ```ts
11921
+ const [button] = host.barButtons;
11922
+ assert.equal(button?.enabled, false);
11923
+ assert.match(button?.tooltip ?? "", /\n/); // the command declares a reason
11924
+ ```
11925
+
9734
11926
  ### FakeProducer
9735
11927
 
9736
11928
  A generated stream (`plugin.player.openStream`) as the test host sees it:
9737
11929
  the fake player end of the frame connection. Assertion surface; see
9738
11930
  TestHost.producers.
9739
11931
 
11932
+ The player's end of a generated stream's frame connection
11933
+ (TestHost.producers): how many frames arrived and whether the
11934
+ plugin released it or the player retired it. Tests assert on it; nothing
11935
+ is played.
11936
+
11937
+ - @capability None.
11938
+
9740
11939
  ```ts
9741
11940
  interface FakeProducer {
9742
11941
  /** The producer id the host assigned (`1`, `2`, … per host). */
@@ -9758,6 +11957,18 @@ interface FakeProducer {
9758
11957
  }
9759
11958
  ```
9760
11959
 
11960
+ Example: Check that the stream delivers frames
11961
+
11962
+ The test starts the plugin's generator and checks that video and audio
11963
+ arrive. The counters grow as the SDK pulls frames and sends them over
11964
+ the fake connection.
11965
+ ```ts
11966
+ await host.command("start-generator").run();
11967
+ await host.settle(200); // let a few frames through
11968
+ const [producer] = host.producers.values();
11969
+ assert.ok((producer?.videoFrames ?? 0) > 0 && (producer?.receivedFrames ?? 0) > 0);
11970
+ ```
11971
+
9761
11972
  ### MarkdownWire
9762
11973
 
9763
11974
  A Markdown document as TestHost.markdown reads it back.
@@ -9812,6 +12023,15 @@ Fields that mirror player state at the wire level (`source`, `subtitles`,
9812
12023
  `calls`) use the protocol's shapes. They are fine for assertions but are
9813
12024
  not part of the plugin API: their shape may change in minor versions.
9814
12025
 
12026
+ An in-memory player that speaks the real wire protocol, so the SDK's own
12027
+ logic runs as it does in the player; only the player side is simulated
12028
+ (no decoding, no Slint, no windows). Make one per test, preset it if the
12029
+ plugin reads something during activation, start the plugin with
12030
+ startPlugin, drive it, `settle()`, and assert on what it
12031
+ recorded.
12032
+
12033
+ - @capability None.
12034
+
9815
12035
  ```ts
9816
12036
  class TestHost implements Transport {
9817
12037
  /** Every request the plugin sent, in order: `{ method, params }`. Wire-level; prefer TestHost.sent for one method. */
@@ -9894,6 +12114,15 @@ class TestHost implements Transport {
9894
12114
  /**
9895
12115
  * Read the plugin's translations for TestHost.locale from a package directory, as the player would (`locales/<locale>.json` of a built package, else the source `locales/<locale>.po`); `startPlugin` does this. None for English; a missing file means none; a broken `.po` throws.
9896
12116
  * @param directory - The package directory (holding `locales/`).
12117
+ * @capability None.
12118
+ * @example Load the translations after changing the language
12119
+ * The test switches the host to German before starting the plugin and
12120
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
12121
+ * shown in German.
12122
+ * ```ts
12123
+ * host.locale = "de";
12124
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
12125
+ * ```
9897
12126
  */
9898
12127
  loadCatalog(directory: string): void;
9899
12128
  /**
@@ -9901,6 +12130,7 @@ class TestHost implements Transport {
9901
12130
  * @param text - English source text or template.
9902
12131
  * @param args - Values for its placeholders.
9903
12132
  * @returns The shown text.
12133
+ * @capability None.
9904
12134
  * @example Compare with a translated message
9905
12135
  * The test expects the text a Chinese user sees for a template the plugin
9906
12136
  * shows. `translate` looks the English source up in the plugin's catalog
@@ -9915,6 +12145,19 @@ class TestHost implements Transport {
9915
12145
  /**
9916
12146
  * Simulate another plugin (or the user) saving the interface language: pushes `appearance.changed`.
9917
12147
  * @param language - `"system"` or a locale.
12148
+ * @capability None.
12149
+ * @example Simulate the user choosing French for the next start
12150
+ * The plugin hears that the saved interface language changed. The host
12151
+ * pushes `appearance.changed`, which a plugin with `events` sees in
12152
+ * `appearance.language` once it has read it and subscribed; this run's
12153
+ * language does not change.
12154
+ * ```ts
12155
+ * void plugin.appearance.language; // read once: the SDK then follows it
12156
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
12157
+ * host.setLanguage("fr");
12158
+ * await host.settle();
12159
+ * assert.equal(plugin.appearance.language, "fr");
12160
+ * ```
9918
12161
  */
9919
12162
  setLanguage(language: LanguagePreference): void;
9920
12163
  /**
@@ -10071,6 +12314,7 @@ class TestHost implements Transport {
10071
12314
  * Simulate another plugin publishing its timeline layer (or clearing it with `[]`); pushes `timeline.changed` when the plugin subscribed.
10072
12315
  * @param pluginId - The other plugin's manifest id.
10073
12316
  * @param marks - Its whole layer.
12317
+ * @capability None.
10074
12318
  * @example Let another plugin publish chapters
10075
12319
  * The plugin under test sees two chapter marks another plugin put on the
10076
12320
  * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
@@ -10104,6 +12348,7 @@ class TestHost implements Transport {
10104
12348
  }[];
10105
12349
  /**
10106
12350
  * The file an image property shows, or `null` when the value is not a runtime image. View properties hold images in their wire form; this reads the path back.
12351
+ * @capability None.
10107
12352
  * @example Check which file a window shows as its cover
10108
12353
  * The test checks that the Cover window's image comes from the plugin's
10109
12354
  * data directory. An image property holds the SDK's wire form of
@@ -10129,6 +12374,8 @@ class TestHost implements Transport {
10129
12374
  * Drop files like the player: on a panel with an `onDrop` listener the whole list goes there; otherwise (and on the video area) each path goes to the first provider of its extension (`folder` for existing directories), one call per provider with all of its paths. Files nobody takes are ignored. Resolves once the host is idle again.
10130
12375
  * @param paths - The dropped paths, in the order the file manager gives.
10131
12376
  * @param options - `target`: `"video"` (default) or `"panel"`; `at`: the drop point in panel coordinates (default `{ x: 0, y: 0 }`); `modifiers`: keys held (default all `false`).
12377
+ * @capability None.
12378
+ * @throws Never: files nobody takes are ignored, as in the player.
10132
12379
  * @example Drop files on the video and on the panel
10133
12380
  * The first drop lands on the video area: each extension belongs to the
10134
12381
  * provider that registered it first, and each provider gets one call with
@@ -10159,7 +12406,20 @@ class TestHost implements Transport {
10159
12406
  }): Promise<void>;
10160
12407
  /** The palette (`plugin.appearance.theme`); `setAppearance` changes it elsewhere. */
10161
12408
  appearance: ThemeId;
10162
- /** Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`. */
12409
+ /**
12410
+ * Simulate another plugin switching the palette: updates TestHost.appearance and pushes `appearance.changed`.
12411
+ * @capability None.
12412
+ * @example Simulate another plugin switching to the light palette
12413
+ * The plugin hears the palette change. The host updates `appearance` and
12414
+ * pushes `appearance.changed` to a plugin that listens with `events`.
12415
+ * ```ts
12416
+ * const themes: ThemeId[] = [];
12417
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
12418
+ * host.setAppearance("vestra-light");
12419
+ * await host.settle();
12420
+ * assert.deepEqual(themes, ["vestra-light"]);
12421
+ * ```
12422
+ */
10163
12423
  setAppearance(theme: ThemeId): void;
10164
12424
  /** Settings pages by id (`plugin.settings.page`), every `show` (page id or null), and the page the options window shows. */
10165
12425
  readonly settings: {
@@ -10191,6 +12451,7 @@ class TestHost implements Transport {
10191
12451
  };
10192
12452
  /**
10193
12453
  * One settings page by id. Throws when it was never registered.
12454
+ * @capability None.
10194
12455
  * @example Change a value on the plugin's settings page
10195
12456
  * The test sets the step on the General page and checks the page shows
10196
12457
  * it. `emit` fires the page's callback as the user's input would; the
@@ -10228,13 +12489,26 @@ class TestHost implements Transport {
10228
12489
  /** Whether it shows now (no video frame). `true` when mounted. */
10229
12490
  effective: boolean;
10230
12491
  } | null;
10231
- /** Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin. */
12492
+ /**
12493
+ * Simulate the backdrop's `effective` changing (`false`: a video frame appeared; `true`: there is none again); pushed to the plugin.
12494
+ * @capability None.
12495
+ * @example Make a video picture replace the backdrop
12496
+ * The plugin's backdrop hears that a video frame now covers it. `false`
12497
+ * means a picture appeared; the change is pushed like the player's
12498
+ * `window.state`.
12499
+ * ```ts
12500
+ * host.setBackdropEffective(false);
12501
+ * await host.settle();
12502
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
12503
+ * ```
12504
+ */
10232
12505
  setBackdropEffective(effective: boolean): void;
10233
12506
  /**
10234
12507
  * Simulate another plugin mounting a panel, before or after the plugin under test, as the player handles it: in the sidebar it is another tab (after the existing ones; the first panel's tab is current); the bottom bar and the backdrop take one panel, so the plugin's own mount there then rejects with `conflict` (not an activation failure, as in the player). Pushed as a `window.state` change.
10235
12508
  * @param slot - The position.
10236
12509
  * @param plugin - The other plugin's manifest id.
10237
12510
  * @param options - `title`: its tab label (default: `plugin`).
12511
+ * @capability None.
10238
12512
  * @example Put the playlist next to this plugin's sidebar panel
10239
12513
  * The sidebar ends up with two tabs: the playlist, mounted first, and the
10240
12514
  * plugin's own panel after it. `mountOther` before `startPlugin` mounts
@@ -10251,10 +12525,31 @@ class TestHost implements Transport {
10251
12525
  mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
10252
12526
  title?: string;
10253
12527
  }): void;
10254
- /** Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is. */
12528
+ /**
12529
+ * Simulate another plugin's panel going away (TestHost.mountOther): when its tab was current, the next tab (else the previous one) is.
12530
+ * @capability None.
12531
+ * @example Let the playlist's tab go away
12532
+ * The other plugin's sidebar tab disappears and the plugin's own tab
12533
+ * becomes current. When the removed tab was current, the next tab (else
12534
+ * the previous one) becomes current, as in the player.
12535
+ * ```ts
12536
+ * host.unmountOther("vestra.playlist");
12537
+ * await host.settle();
12538
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
12539
+ * ```
12540
+ */
10255
12541
  unmountOther(plugin: string): void;
10256
12542
  /**
10257
12543
  * Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin under test's `manifestId`, or one of TestHost.mountOther) becomes the current tab. Pushed as a `window.state` change.
12544
+ * @capability None.
12545
+ * @example Click another plugin's sidebar tab
12546
+ * The user switches the sidebar to the playlist; the plugin's panel is in
12547
+ * a background tab now. The change is pushed as `window.state`.
12548
+ * ```ts
12549
+ * host.selectTab("vestra.playlist");
12550
+ * await host.settle();
12551
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
12552
+ * ```
10258
12553
  */
10259
12554
  selectTab(plugin: string): void;
10260
12555
  /** The display layer (`plugin.video.view()`): who owns it (this plugin is `manifestId`) and the values the player holds. */
@@ -10266,6 +12561,17 @@ class TestHost implements Transport {
10266
12561
  };
10267
12562
  /**
10268
12563
  * Simulate another plugin taking the display layer first (the plugin's `video.view()` then fails with `conflict`), or, with `null`, releasing it (values reset to the defaults).
12564
+ * @capability None.
12565
+ * @example Let another plugin own the display layer
12566
+ * The plugin's `video.view()` fails with `conflict` because another plugin
12567
+ * took the display layer first. `null` releases it again.
12568
+ * ```ts
12569
+ * host.setVideoViewOwner("someone.zoom");
12570
+ * const view = plugin.video.view();
12571
+ * view.set({ zoom: 2 });
12572
+ * await host.settle();
12573
+ * assert.equal(view.status.state, "failed");
12574
+ * ```
10269
12575
  */
10270
12576
  setVideoViewOwner(pluginId: string | null): void;
10271
12577
  /**
@@ -10281,10 +12587,22 @@ class TestHost implements Transport {
10281
12587
  };
10282
12588
  /**
10283
12589
  * Simulate another plugin owning the render options (the plugin's `video.render()` then fails with `conflict`), or, with `null`, releasing them (values reset to the defaults).
12590
+ * @capability None.
12591
+ * @example Let another plugin own the render options
12592
+ * The plugin's render options report `conflict` while another plugin owns
12593
+ * them; `null` releases them and resets the values to the defaults.
12594
+ * ```ts
12595
+ * host.setVideoRenderOwner("someone.hdr");
12596
+ * const render = plugin.video.render();
12597
+ * render.set({ toneMapping: "bt.2390" });
12598
+ * await host.settle();
12599
+ * assert.equal(render.status.state, "failed");
12600
+ * ```
10284
12601
  */
10285
12602
  setVideoRenderOwner(pluginId: string | null): void;
10286
12603
  /**
10287
12604
  * What the player pushes when mpv cannot compile a shader (code `failed`) or switches to software rendering (`unsupported`, which also sets `videoRender.gpu` to false). Pushed only when this plugin is the owner.
12605
+ * @capability None.
10288
12606
  * @example Make a shader fail to compile
10289
12607
  * The plugin's render options report `failed`, as when mpv rejects a
10290
12608
  * shader. The host pushes the failure only while this plugin owns the
@@ -10405,10 +12723,21 @@ class TestHost implements Transport {
10405
12723
  /** Exact host names it resolves (a web resolver), with `schemes`. */
10406
12724
  hosts?: string[];
10407
12725
  }[];
10408
- /** What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once. */
12726
+ /**
12727
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this plugin's file resolvers and of TestHost.otherResolvers, sorted, each once.
12728
+ * @capability None.
12729
+ * @example Check the file types a resolver adds
12730
+ * The test checks the list the plugin will read as
12731
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
12732
+ * `otherResolvers`, sorted, each once.
12733
+ * ```ts
12734
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
12735
+ * ```
12736
+ */
10409
12737
  fileTypes(): string[];
10410
12738
  /**
10411
12739
  * Push `resolvers.fileTypes` when TestHost.fileTypes changed: called after this plugin's resolvers come and go; call it yourself after changing TestHost.otherResolvers (another plugin started or stopped).
12740
+ * @capability None.
10412
12741
  * @example Let another plugin's file type appear while running
10413
12742
  * Another plugin starts and adds `.ch8`; the plugin under test then sees
10414
12743
  * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
@@ -10526,6 +12855,9 @@ class TestHost implements Transport {
10526
12855
  readonly dataDirectory: string;
10527
12856
  /** The manifest id of the plugin under test (set by startPlugin; `"test.plugin"` by default). */
10528
12857
  manifestId: string;
12858
+ /**
12859
+ * A fresh in-memory player: nothing open, no plugin, the player's core actions registered. Preset what the plugin reads during activation, then pass it to startPlugin as `host`.
12860
+ */
10529
12861
  constructor();
10530
12862
  /** The `player.setStepSeek` value in effect (default 5). */
10531
12863
  stepSeek: number;
@@ -10581,7 +12913,8 @@ class TestHost implements Transport {
10581
12913
  /** Every core action run (`host.run`, a core command, a pressed core binding), newest last. */
10582
12914
  readonly coreRuns: string[];
10583
12915
  /**
10584
- * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...) that the test host registers like the player, as `host/<id>`. Throws for an unknown id. `run()` executes it with the player's checks and writes its on-screen text to TestHost.osd.
12916
+ * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...) that the test host registers like the player, as `host/<id>`. Throws for an unknown id. `run()` executes it like its menu item: a disabled action rejects with `command`, as in the player (`plugin.host.run` rejects with `unavailable` instead); otherwise it runs with the player's checks and writes its on-screen text to TestHost.osd.
12917
+ * @capability None.
10585
12918
  * @example Pause through the player's own action
10586
12919
  * The test pauses playback the way the Space key does and checks that the
10587
12920
  * host recorded it. Core actions belong to the player (`host/<id>`);
@@ -10608,6 +12941,7 @@ class TestHost implements Transport {
10608
12941
  * find the one a call uses.
10609
12942
  * @param method - The wire method name.
10610
12943
  * @param handler - Produces the reply from the request parameters.
12944
+ * @capability None.
10611
12945
  * @example Make opening a folder fail
10612
12946
  * The plugin's Open Folder command meets a missing folder and must tell
10613
12947
  * the user. `handle` replaces the host's answer to one wire method; the
@@ -10624,17 +12958,43 @@ class TestHost implements Transport {
10624
12958
  * ```
10625
12959
  */
10626
12960
  handle(method: string, handler: Handler): void;
10627
- /** Restore the default behaviour of a method replaced with TestHost.handle. */
12961
+ /**
12962
+ * Restore the default behaviour of a method replaced with TestHost.handle.
12963
+ * @capability None.
12964
+ * @example Restore the default answer after a simulated failure
12965
+ * The first write fails, the second succeeds. `reset` removes the handler
12966
+ * that `handle` installed, so the method is answered by the test host
12967
+ * again.
12968
+ * ```ts
12969
+ * host.handle("clipboard.writeText", () => {
12970
+ * throw new HostError("failed", "denied");
12971
+ * });
12972
+ * await assert.rejects(plugin.clipboard.writeText("x"));
12973
+ * host.reset("clipboard.writeText");
12974
+ * await plugin.clipboard.writeText("x"); // the default handler again
12975
+ * ```
12976
+ */
10628
12977
  reset(method: string): void;
10629
12978
  /**
10630
12979
  * Deliver a host event to the plugin. Like the player, subscription-based events (`source.state`, `playback.backend`, `window.state`, `timeline.changed`, `appearance.changed`) are delivered only when the plugin subscribed (it declared `events` and listens). Most tests use the higher-level helpers (`setSource`, `setMainWindow`, ...) instead.
10631
12980
  * @param event - The wire event name.
10632
12981
  * @param data - The event payload (wire-level shape).
12982
+ * @capability None.
12983
+ * @example Deliver a raw event the helpers do not cover
12984
+ * The test pushes a `window.state` event after changing `layout` by hand.
12985
+ * Like the player, the host delivers it only when the plugin subscribed;
12986
+ * most tests use `setSource`, `setMainWindow` and the other helpers
12987
+ * instead.
12988
+ * ```ts
12989
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
12990
+ * await host.settle();
12991
+ * ```
10633
12992
  */
10634
12993
  emit(event: string, data: unknown): void;
10635
12994
  /**
10636
12995
  * Change the source facts and broadcast them, as if the player's state changed (the user paused, playback reached the end, a file finished loading). Any phase other than `empty` also turns on the source capabilities (pause, seek, rate, ...) unless the patch sets them. `phase: "loading"` makes a mounted backdrop effective, like the player.
10637
12996
  * @param patch - Wire-level source fields, e.g. `phase`, `position`, `duration`, `paused`, `audioTracks`.
12997
+ * @capability None.
10638
12998
  * @example Load a file, then let it play to the end
10639
12999
  * The plugin sees a one-minute source become ready, then end on its own.
10640
13000
  * `setSource` changes the host's source facts and broadcasts them like
@@ -10651,6 +13011,7 @@ class TestHost implements Transport {
10651
13011
  setSource(patch: Partial<SourceState>): void;
10652
13012
  /**
10653
13013
  * Simulate the user changing the main window (resizing it, entering or leaving fullscreen, closing the picture-in-picture window) and broadcast it. Like the player, entering fullscreen collapses an attached, visible sidebar (`effective: false`, `reason: "fullscreen"`) and leaving fullscreen docks it again.
13014
+ * @capability None.
10654
13015
  * @example Go fullscreen, then report picture-in-picture support
10655
13016
  * The plugin sees the main window fullscreen at 1920×1080, then learns
10656
13017
  * that picture-in-picture is available. `setMainWindow` changes the
@@ -10677,12 +13038,23 @@ class TestHost implements Transport {
10677
13038
  }>): void;
10678
13039
  /**
10679
13040
  * The pointer reached the right edge in fullscreen (`true`) or left the popped-out sidebar (`false`): the attached sidebar pops out or collapses (reference §6.3). No effect outside fullscreen, when hidden or floating.
13041
+ * @capability None.
13042
+ * @example Pop the sidebar out in fullscreen
13043
+ * The pointer reaches the right edge in fullscreen and the attached
13044
+ * sidebar pops out over the video. Outside fullscreen, or with the
13045
+ * sidebar hidden or floating, nothing happens.
13046
+ * ```ts
13047
+ * host.setMainWindow({ fullscreen: true });
13048
+ * host.popSidebar(true);
13049
+ * await host.settle();
13050
+ * ```
10680
13051
  */
10681
13052
  popSidebar(popped: boolean): void;
10682
13053
  /**
10683
13054
  * Simulate another plugin (or the user) opening a source: a new `sourceId`, then the given phase. Calls the plugin made against the old state now fail with `stale`.
10684
13055
  * @param source - The path or URL.
10685
13056
  * @param phase - The phase to end in (default `"ready"`).
13057
+ * @capability None.
10686
13058
  * @example Let the user open another file meanwhile
10687
13059
  * A state the plugin captured before stops being current once another
10688
13060
  * file opens. `openElsewhere` gives the source a new `sourceId`, so
@@ -10701,6 +13073,7 @@ class TestHost implements Transport {
10701
13073
  * Simulate a generated stream failing in the player (its audio device lost, its frame connection broken), like the player: the source fails with `message` (`phase: "error"`) and the producer is retired at once. The stream's `onStop` listeners get a `stream` error with `message`.
10702
13074
  * @param producerId - The producer, from TestHost.producers.
10703
13075
  * @param message - The player's failure text.
13076
+ * @capability None.
10704
13077
  * @example Break the plugin's generated stream
10705
13078
  * The plugin's stream stops with an error, as when the player loses its
10706
13079
  * audio device. `failStream` puts the source into `error` with the
@@ -10713,8 +13086,11 @@ class TestHost implements Transport {
10713
13086
  */
10714
13087
  failStream(producerId: number, message: string): void;
10715
13088
  /**
10716
- * Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; within a scope the earliest registration first (the player's own commands before the plugin's), as the conflict rule orders them. A disabled command lets the key pass to the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it shadowed, then the global scope. The first enabled one runs; `press` resolves after it ran (`"ran"`), or rejects when it is busy. When only disabled commands hold the key, the first one with a `reason` shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without a reason the key goes on to the focused control (`"passed"`, nothing runs). Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
13089
+ * Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; within a scope the earliest registration first (the player's own commands before the plugin's), as the conflict rule orders them. A disabled command lets the key pass to the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it shadowed, then the global scope. The first enabled one runs; `press` resolves after it ran (`"ran"`). When only disabled commands hold the key, the first of them decides, as in the player: with a `reason` it shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without one the key goes on to the focused control (`"passed"`, nothing runs). Unlike the player, which only logs a key that reaches a busy command, `press` then rejects, so a test notices. Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
10717
13090
  * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
13091
+ * @capability None.
13092
+ * @throws command (rejects) A `HostError`: the command that would run is busy (the player only logs this).
13093
+ * @throws Error (rejects) Nothing is bound to the key.
10718
13094
  * @example Press a global key and a panel key
10719
13095
  * Ctrl+B runs whatever is bound globally; Delete is pressed while the
10720
13096
  * plugin's panel has focus, so panel bindings are tried first. `press`
@@ -10805,6 +13181,7 @@ class TestHost implements Transport {
10805
13181
  readonly remoteImages: Map<string, string | null>;
10806
13182
  /**
10807
13183
  * The Markdown document a value holds (its wire form read back), or `null` when the value is not one. `images` maps each remote address to the downloaded file. Pass a whole property value or a field of one: a document nested in rows is read the same way.
13184
+ * @capability None.
10808
13185
  * @example Read back a Markdown message in a chat window
10809
13186
  * The test checks the Markdown source of the first chat message. Rows
10810
13187
  * hold the document in its wire form, as `plugin.ui.markdown` produced
@@ -10822,13 +13199,47 @@ class TestHost implements Transport {
10822
13199
  /** The system's name for it. */
10823
13200
  name: string;
10824
13201
  }[];
10825
- /** Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed). */
13202
+ /**
13203
+ * Replace the plugin list and push `packages.changed` (another plugin started, stopped, was installed or removed).
13204
+ * @capability None.
13205
+ * @example Simulate another plugin starting
13206
+ * The plugin's store window hears that a plugin is running now. The list
13207
+ * replaces `packages` and is pushed as `packages.changed`, which the
13208
+ * plugin's `packages.onChange` listeners receive.
13209
+ * ```ts
13210
+ * host.setPackages([...host.packages]); // edit a copy, then push it
13211
+ * await host.settle();
13212
+ * ```
13213
+ */
10826
13214
  setPackages(list: TestHost["packages"]): void;
10827
13215
  /**
10828
13216
  * Simulate another plugin calling `player.next()` / `previous()`: the host asks this plugin's sequence provider. Rejects with `unavailable` when there is no provider or its flag is false.
13217
+ * @capability None.
13218
+ * @throws unavailable (rejects) A `HostError`: there is no sequence provider, or its flag is false.
13219
+ * @example Ask the plugin's provider for the next item
13220
+ * Another plugin calls `player.next()`; the host asks the plugin under
13221
+ * test, which is the sequence provider, and the test checks what it
13222
+ * opened.
13223
+ * ```ts
13224
+ * await host.step("next");
13225
+ * await host.settle();
13226
+ * assert.equal(host.source.source, "/media/b.mp4");
13227
+ * ```
10829
13228
  */
10830
13229
  step(direction: "next" | "previous"): Promise<unknown>;
10831
- /** The plugin's command with this local id. Throws when it is not registered (never added, or disposed). */
13230
+ /**
13231
+ * The plugin's command with this local id. Throws when it is not registered (never added, or disposed).
13232
+ * @capability None.
13233
+ * @example Read a command's state after a change
13234
+ * The test checks that Export becomes enabled once a file is ready. The
13235
+ * record is updated as the plugin sends new state; read it after
13236
+ * `settle()`.
13237
+ * ```ts
13238
+ * host.setSource({ phase: "ready", duration: 60 });
13239
+ * await host.settle();
13240
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
13241
+ * ```
13242
+ */
10832
13243
  command(id: string): FakeCommand;
10833
13244
  /**
10834
13245
  * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a later one stays a menu item only). Empty once the command is disposed. Whether the bar places them is the bar's business: the list is what the player hands to `BarButtons`.
@@ -10848,6 +13259,7 @@ class TestHost implements Transport {
10848
13259
  get barButtons(): readonly FakeBarButton[];
10849
13260
  /**
10850
13261
  * The plugin's tool window with this id (by default the file name without extension: `ui/picture.slint` → `picture`). Throws when it was never opened with `show()`.
13262
+ * @capability None.
10851
13263
  * @example Press a window's Reset button from a test
10852
13264
  * The Picture window's Reset callback runs and the brightness returns to
10853
13265
  * 0. The window is found by its id (the `.slint` file name by default);
@@ -10862,6 +13274,7 @@ class TestHost implements Transport {
10862
13274
  window(id: string): FakeWindow;
10863
13275
  /**
10864
13276
  * The plugin's mounted panel (bottom bar, sidebar or backdrop). Throws when none is mounted. Its `props` use Slint (kebab-case) names; `emit` fires a callback on the panel's global.
13277
+ * @capability None.
10865
13278
  * @example Select a row in the plugin's sidebar panel
10866
13279
  * The test checks the panel sits in the sidebar and selects its third
10867
13280
  * row. `emit` fires a callback on the panel's global as a click would;
@@ -10883,6 +13296,7 @@ class TestHost implements Transport {
10883
13296
  };
10884
13297
  /**
10885
13298
  * The parameters of every request of one wire method, oldest first.
13299
+ * @capability None.
10886
13300
  * @example Check the request behind an OSD message
10887
13301
  * The test checks the last OSD request the plugin sent, before
10888
13302
  * translation. `sent` lists the parameters of every request of one wire
@@ -10903,6 +13317,8 @@ class TestHost implements Transport {
10903
13317
  * for: on a loaded machine it can finish after `settle()` returns. Poll
10904
13318
  * the result you assert on instead (second example).
10905
13319
  * @param ms - Extra time to wait, for debounces and timers (default 0).
13320
+ * @capability None.
13321
+ * @throws Never.
10906
13322
  * @example Wait past a debounced save
10907
13323
  * The plugin's store writes its file 300 ms after the last `save`, so
10908
13324
  * the test waits 400 ms before it checks what the plugin did next.
@@ -10928,7 +13344,22 @@ class TestHost implements Transport {
10928
13344
  * ```
10929
13345
  */
10930
13346
  settle(ms?: number): Promise<void>;
10931
- /** Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it. */
13347
+ /**
13348
+ * Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it.
13349
+ * @capability None.
13350
+ * @example Clean up a host that was never started
13351
+ * A test that made a host but failed before `startPlugin` removes the
13352
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
13353
+ * `close` for you otherwise.
13354
+ * ```ts
13355
+ * const host = new TestHost();
13356
+ * try {
13357
+ * // … preset the host; the test fails here …
13358
+ * } finally {
13359
+ * host.close(); // deletes the host's temporary data
13360
+ * }
13361
+ * ```
13362
+ */
10932
13363
  close(): void;
10933
13364
  }
10934
13365
  ```
@@ -10978,9 +13409,14 @@ throws, or when a registration made during activation fails (for example
10978
13409
  `limit`, `conflict`, `invalid`), which is when the player would fail the
10979
13410
  activation.
10980
13411
 
13412
+ The way into a plugin's tests: one call starts the plugin the way the
13413
+ player's runner does and returns the host to drive, the `plugin` object
13414
+ to call directly, and `stop` to end it like the player.
13415
+
10981
13416
  - @param activate - The plugin's default-exported `activate` function.
10982
13417
  - @param options - Where the plugin lives and how to start it.
10983
13418
  - @returns `host`: the TestHost; `plugin`: the object passed to `activate` (call the plugin API on it directly); `stop()`: dispose the plugin like the player stopping it (scopes released, stores flushed), wait until the host is idle and remove the host's temporary files.
13419
+ - @capability None.
10984
13420
 
10985
13421
  ```ts
10986
13422
  function startPlugin(activate: (plugin: Plugin) => unknown, options?: {
@@ -11604,7 +14040,8 @@ The plugin keeps a token the user pasted, or tells them it is empty or
11604
14040
  too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
11605
14041
  `Buffer.byteLength`; `secrets.set` would refuse other values with
11606
14042
  `invalid`. The store itself can still refuse (`unsupported` without a
11607
- system store, `unavailable` or `failed` when it denies access): only
14043
+ system store, `unavailable` when it cannot be reached, `failed` when the
14044
+ user denies access): only
11608
14045
  those codes get the "could not be saved" message, anything else (a
11609
14046
  missing `secrets` capability) is a bug and is thrown on.
11610
14047
  ```ts
@@ -11688,7 +14125,7 @@ list.on("scrolled", (row: number) => {
11688
14125
 
11689
14126
  ### IMAGE_LIMITS
11690
14127
 
11691
- The player's runtime image limits (`host/src/ui_image.rs`): a file's
14128
+ The player's runtime image limits (`src/host/src/ui/ui_image.rs`): a file's
11692
14129
  bytes and sides, the decoded pixels each plugin caches, and (SDK 1.5.19,
11693
14130
  player 0.5.1) the decoded pixels the nested images of one view may show.
11694
14131
 
@@ -11724,11 +14161,17 @@ Common codes: `stale` (the condition no longer holds; nothing was done),
11724
14161
  `timeout`, `aborted`, `limit` (a player limit such as the command count),
11725
14162
  `disabled` (the plugin is stopping).
11726
14163
 
14164
+ A definite refusal, as opposed to a lost reply: the SDK does not retry
14165
+ it, and the plugin sees an `Error` with `code`.
14166
+
14167
+ - @capability None.
14168
+
11727
14169
  ```ts
11728
14170
  class HostError extends Error {
11729
14171
  /** The stable error code, e.g. `"invalid"`, `"stale"`, `"unavailable"`. */
11730
14172
  code: string;
11731
14173
  /**
14174
+ * A refusal with this code, as the player would send it.
11732
14175
  * @param code - The stable error code the caller will see as `error.code`.
11733
14176
  * @param message - Human-readable detail (not for branching on).
11734
14177
  */