@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.
@@ -43,11 +43,39 @@ export type { Json, AudioNode, VideoNode, SubtitleStyle, ResolvedMedia };
43
43
  * if (state.phase === "ready") void subscription.dispose();
44
44
  * });
45
45
  * ```
46
+ *
47
+ * @remarks
48
+ * Every handle a plugin creates is a `Disposable` owned by the scope that
49
+ * created it; disposing the scope (or stopping the plugin) disposes them in
50
+ * reverse creation order, so a plugin rarely calls `dispose` itself.
51
+ *
52
+ * @capability None of its own: that of the handle.
53
+ * @since 1.0.0
54
+ * @category Plugin
46
55
  */
47
56
  export interface Disposable {
48
57
  /**
49
58
  * Release the resource. Resolves once the player confirmed the release
50
59
  * (or immediately for purely local resources). Never rejects.
60
+ *
61
+ * @capability None of its own: that of the handle.
62
+ * @throws Never.
63
+ *
64
+ * @example Remove a command when its feature is turned off
65
+ * A "Turn feature off" item removes the feature's own menu item at once,
66
+ * before the plugin stops. `dispose` takes the command out of the menu and
67
+ * the binding table and resolves once the player confirmed it; the id can
68
+ * be added again later.
69
+ * ```ts
70
+ * const extra = plugin.commands.add({ id: "extra", path: ["Tools", "Extra"], run() {} });
71
+ * plugin.commands.add({
72
+ * id: "extra-off",
73
+ * path: ["Tools", "Turn extra off"],
74
+ * run: () => extra.dispose(), // never rejects
75
+ * });
76
+ * ```
77
+ *
78
+ * @since 1.0.0
51
79
  */
52
80
  dispose(): Promise<void>;
53
81
  }
@@ -59,7 +87,6 @@ export interface Disposable {
59
87
  * | code | meaning |
60
88
  * | --- | --- |
61
89
  * | `stale` | A condition no longer held (the source or the subtitle selection changed); nothing was done. See {@link Conditional}. |
62
- * | `staleDigest` | {@link PackageCandidate.install}: the package's files changed after it was validated; validate it again. |
63
90
  * | `capability` | The manifest does not declare the capability the call needs. |
64
91
  * | `invalid` | A bad argument (out of range, malformed, unknown id). |
65
92
  * | `missing` | The target does not exist (an empty panel position, a missing file, an unknown command). |
@@ -79,7 +106,7 @@ export interface Disposable {
79
106
  * | `backend-uncertain` | The player's backend blocked a filter stage; call `retry()`. |
80
107
  * | `limit` | A player-wide limit was reached (for example the number of commands), a property value is over the limits of {@link PropValue}, or a request is longer than the player's 1 MiB message. |
81
108
  * | `reserved` | {@link Packages.validate}, {@link 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). |
82
- * | `versionMismatch` | The package needs a newer player (`minPlayerVersion`) or another `apiVersion`. |
109
+ * | `versionMismatch` | The package needs a newer player (`minPlayerVersion`). |
83
110
  * | `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. |
84
111
  * | `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 {@link 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). |
85
112
  * | `host` | Any other failure inside the player; `message` is the player's reason. |
@@ -117,6 +144,9 @@ export interface Disposable {
117
144
  * throw error;
118
145
  * }
119
146
  * ```
147
+ *
148
+ * @since 1.0.0
149
+ * @category Plugin
120
150
  */
121
151
  export type PluginError = Error & {
122
152
  /** The stable failure code (see the table above). */
@@ -159,6 +189,17 @@ export type PluginError = Error & {
159
189
  * );
160
190
  * });
161
191
  * ```
192
+ *
193
+ * @remarks
194
+ * Every module of the API (`commands`, `windows`, `player`…) is reachable
195
+ * from a scope, and every handle made through it belongs to it. A child
196
+ * scope from {@link Scope.scope} is the unit for features that come and
197
+ * go: dispose the child and everything it created is released, while the
198
+ * rest of the plugin keeps running.
199
+ *
200
+ * @capability That of each module (see the modules).
201
+ * @since 1.0.0
202
+ * @category Plugin
162
203
  */
163
204
  export interface Scope extends Disposable {
164
205
  /** Menu commands and their default bindings (`commands`). */
@@ -189,12 +230,23 @@ export interface Scope extends Disposable {
189
230
  /**
190
231
  * The display layer (needs `processing.video`). The first plugin to call
191
232
  * it owns the layer; another plugin's view fails with `conflict` until the
192
- * owner disposes or stops. A second call in the same plugin throws.
233
+ * owner disposes or stops.
234
+ *
235
+ * @throws `capability` (throws) The manifest does not declare
236
+ * `processing.video`.
237
+ * @throws `Error` without a code (throws) A second call in the same plugin
238
+ * while the first view is not disposed.
193
239
  */
194
240
  view(): VideoView;
195
241
  /**
196
242
  * The GPU renderer's options (needs `processing.video`), owned like the
197
- * display layer. `failed` with `unsupported` under software rendering.
243
+ * display layer. Under software rendering the handle is `failed` with
244
+ * `unsupported` after the player's first answer, and `onError` hears it.
245
+ *
246
+ * @throws `capability` (throws) The manifest does not declare
247
+ * `processing.video`.
248
+ * @throws `Error` without a code (throws) A second call in the same plugin
249
+ * while the first handle is not disposed.
198
250
  */
199
251
  render(): RenderOptions;
200
252
  };
@@ -258,6 +310,36 @@ export interface Scope extends Disposable {
258
310
  /**
259
311
  * A child scope; disposing it releases only what was created through it.
260
312
  * The child is itself owned by this scope.
313
+ *
314
+ * @capability None.
315
+ *
316
+ * @example Tie a temporary window and its timer to one feature
317
+ * A "Live stats" feature opens a window and starts a timer; turning it off
318
+ * releases both together. Everything made through the child scope, and
319
+ * cleanups registered on it, go when the child is disposed; the parent
320
+ * scope is untouched.
321
+ * ```ts
322
+ * let live: Scope | null = null;
323
+ * plugin.commands.add({
324
+ * id: "live-stats",
325
+ * path: ["Tools", "Live stats"],
326
+ * checked: () => live !== null,
327
+ * run: async () => {
328
+ * if (live) {
329
+ * await live.dispose(); // closes the window, stops the timer
330
+ * live = null;
331
+ * return;
332
+ * }
333
+ * live = plugin.scope();
334
+ * const window = live.windows.create("ui/stats.slint");
335
+ * const timer = setInterval(() => live?.update(), 1000);
336
+ * live.onDispose(() => clearInterval(timer));
337
+ * await window.show();
338
+ * },
339
+ * });
340
+ * ```
341
+ *
342
+ * @since 1.0.0
261
343
  */
262
344
  scope(): Scope;
263
345
  /**
@@ -266,6 +348,21 @@ export interface Scope extends Disposable {
266
348
  * @param item - Any {@link Disposable}, for example a handle created
267
349
  * through another scope.
268
350
  * @returns `item` itself, for chaining.
351
+ *
352
+ * @capability None.
353
+ *
354
+ * @example Dispose a handle from another scope together with this one
355
+ * A filter made through the root scope is withdrawn when a child feature
356
+ * ends. `add` hands an existing disposable to this scope, which then
357
+ * disposes it with its own handles; it returns the same handle for
358
+ * chaining.
359
+ * ```ts
360
+ * const feature = plugin.scope();
361
+ * const blur = feature.add(plugin.video.filter("blur")); // now ends with `feature`
362
+ * blur.set([{ kind: "gblur", sigma: 4, steps: 3 }]);
363
+ * ```
364
+ *
365
+ * @since 1.0.0
269
366
  */
270
367
  add<T extends Disposable>(item: T): T;
271
368
  /**
@@ -283,6 +380,9 @@ export interface Scope extends Disposable {
283
380
  * const timer = setInterval(() => plugin.update(), 1000); // re-evaluate bindings
284
381
  * plugin.onDispose(() => clearInterval(timer));
285
382
  * ```
383
+ *
384
+ * @capability None.
385
+ * @since 1.0.0
286
386
  */
287
387
  onDispose(cleanup: () => void | Promise<void>): Disposable;
288
388
  /**
@@ -304,6 +404,9 @@ export interface Scope extends Disposable {
304
404
  * void plugin.osd.show("Bookmarks: {}", [error.message]);
305
405
  * });
306
406
  * ```
407
+ *
408
+ * @capability None.
409
+ * @since 1.0.0
307
410
  */
308
411
  onError(listener: (error: PluginError) => void): Disposable;
309
412
  /**
@@ -313,6 +416,30 @@ export interface Scope extends Disposable {
313
416
  * changing state from your own timers or between awaits of a long task.
314
417
  *
315
418
  * Several calls within one microtask are merged into one pass.
419
+ *
420
+ * @capability None.
421
+ *
422
+ * @example Show progress from a long task between awaits
423
+ * A progress label moves while a long download runs, not only when it
424
+ * ends. The SDK re-evaluates bound values after each handler and when its
425
+ * promise settles; state changed between the awaits of a long handler
426
+ * shows only after an explicit `update()`.
427
+ * ```ts
428
+ * declare const window: Window;
429
+ * declare function downloadPart(part: number): Promise<void>;
430
+ * // ---cut---
431
+ * let done = 0;
432
+ * window.bind(() => ({ progress: done }));
433
+ * window.on("start", async () => {
434
+ * for (let part = 0; part < 10; part++) {
435
+ * await downloadPart(part);
436
+ * done = part + 1;
437
+ * plugin.update(); // send `progress` now, not when the handler ends
438
+ * }
439
+ * });
440
+ * ```
441
+ *
442
+ * @since 1.0.0
316
443
  */
317
444
  update(): void;
318
445
  }
@@ -351,6 +478,16 @@ export interface Scope extends Disposable {
351
478
  * });
352
479
  * }
353
480
  * ```
481
+ *
482
+ * @remarks
483
+ * The root scope: disposing it is what stopping the plugin does. Besides
484
+ * the modules of {@link Scope}, it carries the plugin's `id` and its
485
+ * `dataDirectory`.
486
+ *
487
+ * @capability That of each module (see the modules).
488
+ * @see [How a plugin runs](../../docs/concepts/plugins.md)
489
+ * @since 1.0.0
490
+ * @category Plugin
354
491
  */
355
492
  export interface Plugin extends Scope {
356
493
  /** The manifest id, e.g. `"community.bookmark"`. */
@@ -372,7 +509,13 @@ export interface Plugin extends Scope {
372
509
  */
373
510
  readonly dataDirectory: string;
374
511
  }
375
- /** The state of a command's menu item, as the player shows it. */
512
+ /**
513
+ * The state of a command's menu item, as the player shows it.
514
+ *
515
+ * @see {@link Command.set}, {@link CommandOptions}
516
+ * @since 1.0.0
517
+ * @category Commands
518
+ */
376
519
  export interface CommandState {
377
520
  /** False greys the item out; the player then never calls `run` (bound keys are swallowed). Default true. */
378
521
  enabled: boolean;
@@ -404,6 +547,9 @@ export interface CommandState {
404
547
  * run: () => plugin.player.setLoop(null),
405
548
  * });
406
549
  * ```
550
+ *
551
+ * @since 1.0.0
552
+ * @category Commands
407
553
  */
408
554
  export type Bindable<T> = T | (() => T);
409
555
  /**
@@ -411,6 +557,9 @@ export type Bindable<T> = T | (() => T);
411
557
  * `@<id>` or by its English key (see {@link CommandOptions.path}): Open,
412
558
  * Playback, Subtitles, Video, Audio, Screen, Settings. The list is
413
559
  * `MENU_IDS` in `@vestra-player/sdk/testing`.
560
+ *
561
+ * @since 1.3.0
562
+ * @category Commands
414
563
  */
415
564
  export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings";
416
565
  /**
@@ -419,6 +568,8 @@ export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "sc
419
568
  * `@vestra-player/sdk/testing`.
420
569
  *
421
570
  * @since 1.5.29
571
+ *
572
+ * @category Commands
422
573
  */
423
574
  export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "open" | "volume" | "mute" | "playlist" | "detach" | "attach" | "close" | "failed" | "move-top" | "move-up" | "move-down" | "move-bottom" | "search" | "chapter" | "bookmark" | "check-task" | "check-box" | "menu-check" | "submenu" | "scroll-up" | "scroll-down" | "caret-down" | "sort-asc" | "sort-desc" | "tag-remove" | "preview-play" | "add" | "remove" | "sort" | "fullscreen" | "exit-fullscreen" | "store" | "ai";
424
575
  /**
@@ -470,6 +621,8 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
470
621
  * @player 0.5.10; older players show the menu entry without a button
471
622
  * (declare `"minPlayerVersion": "0.5.10"` when the button matters).
472
623
  * @since 1.5.29
624
+ *
625
+ * @category Commands
473
626
  */
474
627
  export interface CommandButton {
475
628
  /**
@@ -518,6 +671,10 @@ export interface CommandButton {
518
671
  * {@link Commands.add} throws `invalid` synchronously for a bad path,
519
672
  * order, group or binding. The same path registered twice, or a path that
520
673
  * is both an item and a submenu, fails with `conflict` (through `ready`).
674
+ *
675
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
676
+ * @since 1.0.0
677
+ * @category Commands
521
678
  */
522
679
  export interface CommandOptions {
523
680
  /** Local id, unique within the plugin (the global id is `<manifest id>/<id>`). */
@@ -644,6 +801,38 @@ export interface CommandOptions {
644
801
  /**
645
802
  * A registered command, from {@link Commands.add}. Disposing it removes it
646
803
  * from the menu and the binding table; the id can be added again right away.
804
+ *
805
+ * @remarks
806
+ * The handle of one menu item and its keys. Its state (`enabled`, `checked`,
807
+ * `busy`, `reason`) is bound: functions given to {@link Commands.add} or
808
+ * {@link Command.set} are evaluated again in every update pass and only
809
+ * changes reach the player. Its title, path, bindings and button cannot
810
+ * change in place; dispose the command (or a child {@link Scope}) and add it
811
+ * again. Disposing it, its scope or stopping the plugin removes the menu
812
+ * item, its keys and its bar button.
813
+ *
814
+ * @capability commands
815
+ *
816
+ * @example Enable an item only while a file is open
817
+ * Video ▸ Export… is greyed out until a file is open. `set` replaces the
818
+ * `enabled` field with a function; the SDK evaluates it again after every
819
+ * player state change and sends the new state when it changes, so the
820
+ * item follows the player without further code.
821
+ * ```ts
822
+ * declare function runExport(): Promise<void>;
823
+ * // ---cut---
824
+ * const exportCommand = plugin.commands.add({
825
+ * id: "export",
826
+ * path: ["@video", "Export…"],
827
+ * run: runExport,
828
+ * });
829
+ * // A function: re-evaluated in every update pass.
830
+ * exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
831
+ * ```
832
+ *
833
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
834
+ * @since 1.0.0
835
+ * @category Commands
647
836
  */
648
837
  export interface Command extends Disposable {
649
838
  /** The local id given in {@link CommandOptions.id}. */
@@ -672,6 +861,9 @@ export interface Command extends Disposable {
672
861
  * },
673
862
  * });
674
863
  * ```
864
+ *
865
+ * @capability commands
866
+ * @since 1.0.0
675
867
  */
676
868
  set(state: Partial<{
677
869
  [K in keyof CommandState]: Bindable<CommandState[K]>;
@@ -682,10 +874,49 @@ export interface Command extends Disposable {
682
874
  * commands in this plugin, or 1024 in the whole player). You rarely need
683
875
  * to await it: during activation a failure fails activation, later it
684
876
  * goes to {@link Scope.onError}.
877
+ *
878
+ * @throws `conflict` (rejects) Another command already has this menu path,
879
+ * or the path would be both a menu item and a submenu.
880
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
881
+ * @throws `invalid` (rejects) The player refused the declaration.
882
+ * @throws `limit` (rejects) More than 160 commands in this plugin, or 1024 in
883
+ * the player.
685
884
  */
686
885
  readonly ready: Promise<void>;
687
886
  }
688
- /** `plugin.commands`: menu commands with default bindings. Needs `commands`. */
887
+ /**
888
+ * `plugin.commands`: menu commands with default bindings. Needs `commands`.
889
+ *
890
+ * @remarks
891
+ * A command is one action of the plugin with a menu item (`path`), default
892
+ * keys (`bindings`), optionally a bottom-bar button (`button`) and a state
893
+ * the menu shows (`enabled`, `checked`, `busy`, `reason`). The player owns
894
+ * the menu, the binding table and the user's key overrides: it calls `run`
895
+ * when the user picks the item, presses a key or clicks the button.
896
+ * Commands of all plugins merge into the player's menus by their English
897
+ * path keys. Registration is asynchronous: `add` returns at once, and
898
+ * {@link Command.ready} reports the player's answer (during activation a
899
+ * refusal fails activation).
900
+ *
901
+ * @capability commands
902
+ *
903
+ * @example Add a menu item with a default key
904
+ * Tools ▸ Say hello, or Ctrl+H, shows a greeting. `add` declares the item;
905
+ * the player calls `run` when the user picks it or presses its key, and the
906
+ * user can change the key in the player's key settings.
907
+ * ```ts
908
+ * plugin.commands.add({
909
+ * id: "hello",
910
+ * path: ["Tools", "Say hello"],
911
+ * bindings: ["Ctrl+H"], // a default; the user may override it
912
+ * run: () => plugin.osd.show("Hello"),
913
+ * });
914
+ * ```
915
+ *
916
+ * @see {@link Command}, {@link CommandOptions}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
917
+ * @since 1.0.0
918
+ * @category Commands
919
+ */
689
920
  export interface Commands {
690
921
  /**
691
922
  * Declare a command. It is registered and kept in sync; `dispose()` removes
@@ -715,6 +946,9 @@ export interface Commands {
715
946
  * run: () => plugin.player.setPaused(!plugin.player.state.paused),
716
947
  * });
717
948
  * ```
949
+ *
950
+ * @capability commands
951
+ * @since 1.0.0
718
952
  */
719
953
  add(options: CommandOptions): Command;
720
954
  }
@@ -2325,7 +2559,12 @@ export interface Panel extends View, Disposable {
2325
2559
  modifiers: DropModifiers;
2326
2560
  }) => unknown): Disposable;
2327
2561
  }
2328
- /** Keys held when the files were dropped. */
2562
+ /**
2563
+ * Keys held when the files were dropped.
2564
+ *
2565
+ * @since 1.0.0
2566
+ * @category UI
2567
+ */
2329
2568
  export interface DropModifiers {
2330
2569
  /** Option (macOS) / Alt (Windows). */
2331
2570
  readonly alt: boolean;
@@ -2334,7 +2573,13 @@ export interface DropModifiers {
2334
2573
  /** ⌘ on macOS, Ctrl on Windows. */
2335
2574
  readonly cmd: boolean;
2336
2575
  }
2337
- /** A provider for files dropped on the video area ({@link Drops.provide}). */
2576
+ /**
2577
+ * A provider for files dropped on the video area ({@link Drops.provide}).
2578
+ *
2579
+ * @see {@link Drops.provide}
2580
+ * @since 1.0.0
2581
+ * @category UI
2582
+ */
2338
2583
  export interface DropHandler {
2339
2584
  /**
2340
2585
  * Lower case, without the dot; `"folder"` matches directories.
@@ -2347,7 +2592,39 @@ export interface DropHandler {
2347
2592
  modifiers: DropModifiers;
2348
2593
  }): unknown;
2349
2594
  }
2350
- /** `plugin.drops`: files dropped on the video area. Needs `ui.drop`. */
2595
+ /**
2596
+ * `plugin.drops`: files dropped on the video area. Needs `ui.drop`.
2597
+ *
2598
+ * @remarks
2599
+ * Files dropped on the video area go to providers by extension: each
2600
+ * extension belongs to the provider registered for it first, and each
2601
+ * provider gets one call with all of its paths. Files nobody takes are
2602
+ * ignored. A panel with {@link Panel.onDrop} takes drops on itself first.
2603
+ *
2604
+ * @capability ui.drop
2605
+ *
2606
+ * @example Collect dropped CHIP-8 programs
2607
+ * `.ch8` files dropped on the player are added to the plugin's list. The
2608
+ * provider claims the extension; the player calls `drop` with the dropped
2609
+ * paths of that type, in the order the file manager gave them. Extensions
2610
+ * go to the first provider: the built-in playlist claims every audio and
2611
+ * video extension when it starts, and a built-in plugin cannot be disabled,
2612
+ * so another plugin's provider for those never gets them (except a
2613
+ * development load that replaces the playlist under its id).
2614
+ * ```ts
2615
+ * const queue: string[] = [];
2616
+ * plugin.drops.provide({
2617
+ * extensions: ["ch8"],
2618
+ * drop: ({ paths }) => {
2619
+ * queue.push(...paths); // only .ch8 files reach here
2620
+ * },
2621
+ * });
2622
+ * ```
2623
+ *
2624
+ * @see {@link Panel.onDrop}
2625
+ * @since 1.0.0
2626
+ * @category UI
2627
+ */
2351
2628
  export interface Drops {
2352
2629
  /**
2353
2630
  * Take files dropped on the video area. The first provider registered for
@@ -2381,6 +2658,9 @@ export interface Drops {
2381
2658
  * },
2382
2659
  * });
2383
2660
  * ```
2661
+ *
2662
+ * @capability ui.drop
2663
+ * @since 1.0.0
2384
2664
  */
2385
2665
  provide(handler: DropHandler): Disposable;
2386
2666
  }
@@ -3297,9 +3577,19 @@ export interface Panels {
3297
3577
  /** The sidebar position, including its floating state; any plugin may read and toggle it. */
3298
3578
  readonly sidebar: SidebarSlot;
3299
3579
  }
3300
- /** What a timeline mark stands for; readers choose colours by it. */
3580
+ /**
3581
+ * What a timeline mark stands for; readers choose colours by it.
3582
+ *
3583
+ * @since 1.0.0
3584
+ * @category Playback
3585
+ */
3301
3586
  export type MarkKind = "chapter" | "bookmark" | "marker";
3302
- /** One mark on the seek bar. */
3587
+ /**
3588
+ * One mark on the seek bar.
3589
+ *
3590
+ * @since 1.0.0
3591
+ * @category Playback
3592
+ */
3303
3593
  export interface TimelineMark {
3304
3594
  /** Seconds from the start. */
3305
3595
  readonly time: number;
@@ -3310,7 +3600,28 @@ export interface TimelineMark {
3310
3600
  /** The manifest id of the plugin that published it. */
3311
3601
  readonly plugin: string;
3312
3602
  }
3313
- /** This plugin's own marks; disposing it removes them. */
3603
+ /**
3604
+ * This plugin's own marks; disposing it removes them.
3605
+ *
3606
+ * @remarks
3607
+ * This plugin's marks on the seek bar, from {@link Timeline.layer}. `set`
3608
+ * replaces the whole layer and never throws: the newest write wins, and a
3609
+ * refused one is reported to `onError`. Disposing the layer, its scope or
3610
+ * stopping the plugin removes the marks.
3611
+ *
3612
+ * @capability timeline.marks
3613
+ *
3614
+ * @example Clear the plugin's marks
3615
+ * The plugin's marks disappear from the seek bar while it keeps its
3616
+ * layer for later. An empty list clears the layer; other plugins' marks
3617
+ * stay.
3618
+ * ```ts
3619
+ * plugin.timeline.layer().set([]);
3620
+ * ```
3621
+ *
3622
+ * @since 1.0.0
3623
+ * @category Playback
3624
+ */
3314
3625
  export interface TimelineLayer extends Disposable {
3315
3626
  /**
3316
3627
  * Replace the layer (last write wins; `[]` clears it). At most 1000 marks,
@@ -3335,6 +3646,9 @@ export interface TimelineLayer extends Disposable {
3335
3646
  * { ifCurrent: seen },
3336
3647
  * );
3337
3648
  * ```
3649
+ *
3650
+ * @capability timeline.marks
3651
+ * @since 1.0.0
3338
3652
  */
3339
3653
  set(marks: {
3340
3654
  time: number;
@@ -3347,16 +3661,79 @@ export interface TimelineLayer extends Disposable {
3347
3661
  /**
3348
3662
  * `plugin.timeline`: marks on the seek bar. Every plugin publishes one
3349
3663
  * layer; the player merges them. A new source clears every layer.
3664
+ *
3665
+ * @remarks
3666
+ * Marks on the player's seek bar: chapters, bookmarks, loop points. Each
3667
+ * plugin publishes one layer with {@link Timeline.layer}; the player merges
3668
+ * the layers by time and shows them. A new source clears every layer, so a
3669
+ * plugin publishes again for each file. `marks` is the merged list, which
3670
+ * any plugin can read.
3671
+ *
3672
+ * @capability `timeline.marks` to publish; `playback.read` to read;
3673
+ * `events` to follow changes.
3674
+ *
3675
+ * @example Count the chapter marks other plugins published
3676
+ * The plugin reads how many chapter marks the seek bar shows, whoever
3677
+ * published them. `marks` is the merged list of every layer, sorted by
3678
+ * time, with each mark's `plugin`.
3679
+ * ```ts
3680
+ * const chapters = plugin.timeline.marks.filter((mark) => mark.kind === "chapter");
3681
+ * await plugin.osd.show("{} chapters", [chapters.length]);
3682
+ * ```
3683
+ *
3684
+ * @since 1.0.0
3685
+ * @category Playback
3350
3686
  */
3351
3687
  export interface Timeline {
3352
3688
  /** Every layer merged, by time (`playback.read`). Empty on a new source. */
3353
3689
  readonly marks: readonly TimelineMark[];
3354
- /** The merged list changed (needs `events`; at most every 100 ms). */
3690
+ /**
3691
+ * The merged list changed (needs `events`; at most every 100 ms).
3692
+ *
3693
+ * @capability events and playback.read
3694
+ *
3695
+ * @example Follow the marks as they change
3696
+ * A panel lists the seek bar's marks and updates when any plugin publishes
3697
+ * or a new file clears them. The listener gets the merged list, at most
3698
+ * every 100 ms.
3699
+ * ```ts
3700
+ * plugin.timeline.onChange((marks) => {
3701
+ * console.log(marks.map((mark) => `${mark.time}s ${mark.title ?? mark.kind}`));
3702
+ * });
3703
+ * ```
3704
+ *
3705
+ * @since 1.0.0
3706
+ */
3355
3707
  onChange(listener: (marks: readonly TimelineMark[]) => void): Disposable;
3356
- /** This plugin's layer (`timeline.marks`); a second call throws. */
3708
+ /**
3709
+ * This plugin's layer (`timeline.marks`); a second call throws.
3710
+ *
3711
+ * @capability timeline.marks
3712
+ *
3713
+ * @example Publish this plugin's marks
3714
+ * The plugin gets its one layer and puts two chapter marks on the seek
3715
+ * bar. Calling `layer()` again while the first layer is not disposed
3716
+ * throws; disposing the layer removes the
3717
+ * marks.
3718
+ * ```ts
3719
+ * const layer = plugin.timeline.layer();
3720
+ * layer.set([
3721
+ * { time: 0, kind: "chapter", title: "Intro" },
3722
+ * { time: 95, kind: "chapter", title: "Part 1" }, // seconds
3723
+ * ]);
3724
+ * ```
3725
+ *
3726
+ * @since 1.0.0
3727
+ */
3357
3728
  layer(): TimelineLayer;
3358
3729
  }
3359
- /** The main window as `plugin.window.state` reports it (logical points). */
3730
+ /**
3731
+ * The main window as `plugin.window.state` reports it (logical points).
3732
+ *
3733
+ * @see {@link MainWindow.state}
3734
+ * @since 1.0.0
3735
+ * @category Plugin
3736
+ */
3360
3737
  export interface WindowState {
3361
3738
  /** The main window is fullscreen. */
3362
3739
  readonly fullscreen: boolean;
@@ -3406,11 +3783,38 @@ export interface WindowState {
3406
3783
  * },
3407
3784
  * });
3408
3785
  * ```
3786
+ *
3787
+ * @remarks
3788
+ * `plugin.window` is the player's main window: its size, fullscreen,
3789
+ * picture-in-picture and the video area. Reading it needs no capability;
3790
+ * changing it needs `window.control`. Changes that last only while the
3791
+ * plugin runs (always on top, pointer auto-hide) revert when it stops.
3792
+ *
3793
+ * @capability None to read; window.control to change; events to follow
3794
+ * outside changes.
3795
+ * @since 1.0.0
3796
+ * @category Plugin
3409
3797
  */
3410
3798
  export interface MainWindow {
3411
3799
  /** Latest state; zero video area without a native window. */
3412
3800
  readonly state: WindowState;
3413
- /** The window state changed (needs `events` for outside changes). */
3801
+ /**
3802
+ * The window state changed (needs `events` for outside changes).
3803
+ *
3804
+ * @capability events (for outside changes)
3805
+ *
3806
+ * @example Fit a panel to fullscreen
3807
+ * The plugin's panel uses larger text while the player is fullscreen. The
3808
+ * listener runs after the SDK updated `state` from the player's events
3809
+ * (with `events`; without it only after this plugin's own changes).
3810
+ * ```ts
3811
+ * declare const bar: Panel;
3812
+ * // ---cut---
3813
+ * plugin.window.onChange((state) => bar.set({ large: state.fullscreen }));
3814
+ * ```
3815
+ *
3816
+ * @since 1.0.0
3817
+ */
3414
3818
  onChange(listener: (state: WindowState) => void): Disposable;
3415
3819
  /**
3416
3820
  * Resize the window so the video column is `width`×`height` (the bottom bar
@@ -3422,6 +3826,23 @@ export interface MainWindow {
3422
3826
  * Sizes are 1–16384 logical points; the minimum video area is 318×120.
3423
3827
  * The visible area excludes the menu bar and Dock on macOS; on Windows it
3424
3828
  * is the whole display. `unavailable` without a user interface too.
3829
+ *
3830
+ * @capability window.control
3831
+ * @throws `unavailable` (rejects) Fullscreen, or no user interface.
3832
+ * @throws `invalid` (rejects) A size outside 1–16384 logical points.
3833
+ * @throws `capability` (rejects) The manifest does not declare
3834
+ * `window.control`.
3835
+ *
3836
+ * @example Make the video area 1280×720 points
3837
+ * The window resizes so that the video column is 1280×720 logical points,
3838
+ * the bars and a docked sidebar added around it. A request that does not
3839
+ * fit the screen is scaled down keeping the aspect, and `clamped` says so.
3840
+ * ```ts
3841
+ * const applied = await plugin.window.setVideoArea({ width: 1280, height: 720 });
3842
+ * if (applied.clamped) await plugin.osd.show("{}×{}", [applied.width, applied.height]);
3843
+ * ```
3844
+ *
3845
+ * @since 1.0.0
3425
3846
  */
3426
3847
  setVideoArea(size: {
3427
3848
  width: number;
@@ -3437,12 +3858,53 @@ export interface MainWindow {
3437
3858
  /**
3438
3859
  * Float the player's windows above other applications. Newest call wins; reverts when this plugin stops.
3439
3860
  * `unavailable` without a user interface.
3861
+ *
3862
+ * @capability window.control
3863
+ * @throws `unavailable` (rejects) No user interface.
3864
+ * @throws `invalid` (rejects) `onTop` is not a boolean.
3865
+ * @throws `capability` (rejects) The manifest does not declare
3866
+ * `window.control`.
3867
+ *
3868
+ * @example Keep the player above other windows
3869
+ * The player's windows (tool windows too) float above other applications
3870
+ * while the plugin's "Always on top" item is checked. The newest call of
3871
+ * any plugin wins, and the setting reverts when this plugin stops.
3872
+ * ```ts
3873
+ * let onTop = false;
3874
+ * plugin.commands.add({
3875
+ * id: "on-top",
3876
+ * path: ["@screen", "Always on top"],
3877
+ * checked: () => onTop,
3878
+ * run: async () => {
3879
+ * onTop = !onTop;
3880
+ * await plugin.window.setAlwaysOnTop(onTop); // reverts when this plugin stops
3881
+ * },
3882
+ * });
3883
+ * ```
3884
+ *
3885
+ * @since 1.0.0
3440
3886
  */
3441
3887
  setAlwaysOnTop(onTop: boolean): Promise<void>;
3442
3888
  /**
3443
3889
  * `"playing"`: hide the pointer over the video after 3 idle seconds while
3444
3890
  * playing, in a window too (not only fullscreen). Newest call wins;
3445
3891
  * reverts to `"fullscreen"` when this plugin stops.
3892
+ *
3893
+ * @capability window.control
3894
+ * @throws `invalid` (rejects) A mode other than `"fullscreen"` or
3895
+ * `"playing"`.
3896
+ * @throws `capability` (rejects) The manifest does not declare
3897
+ * `window.control`.
3898
+ *
3899
+ * @example Hide the pointer over playing video in a window too
3900
+ * The pointer disappears after 3 idle seconds over playing video, not only
3901
+ * in fullscreen. The newest call wins; when this plugin stops the player
3902
+ * goes back to hiding it in fullscreen only.
3903
+ * ```ts
3904
+ * await plugin.window.setCursorAutoHide("playing");
3905
+ * ```
3906
+ *
3907
+ * @since 1.0.0
3446
3908
  */
3447
3909
  setCursorAutoHide(mode: "fullscreen" | "playing"): Promise<void>;
3448
3910
  /**
@@ -3467,10 +3929,26 @@ export interface MainWindow {
3467
3929
  * if (plugin.window.state.pictureInPicture.supported)
3468
3930
  * await plugin.window.setPictureInPicture(!plugin.window.state.pictureInPicture.active);
3469
3931
  * ```
3932
+ *
3933
+ * @capability window.control
3934
+ * @throws `unsupported` (rejects) The system has no picture-in-picture
3935
+ * (`supported` is false).
3936
+ * @throws `unavailable` (rejects) No video, or no user interface.
3937
+ * @throws `invalid` (rejects) `on` is not a boolean.
3938
+ * @throws `busy` (rejects) The previous call is still switching.
3939
+ * @throws `failed` (rejects) The system refused.
3940
+ * @throws `capability` (rejects) The manifest does not declare
3941
+ * `window.control`.
3942
+ * @since 1.0.0
3470
3943
  */
3471
3944
  setPictureInPicture(on: boolean): Promise<void>;
3472
3945
  }
3473
- /** `plugin.app`: fixed for the life of the plugin process. */
3946
+ /**
3947
+ * `plugin.app`: fixed for the life of the plugin process.
3948
+ *
3949
+ * @since 1.0.0
3950
+ * @category Plugin
3951
+ */
3474
3952
  export interface AppInfo {
3475
3953
  /** The player's version, e.g. `"1.0.0"`. */
3476
3954
  readonly version: string;
@@ -3500,15 +3978,47 @@ export interface AppInfo {
3500
3978
  * const system = await plugin.app.system();
3501
3979
  * const text = `Vestra ${plugin.app.version} on ${system.name} ${system.version ?? ""}`;
3502
3980
  * ```
3981
+ *
3982
+ * @remarks
3983
+ * `plugin.app` holds facts fixed for the player's run (its version, build,
3984
+ * library versions) and reads the system's on request: `system()` reads
3985
+ * the displays at each call; the operating system's name and version are
3986
+ * worked out once, at the first call, and reused.
3987
+ *
3988
+ * @capability None.
3989
+ * @since 1.0.0
3990
+ * @category Plugin
3503
3991
  */
3504
3992
  export interface App extends AppInfo {
3505
3993
  /**
3506
3994
  * The operating system and the displays, read when called (displays come
3507
3995
  * and go). No capability. CPU and memory: Node's `os` module.
3996
+ *
3997
+ * @capability None.
3998
+ * @throws `busy` (rejects) The player could not start the request (no
3999
+ * thread available).
4000
+ *
4001
+ * @example Choose a size for the primary display
4002
+ * The plugin sizes its picture for the primary display. `system()` asks
4003
+ * the player; `displays` lists the displays present now, the primary one
4004
+ * first (empty without a user interface).
4005
+ * ```ts
4006
+ * const { displays } = await plugin.app.system();
4007
+ * const primary = displays[0];
4008
+ * if (primary) console.log(primary.width, primary.height);
4009
+ * ```
4010
+ *
4011
+ * @since 1.0.0
3508
4012
  */
3509
4013
  system(): Promise<SystemInfo>;
3510
4014
  }
3511
- /** What {@link App.system} reports. */
4015
+ /**
4016
+ * What {@link App.system} reports.
4017
+ *
4018
+ * @see {@link App.system}
4019
+ * @since 1.0.0
4020
+ * @category Plugin
4021
+ */
3512
4022
  export interface SystemInfo {
3513
4023
  /** The operating system family. */
3514
4024
  readonly os: "macos" | "windows" | "linux" | "other";
@@ -3525,7 +4035,12 @@ export interface SystemInfo {
3525
4035
  /** Primary display first; empty without a user interface. */
3526
4036
  readonly displays: readonly DisplayInfo[];
3527
4037
  }
3528
- /** One display in {@link SystemInfo.displays}. */
4038
+ /**
4039
+ * One display in {@link SystemInfo.displays}.
4040
+ *
4041
+ * @since 1.0.0
4042
+ * @category Plugin
4043
+ */
3529
4044
  export interface DisplayInfo {
3530
4045
  /** The system's name for it, `null` when it has none. */
3531
4046
  readonly name: string | null;
@@ -3814,7 +4329,12 @@ export interface Dialogs {
3814
4329
  fileName?: TextValue;
3815
4330
  }): Promise<string | null>;
3816
4331
  }
3817
- /** One audio track of the current media ({@link PlayerState.audioTracks}). */
4332
+ /**
4333
+ * One audio track of the current media ({@link PlayerState.audioTracks}).
4334
+ *
4335
+ * @since 1.0.0
4336
+ * @category Playback
4337
+ */
3818
4338
  export interface AudioTrack {
3819
4339
  /** The backend's track id; pass it to {@link Player.selectAudioTrack}. */
3820
4340
  id: number;
@@ -3836,6 +4356,10 @@ export interface AudioTrack {
3836
4356
  *
3837
4357
  * Without `events`, {@link Player.state} only updates at start and after
3838
4358
  * this plugin's own operations.
4359
+ *
4360
+ * @see {@link Player.state}, [Playback state, controls and sources](../../docs/concepts/playback.md)
4361
+ * @since 1.0.0
4362
+ * @category Playback
3839
4363
  */
3840
4364
  export interface PlayerState {
3841
4365
  /** Changes every time a source is opened, even the same file again. */
@@ -3913,14 +4437,24 @@ export interface PlayerState {
3913
4437
  active: string | null;
3914
4438
  };
3915
4439
  }
3916
- /** An audio output device (`player.audioDevices`). */
4440
+ /**
4441
+ * An audio output device (`player.audioDevices`).
4442
+ *
4443
+ * @since 1.0.0
4444
+ * @category Playback
4445
+ */
3917
4446
  export interface AudioDevice {
3918
4447
  /** Stable id to pass to `setAudioDevice` (e.g. `coreaudio/<UID>`). */
3919
4448
  id: string;
3920
4449
  /** What the system calls it. */
3921
4450
  name: string;
3922
4451
  }
3923
- /** One chapter of the current media. */
4452
+ /**
4453
+ * One chapter of the current media.
4454
+ *
4455
+ * @since 1.0.0
4456
+ * @category Playback
4457
+ */
3924
4458
  export interface Chapter {
3925
4459
  /** Chapter title, `null` without one. */
3926
4460
  title: string | null;
@@ -3955,12 +4489,41 @@ export interface Chapter {
3955
4489
  * }
3956
4490
  * });
3957
4491
  * ```
4492
+ *
4493
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md)
4494
+ * @since 1.0.0
4495
+ * @category Playback
3958
4496
  */
3959
4497
  export interface Conditional {
3960
4498
  /** The state the call is conditional on; default: the current `player.state`. */
3961
4499
  ifCurrent?: PlayerState;
3962
4500
  }
3963
- /** A source this plugin opened. */
4501
+ /**
4502
+ * A source this plugin opened.
4503
+ *
4504
+ * @remarks
4505
+ * A source this plugin opened with {@link Player.open}. It does not own
4506
+ * the source: the user or another plugin can replace it at any time, and
4507
+ * `current` then turns false. `ready` and `onEnd` follow the player state:
4508
+ * with `events` they react to the player's events, without it `ready`
4509
+ * polls the state every 100 ms (which needs `playback.read`). Disposing the
4510
+ * scope that opened it does not close the media; call `close` for that.
4511
+ *
4512
+ * @capability That of {@link Player.open} (`playback.control`); `ready` and
4513
+ * `onEnd` follow the state through `events` and `playback.read`.
4514
+ *
4515
+ * @example Open a file and say when it ends
4516
+ * The file opens and plays, and the OSD says "Finished" when it reaches its
4517
+ * end on its own. The handle belongs to this source: `onEnd` fires only
4518
+ * for a natural end of this media, not when someone opens another file.
4519
+ * ```ts
4520
+ * const media = await plugin.player.open("/Movies/clip.mp4");
4521
+ * media.onEnd(() => void plugin.osd.show("Finished")); // natural ends only
4522
+ * ```
4523
+ *
4524
+ * @since 1.0.0
4525
+ * @category Playback
4526
+ */
3964
4527
  export interface Media {
3965
4528
  /** Still the player's source (a restart keeps it current). */
3966
4529
  readonly current: boolean;
@@ -3968,13 +4531,77 @@ export interface Media {
3968
4531
  * Resolves when it is playing. Rejects with code `failed` (load error),
3969
4532
  * `replaced` (another source took over) or `ended`; with `AbortError` when
3970
4533
  * the signal aborts, after closing the media.
4534
+ *
4535
+ * @capability That of {@link Player.open}; `playback.read` or `events` to
4536
+ * see the state change.
4537
+ * @throws `failed` (rejects) The media could not be loaded.
4538
+ * @throws `replaced` (rejects) Another source took over first.
4539
+ * @throws `ended` (rejects) The media already played to the end.
4540
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the media
4541
+ * is closed.
4542
+ *
4543
+ * @example Wait at most ten seconds for a stream to start
4544
+ * A network stream gets ten seconds to start playing; otherwise it is
4545
+ * closed and the user is told. Aborting the signal makes `ready` reject
4546
+ * with an `AbortError` after closing the media.
4547
+ * ```ts
4548
+ * const media = await plugin.player.open("https://example.com/live.m3u8");
4549
+ * // The timeout's abort closes the media and rejects with an AbortError.
4550
+ * try {
4551
+ * await media.ready({ signal: AbortSignal.timeout(10_000) });
4552
+ * } catch (error) {
4553
+ * await plugin.osd.show("Not playing: {}", [(error as Error).message]);
4554
+ * }
4555
+ * ```
4556
+ *
4557
+ * @since 1.0.0
3971
4558
  */
3972
4559
  ready(options?: {
3973
4560
  signal?: AbortSignal;
3974
4561
  }): Promise<void>;
3975
- /** Called once each time a playthrough of this media ends naturally. */
4562
+ /**
4563
+ * Called once each time a playthrough of this media ends naturally.
4564
+ *
4565
+ * @capability That of {@link Player.open}; `events` to hear the end.
4566
+ *
4567
+ * @example Play the next file when this one ends
4568
+ * When the plugin's file ends naturally, the next one opens. The listener
4569
+ * gets the state at the end; passing it as `ifCurrent` makes the next
4570
+ * `open` fail with `stale` (and do nothing) if the user opened something
4571
+ * else in the meantime.
4572
+ * ```ts
4573
+ * declare const media: Media;
4574
+ * declare const next: string;
4575
+ * // ---cut---
4576
+ * media.onEnd((ended) =>
4577
+ * plugin.player.open(next, { ifCurrent: ended }).catch((error: PluginError) => {
4578
+ * if (error.code !== "stale") throw error; // the user opened something else
4579
+ * }),
4580
+ * );
4581
+ * ```
4582
+ *
4583
+ * @since 1.0.0
4584
+ */
3976
4585
  onEnd(listener: (state: PlayerState) => void): Disposable;
3977
- /** Close it if it is still the player's source; otherwise do nothing. */
4586
+ /**
4587
+ * Close it if it is still the player's source; otherwise do nothing.
4588
+ *
4589
+ * @capability That of {@link Player.open} (`playback.control`).
4590
+ * @throws `capability` (rejects) The manifest does not declare
4591
+ * `playback.control`.
4592
+ *
4593
+ * @example Close the preview the plugin opened
4594
+ * The plugin closes the preview it opened, but leaves alone anything the
4595
+ * user opened since. `close` acts only while this media is still the
4596
+ * player's source and does nothing otherwise.
4597
+ * ```ts
4598
+ * declare const preview: Media;
4599
+ * // ---cut---
4600
+ * await preview.close(); // a no-op when something else is playing now
4601
+ * ```
4602
+ *
4603
+ * @since 1.0.0
4604
+ */
3978
4605
  close(): Promise<void>;
3979
4606
  }
3980
4607
  /**
@@ -3997,6 +4624,18 @@ export interface Media {
3997
4624
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
3998
4625
  * bar.on("dragEnd", () => scrub?.end());
3999
4626
  * ```
4627
+ *
4628
+ * @remarks
4629
+ * A drag gesture on a seek slider. Each `seek` is conditional on the
4630
+ * previous one and only the newest target is sent while a seek is
4631
+ * running, so a fast drag never queues seeks. Any outside change (another
4632
+ * file, the user pausing) ends the gesture: `active` turns false and later
4633
+ * seeks resolve `false`.
4634
+ *
4635
+ * @capability playback.control
4636
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
4637
+ * @since 1.0.0
4638
+ * @category Playback
4000
4639
  */
4001
4640
  export interface Scrub {
4002
4641
  /**
@@ -4029,13 +4668,40 @@ export interface Scrub {
4029
4668
  * const keyframes = true; // the plugin's own setting
4030
4669
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
4031
4670
  * ```
4671
+ *
4672
+ * @capability playback.control
4673
+ * @throws `capability` (rejects) The manifest does not declare
4674
+ * `playback.control`.
4675
+ * @throws `stale` (rejects) The source changed from outside while this
4676
+ * seek was being sent (a superseded target or an ended gesture resolves
4677
+ * `false` instead).
4678
+ * @throws `unsupported` (rejects) The source is a generated stream.
4679
+ * @throws `backend` (rejects) The player failed the seek.
4680
+ * @since 1.0.0
4032
4681
  */
4033
4682
  seek(seconds: number, options?: {
4034
4683
  keyframes?: boolean;
4035
4684
  }): Promise<boolean>;
4036
4685
  /** False once anyone else changed the source; later seeks are ignored. */
4037
4686
  readonly active: boolean;
4038
- /** End the gesture; later seeks resolve `false`. */
4687
+ /**
4688
+ * End the gesture; later seeks resolve `false`.
4689
+ *
4690
+ * @capability playback.control
4691
+ *
4692
+ * @example End the gesture when the user lets go
4693
+ * Releasing the slider ends the gesture, so a late `dragMove` cannot move
4694
+ * playback any more. After `end`, `seek` resolves `false` without doing
4695
+ * anything.
4696
+ * ```ts
4697
+ * declare const bar: Panel;
4698
+ * declare const scrub: Scrub;
4699
+ * // ---cut---
4700
+ * bar.on("dragEnd", () => scrub.end()); // later seeks resolve false
4701
+ * ```
4702
+ *
4703
+ * @since 1.0.0
4704
+ */
4039
4705
  end(): void;
4040
4706
  }
4041
4707
  /**
@@ -4049,6 +4715,9 @@ export interface Scrub {
4049
4715
  *
4050
4716
  * The SDK copies the frame once before `frame()` is called again, so you
4051
4717
  * may reuse the same buffers for every frame.
4718
+ *
4719
+ * @since 1.0.0
4720
+ * @category Playback
4052
4721
  */
4053
4722
  export interface StreamFrame {
4054
4723
  /** `width × height × 4` bytes of RGBA, at most 4 MiB. */
@@ -4060,7 +4729,13 @@ export interface StreamFrame {
4060
4729
  */
4061
4730
  audio: Float32Array;
4062
4731
  }
4063
- /** Options of {@link Player.openStream}. */
4732
+ /**
4733
+ * Options of {@link Player.openStream}.
4734
+ *
4735
+ * @see {@link Player.openStream}
4736
+ * @since 1.0.0
4737
+ * @category Playback
4738
+ */
4064
4739
  export interface StreamOptions {
4065
4740
  /** Frame width in pixels. */
4066
4741
  width: number;
@@ -4075,12 +4750,48 @@ export interface StreamOptions {
4075
4750
  * `openStream` start-up timeouts: code `timeout` with the step that ran out
4076
4751
  * of time, the first buffers (`prefill`) or the host's activation.
4077
4752
  * Both limits are 10 seconds.
4753
+ *
4754
+ * @since 1.0.0
4755
+ * @category Playback
4078
4756
  */
4079
4757
  export type StreamStartError = PluginError & {
4080
4758
  /** The start-up step that timed out. */
4081
4759
  stage: "prefill" | "activation";
4082
4760
  };
4083
- /** A generated source, from {@link Player.openStream}. Disposing it stops it. */
4761
+ /**
4762
+ * A generated source, from {@link Player.openStream}. Disposing it stops it.
4763
+ *
4764
+ * @remarks
4765
+ * A source whose frames this plugin generates, from
4766
+ * {@link Player.openStream}. The SDK pulls frames on the audio clock over
4767
+ * a local connection and handles prefill, activation and pausing; the plugin
4768
+ * only supplies `frame()`. A stream ends when the plugin stops it, when
4769
+ * another source replaces it or the user closes it (`onStop` with `null`),
4770
+ * or when it fails (`onStop` with the error).
4771
+ *
4772
+ * @capability producer.av and playback.control
4773
+ *
4774
+ * @example Stop the stream from a menu item
4775
+ * A menu item ends the plugin's generated stream and closes it as the
4776
+ * player's source. `stop` closes the source first and then the frame
4777
+ * connection, so the user never sees a playback error; `onStop` is not
4778
+ * called for the plugin's own stop.
4779
+ * ```ts
4780
+ * declare const stream: Stream;
4781
+ * // ---cut---
4782
+ * // Enabled while the stream produces frames.
4783
+ * plugin.commands.add({
4784
+ * id: "stop-generator",
4785
+ * path: ["@playback", "Stop generator"],
4786
+ * enabled: () => stream.running,
4787
+ * run: () => stream.stop(),
4788
+ * });
4789
+ * ```
4790
+ *
4791
+ * @see {@link Player.openStream}
4792
+ * @since 1.0.0
4793
+ * @category Playback
4794
+ */
4084
4795
  export interface Stream extends Disposable {
4085
4796
  /** The stream is producing frames. */
4086
4797
  readonly running: boolean;
@@ -4096,6 +4807,23 @@ export interface Stream extends Disposable {
4096
4807
  * (before 1.5.7 a replaced or closed stream could get `unavailable`
4097
4808
  * "producer absent"). Without `playback.read` a failure the SDK has not
4098
4809
  * seen yet is reported as null.
4810
+ *
4811
+ * @capability producer.av
4812
+ *
4813
+ * @example Tell the user why the stream stopped
4814
+ * The OSD explains a failure, and nothing is said when the user simply
4815
+ * opened another file. The listener runs only for stops from outside the
4816
+ * plugin: `null` for a replacement or the user's close, an error (such as
4817
+ * `stream` with the player's text) for a failure.
4818
+ * ```ts
4819
+ * declare const stream: Stream;
4820
+ * // ---cut---
4821
+ * stream.onStop((error) => {
4822
+ * if (error) void plugin.osd.show("Generator stopped: {}", [error.message]);
4823
+ * });
4824
+ * ```
4825
+ *
4826
+ * @since 1.0.0
4099
4827
  */
4100
4828
  onStop(listener: (error: PluginError | null) => void): Disposable;
4101
4829
  /**
@@ -4103,10 +4831,34 @@ export interface Stream extends Disposable {
4103
4831
  * The source closes first, then the frame connection, then the producer,
4104
4832
  * so stopping never shows as a playback failure. `dispose()` and
4105
4833
  * deactivation do the same.
4834
+ *
4835
+ * @capability producer.av and playback.control
4836
+ * @throws Whatever code closing the player's source failed with (the
4837
+ * frame connection and the producer are released either way). A
4838
+ * stream that is no longer the source just resolves.
4839
+ *
4840
+ * @example Stop the stream when the plugin's window closes
4841
+ * Closing the generator window stops the stream it feeds. `stop` closes
4842
+ * the player's source if it is still this stream and then the frame
4843
+ * connection; it resolves once both are closed.
4844
+ * ```ts
4845
+ * declare const stream: Stream;
4846
+ * declare const window: Window;
4847
+ * // ---cut---
4848
+ * window.onClose(() => stream.stop());
4849
+ * ```
4850
+ *
4851
+ * @since 1.0.0
4106
4852
  */
4107
4853
  stop(): Promise<void>;
4108
4854
  }
4109
- /** The plugin that owns "next / previous" (a playlist). */
4855
+ /**
4856
+ * The plugin that owns "next / previous" (a playlist).
4857
+ *
4858
+ * @see {@link Player.provideSequence}
4859
+ * @since 1.0.0
4860
+ * @category Playback
4861
+ */
4110
4862
  export interface SequenceProvider {
4111
4863
  /** Go to the next item (called in the provider's scope). */
4112
4864
  next(): unknown;
@@ -4120,6 +4872,10 @@ export interface SequenceProvider {
4120
4872
  /**
4121
4873
  * What {@link Player.stats} reports. Every field is the backend's own value
4122
4874
  * (libmpv properties), `null` when it has none; nothing is converted.
4875
+ *
4876
+ * @see {@link Player.stats}
4877
+ * @since 1.0.0
4878
+ * @category Playback
4123
4879
  */
4124
4880
  export interface PlaybackStats {
4125
4881
  /**
@@ -4234,15 +4990,90 @@ export interface PlaybackStats {
4234
4990
  * void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
4235
4991
  * }
4236
4992
  * ```
4993
+ *
4994
+ * @remarks
4995
+ * `plugin.player` is the player's one source: what is open, its phase and
4996
+ * position, and the controls. `state` is a snapshot the SDK keeps current
4997
+ * from the player's events (with `events`) and refreshes after this
4998
+ * plugin's own calls; `onChange` runs after it changed. Every control is
4999
+ * conditional: it acts on the state the plugin last saw and fails with
5000
+ * `stale` when the source changed meanwhile: another file, a close, or any
5001
+ * pause, seek or speed change by the user or another plugin (see
5002
+ * {@link Conditional}). The player keeps the source when a plugin stops;
5003
+ * only the plugin's own handles (a stream, its sequence provider, its
5004
+ * output device) are withdrawn.
5005
+ *
5006
+ * @capability `playback.read` to read; `playback.control` to control;
5007
+ * `events` to follow changes made elsewhere; `producer.av` for
5008
+ * {@link Player.openStream}.
5009
+ *
5010
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md), [Control playback](../../docs/how-to/control-playback.md)
5011
+ * @since 1.0.0
5012
+ * @category Playback
4237
5013
  */
4238
5014
  export interface Player {
4239
5015
  /** Latest state; always newer than anything delivered before. */
4240
5016
  readonly state: PlayerState;
4241
- /** The state changed (needs `events` for outside changes). */
5017
+ /**
5018
+ * The state changed (needs `events` for outside changes).
5019
+ *
5020
+ * @capability events (for changes made elsewhere; without it the listener
5021
+ * runs only after this plugin's own calls)
5022
+ *
5023
+ * @example Show the position while it plays
5024
+ * The OSD block shows the position, updated as playback moves. The SDK
5025
+ * updates `player.state` from the player's events and then calls the
5026
+ * listener with the new snapshot; the handler only reads it.
5027
+ * ```ts
5028
+ * const info = plugin.osd.panel();
5029
+ * plugin.player.onChange((state) => {
5030
+ * // `position` is null while nothing is open.
5031
+ * info.set(state.position === null ? null : "{} s", [state.position?.toFixed(0) ?? ""]);
5032
+ * });
5033
+ * ```
5034
+ *
5035
+ * @since 1.0.0
5036
+ */
4242
5037
  onChange(listener: (state: PlayerState) => void): Disposable;
4243
- /** Read the state from the player now (`playback.read`). */
5038
+ /**
5039
+ * Read the state from the player now (`playback.read`).
5040
+ *
5041
+ * @capability playback.read
5042
+ * @throws `capability` (rejects) The manifest does not declare
5043
+ * `playback.read`.
5044
+ *
5045
+ * @example Read the state before deciding
5046
+ * A command reads the newest state from the player before acting, even
5047
+ * without `events`. `refresh` asks the player and resolves with the new
5048
+ * snapshot; `player.state` holds it afterwards.
5049
+ * ```ts
5050
+ * const state = await plugin.player.refresh();
5051
+ * if (state.phase === "ready") await plugin.osd.show("{} s left", [((state.duration ?? 0) - (state.position ?? 0)).toFixed(0)]);
5052
+ * ```
5053
+ *
5054
+ * @since 1.0.0
5055
+ */
4244
5056
  refresh(): Promise<PlayerState>;
4245
- /** True if nothing changed the source since `state` was observed. */
5057
+ /**
5058
+ * True if nothing changed the source since `state` was observed.
5059
+ *
5060
+ * @capability None.
5061
+ *
5062
+ * @example Drop a result that arrives after the file changed
5063
+ * A slow lookup for the current file is shown only if that file is still
5064
+ * open. `isCurrent` compares the snapshot taken before the work with the
5065
+ * SDK's newest state; it is false once the source changed in any way:
5066
+ * another file, a close, or a pause, seek or speed change by anyone.
5067
+ * ```ts
5068
+ * declare function lookUpTitle(path: string): Promise<string>;
5069
+ * // ---cut---
5070
+ * const seen = plugin.player.state;
5071
+ * const title = await lookUpTitle(seen.source ?? "");
5072
+ * if (plugin.player.isCurrent(seen)) await plugin.osd.show("{}", [title]); // still the same file
5073
+ * ```
5074
+ *
5075
+ * @since 1.0.0
5076
+ */
4246
5077
  isCurrent(state: PlayerState): boolean;
4247
5078
  /**
4248
5079
  * Open a path or URL, replacing the current source (unless `ifCurrent` no
@@ -4253,6 +5084,37 @@ export interface Player {
4253
5084
  * {@link Resolver} are resolved first, and so are local files of an
4254
5085
  * extension a file resolver claims (the source is then a stream that
4255
5086
  * plugin generates; `paused` reaches it).
5087
+ *
5088
+ * @capability playback.control
5089
+ * @throws `stale` (rejects) `ifCurrent` no longer holds; nothing was opened.
5090
+ * @throws `replaced` (rejects) Accepted, then another source took over
5091
+ * before it finished (for example while a URL was still being resolved).
5092
+ * @throws `invalid` (rejects) An empty `source`.
5093
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the
5094
+ * source is closed if it was opened.
5095
+ * @throws `busy` (rejects) The address needs a resolver plugin and the
5096
+ * player is already resolving 32 addresses.
5097
+ * @throws `backend` (rejects) A resolver plugin handled the address or
5098
+ * file and failed (its message), or the resolution was cancelled or
5099
+ * expired.
5100
+ * @throws `unsupported` (rejects) The player refused what a resolver
5101
+ * returned (for example split streams with different headers).
5102
+ * @throws `unavailable` (rejects) The stream a file resolver prepared was
5103
+ * not ready to play.
5104
+ * @throws `capability` (rejects) The manifest does not declare
5105
+ * `playback.control`.
5106
+ *
5107
+ * @example Open a file paused
5108
+ * The file opens at its start without playing. `open` resolves once the
5109
+ * player accepted the load and returns a {@link Media} handle for that
5110
+ * source; `ready` then waits until it can play (here: until it is loaded
5111
+ * and paused).
5112
+ * ```ts
5113
+ * const media = await plugin.player.open("/Movies/clip.mp4", { paused: true });
5114
+ * await media.ready(); // rejects `failed` when the file cannot be loaded
5115
+ * ```
5116
+ *
5117
+ * @since 1.0.0
4256
5118
  */
4257
5119
  open(source: string, options?: {
4258
5120
  paused?: boolean;
@@ -4290,32 +5152,219 @@ export interface Player {
4290
5152
  * // `error` is null when the stream stopped normally.
4291
5153
  * stream.onStop((error) => error && plugin.osd.show(error.message));
4292
5154
  * ```
5155
+ *
5156
+ * @capability producer.av and playback.control
5157
+ * @throws `timeout` (rejects) A {@link StreamStartError}: the stream did not
5158
+ * start in time.
5159
+ * @throws `stream` (rejects) The player failed to start the stream.
5160
+ * @throws `device` (rejects) The audio device was not ready.
5161
+ * @throws `replaced` (rejects) Another source replaced it, or it was closed,
5162
+ * before it ran.
5163
+ * @throws `invalid` (rejects) Bad options (a size or a frame function).
5164
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed,
5165
+ * while the stream was starting (an `AbortError`).
5166
+ * @since 1.0.0
4293
5167
  */
4294
5168
  openStream(options: StreamOptions): Promise<Stream>;
4295
- /** Pause or resume. */
5169
+ /**
5170
+ * Pause or resume.
5171
+ *
5172
+ * @capability playback.control
5173
+ * @throws `stale` (rejects) The source changed since the state the call is
5174
+ * based on (another file, a close, or any pause, seek
5175
+ * or speed change by the user or another plugin); nothing was done.
5176
+ * @throws `invalid` (rejects) Nothing is open.
5177
+ * @throws `busy` (rejects) A generated stream is still handling a previous
5178
+ * control.
5179
+ * @throws `capability` (rejects) The manifest does not declare
5180
+ * `playback.control`.
5181
+ *
5182
+ * @example Pause, but only the file the user was watching
5183
+ * A "pause on lock" helper pauses the current file and does nothing when
5184
+ * another file was opened in between. The call is conditional on
5185
+ * `player.state` by default; `stale` means the source changed and the
5186
+ * pause was not applied, which is the intended outcome here.
5187
+ * ```ts
5188
+ * try {
5189
+ * await plugin.player.setPaused(true);
5190
+ * } catch (error) {
5191
+ * if ((error as PluginError).code !== "stale") throw error; // another file now
5192
+ * }
5193
+ * ```
5194
+ *
5195
+ * @since 1.0.0
5196
+ */
4296
5197
  setPaused(paused: boolean, options?: Conditional): Promise<void>;
4297
- /** Jump to `seconds`. */
5198
+ /**
5199
+ * Jump to `seconds`.
5200
+ *
5201
+ * @capability playback.control
5202
+ * @throws `stale` (rejects) The source changed since the state the call is
5203
+ * based on (another file, a close, or any pause, seek
5204
+ * or speed change by the user or another plugin); nothing was done.
5205
+ * @throws `invalid` (rejects) Nothing is open, or `seconds` is negative or
5206
+ * not finite.
5207
+ * @throws `unsupported` (rejects) The source is a generated stream, which
5208
+ * cannot seek.
5209
+ * @throws `busy` (rejects) A generated stream is still handling a previous
5210
+ * control.
5211
+ * @throws `capability` (rejects) The manifest does not declare
5212
+ * `playback.control`.
5213
+ *
5214
+ * @example Jump back ten seconds
5215
+ * Playback jumps ten seconds back, never before the start. The target is
5216
+ * computed from the snapshot the call is conditional on, so if the user
5217
+ * opened another file meanwhile the seek fails with `stale` instead of
5218
+ * landing in the wrong file.
5219
+ * ```ts
5220
+ * const { position } = plugin.player.state;
5221
+ * if (position !== null && plugin.player.state.can.seek)
5222
+ * await plugin.player.seek(Math.max(0, position - 10)); // seconds
5223
+ * ```
5224
+ *
5225
+ * @since 1.0.0
5226
+ */
4298
5227
  seek(seconds: number, options?: Conditional): Promise<void>;
4299
- /** Set the playback speed. */
5228
+ /**
5229
+ * Set the playback speed.
5230
+ *
5231
+ * @capability playback.control
5232
+ * @throws `stale` (rejects) The source changed since the state the call is
5233
+ * based on (another file, a close, or any pause, seek
5234
+ * or speed change by the user or another plugin); nothing was done.
5235
+ * @throws `invalid` (rejects) Nothing is open, or `rate` is outside
5236
+ * 0.05–10.
5237
+ * @throws `unsupported` (rejects) The source is a generated stream, which
5238
+ * cannot change speed.
5239
+ * @throws `busy` (rejects) A generated stream is still handling a previous
5240
+ * control.
5241
+ * @throws `capability` (rejects) The manifest does not declare
5242
+ * `playback.control`.
5243
+ *
5244
+ * @example Play at one and a half times the speed
5245
+ * Playback speeds up to 1.5×, with the speed on the OSD. `setRate` sets
5246
+ * the speed directly (the player's own keys step it by 0.1 within
5247
+ * 0.25–4); `state.can.rate` is false for sources that cannot change speed.
5248
+ * ```ts
5249
+ * if (plugin.player.state.can.rate) {
5250
+ * await plugin.player.setRate(1.5);
5251
+ * await plugin.osd.show("Speed {}×", [1.5]);
5252
+ * }
5253
+ * ```
5254
+ *
5255
+ * @since 1.0.0
5256
+ */
4300
5257
  setRate(rate: number, options?: Conditional): Promise<void>;
4301
- /** Play from the start (the "replay" after the end). */
5258
+ /**
5259
+ * Play from the start (the "replay" after the end).
5260
+ *
5261
+ * @capability playback.control
5262
+ * @throws `stale` (rejects) The source changed since the state the call is
5263
+ * based on (another file, a close, or any pause, seek
5264
+ * or speed change by the user or another plugin); nothing was done.
5265
+ * @throws `invalid` (rejects) Nothing is open.
5266
+ * @throws `unsupported` (rejects) The source is a generated stream, which
5267
+ * cannot restart.
5268
+ * @throws `capability` (rejects) The manifest does not declare
5269
+ * `playback.control`.
5270
+ *
5271
+ * @example Replay a file after it ended
5272
+ * A Replay button starts the file again from its start once it has ended.
5273
+ * `restart` plays the current source from the beginning and keeps it the
5274
+ * same source, so a {@link Media} handle stays current.
5275
+ * ```ts
5276
+ * if (plugin.player.state.phase === "ended") await plugin.player.restart();
5277
+ * ```
5278
+ *
5279
+ * @since 1.0.0
5280
+ */
4302
5281
  restart(options?: Conditional): Promise<void>;
4303
- /** Close the current source. */
5282
+ /**
5283
+ * Close the current source.
5284
+ *
5285
+ * @capability playback.control
5286
+ * @throws `stale` (rejects) The source changed since the state the call is
5287
+ * based on (another file, a close, or any pause, seek
5288
+ * or speed change by the user or another plugin); nothing was done.
5289
+ * @throws `capability` (rejects) The manifest does not declare
5290
+ * `playback.control`.
5291
+ *
5292
+ * @example Close the file when the sleep timer runs out
5293
+ * After 30 minutes the open file is closed, unless the user opened another
5294
+ * one meanwhile. The snapshot taken when the timer started is passed as
5295
+ * `ifCurrent`, so the close applies only to that source; `stale` means it
5296
+ * was replaced.
5297
+ * ```ts
5298
+ * const seen = plugin.player.state;
5299
+ * setTimeout(() => {
5300
+ * // Only the source that was open when the timer started.
5301
+ * plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
5302
+ * if (error.code !== "stale") plugin.osd.show("Could not close: {}", [error.code]);
5303
+ * });
5304
+ * }, 30 * 60 * 1000);
5305
+ * ```
5306
+ *
5307
+ * @since 1.0.0
5308
+ */
4304
5309
  close(options?: Conditional): Promise<void>;
4305
- /** Start a drag gesture from the current state. */
5310
+ /**
5311
+ * Start a drag gesture from the current state.
5312
+ *
5313
+ * @capability playback.control
5314
+ *
5315
+ * @example Start a gesture when the user grabs the slider
5316
+ * A drag on the plugin's seek slider becomes one gesture whose seeks never
5317
+ * pile up. `scrub()` captures the current state; the gesture's seeks are
5318
+ * conditional on it and on each other, and it ends on its own when
5319
+ * someone else changes the source.
5320
+ * ```ts
5321
+ * declare const bar: Panel;
5322
+ * // ---cut---
5323
+ * let scrub: Scrub | null = null;
5324
+ * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
5325
+ * bar.on("dragEnd", () => scrub?.end());
5326
+ * ```
5327
+ *
5328
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
5329
+ * @since 1.0.0
5330
+ */
4306
5331
  scrub(): Scrub;
4307
5332
  /**
4308
5333
  * Device output; last write wins and volume is applied before mute.
4309
5334
  * Resolves `true` when this request was applied, `false` if superseded.
4310
5335
  * `volume` is 0–200.
5336
+ *
5337
+ * @capability playback.control
5338
+ * @throws `invalid` (rejects) `volume` outside 0–200, or `muted` not a
5339
+ * boolean.
5340
+ * @throws `capability` (rejects) The manifest does not declare
5341
+ * `playback.control`.
5342
+ *
5343
+ * @example Duck the volume during an announcement
5344
+ * The volume drops to 30 while the plugin's announcement plays, then
5345
+ * returns. Writes are "latest wins": the promise resolves `true` when this
5346
+ * write was applied and `false` when a newer one replaced it first, which
5347
+ * is not an error.
5348
+ * ```ts
5349
+ * const before = plugin.player.state.volume; // 0–200
5350
+ * await plugin.player.setOutput({ volume: 30 });
5351
+ * // … the announcement …
5352
+ * await plugin.player.setOutput({ volume: before });
5353
+ * ```
5354
+ *
5355
+ * @since 1.0.0
4311
5356
  */
4312
5357
  setOutput(output: {
4313
5358
  volume?: number;
4314
5359
  muted?: boolean;
4315
5360
  }): Promise<boolean>;
4316
5361
  /**
4317
- * Become the sequence provider (one at a time; another provider rejects with
4318
- * `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
5362
+ * Become the sequence provider, first come first served: while another
5363
+ * plugin holds it (the built-in playlist registers at activation), this
5364
+ * one is refused with `conflict` (`sequence-taken`). That does not fail
5365
+ * activation: the player shows a warning ({@link PluginInfo.warnings});
5366
+ * a refusal after activation goes to the scope's `onError`. Dispose to
5367
+ * stop.
4319
5368
  *
4320
5369
  * @example Drive the player's Next and Previous from a playlist
4321
5370
  * The player's Next and Previous items and keys play the plugin's list.
@@ -4339,15 +5388,82 @@ export interface Player {
4339
5388
  * canPrevious: () => index > 0,
4340
5389
  * });
4341
5390
  * ```
5391
+ *
5392
+ * @capability playback.control
5393
+ * @since 1.0.0
4342
5394
  */
4343
5395
  provideSequence(provider: SequenceProvider): Disposable;
4344
- /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
5396
+ /**
5397
+ * Ask the provider to go forward / back. Rejects `unavailable` when it cannot.
5398
+ *
5399
+ * @capability playback.control
5400
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
5401
+ * no next item.
5402
+ * @throws `capability` (rejects) The manifest does not declare
5403
+ * `playback.control`.
5404
+ *
5405
+ * @example Skip to the next item from a plugin button
5406
+ * The plugin's own Next button plays the next item of whichever plugin
5407
+ * provides the sequence (the playlist, usually). `next` asks the
5408
+ * provider, the same as the player's Next key; `unavailable` means there
5409
+ * is no next item, which the button treats as the end of the list.
5410
+ * ```ts
5411
+ * try {
5412
+ * await plugin.player.next(); // as the player's Next key does
5413
+ * } catch (error) {
5414
+ * if ((error as PluginError).code !== "unavailable") throw error;
5415
+ * await plugin.osd.show("End of the list");
5416
+ * }
5417
+ * ```
5418
+ *
5419
+ * @since 1.0.0
5420
+ */
4345
5421
  next(): Promise<void>;
4346
- /** Ask the provider to go back. Rejects `unavailable` when it cannot. */
5422
+ /**
5423
+ * Ask the provider to go back. Rejects `unavailable` when it cannot.
5424
+ *
5425
+ * @capability playback.control
5426
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
5427
+ * no previous item.
5428
+ * @throws `capability` (rejects) The manifest does not declare
5429
+ * `playback.control`.
5430
+ *
5431
+ * @example Go back to the previous item
5432
+ * The plugin's Previous button asks the sequence provider for the item
5433
+ * before the current one, like the player's PageUp. It is disabled while
5434
+ * `state.can.previous` is false, which the provider's `canPrevious`
5435
+ * decides.
5436
+ * ```ts
5437
+ * plugin.commands.add({
5438
+ * id: "previous",
5439
+ * title: "Previous item",
5440
+ * // The provider's `canPrevious`, as the player reports it.
5441
+ * enabled: () => plugin.player.state.can.previous,
5442
+ * run: () => plugin.player.previous(),
5443
+ * });
5444
+ * ```
5445
+ *
5446
+ * @since 1.0.0
5447
+ */
4347
5448
  previous(): Promise<void>;
4348
5449
  /**
4349
5450
  * Codec, size, frame counts and backend of the current playback.
4350
5451
  * `playback.read`; `video` and `audio` are `null` without a source.
5452
+ *
5453
+ * @capability playback.read
5454
+ * @throws `capability` (rejects) The manifest does not declare
5455
+ * `playback.read`.
5456
+ *
5457
+ * @example Show the video codec and size
5458
+ * The OSD shows something like "h264 1920×1080". `stats` asks the player
5459
+ * for the backend's own values at that moment; `video` is `null` without
5460
+ * a video stream (nothing open, an audio file).
5461
+ * ```ts
5462
+ * const { video } = await plugin.player.stats();
5463
+ * if (video) await plugin.osd.show("{} {}×{}", [video.codec ?? "?", video.width ?? 0, video.height ?? 0]);
5464
+ * ```
5465
+ *
5466
+ * @since 1.0.0
4351
5467
  */
4352
5468
  stats(): Promise<PlaybackStats>;
4353
5469
  /**
@@ -4355,11 +5471,56 @@ export interface Player {
4355
5471
  * unless `select: false`, select it. Resolves with the new track (`external`,
4356
5472
  * not exportable). It stays until the source changes. Rejects `invalid` for a
4357
5473
  * bad path, `unavailable` without ready media, `backend` when it cannot load.
5474
+ *
5475
+ * @capability playback.control
5476
+ * @throws `invalid` (rejects) Not an absolute path to a file.
5477
+ * @throws `unavailable` (rejects) No ready media.
5478
+ * @throws `backend` (rejects) The player could not load the file.
5479
+ * @throws `stale` (rejects) `ifCurrent` no longer holds.
5480
+ * @throws `capability` (rejects) The manifest does not declare
5481
+ * `playback.control`.
5482
+ *
5483
+ * @example Add a dubbed audio file and switch to it
5484
+ * A separate audio file plays with the film instead of its own sound.
5485
+ * `addAudioTrack` attaches the file to the current media and selects it;
5486
+ * the track stays until another source opens.
5487
+ * ```ts
5488
+ * declare const dubPath: string;
5489
+ * // ---cut---
5490
+ * const track = await plugin.player.addAudioTrack(dubPath); // selected too
5491
+ * await plugin.osd.show("Audio: {}", [track.title ?? dubPath]);
5492
+ * ```
5493
+ *
5494
+ * @since 1.0.0
4358
5495
  */
4359
5496
  addAudioTrack(path: string, options?: {
4360
5497
  select?: boolean;
4361
5498
  } & Conditional): Promise<AudioTrack>;
4362
- /** Select one of `state.audioTracks` by id; `invalid` for an unknown id. */
5499
+ /**
5500
+ * Select one of `state.audioTracks` by id; `invalid` for an unknown id.
5501
+ *
5502
+ * @capability playback.control
5503
+ * @throws `invalid` (rejects) No track with this id.
5504
+ * @throws `unavailable` (rejects) No ready media.
5505
+ * @throws `stale` (rejects) The source changed since the state the call is
5506
+ * based on (another file, a close, or any pause, seek
5507
+ * or speed change by the user or another plugin).
5508
+ * @throws `capability` (rejects) The manifest does not declare
5509
+ * `playback.control`.
5510
+ *
5511
+ * @example Switch to the next audio track
5512
+ * Each press of the plugin's key moves to the next audio track of the
5513
+ * file. The tracks and the selected one are in `state.audioTracks`;
5514
+ * `selectAudioTrack` resolves with the new state.
5515
+ * ```ts
5516
+ * const audioTracks = plugin.player.state.audioTracks ?? []; // null: not known yet
5517
+ * const at = audioTracks.findIndex((track) => track.selected);
5518
+ * const next = audioTracks[(at + 1) % audioTracks.length];
5519
+ * if (next) await plugin.player.selectAudioTrack(next.id);
5520
+ * ```
5521
+ *
5522
+ * @since 1.0.0
5523
+ */
4363
5524
  selectAudioTrack(id: number, options?: Conditional): Promise<PlayerState>;
4364
5525
  /**
4365
5526
  * One frame or to the next / previous keyframe (pauses for frames). Rejects
@@ -4369,6 +5530,29 @@ export interface Player {
4369
5530
  * Resolves once the new position is in effect, so `player.state.position`
4370
5531
  * is current afterwards. One step at a time: a second call while one is
4371
5532
  * in flight rejects `busy`.
5533
+ *
5534
+ * @capability playback.control
5535
+ * @throws `unsupported` (rejects) Nothing playable to step through (no
5536
+ * moving video: audio only, cover art, a generated stream), a backward
5537
+ * frame step on a source that cannot seek, or a keyframe step in either
5538
+ * direction on a source that cannot seek.
5539
+ * @throws `busy` (rejects) A step is still in flight.
5540
+ * @throws `stale` (rejects) The source changed since the state the call is
5541
+ * based on (another file, a close, or any pause, seek
5542
+ * or speed change by the user or another plugin).
5543
+ * @throws `capability` (rejects) The manifest does not declare
5544
+ * `playback.control`.
5545
+ *
5546
+ * @example Step one frame forward for a close look
5547
+ * The picture advances by one frame and playback pauses. `step` resolves
5548
+ * once the new position is in effect, so `state.position` is current
5549
+ * afterwards; a second step while one is running rejects with `busy`.
5550
+ * ```ts
5551
+ * await plugin.player.step("frame", "forward");
5552
+ * await plugin.osd.show("{} s", [plugin.player.state.position?.toFixed(3) ?? "?"]);
5553
+ * ```
5554
+ *
5555
+ * @since 1.0.0
4372
5556
  */
4373
5557
  step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
4374
5558
  /**
@@ -4391,6 +5575,23 @@ export interface Player {
4391
5575
  *
4392
5576
  * Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
4393
5577
  * the manifest when you rely on it.
5578
+ *
5579
+ * @capability playback.control
5580
+ * @throws `invalid` (rejects) `seconds` outside 0.1–3600 or not in tenths,
5581
+ * or `keyframes` not a boolean.
5582
+ * @throws `capability` (rejects) The manifest does not declare
5583
+ * `playback.control`.
5584
+ *
5585
+ * @example Make ← and → jump 10 seconds to keyframes
5586
+ * The arrow keys now jump 10 seconds and land on keyframes, which is fast
5587
+ * on large files. The newest call of any plugin wins and is withdrawn
5588
+ * when that plugin stops, so a plugin offering this as a setting applies
5589
+ * it on activation.
5590
+ * ```ts
5591
+ * await plugin.player.setStepSeek({ seconds: 10, keyframes: true });
5592
+ * ```
5593
+ *
5594
+ * @since 1.0.0
4394
5595
  */
4395
5596
  setStepSeek(options: {
4396
5597
  seconds: number;
@@ -4400,6 +5601,23 @@ export interface Player {
4400
5601
  * The chapters of the current media (empty without media or chapters),
4401
5602
  * read on demand; `sourceId` tells which source they belong to.
4402
5603
  * `playback.read`.
5604
+ *
5605
+ * @capability playback.read
5606
+ * @throws `capability` (rejects) The manifest does not declare
5607
+ * `playback.read`.
5608
+ *
5609
+ * @example List the chapters of the open file
5610
+ * The plugin reads the chapter titles of the current file to show them in
5611
+ * its panel. `chapters` asks the player when called; `sourceId` says which
5612
+ * source they belong to, so a reply for a file that was replaced meanwhile
5613
+ * can be dropped.
5614
+ * ```ts
5615
+ * const { sourceId, chapters } = await plugin.player.chapters();
5616
+ * if (sourceId === plugin.player.state.sourceId) // still the same file
5617
+ * console.log(chapters.map((chapter) => chapter.title));
5618
+ * ```
5619
+ *
5620
+ * @since 1.0.0
4403
5621
  */
4404
5622
  chapters(): Promise<{
4405
5623
  /** The source the chapters belong to (compare with `state.sourceId`). */
@@ -4413,13 +5631,59 @@ export interface Player {
4413
5631
  * stops it. After the last jump back `state.loop` becomes null.
4414
5632
  * `invalid` out of range, `unsupported` for a source that cannot seek,
4415
5633
  * `unavailable` without ready media. A new source clears it.
5634
+ *
5635
+ * @capability playback.control
5636
+ * @throws `invalid` (rejects) `a`/`b` out of range or `count` not a positive
5637
+ * integer.
5638
+ * @throws `unsupported` (rejects) The source cannot seek, or is a generated
5639
+ * stream.
5640
+ * @throws `unavailable` (rejects) No ready media.
5641
+ * @throws `stale` (rejects) The source changed since the state the call is
5642
+ * based on (another file, a close, or any pause, seek
5643
+ * or speed change by the user or another plugin).
5644
+ * @throws `capability` (rejects) The manifest does not declare
5645
+ * `playback.control`.
5646
+ *
5647
+ * @example Loop a passage three times
5648
+ * Playback repeats 1:00–1:30 three times, then goes on. The player jumps
5649
+ * back to `a` each time it reaches `b`; after the last repeat
5650
+ * `state.loop` becomes `null`, and a new source clears the loop.
5651
+ * ```ts
5652
+ * await plugin.player.setLoop({ a: 60, b: 90, count: 3 }); // seconds
5653
+ * ```
5654
+ *
5655
+ * @since 1.0.0
4416
5656
  */
4417
5657
  setLoop(loop: {
4418
5658
  a: number;
4419
5659
  b: number;
4420
5660
  count?: number;
4421
5661
  } | null, options?: Conditional): Promise<void>;
4422
- /** The output devices present now (not the system default entry). `playback.read`. */
5662
+ /**
5663
+ * The output devices present now (not the system default entry). `playback.read`.
5664
+ *
5665
+ * @capability playback.read
5666
+ * @throws `capability` (rejects) The manifest does not declare
5667
+ * `playback.read`.
5668
+ *
5669
+ * @example Offer the audio output devices in a menu
5670
+ * A submenu lists the output devices present now, with a radio mark on the
5671
+ * one in use. `audioDevices` asks the player when called (devices come and
5672
+ * go), and `state.audioOutput` tells which one is active.
5673
+ * ```ts
5674
+ * // The devices present now; build the menu again when they change.
5675
+ * for (const device of await plugin.player.audioDevices())
5676
+ * plugin.commands.add({
5677
+ * id: `device.${device.id}`,
5678
+ * path: ["@audio", "Output", device.name],
5679
+ * radio: true,
5680
+ * checked: () => plugin.player.state.audioOutput?.active === device.id,
5681
+ * run: () => plugin.player.setAudioDevice(device.id),
5682
+ * });
5683
+ * ```
5684
+ *
5685
+ * @since 1.0.0
5686
+ */
4423
5687
  audioDevices(): Promise<AudioDevice[]>;
4424
5688
  /**
4425
5689
  * Play on this device while it is present, else on the system default
@@ -4429,6 +5693,23 @@ export interface Player {
4429
5693
  * Unknown ids are accepted (`state.audioOutput.active` stays `null`); a
4430
5694
  * malformed id is `invalid`. Reverts to the default when this plugin
4431
5695
  * stops; save the choice yourself to restore it on activation.
5696
+ *
5697
+ * @capability playback.control
5698
+ * @throws `invalid` (rejects) A malformed id.
5699
+ * @throws `capability` (rejects) The manifest does not declare
5700
+ * `playback.control`.
5701
+ *
5702
+ * @example Restore the user's output device on start
5703
+ * The plugin plays on the device the user picked last time, or on the
5704
+ * system default when it is not connected. The choice lasts only for
5705
+ * this run and is withdrawn when the plugin stops, so the plugin saves
5706
+ * it itself and sets it again on activation.
5707
+ * ```ts
5708
+ * const store = plugin.storage.json("output", { defaults: { device: null as string | null } });
5709
+ * await plugin.player.setAudioDevice(store.value.device); // null: the system default
5710
+ * ```
5711
+ *
5712
+ * @since 1.0.0
4432
5713
  */
4433
5714
  setAudioDevice(id: string | null): Promise<void>;
4434
5715
  }
@@ -4453,9 +5734,45 @@ export interface Player {
4453
5734
  * (`plugin.window.onChange`), which also changes when the user toggles it.
4454
5735
  * Players before 0.5.2 reject it with `invalid`: set
4455
5736
  * `"minPlayerVersion": "0.5.2"` when you use it.
5737
+ *
5738
+ * @see {@link Host.run}
5739
+ * @since 1.0.0
5740
+ * @category Commands
4456
5741
  */
4457
5742
  export type HostAction = "play-pause" | "stop" | "previous" | "next" | "frame-back" | "frame-forward" | "seek-backward" | "seek-forward" | "rate-down" | "rate-up" | "rate-normal" | "volume-up" | "volume-down" | "mute" | "fullscreen";
4458
- /** `plugin.host`: the player's own core actions. */
5743
+ /**
5744
+ * `plugin.host`: the player's own core actions.
5745
+ *
5746
+ * @remarks
5747
+ * The player's core actions (play/pause, stop, step seeks, speed, volume,
5748
+ * fullscreen…) are commands of the player itself, `host/<action>` in the
5749
+ * binding table. Running them through `host.run` gives a plugin's own
5750
+ * buttons exactly the player's behaviour: the same checks, the same
5751
+ * accumulation of repeated presses and the same on-screen text, instead of
5752
+ * reimplementing them with {@link Player} calls.
5753
+ *
5754
+ * @capability commands
5755
+ *
5756
+ * @example Give a custom bar the player's own Stop and Mute
5757
+ * The plugin's bar buttons stop playback and mute exactly as the player's
5758
+ * own keys do, with the same on-screen text. Each callback runs a core
5759
+ * action; the player applies its checks and rejects with `unavailable`
5760
+ * while the action is disabled, which the bar ignores.
5761
+ * ```ts
5762
+ * declare const bar: Panel;
5763
+ * // ---cut---
5764
+ * const run = (action: HostAction) =>
5765
+ * plugin.host.run(action).catch((error: PluginError) => {
5766
+ * if (error.code !== "unavailable") throw error; // disabled right now
5767
+ * });
5768
+ * bar.on("stop", () => run("stop"));
5769
+ * bar.on("mute", () => run("mute"));
5770
+ * ```
5771
+ *
5772
+ * @see {@link HostAction}, {@link Player}
5773
+ * @since 1.0.0
5774
+ * @category Commands
5775
+ */
4459
5776
  export interface Host {
4460
5777
  /**
4461
5778
  * Run a core action exactly like its menu item or key: same checks, same
@@ -4482,6 +5799,14 @@ export interface Host {
4482
5799
  * }
4483
5800
  * });
4484
5801
  * ```
5802
+ *
5803
+ * @capability commands
5804
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5805
+ * open, no window for `fullscreen`).
5806
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5807
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
5808
+ *
5809
+ * @since 1.0.0
4485
5810
  */
4486
5811
  run(action: HostAction): Promise<void>;
4487
5812
  /**
@@ -4511,6 +5836,12 @@ export interface Host {
4511
5836
  * // ---cut---
4512
5837
  * bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
4513
5838
  * ```
5839
+ *
5840
+ * @capability commands
5841
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5842
+ * open, no window for `fullscreen`).
5843
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5844
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
4514
5845
  */
4515
5846
  run(action: "seek-backward" | "seek-forward", options: {
4516
5847
  seconds: number;
@@ -4717,6 +6048,9 @@ export interface Osd {
4717
6048
  * The state of a filter, display layer or render options handle.
4718
6049
  * After `failed` nothing retries automatically until the next `set()`,
4719
6050
  * `retry()` or a new source.
6051
+ *
6052
+ * @since 1.0.0
6053
+ * @category Processing
4720
6054
  */
4721
6055
  export type FilterStatus =
4722
6056
  /** No ready media; the value is kept and applied when media is ready. */
@@ -4792,6 +6126,18 @@ interface Filter<T> extends Disposable {
4792
6126
  * picture.onStatus((s) => s.state === "failed" && plugin.osd.show(s.error.message));
4793
6127
  * picture.set([{ kind: "eq", brightness: 0.1, contrast: 1.1, saturation: 1, gamma: 1 }]);
4794
6128
  * ```
6129
+ *
6130
+ * @remarks
6131
+ * Members: `set(nodes)` records the desired chain (last write wins) and
6132
+ * sends it, one request at a time and only the newest; `status` and
6133
+ * `onStatus` report `waiting`, `applying`, `applied` or `failed`; `retry`
6134
+ * applies the current value again; `dispose` withdraws the chain. `set`
6135
+ * never throws: a refused chain shows as `failed` in `status`.
6136
+ *
6137
+ * @capability processing.video, playback.read and events
6138
+ * @see [Video and audio processing](../../docs/concepts/processing.md)
6139
+ * @since 1.0.0
6140
+ * @category Processing
4795
6141
  */
4796
6142
  export type VideoFilter = Filter<VideoNode[]>;
4797
6143
  /**
@@ -4811,6 +6157,16 @@ export type VideoFilter = Filter<VideoNode[]>;
4811
6157
  * const audio = plugin.audio.filter("sound");
4812
6158
  * audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 }); // 10 ms steps
4813
6159
  * ```
6160
+ *
6161
+ * @remarks
6162
+ * The same members and rules as {@link VideoFilter}: `set` records the
6163
+ * desired value and never throws, `status` and `onStatus` report the
6164
+ * result, `retry` applies it again, `dispose` withdraws it.
6165
+ *
6166
+ * @capability processing.audio, playback.read and events
6167
+ * @see [Video and audio processing](../../docs/concepts/processing.md)
6168
+ * @since 1.0.0
6169
+ * @category Processing
4814
6170
  */
4815
6171
  export type AudioFilter = Filter<{
4816
6172
  /** The audio chain; `[]` for none. */
@@ -4821,6 +6177,9 @@ export type AudioFilter = Filter<{
4821
6177
  /**
4822
6178
  * How the video sits in the video area; not part of snapshots or exports.
4823
6179
  * Defaults: `{ aspect: null, fit: "keep", zoom: 1, panX: 0, panY: 0 }`.
6180
+ *
6181
+ * @since 1.0.0
6182
+ * @category Processing
4824
6183
  */
4825
6184
  export interface ViewSettings {
4826
6185
  /** Display aspect override 0.25–4; `null` = as the source. */
@@ -4848,24 +6207,73 @@ export interface ViewSettings {
4848
6207
  * The video fills the video area (cropped instead of letterboxed), then
4849
6208
  * zooms to 150 % shifted a little to the right. The first plugin to call
4850
6209
  * `view()` owns the display layer; each `set` merges into its settings,
4851
- * which stay across sources until the plugin resets them or stops.
6210
+ * which stay across sources until the plugin disposes the view or stops.
4852
6211
  * ```ts
4853
6212
  * const view = plugin.video.view();
4854
6213
  * view.set({ fit: "fill" });
4855
6214
  * view.set({ zoom: 1.5, panX: 0.1 }); // merged with the previous write
4856
6215
  * ```
6216
+ *
6217
+ * @remarks
6218
+ * Not part of snapshots or exports: it changes how the picture sits in the
6219
+ * video area, not the picture. `set` merges into the current settings and
6220
+ * never throws: an out-of-range value or unknown field shows as `failed`
6221
+ * (`invalid`) in `status` and is reported to `onError`, without sending
6222
+ * anything.
6223
+ *
6224
+ * @capability processing.video, playback.read and events (like
6225
+ * {@link VideoFilter})
6226
+ * @see [Video and audio processing](../../docs/concepts/processing.md)
6227
+ * @since 1.0.0
6228
+ * @category Processing
4857
6229
  */
4858
6230
  export interface VideoView extends Disposable {
4859
- /** Merged into the current settings; last write wins. */
6231
+ /**
6232
+ * Merged into the current settings; last write wins.
6233
+ *
6234
+ * @capability processing.video
6235
+ *
6236
+ * @example Stretch a 4:3 video to fill the window
6237
+ * A 4:3 video fills a 16:9 window, distorted, as the user asked. Each
6238
+ * `set` merges the given fields into this plugin's view settings; the
6239
+ * newest write wins, and the settings stay for later files until the
6240
+ * plugin disposes the view or stops.
6241
+ * ```ts
6242
+ * plugin.video.view().set({ fit: "stretch" });
6243
+ * ```
6244
+ *
6245
+ * @since 1.0.0
6246
+ */
4860
6247
  set(value: Partial<ViewSettings>): void;
4861
6248
  /** Current status (the same four states as filters). */
4862
6249
  readonly status: FilterStatus;
4863
- /** The status changed. */
6250
+ /**
6251
+ * The status changed.
6252
+ *
6253
+ * @capability processing.video
6254
+ *
6255
+ * @example Tell the user another plugin owns the view
6256
+ * When another plugin already controls the display layer, the OSD says
6257
+ * so. The first plugin to call `view()` owns it; this plugin's handle
6258
+ * then reports `failed` with `conflict`, and tries again on its next `set`.
6259
+ * ```ts
6260
+ * const view = plugin.video.view();
6261
+ * view.onStatus((status) => {
6262
+ * if (status.state === "failed" && status.error.code === "conflict")
6263
+ * void plugin.osd.show("Another plugin controls the picture size");
6264
+ * });
6265
+ * ```
6266
+ *
6267
+ * @since 1.0.0
6268
+ */
4864
6269
  onStatus(listener: (status: FilterStatus) => void): Disposable;
4865
6270
  }
4866
6271
  /**
4867
6272
  * GPU renderer options; not part of snapshots or exports.
4868
6273
  * Defaults: `{ toneMapping: "auto", targetPeak: 203, peakDetect: true, shaders: [], interpolation: false }`.
6274
+ *
6275
+ * @since 1.0.0
6276
+ * @category Processing
4869
6277
  */
4870
6278
  export interface RenderSettings {
4871
6279
  /** HDR → SDR: "auto" maps HDR sources only; "off" clips without a curve. */
@@ -4904,19 +6312,69 @@ export interface RenderSettings {
4904
6312
  * });
4905
6313
  * render.set({ toneMapping: "bt.2390", targetPeak: 250 }); // nits
4906
6314
  * ```
6315
+ *
6316
+ * @remarks
6317
+ * GPU renderer options (tone mapping, peak detection, shaders,
6318
+ * interpolation). Only the GPU renderer has them, which today means macOS;
6319
+ * under software rendering the handle is `failed` with `unsupported` after
6320
+ * the player's first answer to `render()` (reported to `onError` too), and so is
6321
+ * every `set`. Single owner, like {@link VideoView}. `set` never throws; the
6322
+ * result shows in `status`.
6323
+ *
6324
+ * @capability processing.video, playback.read and events (like
6325
+ * {@link VideoFilter})
6326
+ * @see [Video and audio processing](../../docs/concepts/processing.md)
6327
+ * @since 1.0.0
6328
+ * @category Processing
4907
6329
  */
4908
6330
  export interface RenderOptions extends Disposable {
4909
- /** Merged into the current settings; last write wins. */
6331
+ /**
6332
+ * Merged into the current settings; last write wins.
6333
+ *
6334
+ * @capability processing.video
6335
+ *
6336
+ * @example Turn on motion interpolation
6337
+ * Motion becomes smoother on the GPU renderer. `set` merges the field into
6338
+ * the plugin's render options; under software rendering the status turns
6339
+ * `failed` with `unsupported` instead.
6340
+ * ```ts
6341
+ * plugin.video.render().set({ interpolation: true });
6342
+ * ```
6343
+ *
6344
+ * @since 1.0.0
6345
+ */
4910
6346
  set(value: Partial<RenderSettings>): void;
4911
6347
  /**
4912
6348
  * `failed` with `unsupported` while the player renders in software;
4913
6349
  * `failed` with `failed` when mpv cannot compile a shader (pushed later).
4914
6350
  */
4915
6351
  readonly status: FilterStatus;
4916
- /** The status changed (including later pushes such as shader compile errors). */
6352
+ /**
6353
+ * The status changed (including later pushes such as shader compile errors).
6354
+ *
6355
+ * @capability processing.video
6356
+ *
6357
+ * @example Report a shader that does not compile
6358
+ * The OSD shows mpv's message when a shader the plugin set fails to
6359
+ * compile. The player pushes that failure after the `set` was accepted, so
6360
+ * it arrives through `onStatus`, not as a rejected call.
6361
+ * ```ts
6362
+ * const render = plugin.video.render();
6363
+ * render.onStatus((status) => {
6364
+ * if (status.state === "failed") void plugin.osd.show("Render: {}", [status.error.message]);
6365
+ * });
6366
+ * ```
6367
+ *
6368
+ * @since 1.0.0
6369
+ */
4917
6370
  onStatus(listener: (status: FilterStatus) => void): Disposable;
4918
6371
  }
4919
- /** A subtitle track of the current source. */
6372
+ /**
6373
+ * A subtitle track of the current source.
6374
+ *
6375
+ * @since 1.0.0
6376
+ * @category Subtitles
6377
+ */
4920
6378
  export interface SubtitleTrack {
4921
6379
  /** Stable while the track exists; compare tracks by id. */
4922
6380
  id: string;
@@ -4944,7 +6402,13 @@ export interface SubtitleTrack {
4944
6402
  */
4945
6403
  streamIndex: number | null;
4946
6404
  }
4947
- /** The subtitle state of the current source. */
6405
+ /**
6406
+ * The subtitle state of the current source.
6407
+ *
6408
+ * @see {@link Subtitles.state}
6409
+ * @since 1.0.0
6410
+ * @category Subtitles
6411
+ */
4948
6412
  export interface SubtitlesState {
4949
6413
  /** The source it belongs to ({@link PlayerState.sourceId}). */
4950
6414
  readonly sourceId: number;
@@ -4975,7 +6439,12 @@ export interface SubtitlesState {
4975
6439
  /** The last error, `null` without one. */
4976
6440
  readonly error: string | null;
4977
6441
  }
4978
- /** Subtitle calls act on the selection you see; pass `ifCurrent` to pin an older one. */
6442
+ /**
6443
+ * Subtitle calls act on the selection you see; pass `ifCurrent` to pin an older one.
6444
+ *
6445
+ * @since 1.0.0
6446
+ * @category Subtitles
6447
+ */
4979
6448
  export interface SubtitleConditional {
4980
6449
  /** The subtitle state the call is conditional on; `stale` otherwise. */
4981
6450
  ifCurrent?: SubtitlesState;
@@ -5009,18 +6478,104 @@ export interface SubtitleConditional {
5009
6478
  * offsetMs: 0,
5010
6479
  * });
5011
6480
  * ```
6481
+ *
6482
+ * @remarks
6483
+ * The subtitle tracks of the current source, the primary and secondary
6484
+ * selection, uploaded files and the style. `state` is the SDK's snapshot of
6485
+ * the player's subtitle state (`null` until first read). The player has no
6486
+ * subtitle event: the SDK reads the state again when the source, its phase
6487
+ * or the backend changes (with `events`) and after this plugin's own
6488
+ * changes, so a selection the user or another plugin changed shows only
6489
+ * after `refresh()`. A change without `ifCurrent` reads the state first
6490
+ * and applies to it; pass `ifCurrent` to make it fail with `stale` when the
6491
+ * selection changed since that state. Each change is one operation the
6492
+ * player confirms; `select`, `remove` and `setStyle` resolve with the new
6493
+ * state, `load` with the new track.
6494
+ *
6495
+ * @capability `subtitles.read` to read; `subtitles.control` and
6496
+ * `subtitles.read` to change; `events` to re-read on source changes.
6497
+ *
6498
+ * @see [Load subtitles from a plugin](../../docs/how-to/subtitles.md)
6499
+ * @since 1.0.0
6500
+ * @category Subtitles
5012
6501
  */
5013
6502
  export interface Subtitles {
5014
6503
  /** `null` until first read. */
5015
6504
  readonly state: SubtitlesState | null;
5016
- /** The subtitle state changed. */
6505
+ /**
6506
+ * The subtitle state changed.
6507
+ *
6508
+ * @capability events and subtitles.read
6509
+ *
6510
+ * @example Show the subtitle language after a new file opens
6511
+ * When a new file starts, the OSD names the language of its primary
6512
+ * subtitle. The SDK reads the subtitle state again when the source or its
6513
+ * phase changes (there is no subtitle event, so a selection changed in the
6514
+ * player shows only after `refresh()`) and then calls the listener;
6515
+ * `selected` is `null` when subtitles are off.
6516
+ * ```ts
6517
+ * plugin.subtitles.onChange((state) => {
6518
+ * // A track's language is null when the file does not say.
6519
+ * void plugin.osd.show("Subtitles: {}", [state.selected?.language ?? "off"]);
6520
+ * });
6521
+ * ```
6522
+ *
6523
+ * @since 1.0.0
6524
+ */
5017
6525
  onChange(listener: (state: SubtitlesState) => void): Disposable;
5018
- /** Read the state from the player now. */
6526
+ /**
6527
+ * Read the state from the player now.
6528
+ *
6529
+ * @capability subtitles.read
6530
+ * @throws `capability` (rejects) The manifest does not declare
6531
+ * `subtitles.read`.
6532
+ *
6533
+ * @example Count the subtitle tracks of the file
6534
+ * The plugin reads how many subtitle tracks the file has before building a
6535
+ * menu. `refresh` asks the player and resolves with the current state, also
6536
+ * without `events`; `state` holds it afterwards.
6537
+ * ```ts
6538
+ * const { tracks } = await plugin.subtitles.refresh();
6539
+ * await plugin.osd.show("{} subtitle tracks", [tracks.length]);
6540
+ * ```
6541
+ *
6542
+ * @since 1.0.0
6543
+ */
5019
6544
  refresh(): Promise<SubtitlesState>;
5020
6545
  /**
5021
6546
  * `role: "secondary"` sets the secondary subtitle: a text track that is not
5022
6547
  * the primary one (`invalid` otherwise). A new source clears it.
5023
6548
  * `track: null` turns the role off.
6549
+ *
6550
+ * @capability subtitles.control and subtitles.read
6551
+ * @throws `stale` (rejects) With `ifCurrent`: the source or the subtitle
6552
+ * selection changed since that state.
6553
+ * @throws `invalid` (rejects) An unknown track; as secondary, a bitmap
6554
+ * track or the primary one; as primary, the current secondary.
6555
+ * @throws `missing` (rejects) The track is not in the current source.
6556
+ * @throws `unavailable` (rejects) Nothing loaded, or the source is still
6557
+ * loading or not yet confirmed.
6558
+ * @throws `busy` (rejects) Too many subtitle operations at once.
6559
+ * @throws `capability` (rejects) The manifest does not declare
6560
+ * `subtitles.control` and `subtitles.read`.
6561
+ *
6562
+ * @example Turn on the first English subtitle
6563
+ * The first English track becomes the primary subtitle, but only in the
6564
+ * file the plugin looked at. Embedded tracks are chosen by number, so
6565
+ * without `ifCurrent` a select after another file opened would pick that
6566
+ * file's track with the same number; with `ifCurrent: state` it fails with
6567
+ * `stale` instead. Without `ifCurrent`, `select` reads the newest state
6568
+ * itself and almost never fails with `stale`.
6569
+ * ```ts
6570
+ * const state = await plugin.subtitles.refresh();
6571
+ * const english = state.tracks.find((track) => track.language === "en");
6572
+ * if (english)
6573
+ * await plugin.subtitles.select(english, { ifCurrent: state }).catch((error: PluginError) => {
6574
+ * if (error.code !== "stale") throw error; // another file or selection now
6575
+ * });
6576
+ * ```
6577
+ *
6578
+ * @since 1.0.0
5024
6579
  */
5025
6580
  select(track: SubtitleTrack | null, options?: SubtitleConditional & {
5026
6581
  role?: "primary" | "secondary";
@@ -5037,6 +6592,36 @@ export interface Subtitles {
5037
6592
  * Limits: SRT ≤ 2 MiB and ≤ 10,000 cues; ASS ≤ 2 MiB with `[Script Info]`
5038
6593
  * and `[Events]` and 1–20,000 `Dialogue:` lines. Convert other formats
5039
6594
  * (VTT, SAMI) to SRT first.
6595
+ *
6596
+ * @capability subtitles.control and subtitles.read
6597
+ * @throws `invalid` (rejects) A bad `format` or `title`, or a file over the
6598
+ * limits.
6599
+ * @throws `stale` (rejects) With `ifCurrent`: the source changed since
6600
+ * that state.
6601
+ * @throws `unavailable` (rejects) Nothing loaded, or the source is still
6602
+ * loading.
6603
+ * @throws `aborted` (rejects) `signal` aborted before the commit (an
6604
+ * `AbortError`); the upload is discarded.
6605
+ * @throws `busy` (rejects) Too many subtitle operations at once.
6606
+ * @throws `capability` (rejects) The manifest does not declare
6607
+ * `subtitles.control` and `subtitles.read`.
6608
+ *
6609
+ * @example Load a downloaded SRT and select it
6610
+ * A subtitle file the plugin downloaded becomes a track of the current file
6611
+ * and is selected. The plugin passes the file's bytes; the player parses
6612
+ * them, adds an `external` track for this source only and resolves with
6613
+ * it once the upload is committed.
6614
+ * ```ts
6615
+ * declare const url: string;
6616
+ * // ---cut---
6617
+ * const response = await fetch(url);
6618
+ * if (!response.ok) throw new Error(`subtitles: HTTP ${response.status}`);
6619
+ * const bytes = new Uint8Array(await response.arrayBuffer());
6620
+ * const track = await plugin.subtitles.load(bytes, { title: "Downloaded" }); // selected
6621
+ * await plugin.osd.show("Loaded {}", [track.title]);
6622
+ * ```
6623
+ *
6624
+ * @since 1.0.0
5040
6625
  */
5041
6626
  load(bytes: Uint8Array, options?: SubtitleConditional & {
5042
6627
  format?: "srt" | "ass";
@@ -5044,19 +6629,69 @@ export interface Subtitles {
5044
6629
  select?: "primary" | "secondary" | "none";
5045
6630
  signal?: AbortSignal;
5046
6631
  }): Promise<SubtitleTrack>;
5047
- /** Remove a track this plugin loaded. */
6632
+ /**
6633
+ * Remove a track this plugin loaded.
6634
+ *
6635
+ * @capability subtitles.control and subtitles.read
6636
+ * @throws `invalid` (rejects) The track was not loaded by this plugin.
6637
+ * @throws `stale` (rejects) With `ifCurrent`: the source changed since
6638
+ * that state.
6639
+ * @throws `unavailable` (rejects) Nothing is loaded, or the source is
6640
+ * still loading.
6641
+ * @throws `busy` (rejects) Too many subtitle operations at once.
6642
+ * @throws `capability` (rejects) The manifest does not declare
6643
+ * `subtitles.control` and `subtitles.read`.
6644
+ *
6645
+ * @example Remove the subtitle the plugin loaded
6646
+ * The plugin's downloaded subtitle disappears from the track list. A plugin
6647
+ * can remove only tracks it loaded itself; a new source removes them
6648
+ * anyway.
6649
+ * ```ts
6650
+ * declare const loaded: SubtitleTrack;
6651
+ * // ---cut---
6652
+ * await plugin.subtitles.remove(loaded);
6653
+ * ```
6654
+ *
6655
+ * @since 1.0.0
6656
+ */
5048
6657
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
5049
6658
  /**
5050
6659
  * Set this plugin's style layer; `null` removes it. The newest call of
5051
6660
  * this plugin replaces its earlier layer. When several plugins have a
5052
6661
  * layer, the player uses the one whose manifest id sorts last, whatever
5053
6662
  * the order of the calls.
6663
+ *
6664
+ * @capability subtitles.control and subtitles.read
6665
+ * @throws `invalid` (rejects) A field out of range.
6666
+ * @throws `unsupported` (rejects) A font that is not in `state.fonts`, or a
6667
+ * primary subtitle that is a bitmap track.
6668
+ * @throws `stale` (rejects) With `ifCurrent`: the subtitle state changed
6669
+ * since that state.
6670
+ * @throws `unavailable` (rejects) Nothing is loaded, or the source is
6671
+ * still loading.
6672
+ * @throws `busy` (rejects) Too many subtitle operations at once.
6673
+ * @throws `capability` (rejects) The manifest does not declare
6674
+ * `subtitles.control` and `subtitles.read`.
6675
+ *
6676
+ * @example Take the plugin's style off again
6677
+ * The plugin removes its style layer, so the subtitles go back to the
6678
+ * player's default (or to another plugin's layer). `null` removes only this
6679
+ * plugin's layer; the effective layer is the one whose manifest id sorts
6680
+ * last among the plugins that still have one.
6681
+ * ```ts
6682
+ * await plugin.subtitles.setStyle(null);
6683
+ * ```
6684
+ *
6685
+ * @since 1.0.0
5054
6686
  */
5055
6687
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
5056
6688
  }
5057
6689
  /**
5058
6690
  * Options of {@link MediaCapture.capture}. Conditional on `player.state`
5059
6691
  * like other controls.
6692
+ *
6693
+ * @since 1.0.0
6694
+ * @category Media
5060
6695
  */
5061
6696
  export interface CaptureOptions extends Conditional {
5062
6697
  /** Source time; defaults to the current position at the moment of capture. */
@@ -5068,7 +6703,36 @@ export interface CaptureOptions extends Conditional {
5068
6703
  /** Audio to freeze with the snapshot: the selected track, none, or a track id. */
5069
6704
  audio?: "selected" | "none" | number;
5070
6705
  }
5071
- /** A frozen, immutable input for exports. Dispose to release it. */
6706
+ /**
6707
+ * A frozen, immutable input for exports. Dispose to release it.
6708
+ *
6709
+ * @remarks
6710
+ * A frozen input, from {@link MediaCapture.capture}: the source, the time
6711
+ * and (processed) the effects and subtitle as they were. Exports read it in
6712
+ * the player's offline worker, so one snapshot can be exported several
6713
+ * times and playback is unaffected. Disposing it releases the player's
6714
+ * copy; an export using it after that fails with `released`.
6715
+ *
6716
+ * @capability media.read and media.control
6717
+ *
6718
+ * @example Export one frame as PNG and as JPEG
6719
+ * The same frozen frame is written twice, lossless and compressed. Each
6720
+ * `export` starts a job of its own; the snapshot is released after both
6721
+ * are done.
6722
+ * ```ts
6723
+ * const snapshot = await plugin.media.capture();
6724
+ * try {
6725
+ * const png = await snapshot.export({ format: "png", destination: "/tmp/frame.png" });
6726
+ * const jpeg = await snapshot.export({ format: "jpeg", destination: "/tmp/frame.jpg", quality: 85 });
6727
+ * await Promise.all([png.done, jpeg.done]); // terminal states, not errors
6728
+ * } finally {
6729
+ * await snapshot.dispose();
6730
+ * }
6731
+ * ```
6732
+ *
6733
+ * @since 1.0.0
6734
+ * @category Media
6735
+ */
5072
6736
  export interface Snapshot extends Disposable {
5073
6737
  /** Without or with effects. */
5074
6738
  readonly kind: "raw" | "processed";
@@ -5092,6 +6756,32 @@ export interface Snapshot extends Disposable {
5092
6756
  /**
5093
6757
  * Start exporting; the job runs in the host's offline worker.
5094
6758
  * One snapshot can be exported several times.
6759
+ *
6760
+ * @capability media.read and media.control
6761
+ * @throws `released` (rejects) The snapshot was already released.
6762
+ * @throws `invalid` (rejects) Bad options (`quality` outside 1–100, a
6763
+ * relative destination, a clip `end` not after the snapshot time or
6764
+ * past the duration).
6765
+ * @throws `busy` (rejects) This plugin runs 2 exports (or the player 8).
6766
+ * @throws `unavailable` (rejects) No FFmpeg, or the export worker could not
6767
+ * start.
6768
+ * @throws `capability` (rejects) The manifest does not declare
6769
+ * `media.read` and `media.control`.
6770
+ *
6771
+ * @example Export a 30-second clip
6772
+ * A clip from the snapshot time to 30 seconds later is written as MP4.
6773
+ * `export` resolves once the job is accepted; the job then runs in the
6774
+ * player's offline worker, and `done` resolves with its terminal state,
6775
+ * whose `outcome` has the destination or the error.
6776
+ * ```ts
6777
+ * declare const snapshot: Snapshot;
6778
+ * // ---cut---
6779
+ * const job = await snapshot.export({ format: "mp4", destination: "/tmp/clip.mp4", end: snapshot.time + 30 });
6780
+ * const end = await job.done;
6781
+ * if (end.status !== "completed") await plugin.osd.show("Export {}", [end.status]);
6782
+ * ```
6783
+ *
6784
+ * @since 1.0.0
5095
6785
  */
5096
6786
  export(options: ExportOptions): Promise<ExportJob>;
5097
6787
  }
@@ -5103,6 +6793,9 @@ export interface Snapshot extends Disposable {
5103
6793
  * choose the format. `sampleTime` picks the frame (default: the snapshot
5104
6794
  * time); `end` is the clip's end in source seconds. On Windows the export
5105
6795
  * worker needs FFmpeg 6.1 or newer.
6796
+ *
6797
+ * @since 1.0.0
6798
+ * @category Media
5106
6799
  */
5107
6800
  export type ExportOptions = {
5108
6801
  /** A lossless frame. */
@@ -5133,19 +6826,111 @@ export type ExportOptions = {
5133
6826
  * `completed`, `failed` or `cancelled`; once terminal, `outcome` is
5134
6827
  * `{ destination }`, `{ error: { code, message } }` or `{ cancelled: true }`.
5135
6828
  * `facts` describes what the worker produced (versions, range, streams).
6829
+ *
6830
+ * @since 1.0.0
6831
+ * @category Media
5136
6832
  */
5137
6833
  export type JobState = Omit<MediaJob, "jobId">;
5138
- /** An export, from {@link Snapshot.export}. Disposing an unfinished job cancels it. */
6834
+ /**
6835
+ * An export, from {@link Snapshot.export}. Disposing an unfinished job cancels it.
6836
+ *
6837
+ * @remarks
6838
+ * One export, from {@link Snapshot.export}. `state` is the SDK's last copy
6839
+ * of the job's state: the SDK polls the player about every 350 ms until the
6840
+ * job ends, and `refresh` reads it at once. `done` resolves
6841
+ * once the job is terminal (`completed`, `failed` or `cancelled`) and never
6842
+ * rejects for a failed export: the failure is in `outcome`. Disposing an
6843
+ * unfinished job cancels it.
6844
+ *
6845
+ * @capability media.read and media.control
6846
+ *
6847
+ * @example Show export progress and cancel on request
6848
+ * The OSD shows the job's status while it runs, and a Cancel command stops
6849
+ * it. `onChange` follows the state (`queued`, `running`, `cancelling`,
6850
+ * then a terminal one); `cancel` asks the worker to stop. After
6851
+ * `requested` the job usually ends `cancelled`, or `failed` when the
6852
+ * cleanup failed.
6853
+ * ```ts
6854
+ * declare const job: ExportJob;
6855
+ * // ---cut---
6856
+ * job.onChange((state) => void plugin.osd.show("Export: {}", [state.status]));
6857
+ * plugin.commands.add({ id: "cancel-export", title: "Cancel export", run: () => job.cancel() });
6858
+ * await job.done; // completed, failed or cancelled
6859
+ * ```
6860
+ *
6861
+ * @since 1.0.0
6862
+ * @category Media
6863
+ */
5139
6864
  export interface ExportJob extends Disposable {
5140
6865
  /** Latest state. */
5141
6866
  readonly state: JobState;
5142
- /** The state changed. */
6867
+ /**
6868
+ * The state changed.
6869
+ *
6870
+ * @capability media.read
6871
+ *
6872
+ * @example Log each state of an export
6873
+ * The plugin's log records each status the job goes through (`queued`,
6874
+ * `running`, `cancelling` after a cancel, then a terminal one). The listener runs when the SDK learns a
6875
+ * new state from its polling.
6876
+ * ```ts
6877
+ * declare const job: ExportJob;
6878
+ * // ---cut---
6879
+ * job.onChange((state) => console.log("export", state.status));
6880
+ * ```
6881
+ *
6882
+ * @since 1.0.0
6883
+ */
5143
6884
  onChange(listener: (state: JobState) => void): Disposable;
5144
- /** Resolves with the terminal state (completed, failed or cancelled). */
6885
+ /**
6886
+ * Resolves with the terminal state (completed, failed or cancelled).
6887
+ *
6888
+ * @throws `disposed` (rejects) The job handle was disposed while waiting.
6889
+ */
5145
6890
  readonly done: Promise<JobState>;
5146
- /** Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over). */
6891
+ /**
6892
+ * Ask to cancel: `requested`, `tooLate` (already publishing) or `terminal` (already over).
6893
+ *
6894
+ * @capability media.control
6895
+ * @throws `rejected` (rejects) The player did not accept the request.
6896
+ * @throws `capability` (rejects) The manifest does not declare
6897
+ * `media.control`.
6898
+ *
6899
+ * @example Cancel an export the user no longer wants
6900
+ * The running export stops, unless it is already writing its file.
6901
+ * `cancel` resolves `requested` when the worker will stop (the job ends
6902
+ * `cancelled`), `tooLate` when it is already publishing the file, and
6903
+ * `terminal` when the job had already ended.
6904
+ * ```ts
6905
+ * declare const job: ExportJob;
6906
+ * // ---cut---
6907
+ * const answer = await job.cancel();
6908
+ * if (answer === "tooLate") await plugin.osd.show("Too late: the file is being written");
6909
+ * ```
6910
+ *
6911
+ * @since 1.0.0
6912
+ */
5147
6913
  cancel(): Promise<"requested" | "tooLate" | "terminal">;
5148
- /** Read the state from the player now. */
6914
+ /**
6915
+ * Read the state from the player now.
6916
+ *
6917
+ * @capability media.read
6918
+ * @throws `capability` (rejects) The manifest does not declare
6919
+ * `media.read`.
6920
+ *
6921
+ * @example Read a job's state now
6922
+ * The plugin reads the newest state of an export without waiting for an
6923
+ * event. `refresh` asks the player and resolves with the state, which
6924
+ * `state` then holds.
6925
+ * ```ts
6926
+ * declare const job: ExportJob;
6927
+ * // ---cut---
6928
+ * const state = await job.refresh();
6929
+ * console.log(state.status);
6930
+ * ```
6931
+ *
6932
+ * @since 1.0.0
6933
+ */
5149
6934
  refresh(): Promise<JobState>;
5150
6935
  }
5151
6936
  /**
@@ -5173,21 +6958,73 @@ export interface ExportJob extends Disposable {
5173
6958
  * await snapshot.dispose();
5174
6959
  * }
5175
6960
  * ```
6961
+ *
6962
+ * @remarks
6963
+ * Exports run in the player's offline worker from a frozen
6964
+ * {@link Snapshot}: a still frame or a clip, raw or with this plugin's
6965
+ * effects (and the primary subtitle). Playback is not disturbed. A plugin
6966
+ * holds at most 4 snapshots at a time (16 in the player); release them
6967
+ * when done.
6968
+ *
6969
+ * @capability media.read and media.control (processed snapshots also
6970
+ * `playback.read` and `subtitles.read`)
6971
+ * @since 1.0.0
6972
+ * @category Media
5176
6973
  */
5177
6974
  export interface MediaCapture {
5178
6975
  /**
5179
6976
  * Freeze the current source (and optionally this plugin set's effects)
5180
6977
  * as an input for exports. Fails `stale` when the source changed since
5181
- * `ifCurrent`; `unsupported` for an external ASS track with subtitles; `noSource` when nothing is playing.
6978
+ * `ifCurrent`; `unsupported` for subtitles that cannot be flattened (only
6979
+ * plain SRT text can) or a picture over the size limits; `noSource` when
6980
+ * nothing is playing.
6981
+ *
6982
+ * @capability media.read and media.control
6983
+ * @throws `stale` (rejects) The source changed since `ifCurrent`.
6984
+ * @throws `noSource` (rejects) Nothing is playing.
6985
+ * @throws `invalid` (rejects) `at` at or after the end of the source.
6986
+ * @throws `busy` (rejects) This plugin holds 4 snapshots (or the player 16);
6987
+ * release one.
6988
+ * @throws `unavailable` (rejects) A processed snapshot while the picture is
6989
+ * still being prepared.
6990
+ * @throws `unsupported` (rejects) A processed snapshot the player cannot
6991
+ * freeze: subtitles that cannot be flattened (only plain SRT text can),
6992
+ * or a picture over the size limits.
6993
+ * @throws `capability` (rejects) The manifest does not declare
6994
+ * `media.read` and `media.control`.
6995
+ *
6996
+ * @example Freeze the frame at the 10-second mark
6997
+ * The plugin freezes the frame at 0:10 of the open file, raw, to export it
6998
+ * later. `capture` resolves with a `Snapshot` once the player has frozen
6999
+ * that input; the snapshot stays valid when playback moves on or the file
7000
+ * changes, until it is released.
7001
+ * ```ts
7002
+ * const snapshot = await plugin.media.capture({ at: 10 }); // seconds
7003
+ * try {
7004
+ * // … export it …
7005
+ * } finally {
7006
+ * await snapshot.dispose(); // frees one of the plugin's 4 snapshot slots
7007
+ * }
7008
+ * ```
7009
+ *
7010
+ * @since 1.0.0
5182
7011
  */
5183
7012
  capture(options?: CaptureOptions): Promise<Snapshot>;
5184
7013
  }
5185
7014
  /**
5186
7015
  * Declaration of {@link Resolvers.add}: a web resolver
5187
7016
  * ({@link WebResolverOptions}) or a file resolver ({@link FileResolverOptions}).
7017
+ *
7018
+ * @since 1.0.0
7019
+ * @category Sources
5188
7020
  */
5189
7021
  export type ResolverOptions = WebResolverOptions | FileResolverOptions;
5190
- /** A resolver for web pages: `https://host/…` to playable stream URLs. */
7022
+ /**
7023
+ * A resolver for web pages: `https://host/…` to playable stream URLs.
7024
+ *
7025
+ * @since 1.0.0
7026
+ * @category Sources
7027
+ */
5191
7028
  export interface WebResolverOptions {
5192
7029
  /** Local id, unique within the plugin. */
5193
7030
  id: string;
@@ -5252,6 +7089,9 @@ export interface WebResolverOptions {
5252
7089
  * },
5253
7090
  * });
5254
7091
  * ```
7092
+ *
7093
+ * @since 1.5.3
7094
+ * @category Sources
5255
7095
  */
5256
7096
  export interface FileResolverOptions {
5257
7097
  /** Local id, unique within the plugin. */
@@ -5276,6 +7116,9 @@ export interface FileResolverOptions {
5276
7116
  * failures "无法启动播放". An error thrown by `frame()` keeps its message.
5277
7117
  * To give your own text for a file you can tell is unusable (a bad header),
5278
7118
  * check it in `resolve` and throw there.
7119
+ *
7120
+ * @since 1.5.3
7121
+ * @category Sources
5279
7122
  */
5280
7123
  export interface ResolvedStream {
5281
7124
  /** Always `"stream"`. */
@@ -5294,14 +7137,52 @@ export interface ResolvedStream {
5294
7137
  */
5295
7138
  started?(stream: Stream): void;
5296
7139
  }
5297
- /** A registered resolver. Disposing it unregisters it and aborts resolutions in flight. */
5298
- export interface Resolver extends Disposable {
5299
- /**
5300
- * Resolves when the player registered it; rejects otherwise: `conflict`
7140
+ /**
7141
+ * A registered resolver. Disposing it unregisters it and aborts resolutions in flight.
7142
+ *
7143
+ * @remarks
7144
+ * A registered resolver, from {@link Resolvers.add}. Disposing it, its
7145
+ * scope or stopping the plugin unregisters it and aborts resolutions in
7146
+ * flight; the extensions or hosts are free again for other plugins.
7147
+ *
7148
+ * @capability source.resolve
7149
+ *
7150
+ * @example Report when another plugin already handles the format
7151
+ * The plugin tells the user when its `.ch8` resolver could not register
7152
+ * because another plugin holds that extension. `ready` rejects with
7153
+ * `conflict`; for another plugin's claim that does not fail activation, so
7154
+ * the plugin keeps running without the resolver.
7155
+ * ```ts
7156
+ * const resolver = plugin.resolvers.add({
7157
+ * id: "chip8",
7158
+ * extensions: ["ch8"],
7159
+ * async resolve() {
7160
+ * throw plugin.i18n.error("Not ready yet.");
7161
+ * },
7162
+ * });
7163
+ * // Rejects with `conflict` when the extension is taken.
7164
+ * resolver.ready.catch((error: PluginError) => {
7165
+ * if (error.code === "conflict") void plugin.osd.show("Another plugin plays .ch8 files");
7166
+ * });
7167
+ * ```
7168
+ *
7169
+ * @since 1.0.0
7170
+ * @category Sources
7171
+ */
7172
+ export interface Resolver extends Disposable {
7173
+ /**
7174
+ * Resolves when the player registered it; rejects otherwise: `conflict`
5301
7175
  * when another resolver has the extension or the scheme + host (another
5302
7176
  * plugin's: activation goes on; this plugin's own: activation fails),
5303
7177
  * `invalid` for a malformed declaration (a player before 0.4.1 also for
5304
7178
  * `extensions`).
7179
+ *
7180
+ * @throws `conflict` (rejects) Another resolver has the extension or the
7181
+ * scheme + host, or this plugin already registered this id.
7182
+ * @throws `busy` (rejects) The player already holds 16 resolvers.
7183
+ * @throws `invalid` (rejects) A malformed declaration.
7184
+ * @throws `capability` (rejects) The manifest does not declare
7185
+ * `source.resolve`.
5305
7186
  */
5306
7187
  readonly ready: Promise<void>;
5307
7188
  }
@@ -5328,6 +7209,20 @@ export interface Resolver extends Disposable {
5328
7209
  * },
5329
7210
  * });
5330
7211
  * ```
7212
+ *
7213
+ * @remarks
7214
+ * A resolver turns something the player cannot play by itself into
7215
+ * something it can: a web page address into stream URLs
7216
+ * ({@link WebResolverOptions}), or a local file of a format the plugin
7217
+ * understands into a stream the plugin generates
7218
+ * ({@link FileResolverOptions}). The player asks the resolver whenever
7219
+ * anyone opens a matching address or file (the Open dialog, the playlist,
7220
+ * another plugin), so the plugin does not intercept opens itself.
7221
+ *
7222
+ * @capability source.resolve (file resolvers also `producer.av`)
7223
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md)
7224
+ * @since 1.0.0
7225
+ * @category Sources
5331
7226
  */
5332
7227
  export interface Resolvers {
5333
7228
  /**
@@ -5336,6 +7231,29 @@ export interface Resolvers {
5336
7231
  * replaced. One scheme + host pair, and one extension, has one resolver
5337
7232
  * (`conflict` otherwise). A rejected `resolve` fails the open with the
5338
7233
  * error's message.
7234
+ *
7235
+ * @capability source.resolve
7236
+ *
7237
+ * @example Play a site's pages through the plugin
7238
+ * Opening a `https://video.example.com/…` address plays the page's video.
7239
+ * `add` returns at once and registers in the background (`ready` reports
7240
+ * the answer); from then on the player calls `resolve` for every matching
7241
+ * open, and the error a rejected `resolve` carries is what the user sees.
7242
+ * ```ts
7243
+ * plugin.resolvers.add({
7244
+ * id: "example",
7245
+ * schemes: ["https"],
7246
+ * hosts: ["video.example.com"], // exact host names
7247
+ * async resolve(url, signal) {
7248
+ * const response = await fetch(url, { signal });
7249
+ * if (!response.ok) throw new Error(`the page answered ${response.status}`);
7250
+ * const page = (await response.json()) as { title: string; stream: string };
7251
+ * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
7252
+ * },
7253
+ * });
7254
+ * ```
7255
+ *
7256
+ * @since 1.0.0
5339
7257
  */
5340
7258
  add(options: ResolverOptions): Resolver;
5341
7259
  /**
@@ -5391,6 +7309,9 @@ export interface Resolvers {
5391
7309
  * The manifest of a package, as validated. The display fields and
5392
7310
  * `minPlayerVersion` are optional (player 0.2.0); see the API reference for
5393
7311
  * their rules.
7312
+ *
7313
+ * @since 1.0.0
7314
+ * @category Packages
5394
7315
  */
5395
7316
  export interface PackageManifest {
5396
7317
  /** Plugin id. */
@@ -5431,6 +7352,9 @@ export interface PackageManifest {
5431
7352
  * upgrade the plugin and keep its data; links and local files count as one
5432
7353
  * source. `bundled` marks the player's built-in plugins (set by the player
5433
7354
  * only).
7355
+ *
7356
+ * @since 1.1.0
7357
+ * @category Packages
5434
7358
  */
5435
7359
  export type PackageSource = {
5436
7360
  /** A repository. */
@@ -5452,7 +7376,12 @@ export type PackageSource = {
5452
7376
  /** Shipped with the player (built-in). */
5453
7377
  kind: "bundled";
5454
7378
  };
5455
- /** Options of {@link PackageCandidate.install}. */
7379
+ /**
7380
+ * Options of {@link PackageCandidate.install}.
7381
+ *
7382
+ * @since 1.1.0
7383
+ * @category Packages
7384
+ */
5456
7385
  export interface InstallOptions {
5457
7386
  /**
5458
7387
  * Where the package comes from (default `{ kind: "file" }`). Repository
@@ -5467,7 +7396,34 @@ export interface InstallOptions {
5467
7396
  */
5468
7397
  replace?: boolean;
5469
7398
  }
5470
- /** A validated package directory, from {@link Packages.validate}. */
7399
+ /**
7400
+ * A validated package directory, from {@link Packages.validate}.
7401
+ *
7402
+ * @remarks
7403
+ * A package the player has checked, from {@link Packages.validate} or
7404
+ * {@link Packages.validateArchive}: its manifest, digest and the checks
7405
+ * it passed. The player copies the whole package when it checks it and
7406
+ * installs that copy, so later changes to the directory do not matter.
7407
+ * Installing starts a {@link PackageChange}; the player keeps only the 32
7408
+ * most recent candidates.
7409
+ *
7410
+ * @capability plugins.manage
7411
+ *
7412
+ * @example Show what a package asks for before installing it
7413
+ * Before installing, the plugin shows the package's name and the
7414
+ * capabilities it declares. The candidate's `manifest` is the manifest
7415
+ * as the player read it.
7416
+ * ```ts
7417
+ * declare const directory: string;
7418
+ * // ---cut---
7419
+ * const candidate = await plugin.packages.validate(directory);
7420
+ * const { id, version, capabilities } = candidate.manifest;
7421
+ * await plugin.osd.show("{} {}: {}", [id, version, capabilities.join(", ")]);
7422
+ * ```
7423
+ *
7424
+ * @since 1.0.0
7425
+ * @category Packages
7426
+ */
5471
7427
  export interface PackageCandidate {
5472
7428
  /** The package directory. */
5473
7429
  readonly directory: string;
@@ -5484,28 +7440,138 @@ export interface PackageCandidate {
5484
7440
  * player). A reserved id (`vestra` or `vestra.*`, which every built-in
5485
7441
  * uses) fails with `reserved` (player 0.4.1; already when validated), as
5486
7442
  * only the player installs built-in plugins.
7443
+ *
7444
+ * @capability plugins.manage
7445
+ * @throws `conflict` (rejects) The plugin is installed from another source
7446
+ * and `replace` is not set.
7447
+ * @throws `unsupported` (rejects) A built-in plugin's id.
7448
+ * @throws `reserved` (rejects) A reserved id (`vestra`, `vestra.*`).
7449
+ * @throws `busy` (rejects) The plugin library is being cleaned up, or the
7450
+ * player already keeps 64 change records.
7451
+ * @throws `revisionConflict` (rejects) The installed revision of this
7452
+ * plugin changed meanwhile; call `install` again (no new validation
7453
+ * needed).
7454
+ * @throws `stale` (rejects) The candidate is older than the 32 the player
7455
+ * keeps.
7456
+ * @throws `capability` (rejects) The manifest does not declare
7457
+ * `plugins.manage`.
7458
+ *
7459
+ * @example Replace a plugin installed from another source
7460
+ * The new package replaces the installed plugin even though it came from
7461
+ * another source. Without `replace` the player refuses with `conflict`;
7462
+ * the same source simply upgrades and keeps the plugin's data.
7463
+ * ```ts
7464
+ * declare const candidate: PackageCandidate;
7465
+ * // ---cut---
7466
+ * const change = await candidate.install({ replace: true });
7467
+ * await change.done; // "committed" once the new plugin runs
7468
+ * ```
7469
+ *
7470
+ * @since 1.0.0
5487
7471
  */
5488
7472
  install(options?: InstallOptions): Promise<PackageChange>;
5489
7473
  }
5490
- /** The progress of an install or uninstall. */
7474
+ /**
7475
+ * The progress of an install or uninstall.
7476
+ *
7477
+ * @since 1.0.0
7478
+ * @category Packages
7479
+ */
5491
7480
  export type PackageChangeStatus = "preparing" | "activating" | "restoring" | "committed" | "cancelled" | "failed" | "failedDegraded" | "tooLate";
5492
7481
  /**
5493
7482
  * An install or uninstall in progress. The SDK never submits a
5494
7483
  * change twice: after a lost reply it only queries the same request.
7484
+ *
7485
+ * @remarks
7486
+ * An install or uninstall the player carries out in steps (`preparing`,
7487
+ * `activating`, possibly `restoring`) until a terminal status. The SDK
7488
+ * asks the player for the status about every 400 ms until then; `done`
7489
+ * resolves with the terminal status and does not reject for a failed
7490
+ * change: `message` says why.
7491
+ *
7492
+ * @capability plugins.manage
7493
+ *
7494
+ * @example Report each step of an install
7495
+ * The OSD shows each step of the install and the end result. `onChange`
7496
+ * runs for every new status; `done` resolves when the change is over.
7497
+ * ```ts
7498
+ * declare const change: PackageChange;
7499
+ * // ---cut---
7500
+ * change.onChange((status) => void plugin.osd.show("Install: {}", [status]));
7501
+ * const end = await change.done;
7502
+ * if (end !== "committed") await plugin.osd.show("Not installed: {}", [change.message ?? end]);
7503
+ * ```
7504
+ *
7505
+ * @since 1.0.0
7506
+ * @category Packages
5495
7507
  */
5496
7508
  export interface PackageChange {
5497
7509
  /** Current status. */
5498
7510
  readonly status: PackageChangeStatus;
5499
7511
  /** Details for failures, `null` otherwise. */
5500
7512
  readonly message: string | null;
5501
- /** The status changed. */
7513
+ /**
7514
+ * The status changed.
7515
+ *
7516
+ * @capability plugins.manage
7517
+ *
7518
+ * @example Close a progress window when the change ends
7519
+ * The plugin's progress window closes once the install is over, whatever
7520
+ * the outcome. The listener receives each new status; the terminal ones
7521
+ * are `committed`, `cancelled`, `failed`, `failedDegraded` and `tooLate`
7522
+ * (`tooLate` only answers a `cancel` of a change that had committed, and
7523
+ * `committed` may follow it).
7524
+ * ```ts
7525
+ * declare const change: PackageChange;
7526
+ * declare const progress: Window;
7527
+ * // ---cut---
7528
+ * change.onChange((status) => {
7529
+ * if (["committed", "cancelled", "failed", "failedDegraded", "tooLate"].includes(status))
7530
+ * void progress.close();
7531
+ * });
7532
+ * ```
7533
+ *
7534
+ * @since 1.0.0
7535
+ */
5502
7536
  onChange(listener: (status: PackageChangeStatus) => void): Disposable;
5503
- /** Resolves with the final status. */
7537
+ /**
7538
+ * Resolves with the final status.
7539
+ *
7540
+ * @throws `missing` (rejects) The player no longer knows the change (a
7541
+ * `HostError` with that code); a failed change is a terminal status,
7542
+ * not a rejection.
7543
+ */
5504
7544
  readonly done: Promise<PackageChangeStatus>;
5505
- /** Ask to cancel (it may be `tooLate`). */
7545
+ /**
7546
+ * Ask to cancel (it may be `tooLate`).
7547
+ *
7548
+ * @capability plugins.manage
7549
+ * @throws `capability` (rejects) The manifest does not declare
7550
+ * `plugins.manage`.
7551
+ *
7552
+ * @example Cancel an install the user no longer wants
7553
+ * The install stops if it has not gone too far. `cancel` asks the player;
7554
+ * the change then ends `cancelled`. When the change had already committed
7555
+ * the player answers `tooLate` and `done` still resolves `committed`; a
7556
+ * change that is restoring cannot be cancelled and ends `failed` or
7557
+ * `failedDegraded`.
7558
+ * ```ts
7559
+ * declare const change: PackageChange;
7560
+ * // ---cut---
7561
+ * await change.cancel();
7562
+ * console.log(await change.done); // "cancelled"; "committed" when it was too late
7563
+ * ```
7564
+ *
7565
+ * @since 1.0.0
7566
+ */
5506
7567
  cancel(): Promise<void>;
5507
7568
  }
5508
- /** One plugin as the player knows it (`packages.list`). */
7569
+ /**
7570
+ * One plugin as the player knows it (`packages.list`).
7571
+ *
7572
+ * @since 1.0.0
7573
+ * @category Packages
7574
+ */
5509
7575
  export interface PluginInfo {
5510
7576
  /** Manifest id. */
5511
7577
  id: string;
@@ -5564,6 +7630,8 @@ export interface PluginInfo {
5564
7630
  * `description` and `tags` as the player shows them.
5565
7631
  *
5566
7632
  * @since 1.5.9
7633
+ *
7634
+ * @category Packages
5567
7635
  */
5568
7636
  export interface PluginDisplay {
5569
7637
  /** `name`, translated; `null` when the manifest has none. */
@@ -5579,6 +7647,8 @@ export interface PluginDisplay {
5579
7647
  * One refusal in {@link PluginInfo.warnings}.
5580
7648
  *
5581
7649
  * @since 1.5.2
7650
+ *
7651
+ * @category Packages
5582
7652
  */
5583
7653
  export interface PluginWarning {
5584
7654
  /**
@@ -5615,6 +7685,17 @@ export interface PluginWarning {
5615
7685
  * // `message` says why a change did not commit.
5616
7686
  * if ((await change.done) !== "committed") throw new Error(change.message ?? "install failed");
5617
7687
  * ```
7688
+ *
7689
+ * @remarks
7690
+ * The plugins the player knows: installed, built-in and this run's
7691
+ * development loads. Reading the list needs no capability; installing,
7692
+ * enabling, reloading and uninstalling need `plugins.manage` and act on
7693
+ * other plugins, never on the calling one. Built-in plugins update with
7694
+ * the player and cannot be installed, disabled or uninstalled.
7695
+ *
7696
+ * @capability None for `list` and `onChange`; plugins.manage for the rest.
7697
+ * @since 1.0.0
7698
+ * @category Packages
5618
7699
  */
5619
7700
  export interface Packages {
5620
7701
  /**
@@ -5625,6 +7706,37 @@ export interface Packages {
5625
7706
  * The player keeps the 32 most recent candidates of all plugins; an
5626
7707
  * older one's `install` fails with `stale` (validate again). Before
5627
7708
  * player 0.4.2 the 33rd validation of a run failed with `busy` instead.
7709
+ *
7710
+ * @capability plugins.manage
7711
+ * @throws `host` (rejects) Not a package directory, or a manifest that
7712
+ * breaks the rules (another `apiVersion` included); `message` says why.
7713
+ * @throws `invalid` (rejects) A bad request (a path that is not a string).
7714
+ * @throws `javascript` (rejects) The entry module does not parse.
7715
+ * @throws `slint` (rejects) A Slint file does not compile.
7716
+ * @throws `reserved` (rejects) A reserved id (`vestra`, `vestra.*`).
7717
+ * @throws `versionMismatch` (rejects) The package's `minPlayerVersion` is
7718
+ * newer than the player.
7719
+ * @throws `disabled` (rejects) The calling plugin is stopping.
7720
+ * @throws `capability` (rejects) The manifest does not declare
7721
+ * `plugins.manage`.
7722
+ *
7723
+ * @example Check a package directory before installing it
7724
+ * The plugin checks a folder it unpacked and installs it if the player
7725
+ * accepts it. `validate` runs the player's checks and resolves with a
7726
+ * candidate; a package that fails them rejects with the reason.
7727
+ * ```ts
7728
+ * declare const directory: string;
7729
+ * // ---cut---
7730
+ * try {
7731
+ * // Rejects with the reason when the package fails the checks.
7732
+ * const candidate = await plugin.packages.validate(directory);
7733
+ * await (await candidate.install()).done;
7734
+ * } catch (error) {
7735
+ * await plugin.osd.show("Not a valid plugin: {}", [(error as Error).message]);
7736
+ * }
7737
+ * ```
7738
+ *
7739
+ * @since 1.0.0
5628
7740
  */
5629
7741
  validate(directory: string): Promise<PackageCandidate>;
5630
7742
  /**
@@ -5632,19 +7744,97 @@ export interface Packages {
5632
7744
  * same checks as {@link Packages.validate} after unpacking. Check the
5633
7745
  * file's sha256 yourself before calling when you know it. A reserved id
5634
7746
  * fails with `reserved`, as in {@link Packages.validate}.
7747
+ *
7748
+ * @capability plugins.manage
7749
+ * @throws `invalid` (rejects) The path does not end in `.vxp`, or the
7750
+ * archive cannot be unpacked (over 64 MB, too many files, a damaged zip,
7751
+ * an unsafe path).
7752
+ * @throws `host` (rejects) The unpacked package or its manifest breaks the
7753
+ * rules; `message` says why.
7754
+ * @throws `javascript` (rejects) The entry module does not parse.
7755
+ * @throws `slint` (rejects) A Slint file does not compile.
7756
+ * @throws `reserved` (rejects) A reserved id.
7757
+ * @throws `versionMismatch` (rejects) The package's `minPlayerVersion` is
7758
+ * newer than the player.
7759
+ * @throws `disabled` (rejects) The calling plugin is stopping.
7760
+ * @throws `capability` (rejects) The manifest does not declare
7761
+ * `plugins.manage`.
7762
+ *
7763
+ * @example Install a downloaded package file
7764
+ * A `.vxp` the plugin downloaded is checked and installed. The player
7765
+ * unpacks and checks it like a directory; checking the file's sha256
7766
+ * first, when the store publishes one, is up to the plugin.
7767
+ * ```ts
7768
+ * declare const file: string;
7769
+ * // ---cut---
7770
+ * const candidate = await plugin.packages.validateArchive(file); // a .vxp path
7771
+ * const change = await candidate.install();
7772
+ * await plugin.osd.show("Install: {}", [await change.done]);
7773
+ * ```
7774
+ *
7775
+ * @since 1.1.0
5635
7776
  */
5636
7777
  validateArchive(file: string): Promise<PackageCandidate>;
5637
- /** Every plugin: running, installed, and this run's development loads. No capability. */
7778
+ /**
7779
+ * Every plugin: running, installed, and this run's development loads. No capability.
7780
+ *
7781
+ * @capability None.
7782
+ * @throws `Error` (rejects) Only when the player cannot be asked; no
7783
+ * capability is needed.
7784
+ *
7785
+ * @example Show which plugins are running
7786
+ * The plugin lists the running plugins by name. `list` asks the player
7787
+ * and resolves with every plugin it knows, with its state, origin and
7788
+ * display texts in the interface language.
7789
+ * ```ts
7790
+ * const plugins = await plugin.packages.list();
7791
+ * const running = plugins.filter((info) => info.state === "running");
7792
+ * console.log(running.map((info) => info.display?.name ?? info.id));
7793
+ * ```
7794
+ *
7795
+ * @since 1.0.0
7796
+ */
5638
7797
  list(): Promise<PluginInfo[]>;
5639
- /** The new list after any start, stop, failure, install or uninstall. No capability. */
7798
+ /**
7799
+ * The new list after any start, stop, failure, install or uninstall. No capability.
7800
+ *
7801
+ * @capability None.
7802
+ *
7803
+ * @example Refresh a plugin list after any change
7804
+ * A store window's list updates when a plugin starts, stops, fails, is
7805
+ * installed or uninstalled. The listener receives the new list each time.
7806
+ * ```ts
7807
+ * plugin.packages.onChange((plugins) => console.log(plugins.length, "plugins"));
7808
+ * ```
7809
+ *
7810
+ * @since 1.0.0
7811
+ */
5640
7812
  onChange(listener: (plugins: PluginInfo[]) => void): Disposable;
5641
7813
  /**
5642
7814
  * Stop or start a plugin (`plugins.manage`). An installed plugin stays
5643
7815
  * disabled across launches; a development load only for this run.
5644
7816
  * `invalid` to disable oneself, `missing` unknown.
5645
- * `busy` while it is being installed. `unsupported` to disable a built-in
7817
+ * `busy` while it is being installed or reloaded. `unsupported` to disable a built-in
5646
7818
  * ({@link PluginInfo.builtin}): users manage third-party plugins only
5647
7819
  * (players released with SDK 1.5.18 on; older ones disabled built-ins too).
7820
+ *
7821
+ * @capability plugins.manage
7822
+ * @throws `invalid` (rejects) Disabling the calling plugin itself.
7823
+ * @throws `missing` (rejects) An unknown id.
7824
+ * @throws `busy` (rejects) The plugin is being installed or reloaded.
7825
+ * @throws `unsupported` (rejects) A built-in plugin.
7826
+ * @throws `capability` (rejects) The manifest does not declare
7827
+ * `plugins.manage`.
7828
+ *
7829
+ * @example Turn a third-party plugin off
7830
+ * The plugin stops another plugin and keeps it disabled across launches
7831
+ * until it is enabled again. Built-in plugins cannot be disabled
7832
+ * (`unsupported`).
7833
+ * ```ts
7834
+ * await plugin.packages.setEnabled("someone.demo", false);
7835
+ * ```
7836
+ *
7837
+ * @since 1.0.0
5648
7838
  */
5649
7839
  setEnabled(id: string, enabled: boolean): Promise<void>;
5650
7840
  /**
@@ -5665,6 +7855,23 @@ export interface Packages {
5665
7855
  * @player 0.5.1; older players reject it as an unknown request
5666
7856
  * (`unsupported`).
5667
7857
  * @since 1.5.19
7858
+ *
7859
+ * @capability plugins.manage
7860
+ * @throws `invalid` (rejects) The calling plugin itself, or a disabled
7861
+ * plugin.
7862
+ * @throws `missing` (rejects) An unknown id.
7863
+ * @throws `busy` (rejects) The plugin is being installed or reloaded.
7864
+ * @throws `unsupported` (rejects) A player before 0.5.1.
7865
+ * @throws `capability` (rejects) The manifest does not declare
7866
+ * `plugins.manage`.
7867
+ *
7868
+ * @example Restart a plugin that misbehaves
7869
+ * The other plugin is stopped and started again from the same package and
7870
+ * data. `reload` resolves once the old process is stopped and the start is
7871
+ * scheduled; `onChange` then shows `starting` and `running` (or `failed`).
7872
+ * ```ts
7873
+ * await plugin.packages.reload("someone.demo");
7874
+ * ```
5668
7875
  */
5669
7876
  reload(id: string): Promise<void>;
5670
7877
  /**
@@ -5673,19 +7880,51 @@ export interface Packages {
5673
7880
  * reconnects to its data. Its user bindings stay (as orphans).
5674
7881
  * `unsupported` for built-ins and development loads, `missing`
5675
7882
  * unknown, `invalid` for oneself.
7883
+ *
7884
+ * @capability plugins.manage
7885
+ * @throws `unsupported` (rejects) A built-in plugin or a development load.
7886
+ * @throws `missing` (rejects) An unknown id.
7887
+ * @throws `invalid` (rejects) The calling plugin itself.
7888
+ * @throws `busy` (rejects) The plugin library is being cleaned up, or that
7889
+ * plugin is being installed or reloaded.
7890
+ * @throws `revisionConflict` (rejects) The installed revision of that
7891
+ * plugin changed meanwhile; call `uninstall` again.
7892
+ * @throws `capability` (rejects) The manifest does not declare
7893
+ * `plugins.manage`.
7894
+ *
7895
+ * @example Uninstall a plugin together with its data
7896
+ * The other plugin is removed and its data directory deleted, so a later
7897
+ * reinstall starts fresh. Without `removeData` a reinstall from the same
7898
+ * source finds its data again.
7899
+ * ```ts
7900
+ * const change = await plugin.packages.uninstall("someone.demo", { removeData: true });
7901
+ * await change.done;
7902
+ * ```
7903
+ *
7904
+ * @since 1.0.0
5676
7905
  */
5677
7906
  uninstall(id: string, options?: {
5678
7907
  removeData?: boolean;
5679
7908
  }): Promise<PackageChange>;
5680
7909
  }
5681
- /** A command: manifest id (`"host"` for the player's own) and command id. */
7910
+ /**
7911
+ * A command: manifest id (`"host"` for the player's own) and command id.
7912
+ *
7913
+ * @since 1.0.0
7914
+ * @category Commands
7915
+ */
5682
7916
  export interface BindingRef {
5683
7917
  /** Manifest id, or `"host"`. */
5684
7918
  plugin: string;
5685
7919
  /** Command id within that plugin. */
5686
7920
  command: string;
5687
7921
  }
5688
- /** One command's bindings, from {@link Bindings.list}. */
7922
+ /**
7923
+ * One command's bindings, from {@link Bindings.list}.
7924
+ *
7925
+ * @since 1.0.0
7926
+ * @category Commands
7927
+ */
5689
7928
  export interface BindingInfo extends BindingRef {
5690
7929
  /** Menu title. */
5691
7930
  title: string;
@@ -5735,9 +7974,40 @@ export interface BindingInfo extends BindingRef {
5735
7974
  * // later
5736
7975
  * await plugin.bindings.reset({ plugin: "host", command: "mute" });
5737
7976
  * ```
7977
+ *
7978
+ * @remarks
7979
+ * The player's binding table: every command's default keys (from
7980
+ * {@link CommandOptions.bindings}, the player's own as `host/<action>`)
7981
+ * and the user's overrides on top, saved across launches. A key-settings
7982
+ * plugin lists the table, writes overrides and resets them; the player
7983
+ * applies the change at once to every running plugin.
7984
+ *
7985
+ * @capability bindings.manage
7986
+ * @see [Commands, menus and key bindings](../../docs/concepts/commands.md)
7987
+ * @since 1.0.0
7988
+ * @category Commands
5738
7989
  */
5739
7990
  export interface Bindings {
5740
- /** Every declared command, and overrides whose command is gone. */
7991
+ /**
7992
+ * Every declared command, and overrides whose command is gone.
7993
+ *
7994
+ * @capability bindings.manage
7995
+ * @throws `capability` (rejects) The manifest does not declare
7996
+ * `bindings.manage`.
7997
+ *
7998
+ * @example Find the commands that use a key
7999
+ * A key-settings window shows which commands Ctrl+K is bound to now. `list`
8000
+ * resolves with every declared command and its current bindings (the
8001
+ * defaults with the user's overrides applied) and with the overrides
8002
+ * whose command is not running (`orphans`).
8003
+ * ```ts
8004
+ * const { commands } = await plugin.bindings.list();
8005
+ * const users = commands.filter((command) => command.current.includes("Ctrl+K"));
8006
+ * console.log(users.map((command) => `${command.plugin}/${command.command}`)); // "host" for the player
8007
+ * ```
8008
+ *
8009
+ * @since 1.0.0
8010
+ */
5741
8011
  list(): Promise<{
5742
8012
  /** Every declared command (running plugins and the player). */
5743
8013
  commands: BindingInfo[];
@@ -5754,11 +8024,65 @@ export interface Bindings {
5754
8024
  * `invalid` for a bad binding, `missing` for an unknown command that has
5755
8025
  * no override either.
5756
8026
  * At most 16 inputs, syntax as {@link CommandOptions.bindings}.
8027
+ *
8028
+ * @capability bindings.manage
8029
+ * @throws `invalid` (rejects) A malformed binding, or more than 16.
8030
+ * @throws `missing` (rejects) An unknown command that has no override
8031
+ * either.
8032
+ * @throws `capability` (rejects) The manifest does not declare
8033
+ * `bindings.manage`.
8034
+ *
8035
+ * @example Give the player's Mute command a second key
8036
+ * Mute now answers to M and to Ctrl+M. `set` replaces the command's
8037
+ * bindings with the list given (an override saved across launches), so it
8038
+ * repeats the default it keeps; `[]` would unbind it.
8039
+ * ```ts
8040
+ * await plugin.bindings.set({ plugin: "host", command: "mute" }, ["M", "Ctrl+M"]);
8041
+ * ```
8042
+ *
8043
+ * @since 1.0.0
5757
8044
  */
5758
8045
  set(ref: BindingRef, inputs: string[]): Promise<void>;
5759
- /** Back to the defaults: one command, or every override when omitted. */
8046
+ /**
8047
+ * Back to the defaults: one command, or every override when omitted.
8048
+ *
8049
+ * @capability bindings.manage
8050
+ * @throws `missing` (rejects) A command that has no override and is not
8051
+ * declared.
8052
+ * @throws `capability` (rejects) The manifest does not declare
8053
+ * `bindings.manage`.
8054
+ *
8055
+ * @example Restore every default key
8056
+ * A "Restore defaults" button removes all of the user's overrides, so
8057
+ * every command has its declared keys again. Without an argument `reset`
8058
+ * clears every override; with a reference only that command's.
8059
+ * ```ts
8060
+ * await plugin.bindings.reset(); // every override
8061
+ * ```
8062
+ *
8063
+ * @since 1.0.0
8064
+ */
5760
8065
  reset(ref?: BindingRef): Promise<void>;
5761
- /** Declarations or overrides changed (read `list()` again). At most every 200 ms. */
8066
+ /**
8067
+ * Declarations or overrides changed (read `list()` again). At most every 200 ms.
8068
+ *
8069
+ * @capability bindings.manage
8070
+ *
8071
+ * @example Refresh a key list when bindings change
8072
+ * The plugin's key list stays current when the user changes a key in the
8073
+ * player or another plugin starts. The listener carries no data and runs
8074
+ * at most every 200 ms; the plugin reads the table again with `list`.
8075
+ * ```ts
8076
+ * declare function render(commands: BindingInfo[]): void;
8077
+ * // ---cut---
8078
+ * plugin.bindings.onChange(async () => {
8079
+ * const { commands } = await plugin.bindings.list(); // read the new table
8080
+ * render(commands);
8081
+ * });
8082
+ * ```
8083
+ *
8084
+ * @since 1.0.0
8085
+ */
5762
8086
  onChange(listener: () => void): Disposable;
5763
8087
  }
5764
8088
  /**
@@ -5782,16 +8106,109 @@ export interface Bindings {
5782
8106
  * void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
5783
8107
  * }
5784
8108
  * ```
8109
+ *
8110
+ * @remarks
8111
+ * The player reads and writes the system clipboard on the plugin's behalf.
8112
+ * Text is plain text; images are written from a PNG or JPEG file.
8113
+ *
8114
+ * @capability clipboard
8115
+ * @since 1.0.0
8116
+ * @category Services
5785
8117
  */
5786
8118
  export interface Clipboard {
5787
- /** Plain text on the clipboard; `null` when it holds none. */
8119
+ /**
8120
+ * Plain text on the clipboard; `null` when it holds none.
8121
+ *
8122
+ * @capability clipboard
8123
+ * @throws `unavailable` (rejects) The player runs without a user interface.
8124
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8125
+ * @throws `failed` (rejects) The system refused (Windows).
8126
+ * @throws `capability` (rejects) The manifest does not declare
8127
+ * `clipboard`.
8128
+ *
8129
+ * @example Open a URL the user copied
8130
+ * A "Paste and play" command opens the address on the clipboard. `readText`
8131
+ * resolves with the clipboard's text, or `null` when it holds none (an
8132
+ * image, nothing).
8133
+ * ```ts
8134
+ * const text = await plugin.clipboard.readText();
8135
+ * if (text?.startsWith("https://")) await plugin.player.open(text.trim());
8136
+ * ```
8137
+ *
8138
+ * @since 1.0.0
8139
+ */
5788
8140
  readText(): Promise<string | null>;
5789
- /** Replace the clipboard with text (at most 1 MiB of UTF-8). */
8141
+ /**
8142
+ * Replace the clipboard with text (at most 1 MiB of UTF-8).
8143
+ *
8144
+ * @capability clipboard
8145
+ * @throws `invalid` (rejects) More than 1 MiB of UTF-8.
8146
+ * @throws `unavailable` (rejects) The player runs without a user interface.
8147
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8148
+ * @throws `failed` (rejects) The system refused.
8149
+ * @throws `capability` (rejects) The manifest does not declare
8150
+ * `clipboard`.
8151
+ *
8152
+ * @example Copy the playing file's path
8153
+ * The path of the playing file is put on the clipboard, replacing what was
8154
+ * there. It resolves once the system has it.
8155
+ * ```ts
8156
+ * const { source } = plugin.player.state;
8157
+ * if (source) await plugin.clipboard.writeText(source);
8158
+ * ```
8159
+ *
8160
+ * @since 1.0.0
8161
+ */
5790
8162
  writeText(text: string): Promise<void>;
5791
- /** Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB). */
8163
+ /**
8164
+ * Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB).
8165
+ *
8166
+ * @capability clipboard
8167
+ * @throws `missing` (rejects) The file does not exist.
8168
+ * @throws `invalid` (rejects) Not an absolute path, not PNG or JPEG, over
8169
+ * 16 MB, or (Windows) an image the system cannot decode.
8170
+ * @throws `unavailable` (rejects) The player runs without a user interface.
8171
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8172
+ * @throws `failed` (rejects) The system refused.
8173
+ * @throws `capability` (rejects) The manifest does not declare
8174
+ * `clipboard`.
8175
+ *
8176
+ * @example Copy a snapshot to the clipboard
8177
+ * A frame the plugin saved as PNG is put on the clipboard, ready to paste
8178
+ * into another application. The player reads the file and writes the
8179
+ * image (macOS: its original bytes; Windows: a bitmap plus PNG).
8180
+ * ```ts
8181
+ * declare const pngPath: string;
8182
+ * // ---cut---
8183
+ * await plugin.clipboard.writeImage(pngPath); // absolute path, at most 16 MB
8184
+ * await plugin.osd.show("Snapshot copied");
8185
+ * ```
8186
+ *
8187
+ * @since 1.0.0
8188
+ */
5792
8189
  writeImage(path: string): Promise<void>;
5793
8190
  }
5794
- /** Keeping the computer awake (`playback.control`). */
8191
+ /**
8192
+ * Keeping the computer awake (`playback.control`).
8193
+ *
8194
+ * @remarks
8195
+ * A request, not a command: the player keeps the system awake while any
8196
+ * plugin asks for it and a source plays, and withdraws a plugin's request
8197
+ * when it stops.
8198
+ *
8199
+ * @capability playback.control
8200
+ *
8201
+ * @example Keep the display on while playing
8202
+ * The screen does not dim or sleep while a video plays, and may again when
8203
+ * it is paused or ends. The player takes the system's assertion only while
8204
+ * a source is ready and not paused.
8205
+ * ```ts
8206
+ * await plugin.power.preventSleep("playing"); // "off" withdraws the request
8207
+ * ```
8208
+ *
8209
+ * @since 1.0.0
8210
+ * @category Services
8211
+ */
5795
8212
  export interface Power {
5796
8213
  /**
5797
8214
  * `"playing"`: while a source is ready and not paused, keep the display
@@ -5802,10 +8219,32 @@ export interface Power {
5802
8219
  * take the system assertion are logged by the player, not reported.
5803
8220
  * macOS uses `caffeinate`, Windows the thread execution state; elsewhere
5804
8221
  * it has no effect.
8222
+ *
8223
+ * @capability playback.control
8224
+ * @throws `invalid` (rejects) A mode other than `"off"` or `"playing"`.
8225
+ * @throws `capability` (rejects) The manifest does not declare
8226
+ * `playback.control`.
8227
+ *
8228
+ * @example Follow the plugin's own "Keep awake" setting
8229
+ * The plugin keeps the display awake during playback only while its
8230
+ * setting is on. Each call replaces this plugin's request; `"off"`
8231
+ * withdraws it, and other plugins' requests still count.
8232
+ * ```ts
8233
+ * const store = plugin.storage.json("power", { defaults: { keepAwake: true } });
8234
+ * await plugin.power.preventSleep(store.value.keepAwake ? "playing" : "off");
8235
+ * ```
8236
+ *
8237
+ * @since 1.0.0
5805
8238
  */
5806
8239
  preventSleep(mode: "off" | "playing"): Promise<void>;
5807
8240
  }
5808
- /** The output of {@link Tools.run}. */
8241
+ /**
8242
+ * The output of {@link Tools.run}.
8243
+ *
8244
+ * @see {@link Tools.run}
8245
+ * @since 1.0.0
8246
+ * @category Services
8247
+ */
5809
8248
  export interface ToolResult {
5810
8249
  /** Standard output (at most `maxOutput` bytes). */
5811
8250
  stdout: Uint8Array;
@@ -5835,11 +8274,23 @@ export interface ToolResult {
5835
8274
  * void info.format; // container, duration, bit rate…
5836
8275
  * }
5837
8276
  * ```
8277
+ *
8278
+ * @remarks
8279
+ * ffmpeg and ffprobe run as child processes of the plugin. When
8280
+ * `VESTRA_FFMPEG` / `VESTRA_FFPROBE` is set, only that path is used (an
8281
+ * invalid one means the tool is not found); otherwise the tool is searched
8282
+ * on `PATH`. A found path is kept for the plugin's run, a miss is looked up
8283
+ * again next time. Disposing the
8284
+ * scope ends a running process.
8285
+ *
8286
+ * @capability None.
8287
+ * @since 1.0.0
8288
+ * @category Services
5838
8289
  */
5839
8290
  export interface Tools {
5840
8291
  /**
5841
- * Run ffmpeg or ffprobe (found through `VESTRA_FFMPEG` / `VESTRA_FFPROBE`,
5842
- * then `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
8292
+ * Run ffmpeg or ffprobe (`VESTRA_FFMPEG` / `VESTRA_FFPROBE` when set, else
8293
+ * found on `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
5843
8294
  * `failed` (non-zero exit; `message` is the last stderr line), `invalid`
5844
8295
  * (output over `maxOutput`, default 16 MiB), `aborted` (`signal`). Timeout,
5845
8296
  * `signal` and disposing the scope end the process.
@@ -5847,28 +8298,171 @@ export interface Tools {
5847
8298
  * Arguments are passed as-is (no quoting needed). On Windows `PATHEXT` is
5848
8299
  * honoured. Ending sends SIGTERM, then SIGKILL after 300 ms (immediate on
5849
8300
  * Windows).
8301
+ *
8302
+ * @capability None.
8303
+ * @throws `unavailable` (rejects) The tool was not found.
8304
+ * @throws `timeout` (rejects) It ran longer than `timeoutMs`; the process is
8305
+ * ended.
8306
+ * @throws `failed` (rejects) A non-zero exit; `message` is the last stderr
8307
+ * line.
8308
+ * @throws `invalid` (rejects) Bad arguments or options, or output over
8309
+ * `maxOutput`.
8310
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed.
8311
+ *
8312
+ * @example Cut a clip without re-encoding
8313
+ * The plugin writes the first ten seconds of the file to a new file. `run`
8314
+ * starts ffmpeg without a shell (arguments pass as they are, no quoting)
8315
+ * and resolves with its output once it exits with 0; a failure rejects
8316
+ * with the last line ffmpeg printed.
8317
+ * ```ts
8318
+ * declare const input: string;
8319
+ * declare const output: string;
8320
+ * // ---cut---
8321
+ * await plugin.tools.run(
8322
+ * "ffmpeg",
8323
+ * ["-y", "-i", input, "-t", "10", "-c", "copy", output],
8324
+ * { timeoutMs: 60_000 }, // give up after a minute
8325
+ * );
8326
+ * ```
8327
+ *
8328
+ * @since 1.0.0
5850
8329
  */
5851
8330
  run(tool: "ffmpeg" | "ffprobe", args: string[], options: {
5852
8331
  timeoutMs: number;
5853
8332
  maxOutput?: number;
5854
8333
  signal?: AbortSignal;
5855
8334
  }): Promise<ToolResult>;
5856
- /** Whether the tool can be found. */
8335
+ /**
8336
+ * Whether the tool can be found.
8337
+ *
8338
+ * @capability None.
8339
+ * @throws Never; it resolves `false` when the tool cannot be found.
8340
+ *
8341
+ * @example Grey out a command when ffmpeg is missing
8342
+ * The Export item is disabled with a reason when ffmpeg is not installed.
8343
+ * `available` looks the tool up (a found path is kept, a miss is looked
8344
+ * up again on the next call, so installing it later is noticed).
8345
+ * ```ts
8346
+ * const hasFfmpeg = await plugin.tools.available("ffmpeg");
8347
+ * plugin.commands.add({
8348
+ * id: "export",
8349
+ * path: ["@video", "Export…"],
8350
+ * enabled: hasFfmpeg,
8351
+ * // Shown with the greyed-out item.
8352
+ * reason: hasFfmpeg ? null : "Install ffmpeg to export",
8353
+ * run() {},
8354
+ * });
8355
+ * ```
8356
+ *
8357
+ * @since 1.0.0
8358
+ */
5857
8359
  available(tool: "ffmpeg" | "ffprobe"): Promise<boolean>;
5858
8360
  }
5859
- /** A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced. */
8361
+ /**
8362
+ * A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
8363
+ *
8364
+ * @remarks
8365
+ * One JSON file, `<dataDirectory>/<name>.json`, made by
8366
+ * {@link Storage.json}. `value` is read once when the store is made;
8367
+ * `save` replaces it in memory at once and writes the file `debounceMs`
8368
+ * later (300 by default) through a temporary file and a rename, so a
8369
+ * crash never leaves half a file. Writes to one file run one at a time,
8370
+ * newest last. Disposing the store, its scope or stopping the plugin
8371
+ * writes a pending save first.
8372
+ *
8373
+ * @capability None.
8374
+ *
8375
+ * @example Count how often the plugin was started
8376
+ * The counter grows by one at every start and is kept in `stats.json`. The
8377
+ * store reads the file when it is made (the defaults when there is none),
8378
+ * and `save` writes the new count a moment later.
8379
+ * ```ts
8380
+ * const stats = plugin.storage.json("stats", { defaults: { starts: 0 } });
8381
+ * stats.save({ starts: stats.value.starts + 1 }); // written 300 ms later
8382
+ * ```
8383
+ *
8384
+ * @since 1.0.0
8385
+ * @category Services
8386
+ */
5860
8387
  export interface JsonStore<T> extends Disposable {
5861
8388
  /** The loaded value, or `defaults` when the file was missing or broken. */
5862
8389
  readonly value: T;
5863
8390
  /**
5864
8391
  * Replace the value now and write it after `debounceMs`.
5865
8392
  * Only `save` writes: mutating `value` in place is not saved.
8393
+ *
8394
+ * @capability None.
8395
+ *
8396
+ * @example Save a slider value without writing on every move
8397
+ * Dragging the plugin's volume slider updates the stored value at once but
8398
+ * writes the file only once the user stops for 300 ms. Each `save`
8399
+ * replaces the value and restarts the debounce timer.
8400
+ * ```ts
8401
+ * declare const window: Window;
8402
+ * // ---cut---
8403
+ * const store = plugin.storage.json("prefs", { defaults: { volume: 100 } });
8404
+ * window.on("volumeChanged", (volume: number) => store.save({ ...store.value, volume }));
8405
+ * ```
8406
+ *
8407
+ * @since 1.0.0
5866
8408
  */
5867
8409
  save(next: T): void;
5868
- /** Write a pending save now. */
8410
+ /**
8411
+ * Write a pending save now.
8412
+ *
8413
+ * @capability None.
8414
+ * @throws `Error` (rejects) Node's file system error when the file cannot
8415
+ * be written; its `code` is Node's (`ENOSPC`, `EACCES` and so on), not a
8416
+ * player error code.
8417
+ *
8418
+ * @example Write the file now before copying it
8419
+ * Before exporting its settings, the plugin makes sure the file on disk has
8420
+ * the latest value. `flush` writes a pending save at once (or waits for
8421
+ * the write in progress) and resolves when the file is written.
8422
+ * ```ts
8423
+ * import { copyFile } from "node:fs/promises";
8424
+ * import { join } from "node:path";
8425
+ * declare const exportPath: string;
8426
+ * // ---cut---
8427
+ * const store = plugin.storage.json("settings", { defaults: { step: 5 } });
8428
+ * await store.flush(); // the file now has the newest value
8429
+ * await copyFile(join(plugin.dataDirectory, "settings.json"), exportPath);
8430
+ * ```
8431
+ *
8432
+ * @since 1.0.0
8433
+ */
5869
8434
  flush(): Promise<void>;
5870
8435
  }
5871
- /** `plugin.storage`: JSON settings files. No capability. */
8436
+ /**
8437
+ * `plugin.storage`: JSON settings files. No capability.
8438
+ *
8439
+ * @remarks
8440
+ * Two kinds of storage, both private to the plugin and needing no
8441
+ * capability. {@link Storage.json} keeps settings in JSON files in the
8442
+ * plugin's data directory: they last across player restarts and upgrades,
8443
+ * and are written by the plugin's own process. {@link Storage.session}
8444
+ * keeps values in the player's memory for one player run: they survive a
8445
+ * restart of the plugin's process but not of the player. Secrets belong in
8446
+ * {@link Secrets}, never in either.
8447
+ *
8448
+ * @capability None.
8449
+ *
8450
+ * @example Keep a setting across restarts and a queue for this run
8451
+ * The plugin's step size survives a restart of the player; its work queue
8452
+ * survives a reload of the plugin but starts empty with the next player
8453
+ * run. The JSON store reads its file at once and writes it shortly after
8454
+ * each `save`; session values live in the player process.
8455
+ * ```ts
8456
+ * const settings = plugin.storage.json("settings", { defaults: { step: 5 } });
8457
+ * const queue = (await plugin.storage.session.get<string[]>("queue")) ?? [];
8458
+ * void settings.value.step; // read synchronously, from the file or the defaults
8459
+ * void queue;
8460
+ * ```
8461
+ *
8462
+ * @see [Store a plugin's data](../../docs/how-to/store-data.md), [Remember a window's state between runs](../../docs/how-to/window-state.md)
8463
+ * @since 1.0.0
8464
+ * @category Services
8465
+ */
5872
8466
  export interface Storage {
5873
8467
  /**
5874
8468
  * Open `<name>.json` (`name`: 1–64 of `a-z 0-9 -`). A file that cannot be
@@ -5897,6 +8491,11 @@ export interface Storage {
5897
8491
  * // Only `save` writes: changing `store.value` in place is not saved.
5898
8492
  * store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
5899
8493
  * ```
8494
+ *
8495
+ * @capability None.
8496
+ * @throws `invalid` (throws) A bad `name`, or a `debounceMs` that is not a
8497
+ * non-negative number.
8498
+ * @since 1.0.0
5900
8499
  */
5901
8500
  json<T>(name: string, options: {
5902
8501
  defaults: T;
@@ -5970,19 +8569,97 @@ export interface Storage {
5970
8569
  * ```
5971
8570
  *
5972
8571
  * @since 1.5.25
8572
+ *
8573
+ * @remarks
8574
+ * The player keeps the values in its own memory, per plugin, until it
8575
+ * quits; the plugin's process may restart in between. Each call is one
8576
+ * request to the player, answered in order, with no debounce.
8577
+ *
8578
+ * @capability None.
8579
+ * @category Services
5973
8580
  */
5974
8581
  export interface SessionStorage {
5975
- /** The value stored under `key`, or `undefined` when there is none. */
8582
+ /**
8583
+ * The value stored under `key`, or `undefined` when there is none.
8584
+ *
8585
+ * @capability None.
8586
+ * @throws `invalid` (rejects) A bad key (empty, over 1024 bytes, with NUL
8587
+ * or a lone surrogate).
8588
+ * @throws `disabled` (rejects) The plugin is stopping.
8589
+ * @throws `unsupported` (rejects) A player before 0.5.5.
8590
+ *
8591
+ * @example Pick up where the plugin stopped after a reload
8592
+ * After a reload the plugin resumes the item it was working on. `get`
8593
+ * asks the player and resolves with the value parsed from JSON, or
8594
+ * `undefined` when nothing is stored; the value is not checked, so its
8595
+ * shape is checked here.
8596
+ * ```ts
8597
+ * const current = await plugin.storage.session.get<{ url: string }>("current");
8598
+ * if (typeof current?.url === "string") await plugin.osd.show("Resuming {}", [current.url]);
8599
+ * ```
8600
+ *
8601
+ * @since 1.5.25
8602
+ */
5976
8603
  get<T = unknown>(key: string): Promise<T | undefined>;
5977
8604
  /**
5978
8605
  * Store or replace `value` under `key`. `invalid` for a bad key or a value
5979
8606
  * that is not JSON, `limit` when the plugin's values would be over 1 MiB
5980
8607
  * or the request over the 1 MiB message limit.
8608
+ *
8609
+ * @capability None.
8610
+ * @throws `invalid` (rejects) A bad key, or a value JSON cannot hold (or a
8611
+ * string with a lone surrogate).
8612
+ * @throws `disabled` (rejects) The plugin is stopping.
8613
+ * @throws `unsupported` (rejects) A player before 0.5.5.
8614
+ * @throws `limit` (rejects) The plugin's values would pass 1 MiB, or the
8615
+ * request the 1 MiB message limit; nothing changes.
8616
+ *
8617
+ * @example Remember the item being worked on
8618
+ * The plugin records the URL it is processing, so a reload can continue
8619
+ * with it. The value is stored as `JSON.stringify` writes it, and is kept
8620
+ * once the promise resolves.
8621
+ * ```ts
8622
+ * await plugin.storage.session.set("current", { url: "https://example.com/a.mkv" });
8623
+ * ```
8624
+ *
8625
+ * @since 1.5.25
5981
8626
  */
5982
8627
  set(key: string, value: unknown): Promise<void>;
5983
- /** Remove a key; removing a missing one is fine. */
8628
+ /**
8629
+ * Remove a key; removing a missing one is fine.
8630
+ *
8631
+ * @capability None.
8632
+ * @throws `invalid` (rejects) A bad key.
8633
+ * @throws `disabled` (rejects) The plugin is stopping.
8634
+ * @throws `unsupported` (rejects) A player before 0.5.5.
8635
+ *
8636
+ * @example Forget the item once it is done
8637
+ * When the work finishes, the plugin removes the entry so that a later
8638
+ * reload does not resume it. Removing a key that is not there is fine.
8639
+ * ```ts
8640
+ * await plugin.storage.session.delete("current");
8641
+ * ```
8642
+ *
8643
+ * @since 1.5.25
8644
+ */
5984
8645
  delete(key: string): Promise<void>;
5985
- /** The keys stored, in code point order. */
8646
+ /**
8647
+ * The keys stored, in code point order.
8648
+ *
8649
+ * @capability None.
8650
+ * @throws `disabled` (rejects) The plugin is stopping.
8651
+ * @throws `unsupported` (rejects) A player before 0.5.5.
8652
+ *
8653
+ * @example Clear everything the plugin stored for this run
8654
+ * The plugin starts its session state over. `keys` lists the plugin's own
8655
+ * keys in code point order; each `delete` is a request of its own.
8656
+ * ```ts
8657
+ * const session = plugin.storage.session;
8658
+ * for (const key of await session.keys()) await session.delete(key);
8659
+ * ```
8660
+ *
8661
+ * @since 1.5.25
8662
+ */
5986
8663
  keys(): Promise<string[]>;
5987
8664
  }
5988
8665
  /**
@@ -6005,6 +8682,15 @@ export interface SessionStorage {
6005
8682
  * await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
6006
8683
  * }
6007
8684
  * ```
8685
+ *
8686
+ * @remarks
8687
+ * The player opens files, folders and links with the system's own
8688
+ * applications for the plugin, so the plugin never runs `open`,
8689
+ * `explorer` or a shell itself.
8690
+ *
8691
+ * @capability None.
8692
+ * @since 1.0.0
8693
+ * @category Services
6008
8694
  */
6009
8695
  export interface System {
6010
8696
  /**
@@ -6012,13 +8698,66 @@ export interface System {
6012
8698
  * for all, so several files of one folder are all highlighted; Windows:
6013
8699
  * one Explorer window per folder, selecting its first path). Paths that do
6014
8700
  * not exist are skipped; `missing` when none exists; at most 256.
8701
+ *
8702
+ * @capability None.
8703
+ * @throws `missing` (rejects) None of the paths exists.
8704
+ * @throws `invalid` (rejects) No paths, or more than 256.
8705
+ * @throws `failed` (rejects) The system refused.
8706
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
8707
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8708
+ *
8709
+ * @example Show the exported files in the file manager
8710
+ * After an export, the file manager opens with the new files selected.
8711
+ * Paths that no longer exist are skipped; the call rejects with `missing`
8712
+ * only when none of them exists.
8713
+ * ```ts
8714
+ * declare const exported: string[];
8715
+ * // ---cut---
8716
+ * await plugin.system.reveal(exported); // macOS: one Finder window for all
8717
+ * ```
8718
+ *
8719
+ * @since 1.0.0
6015
8720
  */
6016
8721
  reveal(paths: string | string[]): Promise<void>;
6017
- /** Open the file or folder with its default application. `missing` if absent. */
8722
+ /**
8723
+ * Open the file or folder with its default application. `missing` if absent.
8724
+ *
8725
+ * @capability None.
8726
+ * @throws `missing` (rejects) The path does not exist.
8727
+ * @throws `invalid` (rejects) An empty path.
8728
+ * @throws `failed` (rejects) The system refused.
8729
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
8730
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8731
+ *
8732
+ * @example Open the plugin's data folder
8733
+ * A "Show data folder" command opens the plugin's data directory in the
8734
+ * file manager, which is the default application for a folder.
8735
+ * ```ts
8736
+ * await plugin.system.openPath(plugin.dataDirectory);
8737
+ * ```
8738
+ *
8739
+ * @since 1.0.0
8740
+ */
6018
8741
  openPath(path: string): Promise<void>;
6019
8742
  /**
6020
8743
  * Open an http(s) address in the default browser. `invalid` for another
6021
8744
  * protocol or a malformed address; `timeout` after 10 s, `failed` otherwise.
8745
+ *
8746
+ * @capability None.
8747
+ * @throws `invalid` (rejects) Not an `http:` or `https:` address.
8748
+ * @throws `timeout` (rejects) macOS did not hand it to the browser within
8749
+ * 10 s.
8750
+ * @throws `failed` (rejects) The system refused.
8751
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
8752
+ *
8753
+ * @example Open the plugin's help page
8754
+ * A Help command opens the plugin's documentation in the default browser.
8755
+ * Only `http:` and `https:` addresses are opened.
8756
+ * ```ts
8757
+ * await plugin.system.openUrl("https://example.com/plugin/help");
8758
+ * ```
8759
+ *
8760
+ * @since 1.0.0
6022
8761
  */
6023
8762
  openUrl(url: string): Promise<void>;
6024
8763
  }
@@ -6026,7 +8765,8 @@ export interface System {
6026
8765
  * Per-plugin secrets (API keys, passwords) in the system's secret store:
6027
8766
  * the login keychain on macOS, the Credential Manager on Windows. Needs
6028
8767
  * `secrets`. `unsupported` on systems without a store, `unavailable` when
6029
- * the store refuses access: keep a fallback (or ask again).
8768
+ * the store cannot be reached (no keychain, a read-only store, no logged-in
8769
+ * session on Windows): keep a fallback (or ask again).
6030
8770
  *
6031
8771
  * The user denying access and other system errors reject `failed`. Never
6032
8772
  * write secrets to settings files.
@@ -6052,16 +8792,96 @@ export interface System {
6052
8792
  * await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
6053
8793
  * }
6054
8794
  * ```
8795
+ *
8796
+ * @remarks
8797
+ * Values are stored by the player in the system's secret store under this
8798
+ * plugin's id: other plugins cannot read them. Names are 1–64 of
8799
+ * `a-z 0-9 . _ -`, values 1–1024 bytes of text, at most 256 names per
8800
+ * plugin (`SECRET_LIMITS`).
8801
+ *
8802
+ * @capability secrets
8803
+ * @since 1.0.0
8804
+ * @category Services
6055
8805
  */
6056
8806
  export interface Secrets {
6057
- /** The stored value, `null` when there is none. */
8807
+ /**
8808
+ * The stored value, `null` when there is none.
8809
+ *
8810
+ * @capability secrets
8811
+ * @throws `unsupported` (rejects) The system has no secret store.
8812
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
8813
+ * a read-only store, no logged-in session on Windows).
8814
+ * @throws `failed` (rejects) The user denied access, or another system
8815
+ * error.
8816
+ * @throws `capability` (rejects) The manifest does not declare
8817
+ * `secrets`.
8818
+ * @throws `invalid` (rejects) A bad name.
8819
+ *
8820
+ * @example Read a stored token
8821
+ * The plugin uses the token the user saved earlier, or asks for one when
8822
+ * there is none. `get` resolves with the stored text or `null`.
8823
+ * ```ts
8824
+ * const token = await plugin.secrets.get("api-token");
8825
+ * if (token === null) await plugin.osd.show("Sign in first");
8826
+ * ```
8827
+ *
8828
+ * @since 1.0.0
8829
+ */
6058
8830
  get(name: string): Promise<string | null>;
6059
8831
  /**
6060
8832
  * Store or replace (`name`: 1–64 of `a-z 0-9 . _ -`; value 1–1024 bytes).
6061
8833
  * At most 256 different names per plugin: a new name beyond that rejects
6062
8834
  * `limit`. Uninstalling with `removeData` deletes them.
8835
+ *
8836
+ * @capability secrets
8837
+ * @throws `invalid` (rejects) A bad name, or a value that is empty, has NUL
8838
+ * or is over 1024 bytes.
8839
+ * @throws `limit` (rejects) A new name beyond 256.
8840
+ * @throws `unsupported` (rejects) The system has no secret store.
8841
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
8842
+ * a read-only store, no logged-in session on Windows).
8843
+ * @throws `failed` (rejects) The user denied access, or another system
8844
+ * error.
8845
+ * @throws `capability` (rejects) The manifest does not declare
8846
+ * `secrets`.
8847
+ *
8848
+ * @example Save the token the user entered
8849
+ * The token the user typed is kept in the system's secret store for later
8850
+ * runs. `set` stores or replaces it; the store can refuse, in which case
8851
+ * the plugin keeps the token for this run only.
8852
+ * ```ts
8853
+ * declare const token: string;
8854
+ * // ---cut---
8855
+ * await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
8856
+ * // No store, or access denied: the token is not kept for next time.
8857
+ * void plugin.osd.show("The token could not be saved ({})", [error.code]);
8858
+ * });
8859
+ * ```
8860
+ *
8861
+ * @since 1.0.0
6063
8862
  */
6064
8863
  set(name: string, value: string): Promise<void>;
6065
- /** Remove; removing a missing secret is fine. */
8864
+ /**
8865
+ * Remove; removing a missing secret is fine.
8866
+ *
8867
+ * @capability secrets
8868
+ * @throws `invalid` (rejects) A bad name.
8869
+ * @throws `unsupported` (rejects) The system has no secret store.
8870
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
8871
+ * a read-only store, no logged-in session on Windows).
8872
+ * @throws `failed` (rejects) The user denied access, or another system
8873
+ * error.
8874
+ * @throws `capability` (rejects) The manifest does not declare
8875
+ * `secrets`.
8876
+ *
8877
+ * @example Sign out by removing the token
8878
+ * Signing out removes the stored token, so the next start asks again.
8879
+ * Removing a name that is not stored is fine.
8880
+ * ```ts
8881
+ * await plugin.secrets.delete("api-token");
8882
+ * ```
8883
+ *
8884
+ * @since 1.0.0
8885
+ */
6066
8886
  delete(name: string): Promise<void>;
6067
8887
  }