@vestra-player/sdk 1.5.30 → 1.5.31

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.31: 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,203 @@ 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, so a plugin with its own list becomes the provider only while no other plugin holds it (for example with the playlist disabled). Any plugin can ask the provider with `player.next()` and `player.previous()`:
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
+
1035
1232
  ## How-to: Keep a slider responsive while dragging
1036
1233
 
1037
1234
  **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.
@@ -3889,12 +4086,23 @@ interface CommandOptions {
3889
4086
  A registered command, from Commands.add. Disposing it removes it
3890
4087
  from the menu and the binding table; the id can be added again right away.
3891
4088
 
4089
+ The handle of one menu item and its keys. Its state (`enabled`, `checked`,
4090
+ `busy`, `reason`) is bound: functions given to Commands.add or
4091
+ Command.set are evaluated again in every update pass and only
4092
+ changes reach the player. Its title, path, bindings and button cannot
4093
+ change in place; dispose the command (or a child Scope) and add it
4094
+ again. Disposing it, its scope or stopping the plugin removes the menu
4095
+ item, its keys and its bar button.
4096
+
4097
+ - @capability commands
4098
+
3892
4099
  ```ts
3893
4100
  interface Command extends Disposable {
3894
4101
  /** The local id given in CommandOptions.id. */
3895
4102
  readonly id: string;
3896
4103
  /**
3897
4104
  * Replace some state fields (values or functions). Last write wins.
4105
+ * @capability commands
3898
4106
  * @example Show a command as busy while it works
3899
4107
  * The Export item shows as busy with "Exporting…" as its reason while the
3900
4108
  * export runs, then returns to normal. `set` replaces only the fields it
@@ -3920,15 +4128,47 @@ interface Command extends Disposable {
3920
4128
  }>): void;
3921
4129
  /**
3922
4130
  * 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.
4131
+ * @throws conflict (rejects) Another command already has this menu path, or the path would be both a menu item and a submenu.
4132
+ * @throws capability (rejects) The manifest does not declare `commands`.
4133
+ * @throws invalid (rejects) The player refused the declaration.
4134
+ * @throws limit (rejects) More than 160 commands in this plugin, or 1024 in the player.
3923
4135
  */
3924
4136
  readonly ready: Promise<void>;
3925
4137
  }
3926
4138
  ```
3927
4139
 
4140
+ Example: Enable an item only while a file is open
4141
+
4142
+ Video ▸ Export… is greyed out until a file is open. `set` replaces the
4143
+ `enabled` field with a function; the SDK evaluates it again after every
4144
+ player state change and sends the new state when it changes, so the
4145
+ item follows the player without further code.
4146
+ ```ts
4147
+ const exportCommand = plugin.commands.add({
4148
+ id: "export",
4149
+ path: ["@video", "Export…"],
4150
+ run: runExport,
4151
+ });
4152
+ // A function: re-evaluated in every update pass.
4153
+ exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
4154
+ ```
4155
+
3928
4156
  ### Commands
3929
4157
 
3930
4158
  `plugin.commands`: menu commands with default bindings. Needs `commands`.
3931
4159
 
4160
+ A command is one action of the plugin with a menu item (`path`), default
4161
+ keys (`bindings`), optionally a bottom-bar button (`button`) and a state
4162
+ the menu shows (`enabled`, `checked`, `busy`, `reason`). The player owns
4163
+ the menu, the binding table and the user's key overrides: it calls `run`
4164
+ when the user picks the item, presses a key or clicks the button.
4165
+ Commands of all plugins merge into the player's menus by their English
4166
+ path keys. Registration is asynchronous: `add` returns at once, and
4167
+ Command.ready reports the player's answer (during activation a
4168
+ refusal fails activation).
4169
+
4170
+ - @capability commands
4171
+
3932
4172
  ```ts
3933
4173
  interface Commands {
3934
4174
  /**
@@ -3938,6 +4178,7 @@ interface Commands {
3938
4178
  * Titles and paths cannot change in place: put changing items (track
3939
4179
  * lists, presets) in a child Scope and rebuild it.
3940
4180
  * @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.
4181
+ * @capability commands
3941
4182
  * @example Pause playback from a menu item
3942
4183
  * Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
3943
4184
  * while paused and is greyed out when nothing can be paused. The player
@@ -3960,6 +4201,20 @@ interface Commands {
3960
4201
  }
3961
4202
  ```
3962
4203
 
4204
+ Example: Add a menu item with a default key
4205
+
4206
+ Tools ▸ Say hello, or Ctrl+H, shows a greeting. `add` declares the item;
4207
+ the player calls `run` when the user picks it or presses its key, and the
4208
+ user can change the key in the player's key settings.
4209
+ ```ts
4210
+ plugin.commands.add({
4211
+ id: "hello",
4212
+ path: ["Tools", "Say hello"],
4213
+ bindings: ["Ctrl+H"], // a default; the user may override it
4214
+ run: () => plugin.osd.show("Hello"),
4215
+ });
4216
+ ```
4217
+
3963
4218
  ### ImageRef
3964
4219
 
3965
4220
  An image file for a Slint `image` property, made by Ui.image.
@@ -7071,29 +7326,100 @@ media.onEnd(async (ended) => {
7071
7326
 
7072
7327
  A source this plugin opened.
7073
7328
 
7329
+ A source this plugin opened with Player.open. It does not own
7330
+ the source: the user or another plugin can replace it at any time, and
7331
+ `current` then turns false. `ready` and `onEnd` follow the player state:
7332
+ with `events` they react to the player's events, without it `ready`
7333
+ polls the state every 100 ms (which needs `playback.read`). Disposing the
7334
+ scope that opened it does not close the media; call `close` for that.
7335
+
7336
+ - @capability That of Player.open (`playback.control`); `ready` and `onEnd` follow the state through `events` and `playback.read`.
7337
+
7074
7338
  ```ts
7075
7339
  interface Media {
7076
7340
  /** Still the player's source (a restart keeps it current). */
7077
7341
  readonly current: boolean;
7078
7342
  /**
7079
7343
  * 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.
7344
+ * @capability That of Player.open; `playback.read` or `events` to see the state change.
7345
+ * @throws failed (rejects) The media could not be loaded.
7346
+ * @throws replaced (rejects) Another source took over first.
7347
+ * @throws ended (rejects) The media already played to the end.
7348
+ * @throws aborted (rejects) `signal` aborted (an `AbortError`); the media is closed.
7349
+ * @example Wait at most ten seconds for a stream to start
7350
+ * A network stream gets ten seconds to start playing; otherwise it is
7351
+ * closed and the user is told. Aborting the signal makes `ready` reject
7352
+ * with an `AbortError` after closing the media.
7353
+ * ```ts
7354
+ * const media = await plugin.player.open("https://example.com/live.m3u8");
7355
+ * // The timeout's abort closes the media and rejects with an AbortError.
7356
+ * try {
7357
+ * await media.ready({ signal: AbortSignal.timeout(10_000) });
7358
+ * } catch (error) {
7359
+ * await plugin.osd.show("Not playing: {}", [(error as Error).message]);
7360
+ * }
7361
+ * ```
7080
7362
  */
7081
7363
  ready(options?: {
7082
7364
  signal?: AbortSignal;
7083
7365
  }): Promise<void>;
7084
- /** Called once each time a playthrough of this media ends naturally. */
7366
+ /**
7367
+ * Called once each time a playthrough of this media ends naturally.
7368
+ * @capability That of Player.open; `events` to hear the end.
7369
+ * @example Play the next file when this one ends
7370
+ * When the plugin's file ends naturally, the next one opens. The listener
7371
+ * gets the state at the end; passing it as `ifCurrent` makes the next
7372
+ * `open` fail with `stale` (and do nothing) if the user opened something
7373
+ * else in the meantime.
7374
+ * ```ts
7375
+ * media.onEnd((ended) =>
7376
+ * plugin.player.open(next, { ifCurrent: ended }).catch((error: PluginError) => {
7377
+ * if (error.code !== "stale") throw error; // the user opened something else
7378
+ * }),
7379
+ * );
7380
+ * ```
7381
+ */
7085
7382
  onEnd(listener: (state: PlayerState) => void): Disposable;
7086
- /** Close it if it is still the player's source; otherwise do nothing. */
7383
+ /**
7384
+ * Close it if it is still the player's source; otherwise do nothing.
7385
+ * @capability That of Player.open (`playback.control`).
7386
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7387
+ * @example Close the preview the plugin opened
7388
+ * The plugin closes the preview it opened, but leaves alone anything the
7389
+ * user opened since. `close` acts only while this media is still the
7390
+ * player's source and does nothing otherwise.
7391
+ * ```ts
7392
+ * await preview.close(); // a no-op when something else is playing now
7393
+ * ```
7394
+ */
7087
7395
  close(): Promise<void>;
7088
7396
  }
7089
7397
  ```
7090
7398
 
7399
+ Example: Open a file and say when it ends
7400
+
7401
+ The file opens and plays, and the OSD says "Finished" when it reaches its
7402
+ end on its own. The handle belongs to this source: `onEnd` fires only
7403
+ for a natural end of this media, not when someone opens another file.
7404
+ ```ts
7405
+ const media = await plugin.player.open("/Movies/clip.mp4");
7406
+ media.onEnd(() => void plugin.osd.show("Finished")); // natural ends only
7407
+ ```
7408
+
7091
7409
  ### Scrub
7092
7410
 
7093
7411
  A drag gesture: each seek is conditional on the previous one, latest value wins.
7094
7412
  Any outside change (another plugin opens a file, the user pauses) ends
7095
7413
  the gesture.
7096
7414
 
7415
+ A drag gesture on a seek slider. Each `seek` is conditional on the
7416
+ previous one and only the newest target is sent while a seek is
7417
+ running, so a fast drag never queues seeks. Any outside change (another
7418
+ file, the user pausing) ends the gesture: `active` turns false and later
7419
+ seeks resolve `false`.
7420
+
7421
+ - @capability playback.control
7422
+
7097
7423
  ```ts
7098
7424
  interface Scrub {
7099
7425
  /**
@@ -7113,6 +7439,11 @@ interface Scrub {
7113
7439
  * the target of a keyframe seek is always sent. Omitted or `false`: exact,
7114
7440
  * as before. Older players ignore the option and seek exactly: set
7115
7441
  * `"minPlayerVersion": "0.5.3"` when you rely on it.
7442
+ * @capability playback.control
7443
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7444
+ * @throws stale (rejects) The source changed from outside while this seek was being sent (a superseded target or an ended gesture resolves `false` instead).
7445
+ * @throws unsupported (rejects) The source is a generated stream.
7446
+ * @throws backend (rejects) The player failed the seek.
7116
7447
  * @example Scrub on keyframes for a fast picture
7117
7448
  * While dragging, the picture jumps to the nearest earlier keyframe at
7118
7449
  * once instead of decoding up to the exact frame. With `keyframes: true`
@@ -7128,7 +7459,17 @@ interface Scrub {
7128
7459
  }): Promise<boolean>;
7129
7460
  /** False once anyone else changed the source; later seeks are ignored. */
7130
7461
  readonly active: boolean;
7131
- /** End the gesture; later seeks resolve `false`. */
7462
+ /**
7463
+ * End the gesture; later seeks resolve `false`.
7464
+ * @capability playback.control
7465
+ * @example End the gesture when the user lets go
7466
+ * Releasing the slider ends the gesture, so a late `dragMove` cannot move
7467
+ * playback any more. After `end`, `seek` resolves `false` without doing
7468
+ * anything.
7469
+ * ```ts
7470
+ * bar.on("dragEnd", () => scrub.end()); // later seeks resolve false
7471
+ * ```
7472
+ */
7132
7473
  end(): void;
7133
7474
  }
7134
7475
  ```
@@ -7206,6 +7547,15 @@ type StreamStartError = PluginError & {
7206
7547
 
7207
7548
  A generated source, from Player.openStream. Disposing it stops it.
7208
7549
 
7550
+ A source whose frames this plugin generates, from
7551
+ Player.openStream. The SDK pulls frames on the audio clock over
7552
+ a local connection and handles prefill, activation and pausing; the plugin
7553
+ only supplies `frame()`. A stream ends when the plugin stops it, when
7554
+ another source replaces it or the user closes it (`onStop` with `null`),
7555
+ or when it fails (`onStop` with the error).
7556
+
7557
+ - @capability producer.av and playback.control
7558
+
7209
7559
  ```ts
7210
7560
  interface Stream extends Disposable {
7211
7561
  /** The stream is producing frames. */
@@ -7222,15 +7572,51 @@ interface Stream extends Disposable {
7222
7572
  * (before 1.5.7 a replaced or closed stream could get `unavailable`
7223
7573
  * "producer absent"). Without `playback.read` a failure the SDK has not
7224
7574
  * seen yet is reported as null.
7575
+ * @capability producer.av
7576
+ * @example Tell the user why the stream stopped
7577
+ * The OSD explains a failure, and nothing is said when the user simply
7578
+ * opened another file. The listener runs only for stops from outside the
7579
+ * plugin: `null` for a replacement or the user's close, an error (such as
7580
+ * `stream` with the player's text) for a failure.
7581
+ * ```ts
7582
+ * stream.onStop((error) => {
7583
+ * if (error) void plugin.osd.show("Generator stopped: {}", [error.message]);
7584
+ * });
7585
+ * ```
7225
7586
  */
7226
7587
  onStop(listener: (error: PluginError | null) => void): Disposable;
7227
7588
  /**
7228
7589
  * 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.
7590
+ * @capability producer.av and playback.control
7591
+ * @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.
7592
+ * @example Stop the stream when the plugin's window closes
7593
+ * Closing the generator window stops the stream it feeds. `stop` closes
7594
+ * the player's source if it is still this stream and then the frame
7595
+ * connection; it resolves once both are closed.
7596
+ * ```ts
7597
+ * window.onClose(() => stream.stop());
7598
+ * ```
7229
7599
  */
7230
7600
  stop(): Promise<void>;
7231
7601
  }
7232
7602
  ```
7233
7603
 
7604
+ Example: Stop the stream from a menu item
7605
+
7606
+ A menu item ends the plugin's generated stream and closes it as the
7607
+ player's source. `stop` closes the source first and then the frame
7608
+ connection, so the user never sees a playback error; `onStop` is not
7609
+ called for the plugin's own stop.
7610
+ ```ts
7611
+ // Enabled while the stream produces frames.
7612
+ plugin.commands.add({
7613
+ id: "stop-generator",
7614
+ path: ["@playback", "Stop generator"],
7615
+ enabled: () => stream.running,
7616
+ run: () => stream.stop(),
7617
+ });
7618
+ ```
7619
+
7234
7620
  ### SequenceProvider
7235
7621
 
7236
7622
  The plugin that owns "next / previous" (a playlist).
@@ -7337,15 +7723,67 @@ Capabilities: reading (`state` at start, `refresh`, `stats`, `chapters`,
7337
7723
  Controls are conditional (see Conditional): they fail with
7338
7724
  `stale` and do nothing when the source changed since the state you saw.
7339
7725
 
7726
+ `plugin.player` is the player's one source: what is open, its phase and
7727
+ position, and the controls. `state` is a snapshot the SDK keeps current
7728
+ from the player's events (with `events`) and refreshes after this
7729
+ plugin's own calls; `onChange` runs after it changed. Every control is
7730
+ conditional: it acts on the state the plugin last saw and fails with
7731
+ `stale` when the source changed meanwhile: another file, a close, or any
7732
+ pause, seek or speed change by the user or another plugin (see
7733
+ Conditional). The player keeps the source when a plugin stops;
7734
+ only the plugin's own handles (a stream, its sequence provider, its
7735
+ output device) are withdrawn.
7736
+
7737
+ - @capability `playback.read` to read; `playback.control` to control; `events` to follow changes made elsewhere; `producer.av` for Player.openStream.
7738
+
7340
7739
  ```ts
7341
7740
  interface Player {
7342
7741
  /** Latest state; always newer than anything delivered before. */
7343
7742
  readonly state: PlayerState;
7344
- /** The state changed (needs `events` for outside changes). */
7743
+ /**
7744
+ * The state changed (needs `events` for outside changes).
7745
+ * @capability events (for changes made elsewhere; without it the listener runs only after this plugin's own calls)
7746
+ * @example Show the position while it plays
7747
+ * The OSD block shows the position, updated as playback moves. The SDK
7748
+ * updates `player.state` from the player's events and then calls the
7749
+ * listener with the new snapshot; the handler only reads it.
7750
+ * ```ts
7751
+ * const info = plugin.osd.panel();
7752
+ * plugin.player.onChange((state) => {
7753
+ * // `position` is null while nothing is open.
7754
+ * info.set(state.position === null ? null : "{} s", [state.position?.toFixed(0) ?? ""]);
7755
+ * });
7756
+ * ```
7757
+ */
7345
7758
  onChange(listener: (state: PlayerState) => void): Disposable;
7346
- /** Read the state from the player now (`playback.read`). */
7759
+ /**
7760
+ * Read the state from the player now (`playback.read`).
7761
+ * @capability playback.read
7762
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
7763
+ * @example Read the state before deciding
7764
+ * A command reads the newest state from the player before acting, even
7765
+ * without `events`. `refresh` asks the player and resolves with the new
7766
+ * snapshot; `player.state` holds it afterwards.
7767
+ * ```ts
7768
+ * const state = await plugin.player.refresh();
7769
+ * if (state.phase === "ready") await plugin.osd.show("{} s left", [((state.duration ?? 0) - (state.position ?? 0)).toFixed(0)]);
7770
+ * ```
7771
+ */
7347
7772
  refresh(): Promise<PlayerState>;
7348
- /** True if nothing changed the source since `state` was observed. */
7773
+ /**
7774
+ * True if nothing changed the source since `state` was observed.
7775
+ * @capability None.
7776
+ * @example Drop a result that arrives after the file changed
7777
+ * A slow lookup for the current file is shown only if that file is still
7778
+ * open. `isCurrent` compares the snapshot taken before the work with the
7779
+ * SDK's newest state; it is false once the source changed in any way:
7780
+ * another file, a close, or a pause, seek or speed change by anyone.
7781
+ * ```ts
7782
+ * const seen = plugin.player.state;
7783
+ * const title = await lookUpTitle(seen.source ?? "");
7784
+ * if (plugin.player.isCurrent(seen)) await plugin.osd.show("{}", [title]); // still the same file
7785
+ * ```
7786
+ */
7349
7787
  isCurrent(state: PlayerState): boolean;
7350
7788
  /**
7351
7789
  * Open a path or URL, replacing the current source (unless `ifCurrent` no
@@ -7356,6 +7794,25 @@ interface Player {
7356
7794
  * Resolver are resolved first, and so are local files of an
7357
7795
  * extension a file resolver claims (the source is then a stream that
7358
7796
  * plugin generates; `paused` reaches it).
7797
+ * @capability playback.control
7798
+ * @throws stale (rejects) `ifCurrent` no longer holds; nothing was opened.
7799
+ * @throws replaced (rejects) Accepted, then another source took over before it finished (for example while a URL was still being resolved).
7800
+ * @throws invalid (rejects) An empty `source`.
7801
+ * @throws aborted (rejects) `signal` aborted (an `AbortError`); the source is closed if it was opened.
7802
+ * @throws busy (rejects) The address needs a resolver plugin and the player is already resolving 32 addresses.
7803
+ * @throws backend (rejects) A resolver plugin handled the address or file and failed (its message), or the resolution was cancelled or expired.
7804
+ * @throws unsupported (rejects) The player refused what a resolver returned (for example split streams with different headers).
7805
+ * @throws unavailable (rejects) The stream a file resolver prepared was not ready to play.
7806
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7807
+ * @example Open a file paused
7808
+ * The file opens at its start without playing. `open` resolves once the
7809
+ * player accepted the load and returns a Media handle for that
7810
+ * source; `ready` then waits until it can play (here: until it is loaded
7811
+ * and paused).
7812
+ * ```ts
7813
+ * const media = await plugin.player.open("/Movies/clip.mp4", { paused: true });
7814
+ * await media.ready(); // rejects `failed` when the file cannot be loaded
7815
+ * ```
7359
7816
  */
7360
7817
  open(source: string, options?: {
7361
7818
  paused?: boolean;
@@ -7372,6 +7829,13 @@ interface Player {
7372
7829
  * TCP with a token on Windows. Rejects with a StreamStartError
7373
7830
  * (`timeout`), `stream`, `device`, or `replaced` when another source
7374
7831
  * replaced it (or it was closed) before it ran.
7832
+ * @capability producer.av and playback.control
7833
+ * @throws timeout (rejects) A StreamStartError: the stream did not start in time.
7834
+ * @throws stream (rejects) The player failed to start the stream.
7835
+ * @throws device (rejects) The audio device was not ready.
7836
+ * @throws replaced (rejects) Another source replaced it, or it was closed, before it ran.
7837
+ * @throws invalid (rejects) Bad options (a size or a frame function).
7838
+ * @throws aborted (rejects) `signal` aborted, or the scope was disposed, while the stream was starting (an `AbortError`).
7375
7839
  * @example Play pictures the plugin draws itself
7376
7840
  * The player shows a 320×240 picture the plugin renders, with silence as
7377
7841
  * its sound, as the current source. The SDK pulls frames on the audio
@@ -7392,25 +7856,143 @@ interface Player {
7392
7856
  * ```
7393
7857
  */
7394
7858
  openStream(options: StreamOptions): Promise<Stream>;
7395
- /** Pause or resume. */
7859
+ /**
7860
+ * Pause or resume.
7861
+ * @capability playback.control
7862
+ * @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.
7863
+ * @throws invalid (rejects) Nothing is open.
7864
+ * @throws busy (rejects) A generated stream is still handling a previous control.
7865
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7866
+ * @example Pause, but only the file the user was watching
7867
+ * A "pause on lock" helper pauses the current file and does nothing when
7868
+ * another file was opened in between. The call is conditional on
7869
+ * `player.state` by default; `stale` means the source changed and the
7870
+ * pause was not applied, which is the intended outcome here.
7871
+ * ```ts
7872
+ * try {
7873
+ * await plugin.player.setPaused(true);
7874
+ * } catch (error) {
7875
+ * if ((error as PluginError).code !== "stale") throw error; // another file now
7876
+ * }
7877
+ * ```
7878
+ */
7396
7879
  setPaused(paused: boolean, options?: Conditional): Promise<void>;
7397
- /** Jump to `seconds`. */
7880
+ /**
7881
+ * Jump to `seconds`.
7882
+ * @capability playback.control
7883
+ * @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.
7884
+ * @throws invalid (rejects) Nothing is open, or `seconds` is negative or not finite.
7885
+ * @throws unsupported (rejects) The source is a generated stream, which cannot seek.
7886
+ * @throws busy (rejects) A generated stream is still handling a previous control.
7887
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7888
+ * @example Jump back ten seconds
7889
+ * Playback jumps ten seconds back, never before the start. The target is
7890
+ * computed from the snapshot the call is conditional on, so if the user
7891
+ * opened another file meanwhile the seek fails with `stale` instead of
7892
+ * landing in the wrong file.
7893
+ * ```ts
7894
+ * const { position } = plugin.player.state;
7895
+ * if (position !== null && plugin.player.state.can.seek)
7896
+ * await plugin.player.seek(Math.max(0, position - 10)); // seconds
7897
+ * ```
7898
+ */
7398
7899
  seek(seconds: number, options?: Conditional): Promise<void>;
7399
- /** Set the playback speed. */
7900
+ /**
7901
+ * Set the playback speed.
7902
+ * @capability playback.control
7903
+ * @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.
7904
+ * @throws invalid (rejects) Nothing is open, or `rate` is outside 0.05–10.
7905
+ * @throws unsupported (rejects) The source is a generated stream, which cannot change speed.
7906
+ * @throws busy (rejects) A generated stream is still handling a previous control.
7907
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7908
+ * @example Play at one and a half times the speed
7909
+ * Playback speeds up to 1.5×, with the speed on the OSD. `setRate` sets
7910
+ * the speed directly (the player's own keys step it by 0.1 within
7911
+ * 0.25–4); `state.can.rate` is false for sources that cannot change speed.
7912
+ * ```ts
7913
+ * if (plugin.player.state.can.rate) {
7914
+ * await plugin.player.setRate(1.5);
7915
+ * await plugin.osd.show("Speed {}×", [1.5]);
7916
+ * }
7917
+ * ```
7918
+ */
7400
7919
  setRate(rate: number, options?: Conditional): Promise<void>;
7401
- /** Play from the start (the "replay" after the end). */
7920
+ /**
7921
+ * Play from the start (the "replay" after the end).
7922
+ * @capability playback.control
7923
+ * @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.
7924
+ * @throws invalid (rejects) Nothing is open.
7925
+ * @throws unsupported (rejects) The source is a generated stream, which cannot restart.
7926
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7927
+ * @example Replay a file after it ended
7928
+ * A Replay button starts the file again from its start once it has ended.
7929
+ * `restart` plays the current source from the beginning and keeps it the
7930
+ * same source, so a Media handle stays current.
7931
+ * ```ts
7932
+ * if (plugin.player.state.phase === "ended") await plugin.player.restart();
7933
+ * ```
7934
+ */
7402
7935
  restart(options?: Conditional): Promise<void>;
7403
- /** Close the current source. */
7936
+ /**
7937
+ * Close the current source.
7938
+ * @capability playback.control
7939
+ * @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.
7940
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7941
+ * @example Close the file when the sleep timer runs out
7942
+ * After 30 minutes the open file is closed, unless the user opened another
7943
+ * one meanwhile. The snapshot taken when the timer started is passed as
7944
+ * `ifCurrent`, so the close applies only to that source; `stale` means it
7945
+ * was replaced.
7946
+ * ```ts
7947
+ * const seen = plugin.player.state;
7948
+ * setTimeout(() => {
7949
+ * // Only the source that was open when the timer started.
7950
+ * plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
7951
+ * if (error.code !== "stale") plugin.osd.show("Could not close: {}", [error.code]);
7952
+ * });
7953
+ * }, 30 * 60 * 1000);
7954
+ * ```
7955
+ */
7404
7956
  close(options?: Conditional): Promise<void>;
7405
- /** Start a drag gesture from the current state. */
7957
+ /**
7958
+ * Start a drag gesture from the current state.
7959
+ * @capability playback.control
7960
+ * @example Start a gesture when the user grabs the slider
7961
+ * A drag on the plugin's seek slider becomes one gesture whose seeks never
7962
+ * pile up. `scrub()` captures the current state; the gesture's seeks are
7963
+ * conditional on it and on each other, and it ends on its own when
7964
+ * someone else changes the source.
7965
+ * ```ts
7966
+ * let scrub: Scrub | null = null;
7967
+ * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
7968
+ * bar.on("dragEnd", () => scrub?.end());
7969
+ * ```
7970
+ */
7406
7971
  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. */
7972
+ /**
7973
+ * 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.
7974
+ * @capability playback.control
7975
+ * @throws invalid (rejects) `volume` outside 0–200, or `muted` not a boolean.
7976
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
7977
+ * @example Duck the volume during an announcement
7978
+ * The volume drops to 30 while the plugin's announcement plays, then
7979
+ * returns. Writes are "latest wins": the promise resolves `true` when this
7980
+ * write was applied and `false` when a newer one replaced it first, which
7981
+ * is not an error.
7982
+ * ```ts
7983
+ * const before = plugin.player.state.volume; // 0–200
7984
+ * await plugin.player.setOutput({ volume: 30 });
7985
+ * // … the announcement …
7986
+ * await plugin.player.setOutput({ volume: before });
7987
+ * ```
7988
+ */
7408
7989
  setOutput(output: {
7409
7990
  volume?: number;
7410
7991
  muted?: boolean;
7411
7992
  }): Promise<boolean>;
7412
7993
  /**
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.
7994
+ * 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.
7995
+ * @capability playback.control
7414
7996
  * @example Drive the player's Next and Previous from a playlist
7415
7997
  * The player's Next and Previous items and keys play the plugin's list.
7416
7998
  * The player calls `next` and `previous` when the user asks; `canNext`
@@ -7432,19 +8014,99 @@ interface Player {
7432
8014
  * ```
7433
8015
  */
7434
8016
  provideSequence(provider: SequenceProvider): Disposable;
7435
- /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
8017
+ /**
8018
+ * Ask the provider to go forward / back. Rejects `unavailable` when it cannot.
8019
+ * @capability playback.control
8020
+ * @throws unavailable (rejects) There is no sequence provider, or it has no next item.
8021
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8022
+ * @example Skip to the next item from a plugin button
8023
+ * The plugin's own Next button plays the next item of whichever plugin
8024
+ * provides the sequence (the playlist, usually). `next` asks the
8025
+ * provider, the same as the player's Next key; `unavailable` means there
8026
+ * is no next item, which the button treats as the end of the list.
8027
+ * ```ts
8028
+ * try {
8029
+ * await plugin.player.next(); // as the player's Next key does
8030
+ * } catch (error) {
8031
+ * if ((error as PluginError).code !== "unavailable") throw error;
8032
+ * await plugin.osd.show("End of the list");
8033
+ * }
8034
+ * ```
8035
+ */
7436
8036
  next(): Promise<void>;
7437
- /** Ask the provider to go back. Rejects `unavailable` when it cannot. */
8037
+ /**
8038
+ * Ask the provider to go back. Rejects `unavailable` when it cannot.
8039
+ * @capability playback.control
8040
+ * @throws unavailable (rejects) There is no sequence provider, or it has no previous item.
8041
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8042
+ * @example Go back to the previous item
8043
+ * The plugin's Previous button asks the sequence provider for the item
8044
+ * before the current one, like the player's PageUp. It is disabled while
8045
+ * `state.can.previous` is false, which the provider's `canPrevious`
8046
+ * decides.
8047
+ * ```ts
8048
+ * plugin.commands.add({
8049
+ * id: "previous",
8050
+ * title: "Previous item",
8051
+ * // The provider's `canPrevious`, as the player reports it.
8052
+ * enabled: () => plugin.player.state.can.previous,
8053
+ * run: () => plugin.player.previous(),
8054
+ * });
8055
+ * ```
8056
+ */
7438
8057
  previous(): Promise<void>;
7439
- /** Codec, size, frame counts and backend of the current playback. `playback.read`; `video` and `audio` are `null` without a source. */
8058
+ /**
8059
+ * Codec, size, frame counts and backend of the current playback. `playback.read`; `video` and `audio` are `null` without a source.
8060
+ * @capability playback.read
8061
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8062
+ * @example Show the video codec and size
8063
+ * The OSD shows something like "h264 1920×1080". `stats` asks the player
8064
+ * for the backend's own values at that moment; `video` is `null` without
8065
+ * a video stream (nothing open, an audio file).
8066
+ * ```ts
8067
+ * const { video } = await plugin.player.stats();
8068
+ * if (video) await plugin.osd.show("{} {}×{}", [video.codec ?? "?", video.width ?? 0, video.height ?? 0]);
8069
+ * ```
8070
+ */
7440
8071
  stats(): Promise<PlaybackStats>;
7441
8072
  /**
7442
8073
  * 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.
8074
+ * @capability playback.control
8075
+ * @throws invalid (rejects) Not an absolute path to a file.
8076
+ * @throws unavailable (rejects) No ready media.
8077
+ * @throws backend (rejects) The player could not load the file.
8078
+ * @throws stale (rejects) `ifCurrent` no longer holds.
8079
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8080
+ * @example Add a dubbed audio file and switch to it
8081
+ * A separate audio file plays with the film instead of its own sound.
8082
+ * `addAudioTrack` attaches the file to the current media and selects it;
8083
+ * the track stays until another source opens.
8084
+ * ```ts
8085
+ * const track = await plugin.player.addAudioTrack(dubPath); // selected too
8086
+ * await plugin.osd.show("Audio: {}", [track.title ?? dubPath]);
8087
+ * ```
7443
8088
  */
7444
8089
  addAudioTrack(path: string, options?: {
7445
8090
  select?: boolean;
7446
8091
  } & Conditional): Promise<AudioTrack>;
7447
- /** Select one of `state.audioTracks` by id; `invalid` for an unknown id. */
8092
+ /**
8093
+ * Select one of `state.audioTracks` by id; `invalid` for an unknown id.
8094
+ * @capability playback.control
8095
+ * @throws invalid (rejects) No track with this id.
8096
+ * @throws unavailable (rejects) No ready media.
8097
+ * @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).
8098
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8099
+ * @example Switch to the next audio track
8100
+ * Each press of the plugin's key moves to the next audio track of the
8101
+ * file. The tracks and the selected one are in `state.audioTracks`;
8102
+ * `selectAudioTrack` resolves with the new state.
8103
+ * ```ts
8104
+ * const audioTracks = plugin.player.state.audioTracks ?? []; // null: not known yet
8105
+ * const at = audioTracks.findIndex((track) => track.selected);
8106
+ * const next = audioTracks[(at + 1) % audioTracks.length];
8107
+ * if (next) await plugin.player.selectAudioTrack(next.id);
8108
+ * ```
8109
+ */
7448
8110
  selectAudioTrack(id: number, options?: Conditional): Promise<PlayerState>;
7449
8111
  /**
7450
8112
  * One frame or to the next / previous keyframe (pauses for frames). Rejects
@@ -7454,6 +8116,19 @@ interface Player {
7454
8116
  * Resolves once the new position is in effect, so `player.state.position`
7455
8117
  * is current afterwards. One step at a time: a second call while one is
7456
8118
  * in flight rejects `busy`.
8119
+ * @capability playback.control
8120
+ * @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.
8121
+ * @throws busy (rejects) A step is still in flight.
8122
+ * @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).
8123
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8124
+ * @example Step one frame forward for a close look
8125
+ * The picture advances by one frame and playback pauses. `step` resolves
8126
+ * once the new position is in effect, so `state.position` is current
8127
+ * afterwards; a second step while one is running rejects with `busy`.
8128
+ * ```ts
8129
+ * await plugin.player.step("frame", "forward");
8130
+ * await plugin.osd.show("{} s", [plugin.player.state.position?.toFixed(3) ?? "?"]);
8131
+ * ```
7457
8132
  */
7458
8133
  step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
7459
8134
  /**
@@ -7476,12 +8151,37 @@ interface Player {
7476
8151
  *
7477
8152
  * Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
7478
8153
  * the manifest when you rely on it.
8154
+ * @capability playback.control
8155
+ * @throws invalid (rejects) `seconds` outside 0.1–3600 or not in tenths, or `keyframes` not a boolean.
8156
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8157
+ * @example Make ← and → jump 10 seconds to keyframes
8158
+ * The arrow keys now jump 10 seconds and land on keyframes, which is fast
8159
+ * on large files. The newest call of any plugin wins and is withdrawn
8160
+ * when that plugin stops, so a plugin offering this as a setting applies
8161
+ * it on activation.
8162
+ * ```ts
8163
+ * await plugin.player.setStepSeek({ seconds: 10, keyframes: true });
8164
+ * ```
7479
8165
  */
7480
8166
  setStepSeek(options: {
7481
8167
  seconds: number;
7482
8168
  keyframes?: boolean;
7483
8169
  }): 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`. */
8170
+ /**
8171
+ * The chapters of the current media (empty without media or chapters), read on demand; `sourceId` tells which source they belong to. `playback.read`.
8172
+ * @capability playback.read
8173
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8174
+ * @example List the chapters of the open file
8175
+ * The plugin reads the chapter titles of the current file to show them in
8176
+ * its panel. `chapters` asks the player when called; `sourceId` says which
8177
+ * source they belong to, so a reply for a file that was replaced meanwhile
8178
+ * can be dropped.
8179
+ * ```ts
8180
+ * const { sourceId, chapters } = await plugin.player.chapters();
8181
+ * if (sourceId === plugin.player.state.sourceId) // still the same file
8182
+ * console.log(chapters.map((chapter) => chapter.title));
8183
+ * ```
8184
+ */
7485
8185
  chapters(): Promise<{
7486
8186
  /** The source the chapters belong to (compare with `state.sourceId`). */
7487
8187
  sourceId: number;
@@ -7490,13 +8190,45 @@ interface Player {
7490
8190
  }>;
7491
8191
  /**
7492
8192
  * 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.
8193
+ * @capability playback.control
8194
+ * @throws invalid (rejects) `a`/`b` out of range or `count` not a positive integer.
8195
+ * @throws unsupported (rejects) The source cannot seek, or is a generated stream.
8196
+ * @throws unavailable (rejects) No ready media.
8197
+ * @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).
8198
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8199
+ * @example Loop a passage three times
8200
+ * Playback repeats 1:00–1:30 three times, then goes on. The player jumps
8201
+ * back to `a` each time it reaches `b`; after the last repeat
8202
+ * `state.loop` becomes `null`, and a new source clears the loop.
8203
+ * ```ts
8204
+ * await plugin.player.setLoop({ a: 60, b: 90, count: 3 }); // seconds
8205
+ * ```
7493
8206
  */
7494
8207
  setLoop(loop: {
7495
8208
  a: number;
7496
8209
  b: number;
7497
8210
  count?: number;
7498
8211
  } | null, options?: Conditional): Promise<void>;
7499
- /** The output devices present now (not the system default entry). `playback.read`. */
8212
+ /**
8213
+ * The output devices present now (not the system default entry). `playback.read`.
8214
+ * @capability playback.read
8215
+ * @throws capability (rejects) The manifest does not declare `playback.read`.
8216
+ * @example Offer the audio output devices in a menu
8217
+ * A submenu lists the output devices present now, with a radio mark on the
8218
+ * one in use. `audioDevices` asks the player when called (devices come and
8219
+ * go), and `state.audioOutput` tells which one is active.
8220
+ * ```ts
8221
+ * // The devices present now; build the menu again when they change.
8222
+ * for (const device of await plugin.player.audioDevices())
8223
+ * plugin.commands.add({
8224
+ * id: `device.${device.id}`,
8225
+ * path: ["@audio", "Output", device.name],
8226
+ * radio: true,
8227
+ * checked: () => plugin.player.state.audioOutput?.active === device.id,
8228
+ * run: () => plugin.player.setAudioDevice(device.id),
8229
+ * });
8230
+ * ```
8231
+ */
7500
8232
  audioDevices(): Promise<AudioDevice[]>;
7501
8233
  /**
7502
8234
  * Play on this device while it is present, else on the system default
@@ -7506,6 +8238,18 @@ interface Player {
7506
8238
  * Unknown ids are accepted (`state.audioOutput.active` stays `null`); a
7507
8239
  * malformed id is `invalid`. Reverts to the default when this plugin
7508
8240
  * stops; save the choice yourself to restore it on activation.
8241
+ * @capability playback.control
8242
+ * @throws invalid (rejects) A malformed id.
8243
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
8244
+ * @example Restore the user's output device on start
8245
+ * The plugin plays on the device the user picked last time, or on the
8246
+ * system default when it is not connected. The choice lasts only for
8247
+ * this run and is withdrawn when the plugin stops, so the plugin saves
8248
+ * it itself and sets it again on activation.
8249
+ * ```ts
8250
+ * const store = plugin.storage.json("output", { defaults: { device: null as string | null } });
8251
+ * await plugin.player.setAudioDevice(store.value.device); // null: the system default
8252
+ * ```
7509
8253
  */
7510
8254
  setAudioDevice(id: string | null): Promise<void>;
7511
8255
  }
@@ -7558,6 +8302,15 @@ type HostAction = "play-pause" | "stop" | "previous" | "next" | "frame-back" | "
7558
8302
 
7559
8303
  `plugin.host`: the player's own core actions.
7560
8304
 
8305
+ The player's core actions (play/pause, stop, step seeks, speed, volume,
8306
+ fullscreen…) are commands of the player itself, `host/<action>` in the
8307
+ binding table. Running them through `host.run` gives a plugin's own
8308
+ buttons exactly the player's behaviour: the same checks, the same
8309
+ accumulation of repeated presses and the same on-screen text, instead of
8310
+ reimplementing them with Player calls.
8311
+
8312
+ - @capability commands
8313
+
7561
8314
  ```ts
7562
8315
  interface Host {
7563
8316
  /**
@@ -7567,6 +8320,10 @@ interface Host {
7567
8320
  * `invalid` for an unknown id. Needs `commands`.
7568
8321
  *
7569
8322
  * Prefer this over reimplementing playback buttons.
8323
+ * @capability commands
8324
+ * @throws unavailable (rejects) The action is disabled right now (nothing open, no window for `fullscreen`).
8325
+ * @throws invalid (rejects) An unknown action, or `seconds` out of range.
8326
+ * @throws capability (rejects) The manifest does not declare `commands`.
7570
8327
  * @example Make a bar button play and pause like Space
7571
8328
  * The plugin's own Play button behaves exactly like the player's Space
7572
8329
  * key, including its on-screen text. `host.run` runs the core action
@@ -7597,6 +8354,10 @@ interface Host {
7597
8354
  * once the seek is submitted, as before. `invalid` for `seconds` out of
7598
8355
  * range.
7599
8356
  * @player 0.5.1 (set `"minPlayerVersion": "0.5.1"`: older players ignore `seconds` and seek by the `setStepSeek` step)
8357
+ * @capability commands
8358
+ * @throws unavailable (rejects) The action is disabled right now (nothing open, no window for `fullscreen`).
8359
+ * @throws invalid (rejects) An unknown action, or `seconds` out of range.
8360
+ * @throws capability (rejects) The manifest does not declare `commands`.
7600
8361
  * @example Add a 30-second skip that adds up with the arrow keys
7601
8362
  * The plugin's "+30 s" button seeks forward 30 seconds and shows the same
7602
8363
  * text as →. Passing `seconds` makes the core step seek use that step
@@ -7612,6 +8373,21 @@ interface Host {
7612
8373
  }
7613
8374
  ```
7614
8375
 
8376
+ Example: Give a custom bar the player's own Stop and Mute
8377
+
8378
+ The plugin's bar buttons stop playback and mute exactly as the player's
8379
+ own keys do, with the same on-screen text. Each callback runs a core
8380
+ action; the player applies its checks and rejects with `unavailable`
8381
+ while the action is disabled, which the bar ignores.
8382
+ ```ts
8383
+ const run = (action: HostAction) =>
8384
+ plugin.host.run(action).catch((error: PluginError) => {
8385
+ if (error.code !== "unavailable") throw error; // disabled right now
8386
+ });
8387
+ bar.on("stop", () => run("stop"));
8388
+ bar.on("mute", () => run("mute"));
8389
+ ```
8390
+
7615
8391
  ### OsdPanel
7616
8392
 
7617
8393
  This plugin's information block on the OSD: multi-line text over the
@@ -8876,13 +9652,65 @@ PNG/JPEG bytes; Windows writes Unicode text and images as a bitmap plus
8876
9652
  PNG. Other systems: `unsupported`. Failures: `invalid`, `missing`,
8877
9653
  `unavailable`, `failed` (the system refused).
8878
9654
 
9655
+ The player reads and writes the system clipboard on the plugin's behalf.
9656
+ Text is plain text; images are written from a PNG or JPEG file.
9657
+
9658
+ - @capability clipboard
9659
+
8879
9660
  ```ts
8880
9661
  interface Clipboard {
8881
- /** Plain text on the clipboard; `null` when it holds none. */
9662
+ /**
9663
+ * Plain text on the clipboard; `null` when it holds none.
9664
+ * @capability clipboard
9665
+ * @throws unavailable (rejects) The player runs without a user interface.
9666
+ * @throws unsupported (rejects) A system other than macOS and Windows.
9667
+ * @throws failed (rejects) The system refused (Windows).
9668
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
9669
+ * @example Open a URL the user copied
9670
+ * A "Paste and play" command opens the address on the clipboard. `readText`
9671
+ * resolves with the clipboard's text, or `null` when it holds none (an
9672
+ * image, nothing).
9673
+ * ```ts
9674
+ * const text = await plugin.clipboard.readText();
9675
+ * if (text?.startsWith("https://")) await plugin.player.open(text.trim());
9676
+ * ```
9677
+ */
8882
9678
  readText(): Promise<string | null>;
8883
- /** Replace the clipboard with text (at most 1 MiB of UTF-8). */
9679
+ /**
9680
+ * Replace the clipboard with text (at most 1 MiB of UTF-8).
9681
+ * @capability clipboard
9682
+ * @throws invalid (rejects) More than 1 MiB of UTF-8.
9683
+ * @throws unavailable (rejects) The player runs without a user interface.
9684
+ * @throws unsupported (rejects) A system other than macOS and Windows.
9685
+ * @throws failed (rejects) The system refused.
9686
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
9687
+ * @example Copy the playing file's path
9688
+ * The path of the playing file is put on the clipboard, replacing what was
9689
+ * there. It resolves once the system has it.
9690
+ * ```ts
9691
+ * const { source } = plugin.player.state;
9692
+ * if (source) await plugin.clipboard.writeText(source);
9693
+ * ```
9694
+ */
8884
9695
  writeText(text: string): Promise<void>;
8885
- /** Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB). */
9696
+ /**
9697
+ * Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB).
9698
+ * @capability clipboard
9699
+ * @throws missing (rejects) The file does not exist.
9700
+ * @throws invalid (rejects) Not an absolute path, not PNG or JPEG, over 16 MB, or (Windows) an image the system cannot decode.
9701
+ * @throws unavailable (rejects) The player runs without a user interface.
9702
+ * @throws unsupported (rejects) A system other than macOS and Windows.
9703
+ * @throws failed (rejects) The system refused.
9704
+ * @throws capability (rejects) The manifest does not declare `clipboard`.
9705
+ * @example Copy a snapshot to the clipboard
9706
+ * A frame the plugin saved as PNG is put on the clipboard, ready to paste
9707
+ * into another application. The player reads the file and writes the
9708
+ * image (macOS: its original bytes; Windows: a bitmap plus PNG).
9709
+ * ```ts
9710
+ * await plugin.clipboard.writeImage(pngPath); // absolute path, at most 16 MB
9711
+ * await plugin.osd.show("Snapshot copied");
9712
+ * ```
9713
+ */
8886
9714
  writeImage(path: string): Promise<void>;
8887
9715
  }
8888
9716
  ```
@@ -8906,6 +9734,12 @@ try {
8906
9734
 
8907
9735
  Keeping the computer awake (`playback.control`).
8908
9736
 
9737
+ A request, not a command: the player keeps the system awake while any
9738
+ plugin asks for it and a source plays, and withdraws a plugin's request
9739
+ when it stops.
9740
+
9741
+ - @capability playback.control
9742
+
8909
9743
  ```ts
8910
9744
  interface Power {
8911
9745
  /**
@@ -8917,11 +9751,31 @@ interface Power {
8917
9751
  * take the system assertion are logged by the player, not reported.
8918
9752
  * macOS uses `caffeinate`, Windows the thread execution state; elsewhere
8919
9753
  * it has no effect.
9754
+ * @capability playback.control
9755
+ * @throws invalid (rejects) A mode other than `"off"` or `"playing"`.
9756
+ * @throws capability (rejects) The manifest does not declare `playback.control`.
9757
+ * @example Follow the plugin's own "Keep awake" setting
9758
+ * The plugin keeps the display awake during playback only while its
9759
+ * setting is on. Each call replaces this plugin's request; `"off"`
9760
+ * withdraws it, and other plugins' requests still count.
9761
+ * ```ts
9762
+ * const store = plugin.storage.json("power", { defaults: { keepAwake: true } });
9763
+ * await plugin.power.preventSleep(store.value.keepAwake ? "playing" : "off");
9764
+ * ```
8920
9765
  */
8921
9766
  preventSleep(mode: "off" | "playing"): Promise<void>;
8922
9767
  }
8923
9768
  ```
8924
9769
 
9770
+ Example: Keep the display on while playing
9771
+
9772
+ The screen does not dim or sleep while a video plays, and may again when
9773
+ it is paused or ends. The player takes the system's assertion only while
9774
+ a source is ready and not paused.
9775
+ ```ts
9776
+ await plugin.power.preventSleep("playing"); // "off" withdraws the request
9777
+ ```
9778
+
8925
9779
  ### ToolResult
8926
9780
 
8927
9781
  The output of Tools.run.
@@ -8940,11 +9794,20 @@ interface ToolResult {
8940
9794
  `plugin.tools`: ffmpeg and ffprobe, run in this plugin's process. No
8941
9795
  capability.
8942
9796
 
9797
+ ffmpeg and ffprobe run as child processes of the plugin. When
9798
+ `VESTRA_FFMPEG` / `VESTRA_FFPROBE` is set, only that path is used (an
9799
+ invalid one means the tool is not found); otherwise the tool is searched
9800
+ on `PATH`. A found path is kept for the plugin's run, a miss is looked up
9801
+ again next time. Disposing the
9802
+ scope ends a running process.
9803
+
9804
+ - @capability None.
9805
+
8943
9806
  ```ts
8944
9807
  interface Tools {
8945
9808
  /**
8946
- * Run ffmpeg or ffprobe (found through `VESTRA_FFMPEG` / `VESTRA_FFPROBE`,
8947
- * then `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
9809
+ * Run ffmpeg or ffprobe (`VESTRA_FFMPEG` / `VESTRA_FFPROBE` when set, else
9810
+ * found on `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
8948
9811
  * `failed` (non-zero exit; `message` is the last stderr line), `invalid`
8949
9812
  * (output over `maxOutput`, default 16 MiB), `aborted` (`signal`). Timeout,
8950
9813
  * `signal` and disposing the scope end the process.
@@ -8952,13 +9815,50 @@ interface Tools {
8952
9815
  * Arguments are passed as-is (no quoting needed). On Windows `PATHEXT` is
8953
9816
  * honoured. Ending sends SIGTERM, then SIGKILL after 300 ms (immediate on
8954
9817
  * Windows).
9818
+ * @capability None.
9819
+ * @throws unavailable (rejects) The tool was not found.
9820
+ * @throws timeout (rejects) It ran longer than `timeoutMs`; the process is ended.
9821
+ * @throws failed (rejects) A non-zero exit; `message` is the last stderr line.
9822
+ * @throws invalid (rejects) Bad arguments or options, or output over `maxOutput`.
9823
+ * @throws aborted (rejects) `signal` aborted, or the scope was disposed.
9824
+ * @example Cut a clip without re-encoding
9825
+ * The plugin writes the first ten seconds of the file to a new file. `run`
9826
+ * starts ffmpeg without a shell (arguments pass as they are, no quoting)
9827
+ * and resolves with its output once it exits with 0; a failure rejects
9828
+ * with the last line ffmpeg printed.
9829
+ * ```ts
9830
+ * await plugin.tools.run(
9831
+ * "ffmpeg",
9832
+ * ["-y", "-i", input, "-t", "10", "-c", "copy", output],
9833
+ * { timeoutMs: 60_000 }, // give up after a minute
9834
+ * );
9835
+ * ```
8955
9836
  */
8956
9837
  run(tool: "ffmpeg" | "ffprobe", args: string[], options: {
8957
9838
  timeoutMs: number;
8958
9839
  maxOutput?: number;
8959
9840
  signal?: AbortSignal;
8960
9841
  }): Promise<ToolResult>;
8961
- /** Whether the tool can be found. */
9842
+ /**
9843
+ * Whether the tool can be found.
9844
+ * @capability None.
9845
+ * @throws Never; it resolves `false` when the tool cannot be found.
9846
+ * @example Grey out a command when ffmpeg is missing
9847
+ * The Export item is disabled with a reason when ffmpeg is not installed.
9848
+ * `available` looks the tool up (a found path is kept, a miss is looked
9849
+ * up again on the next call, so installing it later is noticed).
9850
+ * ```ts
9851
+ * const hasFfmpeg = await plugin.tools.available("ffmpeg");
9852
+ * plugin.commands.add({
9853
+ * id: "export",
9854
+ * path: ["@video", "Export…"],
9855
+ * enabled: hasFfmpeg,
9856
+ * // Shown with the greyed-out item.
9857
+ * reason: hasFfmpeg ? null : "Install ffmpeg to export",
9858
+ * run() {},
9859
+ * });
9860
+ * ```
9861
+ */
8962
9862
  available(tool: "ffmpeg" | "ffprobe"): Promise<boolean>;
8963
9863
  }
8964
9864
  ```
@@ -8986,21 +9886,75 @@ if (await plugin.tools.available("ffprobe")) {
8986
9886
 
8987
9887
  A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
8988
9888
 
9889
+ One JSON file, `<dataDirectory>/<name>.json`, made by
9890
+ Storage.json. `value` is read once when the store is made;
9891
+ `save` replaces it in memory at once and writes the file `debounceMs`
9892
+ later (300 by default) through a temporary file and a rename, so a
9893
+ crash never leaves half a file. Writes to one file run one at a time,
9894
+ newest last. Disposing the store, its scope or stopping the plugin
9895
+ writes a pending save first.
9896
+
9897
+ - @capability None.
9898
+
8989
9899
  ```ts
8990
9900
  interface JsonStore<T> extends Disposable {
8991
9901
  /** The loaded value, or `defaults` when the file was missing or broken. */
8992
9902
  readonly value: T;
8993
- /** Replace the value now and write it after `debounceMs`. Only `save` writes: mutating `value` in place is not saved. */
9903
+ /**
9904
+ * Replace the value now and write it after `debounceMs`. Only `save` writes: mutating `value` in place is not saved.
9905
+ * @capability None.
9906
+ * @example Save a slider value without writing on every move
9907
+ * Dragging the plugin's volume slider updates the stored value at once but
9908
+ * writes the file only once the user stops for 300 ms. Each `save`
9909
+ * replaces the value and restarts the debounce timer.
9910
+ * ```ts
9911
+ * const store = plugin.storage.json("prefs", { defaults: { volume: 100 } });
9912
+ * window.on("volumeChanged", (volume: number) => store.save({ ...store.value, volume }));
9913
+ * ```
9914
+ */
8994
9915
  save(next: T): void;
8995
- /** Write a pending save now. */
9916
+ /**
9917
+ * Write a pending save now.
9918
+ * @capability None.
9919
+ * @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.
9920
+ * @example Write the file now before copying it
9921
+ * Before exporting its settings, the plugin makes sure the file on disk has
9922
+ * the latest value. `flush` writes a pending save at once (or waits for
9923
+ * the write in progress) and resolves when the file is written.
9924
+ * ```ts
9925
+ * const store = plugin.storage.json("settings", { defaults: { step: 5 } });
9926
+ * await store.flush(); // the file now has the newest value
9927
+ * await copyFile(join(plugin.dataDirectory, "settings.json"), exportPath);
9928
+ * ```
9929
+ */
8996
9930
  flush(): Promise<void>;
8997
9931
  }
8998
9932
  ```
8999
9933
 
9934
+ Example: Count how often the plugin was started
9935
+
9936
+ The counter grows by one at every start and is kept in `stats.json`. The
9937
+ store reads the file when it is made (the defaults when there is none),
9938
+ and `save` writes the new count a moment later.
9939
+ ```ts
9940
+ const stats = plugin.storage.json("stats", { defaults: { starts: 0 } });
9941
+ stats.save({ starts: stats.value.starts + 1 }); // written 300 ms later
9942
+ ```
9943
+
9000
9944
  ### Storage
9001
9945
 
9002
9946
  `plugin.storage`: JSON settings files. No capability.
9003
9947
 
9948
+ Two kinds of storage, both private to the plugin and needing no
9949
+ capability. Storage.json keeps settings in JSON files in the
9950
+ plugin's data directory: they last across player restarts and upgrades,
9951
+ and are written by the plugin's own process. Storage.session
9952
+ keeps values in the player's memory for one player run: they survive a
9953
+ restart of the plugin's process but not of the player. Secrets belong in
9954
+ Secrets, never in either.
9955
+
9956
+ - @capability None.
9957
+
9004
9958
  ```ts
9005
9959
  interface Storage {
9006
9960
  /**
@@ -9011,6 +9965,8 @@ interface Storage {
9011
9965
  *
9012
9966
  * The file is read synchronously on creation; `debounceMs` defaults to
9013
9967
  * 300. Files are created with mode 0600. `invalid` for a bad name.
9968
+ * @capability None.
9969
+ * @throws invalid (throws) A bad `name`, or a `debounceMs` that is not a non-negative number.
9014
9970
  * @example Keep settings and a recent list in a JSON file
9015
9971
  * The plugin remembers whether to resume and the last ten files across
9016
9972
  * restarts. `json` reads `settings.json` from the data directory at once
@@ -9040,6 +9996,19 @@ interface Storage {
9040
9996
  }
9041
9997
  ```
9042
9998
 
9999
+ Example: Keep a setting across restarts and a queue for this run
10000
+
10001
+ The plugin's step size survives a restart of the player; its work queue
10002
+ survives a reload of the plugin but starts empty with the next player
10003
+ run. The JSON store reads its file at once and writes it shortly after
10004
+ each `save`; session values live in the player process.
10005
+ ```ts
10006
+ const settings = plugin.storage.json("settings", { defaults: { step: 5 } });
10007
+ const queue = (await plugin.storage.session.get<string[]>("queue")) ?? [];
10008
+ void settings.value.step; // read synchronously, from the file or the defaults
10009
+ void queue;
10010
+ ```
10011
+
9043
10012
  ### SessionStorage
9044
10013
 
9045
10014
  `plugin.storage.session`: JSON values that live as long as the **player**,
@@ -9085,17 +10054,74 @@ quitting).
9085
10054
  resolves, so a crash right after loses nothing; there is no debounce.
9086
10055
  Calls answer in order.
9087
10056
 
10057
+ The player keeps the values in its own memory, per plugin, until it
10058
+ quits; the plugin's process may restart in between. Each call is one
10059
+ request to the player, answered in order, with no debounce.
10060
+
10061
+ - @capability None.
10062
+
9088
10063
  ```ts
9089
10064
  interface SessionStorage {
9090
- /** The value stored under `key`, or `undefined` when there is none. */
10065
+ /**
10066
+ * The value stored under `key`, or `undefined` when there is none.
10067
+ * @capability None.
10068
+ * @throws invalid (rejects) A bad key (empty, over 1024 bytes, with NUL or a lone surrogate).
10069
+ * @throws disabled (rejects) The plugin is stopping.
10070
+ * @throws unsupported (rejects) A player before 0.5.5.
10071
+ * @example Pick up where the plugin stopped after a reload
10072
+ * After a reload the plugin resumes the item it was working on. `get`
10073
+ * asks the player and resolves with the value parsed from JSON, or
10074
+ * `undefined` when nothing is stored; the value is not checked, so its
10075
+ * shape is checked here.
10076
+ * ```ts
10077
+ * const current = await plugin.storage.session.get<{ url: string }>("current");
10078
+ * if (typeof current?.url === "string") await plugin.osd.show("Resuming {}", [current.url]);
10079
+ * ```
10080
+ */
9091
10081
  get<T = unknown>(key: string): Promise<T | undefined>;
9092
10082
  /**
9093
10083
  * 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.
10084
+ * @capability None.
10085
+ * @throws invalid (rejects) A bad key, or a value JSON cannot hold (or a string with a lone surrogate).
10086
+ * @throws disabled (rejects) The plugin is stopping.
10087
+ * @throws unsupported (rejects) A player before 0.5.5.
10088
+ * @throws limit (rejects) The plugin's values would pass 1 MiB, or the request the 1 MiB message limit; nothing changes.
10089
+ * @example Remember the item being worked on
10090
+ * The plugin records the URL it is processing, so a reload can continue
10091
+ * with it. The value is stored as `JSON.stringify` writes it, and is kept
10092
+ * once the promise resolves.
10093
+ * ```ts
10094
+ * await plugin.storage.session.set("current", { url: "https://example.com/a.mkv" });
10095
+ * ```
9094
10096
  */
9095
10097
  set(key: string, value: unknown): Promise<void>;
9096
- /** Remove a key; removing a missing one is fine. */
10098
+ /**
10099
+ * Remove a key; removing a missing one is fine.
10100
+ * @capability None.
10101
+ * @throws invalid (rejects) A bad key.
10102
+ * @throws disabled (rejects) The plugin is stopping.
10103
+ * @throws unsupported (rejects) A player before 0.5.5.
10104
+ * @example Forget the item once it is done
10105
+ * When the work finishes, the plugin removes the entry so that a later
10106
+ * reload does not resume it. Removing a key that is not there is fine.
10107
+ * ```ts
10108
+ * await plugin.storage.session.delete("current");
10109
+ * ```
10110
+ */
9097
10111
  delete(key: string): Promise<void>;
9098
- /** The keys stored, in code point order. */
10112
+ /**
10113
+ * The keys stored, in code point order.
10114
+ * @capability None.
10115
+ * @throws disabled (rejects) The plugin is stopping.
10116
+ * @throws unsupported (rejects) A player before 0.5.5.
10117
+ * @example Clear everything the plugin stored for this run
10118
+ * The plugin starts its session state over. `keys` lists the plugin's own
10119
+ * keys in code point order; each `delete` is a request of its own.
10120
+ * ```ts
10121
+ * const session = plugin.storage.session;
10122
+ * for (const key of await session.keys()) await session.delete(key);
10123
+ * ```
10124
+ */
9099
10125
  keys(): Promise<string[]>;
9100
10126
  }
9101
10127
  ```
@@ -9119,15 +10145,61 @@ await plugin.storage.session.set("queue", pending);
9119
10145
  `plugin.system`: files and links, executed by the player. No capability.
9120
10146
  Other systems than macOS and Windows: `unsupported`.
9121
10147
 
10148
+ The player opens files, folders and links with the system's own
10149
+ applications for the plugin, so the plugin never runs `open`,
10150
+ `explorer` or a shell itself.
10151
+
10152
+ - @capability None.
10153
+
9122
10154
  ```ts
9123
10155
  interface System {
9124
10156
  /**
9125
10157
  * 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.
10158
+ * @capability None.
10159
+ * @throws missing (rejects) None of the paths exists.
10160
+ * @throws invalid (rejects) No paths, or more than 256.
10161
+ * @throws failed (rejects) The system refused.
10162
+ * @throws timeout (rejects) macOS did not answer within 10 s.
10163
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10164
+ * @example Show the exported files in the file manager
10165
+ * After an export, the file manager opens with the new files selected.
10166
+ * Paths that no longer exist are skipped; the call rejects with `missing`
10167
+ * only when none of them exists.
10168
+ * ```ts
10169
+ * await plugin.system.reveal(exported); // macOS: one Finder window for all
10170
+ * ```
9126
10171
  */
9127
10172
  reveal(paths: string | string[]): Promise<void>;
9128
- /** Open the file or folder with its default application. `missing` if absent. */
10173
+ /**
10174
+ * Open the file or folder with its default application. `missing` if absent.
10175
+ * @capability None.
10176
+ * @throws missing (rejects) The path does not exist.
10177
+ * @throws invalid (rejects) An empty path.
10178
+ * @throws failed (rejects) The system refused.
10179
+ * @throws timeout (rejects) macOS did not answer within 10 s.
10180
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10181
+ * @example Open the plugin's data folder
10182
+ * A "Show data folder" command opens the plugin's data directory in the
10183
+ * file manager, which is the default application for a folder.
10184
+ * ```ts
10185
+ * await plugin.system.openPath(plugin.dataDirectory);
10186
+ * ```
10187
+ */
9129
10188
  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. */
10189
+ /**
10190
+ * Open an http(s) address in the default browser. `invalid` for another protocol or a malformed address; `timeout` after 10 s, `failed` otherwise.
10191
+ * @capability None.
10192
+ * @throws invalid (rejects) Not an `http:` or `https:` address.
10193
+ * @throws timeout (rejects) macOS did not hand it to the browser within 10 s.
10194
+ * @throws failed (rejects) The system refused.
10195
+ * @throws unsupported (rejects) A system other than macOS and Windows.
10196
+ * @example Open the plugin's help page
10197
+ * A Help command opens the plugin's documentation in the default browser.
10198
+ * Only `http:` and `https:` addresses are opened.
10199
+ * ```ts
10200
+ * await plugin.system.openUrl("https://example.com/plugin/help");
10201
+ * ```
10202
+ */
9131
10203
  openUrl(url: string): Promise<void>;
9132
10204
  }
9133
10205
  ```
@@ -9153,20 +10225,74 @@ try {
9153
10225
  Per-plugin secrets (API keys, passwords) in the system's secret store:
9154
10226
  the login keychain on macOS, the Credential Manager on Windows. Needs
9155
10227
  `secrets`. `unsupported` on systems without a store, `unavailable` when
9156
- the store refuses access: keep a fallback (or ask again).
10228
+ the store cannot be reached (no keychain, a read-only store, no logged-in
10229
+ session on Windows): keep a fallback (or ask again).
9157
10230
 
9158
10231
  The user denying access and other system errors reject `failed`. Never
9159
10232
  write secrets to settings files.
9160
10233
 
10234
+ Values are stored by the player in the system's secret store under this
10235
+ plugin's id: other plugins cannot read them. Names are 1–64 of
10236
+ `a-z 0-9 . _ -`, values 1–1024 bytes of text, at most 256 names per
10237
+ plugin (`SECRET_LIMITS`).
10238
+
10239
+ - @capability secrets
10240
+
9161
10241
  ```ts
9162
10242
  interface Secrets {
9163
- /** The stored value, `null` when there is none. */
10243
+ /**
10244
+ * The stored value, `null` when there is none.
10245
+ * @capability secrets
10246
+ * @throws unsupported (rejects) The system has no secret store.
10247
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
10248
+ * @throws failed (rejects) The user denied access, or another system error.
10249
+ * @throws capability (rejects) The manifest does not declare `secrets`.
10250
+ * @throws invalid (rejects) A bad name.
10251
+ * @example Read a stored token
10252
+ * The plugin uses the token the user saved earlier, or asks for one when
10253
+ * there is none. `get` resolves with the stored text or `null`.
10254
+ * ```ts
10255
+ * const token = await plugin.secrets.get("api-token");
10256
+ * if (token === null) await plugin.osd.show("Sign in first");
10257
+ * ```
10258
+ */
9164
10259
  get(name: string): Promise<string | null>;
9165
10260
  /**
9166
10261
  * 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.
10262
+ * @capability secrets
10263
+ * @throws invalid (rejects) A bad name, or a value that is empty, has NUL or is over 1024 bytes.
10264
+ * @throws limit (rejects) A new name beyond 256.
10265
+ * @throws unsupported (rejects) The system has no secret store.
10266
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
10267
+ * @throws failed (rejects) The user denied access, or another system error.
10268
+ * @throws capability (rejects) The manifest does not declare `secrets`.
10269
+ * @example Save the token the user entered
10270
+ * The token the user typed is kept in the system's secret store for later
10271
+ * runs. `set` stores or replaces it; the store can refuse, in which case
10272
+ * the plugin keeps the token for this run only.
10273
+ * ```ts
10274
+ * await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
10275
+ * // No store, or access denied: the token is not kept for next time.
10276
+ * void plugin.osd.show("The token could not be saved ({})", [error.code]);
10277
+ * });
10278
+ * ```
9167
10279
  */
9168
10280
  set(name: string, value: string): Promise<void>;
9169
- /** Remove; removing a missing secret is fine. */
10281
+ /**
10282
+ * Remove; removing a missing secret is fine.
10283
+ * @capability secrets
10284
+ * @throws invalid (rejects) A bad name.
10285
+ * @throws unsupported (rejects) The system has no secret store.
10286
+ * @throws unavailable (rejects) The store cannot be reached (no keychain, a read-only store, no logged-in session on Windows).
10287
+ * @throws failed (rejects) The user denied access, or another system error.
10288
+ * @throws capability (rejects) The manifest does not declare `secrets`.
10289
+ * @example Sign out by removing the token
10290
+ * Signing out removes the stored token, so the next start asks again.
10291
+ * Removing a name that is not stored is fine.
10292
+ * ```ts
10293
+ * await plugin.secrets.delete("api-token");
10294
+ * ```
10295
+ */
9170
10296
  delete(name: string): Promise<void>;
9171
10297
  }
9172
10298
  ```
@@ -11604,7 +12730,8 @@ The plugin keeps a token the user pasted, or tells them it is empty or
11604
12730
  too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
11605
12731
  `Buffer.byteLength`; `secrets.set` would refuse other values with
11606
12732
  `invalid`. The store itself can still refuse (`unsupported` without a
11607
- system store, `unavailable` or `failed` when it denies access): only
12733
+ system store, `unavailable` when it cannot be reached, `failed` when the
12734
+ user denies access): only
11608
12735
  those codes get the "could not be saved" message, anything else (a
11609
12736
  missing `secrets` capability) is a bug and is thrown on.
11610
12737
  ```ts
@@ -11688,7 +12815,7 @@ list.on("scrolled", (row: number) => {
11688
12815
 
11689
12816
  ### IMAGE_LIMITS
11690
12817
 
11691
- The player's runtime image limits (`host/src/ui_image.rs`): a file's
12818
+ The player's runtime image limits (`src/host/src/ui/ui_image.rs`): a file's
11692
12819
  bytes and sides, the decoded pixels each plugin caches, and (SDK 1.5.19,
11693
12820
  player 0.5.1) the decoded pixels the nested images of one view may show.
11694
12821