@vestra-player/sdk 1.5.29 → 1.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,11 @@ export type { Json, AudioNode, VideoNode, SubtitleStyle, ResolvedMedia };
33
33
  * unsubscribed. A {@link Media} is the exception: disposing its scope does
34
34
  * **not** close the media; call {@link Media.close} for that.
35
35
  *
36
- * @example Releasing a listener early
36
+ * @example Stop listening once the file is ready
37
+ * The listener reacts to the first time the source becomes ready and then
38
+ * stops being called. Every registration returns a `Disposable`;
39
+ * disposing it removes just that listener, before the scope that owns it
40
+ * ends.
37
41
  * ```ts
38
42
  * const subscription = plugin.player.onChange((state) => {
39
43
  * if (state.phase === "ready") void subscription.dispose();
@@ -100,7 +104,11 @@ export interface Disposable {
100
104
  * the plugin process. Requests have no client-side timeout: the player
101
105
  * answers every request or stops the plugin.
102
106
  *
103
- * @example
107
+ * @example Ignore a seek that lost a race, rethrow anything else
108
+ * The plugin seeks to 0:30 and stays quiet when another file was opened in
109
+ * between. Every failure is an `Error` with a stable `code`; `stale` means
110
+ * the source changed since the state the call was based on and nothing
111
+ * was done, so it is safe to drop. Other codes are real failures.
104
112
  * ```ts
105
113
  * try {
106
114
  * await plugin.player.seek(30);
@@ -129,7 +137,11 @@ export type PluginError = Error & {
129
137
  * `.theme`, {@link Scope.ui}, {@link Scope.tools}, {@link Scope.storage} and
130
138
  * {@link Scope.system} needs none.
131
139
  *
132
- * @example Rebuilding a dynamic submenu with a child scope
140
+ * @example Rebuild a dynamic submenu with a child scope
141
+ * Subtitles ▸ Track lists the current file's subtitle tracks and is
142
+ * rebuilt whenever they change. Commands added through a child scope
143
+ * belong to it; disposing the scope removes all of them at once, and a
144
+ * new child scope collects the next set.
133
145
  * ```ts
134
146
  * let tracks = plugin.scope();
135
147
  * plugin.subtitles.onChange(async (state) => {
@@ -263,9 +275,12 @@ export interface Scope extends Disposable {
263
275
  * @returns A handle that cancels the registration early (without
264
276
  * running `cleanup`).
265
277
  *
266
- * @example
278
+ * @example Stop a timer when the plugin stops
279
+ * A once-a-second refresh runs as long as the plugin does. `onDispose`
280
+ * runs the cleanup when the scope is disposed, which for `plugin` is
281
+ * when the player stops the plugin, so the timer never outlives it.
267
282
  * ```ts
268
- * const timer = setInterval(() => plugin.update(), 1000);
283
+ * const timer = setInterval(() => plugin.update(), 1000); // re-evaluate bindings
269
284
  * plugin.onDispose(() => clearInterval(timer));
270
285
  * ```
271
286
  */
@@ -279,10 +294,14 @@ export interface Scope extends Disposable {
279
294
  * registrations made after activation (a command whose `ready` rejects,
280
295
  * a property write the player refused).
281
296
  *
282
- * @example
297
+ * @example Show errors from handlers on the OSD
298
+ * When a menu command, a window callback or a listener of the plugin
299
+ * fails, the user sees why. The SDK catches what those handlers throw
300
+ * (and their rejected promises) and passes it here instead of only
301
+ * logging it; the message is data in a translated template.
283
302
  * ```ts
284
303
  * plugin.onError((error) => {
285
- * void plugin.osd.show(`Bookmarks: ${error.message}`);
304
+ * void plugin.osd.show("Bookmarks: {}", [error.message]);
286
305
  * });
287
306
  * ```
288
307
  */
@@ -315,7 +334,11 @@ export interface Scope extends Disposable {
315
334
  * process that has not exited after 300 ms is killed. Media the plugin
316
335
  * opened with {@link Player.open} keeps playing.
317
336
  *
318
- * @example A minimal plugin: main.ts, with capabilities commands and ui.osd
337
+ * @example Write a minimal plugin with one menu item
338
+ * Tools ▸ Say hello, or Ctrl+H, shows "Hello from" and the plugin's id.
339
+ * The player imports `main.ts`, calls its default export with the
340
+ * `Plugin` object, and keeps the registrations made during activation;
341
+ * the manifest declares the capabilities `commands` and `ui.osd`.
319
342
  * ```ts
320
343
  * import type { Plugin } from "@vestra-player/sdk";
321
344
  *
@@ -323,8 +346,8 @@ export interface Scope extends Disposable {
323
346
  * plugin.commands.add({
324
347
  * id: "hello",
325
348
  * path: ["Tools", "Say hello"],
326
- * bindings: ["Ctrl+H"],
327
- * run: () => plugin.osd.show(`Hello from ${plugin.id}`),
349
+ * bindings: ["Ctrl+H"], // a default the user can change
350
+ * run: () => plugin.osd.show("Hello from {}", [plugin.id]),
328
351
  * });
329
352
  * }
330
353
  * ```
@@ -349,7 +372,13 @@ export interface Plugin extends Scope {
349
372
  */
350
373
  readonly dataDirectory: string;
351
374
  }
352
- /** The state of a command's menu item, as the player shows it. */
375
+ /**
376
+ * The state of a command's menu item, as the player shows it.
377
+ *
378
+ * @see {@link Command.set}, {@link CommandOptions}
379
+ * @since 1.0.0
380
+ * @category Commands
381
+ */
353
382
  export interface CommandState {
354
383
  /** False greys the item out; the player then never calls `run` (bound keys are swallowed). Default true. */
355
384
  enabled: boolean;
@@ -367,7 +396,11 @@ export interface CommandState {
367
396
  * A value, or a function the SDK re-evaluates on every update pass (see
368
397
  * {@link Scope.update}); only changes are sent to the player.
369
398
  *
370
- * @example
399
+ * @example Mix a computed state with a fixed one
400
+ * The Loop item carries a check mark while a loop is set and is always
401
+ * enabled. A function is evaluated again in every update pass (after each
402
+ * handler and player state change) and only changes are sent; a plain
403
+ * value never changes.
371
404
  * ```ts
372
405
  * plugin.commands.add({
373
406
  * id: "loop",
@@ -377,6 +410,9 @@ export interface CommandState {
377
410
  * run: () => plugin.player.setLoop(null),
378
411
  * });
379
412
  * ```
413
+ *
414
+ * @since 1.0.0
415
+ * @category Commands
380
416
  */
381
417
  export type Bindable<T> = T | (() => T);
382
418
  /**
@@ -384,6 +420,9 @@ export type Bindable<T> = T | (() => T);
384
420
  * `@<id>` or by its English key (see {@link CommandOptions.path}): Open,
385
421
  * Playback, Subtitles, Video, Audio, Screen, Settings. The list is
386
422
  * `MENU_IDS` in `@vestra-player/sdk/testing`.
423
+ *
424
+ * @since 1.3.0
425
+ * @category Commands
387
426
  */
388
427
  export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings";
389
428
  /**
@@ -392,6 +431,8 @@ export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "sc
392
431
  * `@vestra-player/sdk/testing`.
393
432
  *
394
433
  * @since 1.5.29
434
+ *
435
+ * @category Commands
395
436
  */
396
437
  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";
397
438
  /**
@@ -424,12 +465,17 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
424
465
  * with its command: disposing the command, or disabling, stopping or
425
466
  * uninstalling the plugin, removes it.
426
467
  *
427
- * @example
468
+ * @example Give a command a button in the bottom bar
469
+ * A Store icon appears in the bottom bar's button strip; a click opens
470
+ * the store window, exactly like the menu item. The player draws the
471
+ * button from the command's title, keys and state, and places it by
472
+ * `order` (200 is where the player's own Plugin Store button sits); a
473
+ * bar that does not place `BarButtons` shows only the menu item.
428
474
  * ```ts
429
475
  * const store = plugin.windows.create("ui/store.slint");
430
476
  * plugin.commands.add({
431
477
  * id: "open",
432
- * path: ["Plugin Store…"],
478
+ * path: ["Plugin Store…"], // a button needs a menu path
433
479
  * button: { icon: "store", order: 200 },
434
480
  * run: () => store.show(),
435
481
  * });
@@ -438,6 +484,8 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
438
484
  * @player 0.5.10; older players show the menu entry without a button
439
485
  * (declare `"minPlayerVersion": "0.5.10"` when the button matters).
440
486
  * @since 1.5.29
487
+ *
488
+ * @category Commands
441
489
  */
442
490
  export interface CommandButton {
443
491
  /**
@@ -486,6 +534,10 @@ export interface CommandButton {
486
534
  * {@link Commands.add} throws `invalid` synchronously for a bad path,
487
535
  * order, group or binding. The same path registered twice, or a path that
488
536
  * is both an item and a submenu, fails with `conflict` (through `ready`).
537
+ *
538
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
539
+ * @since 1.0.0
540
+ * @category Commands
489
541
  */
490
542
  export interface CommandOptions {
491
543
  /** Local id, unique within the plugin (the global id is `<manifest id>/<id>`). */
@@ -515,10 +567,19 @@ export interface CommandOptions {
515
567
  * ({})", [n, codec])`. The menu shows it translated and filled in; it
516
568
  * merges by its English rendering ("Track 2 (opus)").
517
569
  *
518
- * @example
570
+ * @example Put items into built-in menus, one with a track number
571
+ * "Blur" appears in the Video menu and "Track 2 (opus)" under Audio ▸
572
+ * Audio Track, both in the user's language. `@video` and `@audio` name
573
+ * the built-in menus by id; "Audio Track" is a submenu key that merges
574
+ * with any other plugin's same key; the `text()` segment is translated
575
+ * with the plugin's catalog and then filled in with the number and codec.
519
576
  * ```ts
577
+ * declare function toggle(): void;
578
+ * declare function select(): void;
579
+ * // ---cut---
520
580
  * plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run: toggle });
521
581
  * const { text } = plugin.i18n;
582
+ * // A segment with data: translated as "Track {} ({})", then filled in.
522
583
  * plugin.commands.add({
523
584
  * id: "track-2",
524
585
  * path: ["@audio", "Audio Track", text("Track {} ({})", [2, "opus"])],
@@ -603,6 +664,38 @@ export interface CommandOptions {
603
664
  /**
604
665
  * A registered command, from {@link Commands.add}. Disposing it removes it
605
666
  * from the menu and the binding table; the id can be added again right away.
667
+ *
668
+ * @remarks
669
+ * The handle of one menu item and its keys. Its state (`enabled`, `checked`,
670
+ * `busy`, `reason`) is bound: functions given to {@link Commands.add} or
671
+ * {@link Command.set} are evaluated again in every update pass and only
672
+ * changes reach the player. Its title, path, bindings and button cannot
673
+ * change in place; dispose the command (or a child {@link Scope}) and add it
674
+ * again. Disposing it, its scope or stopping the plugin removes the menu
675
+ * item, its keys and its bar button.
676
+ *
677
+ * @capability commands
678
+ *
679
+ * @example Enable an item only while a file is open
680
+ * Video ▸ Export… is greyed out until a file is open. `set` replaces the
681
+ * `enabled` field with a function; the SDK evaluates it again after every
682
+ * player state change and sends the new state when it changes, so the
683
+ * item follows the player without further code.
684
+ * ```ts
685
+ * declare function runExport(): Promise<void>;
686
+ * // ---cut---
687
+ * const exportCommand = plugin.commands.add({
688
+ * id: "export",
689
+ * path: ["@video", "Export…"],
690
+ * run: runExport,
691
+ * });
692
+ * // A function: re-evaluated in every update pass.
693
+ * exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
694
+ * ```
695
+ *
696
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
697
+ * @since 1.0.0
698
+ * @category Commands
606
699
  */
607
700
  export interface Command extends Disposable {
608
701
  /** The local id given in {@link CommandOptions.id}. */
@@ -610,10 +703,30 @@ export interface Command extends Disposable {
610
703
  /**
611
704
  * Replace some state fields (values or functions). Last write wins.
612
705
  *
613
- * @example
706
+ * @example Show a command as busy while it works
707
+ * The Export item shows as busy with "Exporting…" as its reason while the
708
+ * export runs, then returns to normal. `set` replaces only the fields it
709
+ * names and the SDK sends the new state to the player; a busy command's
710
+ * menu item and key do not run it again until it is set back.
614
711
  * ```ts
615
- * exportCommand.set({ busy: true, reason: "Exporting…" });
712
+ * declare function runExport(): Promise<void>;
713
+ * // ---cut---
714
+ * const exportCommand = plugin.commands.add({
715
+ * id: "export",
716
+ * path: ["@video", "Export…"],
717
+ * run: async () => {
718
+ * exportCommand.set({ busy: true, reason: "Exporting…" });
719
+ * try {
720
+ * await runExport();
721
+ * } finally {
722
+ * exportCommand.set({ busy: false, reason: null }); // also on failure
723
+ * }
724
+ * },
725
+ * });
616
726
  * ```
727
+ *
728
+ * @capability commands
729
+ * @since 1.0.0
617
730
  */
618
731
  set(state: Partial<{
619
732
  [K in keyof CommandState]: Bindable<CommandState[K]>;
@@ -624,10 +737,49 @@ export interface Command extends Disposable {
624
737
  * commands in this plugin, or 1024 in the whole player). You rarely need
625
738
  * to await it: during activation a failure fails activation, later it
626
739
  * goes to {@link Scope.onError}.
740
+ *
741
+ * @throws `conflict` (rejects) Another command already has this menu path,
742
+ * or the path would be both a menu item and a submenu.
743
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
744
+ * @throws `invalid` (rejects) The player refused the declaration.
745
+ * @throws `limit` (rejects) More than 160 commands in this plugin, or 1024 in
746
+ * the player.
627
747
  */
628
748
  readonly ready: Promise<void>;
629
749
  }
630
- /** `plugin.commands`: menu commands with default bindings. Needs `commands`. */
750
+ /**
751
+ * `plugin.commands`: menu commands with default bindings. Needs `commands`.
752
+ *
753
+ * @remarks
754
+ * A command is one action of the plugin with a menu item (`path`), default
755
+ * keys (`bindings`), optionally a bottom-bar button (`button`) and a state
756
+ * the menu shows (`enabled`, `checked`, `busy`, `reason`). The player owns
757
+ * the menu, the binding table and the user's key overrides: it calls `run`
758
+ * when the user picks the item, presses a key or clicks the button.
759
+ * Commands of all plugins merge into the player's menus by their English
760
+ * path keys. Registration is asynchronous: `add` returns at once, and
761
+ * {@link Command.ready} reports the player's answer (during activation a
762
+ * refusal fails activation).
763
+ *
764
+ * @capability commands
765
+ *
766
+ * @example Add a menu item with a default key
767
+ * Tools ▸ Say hello, or Ctrl+H, shows a greeting. `add` declares the item;
768
+ * the player calls `run` when the user picks it or presses its key, and the
769
+ * user can change the key in the player's key settings.
770
+ * ```ts
771
+ * plugin.commands.add({
772
+ * id: "hello",
773
+ * path: ["Tools", "Say hello"],
774
+ * bindings: ["Ctrl+H"], // a default; the user may override it
775
+ * run: () => plugin.osd.show("Hello"),
776
+ * });
777
+ * ```
778
+ *
779
+ * @see {@link Command}, {@link CommandOptions}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
780
+ * @since 1.0.0
781
+ * @category Commands
782
+ */
631
783
  export interface Commands {
632
784
  /**
633
785
  * Declare a command. It is registered and kept in sync; `dispose()` removes
@@ -640,18 +792,26 @@ export interface Commands {
640
792
  * unknown `@` menu id), order, group or binding; `conflict` for an id this
641
793
  * plugin has already declared and not disposed.
642
794
  *
643
- * @example
795
+ * @example Pause playback from a menu item
796
+ * Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
797
+ * while paused and is greyed out when nothing can be paused. The player
798
+ * calls `run` when the user picks the item or presses its key; `enabled`
799
+ * and `checked` are functions the SDK evaluates again after every
800
+ * handler and player state change, sending only what changed.
644
801
  * ```ts
645
802
  * const toggle = plugin.commands.add({
646
803
  * id: "toggle-pause",
647
804
  * path: ["Playback", "Pause"],
648
- * order: 210,
805
+ * order: 210, // position among the Playback menu's items
649
806
  * bindings: ["Ctrl+P"],
650
807
  * enabled: () => plugin.player.state.can.pause,
651
808
  * checked: () => plugin.player.state.paused,
652
809
  * run: () => plugin.player.setPaused(!plugin.player.state.paused),
653
810
  * });
654
811
  * ```
812
+ *
813
+ * @capability commands
814
+ * @since 1.0.0
655
815
  */
656
816
  add(options: CommandOptions): Command;
657
817
  }
@@ -663,8 +823,8 @@ export interface Commands {
663
823
  * {@link View.bind}: the whole value of an `image` property or, from SDK
664
824
  * 1.5.19 (player 0.5.1), an `image` field at any depth, such as the logo of
665
825
  * each row of a list. Making the reference does not read the file: the
666
- * player checks and decodes it each time a view uses it ({@link Ui.image}
667
- * has the rules and limits).
826
+ * player checks and decodes it each time it receives the value (a property
827
+ * write, a window opening; {@link Ui.image} has the rules and limits).
668
828
  *
669
829
  * A reference belongs to the {@link Scope} that created it. Once that scope
670
830
  * is disposed, writing the reference fails with `invalid` (reported to
@@ -672,13 +832,24 @@ export interface Commands {
672
832
  * property changes or the view closes.
673
833
  *
674
834
  * @example Show a cover image from the data directory
835
+ * A window opens showing `cover.png`, which the plugin saved in its data
836
+ * directory. `ui.image` only makes a reference, without reading the file;
837
+ * the player checks and decodes the file when it receives the value, so a
838
+ * missing file fails that write with `missing` (here: `show` rejects, as
839
+ * it is an initial property). A missing image nested inside a value is
840
+ * drawn empty instead.
675
841
  * ```ts
676
842
  * const cover = plugin.windows.create("ui/cover.slint");
843
+ * // `art` is an `image` property in ui/cover.slint.
677
844
  * cover.set({ art: plugin.ui.image(`${plugin.dataDirectory}/cover.png`) });
678
845
  * await cover.show();
679
846
  * ```
680
847
  *
681
- * @example A logo in each row
848
+ * @example Show a logo in each row of a list
849
+ * Each row of the list draws its own small logo next to its name. An
850
+ * `ImageRef` may sit in any field of a property value (SDK 1.5.19, player
851
+ * 0.5.1), so the rows carry their icons; `maxSide: 64` decodes each logo
852
+ * at most 64 px wide, which keeps the view's image memory small.
682
853
  * ```slint
683
854
  * export struct Row { key: string, name: string, icon: image }
684
855
  * ```
@@ -689,6 +860,7 @@ export interface Commands {
689
860
  * rows: items.map((item) => ({
690
861
  * key: item.id,
691
862
  * name: item.name,
863
+ * // About twice the drawn size (32 px) stays sharp on dense screens.
692
864
  * icon: plugin.ui.image(item.iconPath, { maxSide: 64 }),
693
865
  * })),
694
866
  * }));
@@ -739,7 +911,11 @@ export interface ImageOptions {
739
911
  * scope that created it: once that scope is disposed, writing it fails with
740
912
  * `invalid`, wherever it is in the value.
741
913
  *
742
- * @example Chat messages, one document per row
914
+ * @example Show chat messages, one document per row
915
+ * The chat window shows a formatted assistant message. `ui.markdown`
916
+ * resolves once the document is ready (here without downloading images);
917
+ * the reference goes into the `body` field of a row, whose Slint type is
918
+ * `[MarkdownBlock]`, and the player parses and draws it.
743
919
  * ```ts
744
920
  * const chat = plugin.windows.create("ui/chat.slint");
745
921
  * const body = await plugin.ui.markdown("**Hello**", { remoteImages: false });
@@ -833,7 +1009,11 @@ export type PropValue = PropData | ImageRef;
833
1009
  * JSON whose values may also be Markdown documents and (SDK 1.5.19) images,
834
1010
  * at any depth: the part of {@link PropValue} that nests.
835
1011
  *
836
- * @example A message list whose rows carry a document
1012
+ * @example Type a message list whose rows carry a document
1013
+ * A property value for a chat's message list, typed so that TypeScript
1014
+ * accepts a Markdown document inside each row. `PropData` is the JSON a
1015
+ * property takes plus documents and images at any depth; the player turns
1016
+ * each object into a Slint struct by its field names.
837
1017
  * ```ts
838
1018
  * const messages: PropData = [
839
1019
  * { id: "m1", role: "user", body: await plugin.ui.markdown("Hi") },
@@ -855,7 +1035,11 @@ export type PropData = null | boolean | number | string | MarkdownRef | ImageRef
855
1035
  * kebab-case are kept. Only the property names are converted, not the
856
1036
  * fields of struct values.
857
1037
  *
858
- * @example
1038
+ * @example Prepare a window's initial properties
1039
+ * An object of property values by name, as `windows.create` and
1040
+ * `View.set` take them. Names match the Slint file (`canMark` for
1041
+ * `can-mark`), and each value becomes the Slint type of its property: a
1042
+ * boolean, and an array of structs for a model.
859
1043
  * ```ts
860
1044
  * const props: Props = { canMark: true, rows: [{ rowId: "a", title: "First" }] };
861
1045
  * ```
@@ -879,7 +1063,13 @@ export type Props = Record<string, PropValue>;
879
1063
  *
880
1064
  * @capability None.
881
1065
  *
882
- * @example A window that shows a Markdown file (examples/markdown)
1066
+ * @example Show a Markdown file in a window (examples/markdown)
1067
+ * A menu item opens a window with a formatted Markdown file that ships
1068
+ * with the plugin. The plugin reads the file, turns it into a document
1069
+ * with `ui.markdown` (relative images resolve against the plugin's
1070
+ * directory through `base`) and writes it to the window's `doc` property,
1071
+ * which the Slint file hands to `MarkdownView`.
1072
+ *
883
1073
  * {@includeCode ../../examples/markdown/main.ts#document}
884
1074
  *
885
1075
  * `ui/document.slint`:
@@ -896,8 +1086,8 @@ export interface Ui {
896
1086
  *
897
1087
  * @remarks
898
1088
  * Returns at once without touching the file. The player checks the file
899
- * each time a view uses the reference (a property write, a window opening,
900
- * the main window being rebuilt): it must be inside this plugin's package
1089
+ * each time it receives the reference (a property write, a window or
1090
+ * settings page opening, the main window being rebuilt): it must be inside this plugin's package
901
1091
  * directory or {@link Plugin.dataDirectory} after following links, at most
902
1092
  * 16 MB and 4096×4096 pixels, and PNG or JPEG by content (the extension
903
1093
  * does not matter). A failed check of a whole-value image is a failed
@@ -909,9 +1099,10 @@ export interface Ui {
909
1099
  * the value is shown, as a Markdown image shows its alt text.
910
1100
  *
911
1101
  * The player caches decoded images per plugin (up to 64 MB, least recently
912
- * used first) and does not watch files. Every call returns a new value, so
913
- * after rewriting a file under the same path, call `image()` again and
914
- * write the new reference.
1102
+ * used first) by path, size and modification time, and does not watch
1103
+ * files. After rewriting a file under the same path, call `image()` again
1104
+ * and write the new reference: the SDK does not send a value that did not
1105
+ * change, so the player would not look at the file again.
915
1106
  *
916
1107
  * @capability None.
917
1108
  *
@@ -925,6 +1116,12 @@ export interface Ui {
925
1116
  * `maxSide` is not an integer 1–4096.
926
1117
  *
927
1118
  * @example Show a thumbnail the plugin rendered
1119
+ * A window shows a PNG the plugin wrote into its data directory. The file
1120
+ * must be inside the package or data directory; `image` returns at once.
1121
+ * The player decodes the file when it receives the value and notices a
1122
+ * rewritten file by its size and modification time; the plugin still
1123
+ * calls `image()` again after rewriting it, because the SDK does not send
1124
+ * a value that did not change.
928
1125
  * ```ts
929
1126
  * const window = plugin.windows.create("ui/thumbnail.slint");
930
1127
  * const file = `${plugin.dataDirectory}/thumbnail.png`;
@@ -962,7 +1159,12 @@ export interface Ui {
962
1159
  * large, or is not a PNG or JPEG image; or the plugin has no data
963
1160
  * directory.
964
1161
  *
965
- * @example Search results with thumbnails
1162
+ * @example Show search results with downloaded thumbnails
1163
+ * A results window lists each hit with its thumbnail, or a placeholder
1164
+ * when a thumbnail cannot be fetched. `remoteImage` downloads the image
1165
+ * in the plugin's process into the data directory's cache (at most 4 at a
1166
+ * time) and resolves with a local reference; a failed download rejects
1167
+ * with `unavailable`, which the `catch` turns into the placeholder.
966
1168
  * ```ts
967
1169
  * const results = [{ title: "A", thumbnailUrl: "https://example.com/a.jpg" }];
968
1170
  * const placeholder = plugin.ui.image(`${plugin.dataDirectory}/blank.png`);
@@ -971,7 +1173,7 @@ export interface Ui {
971
1173
  * title: r.title,
972
1174
  * thumb: await plugin.ui
973
1175
  * .remoteImage(r.thumbnailUrl, { maxSide: 320 })
974
- * .catch(() => placeholder),
1176
+ * .catch(() => placeholder), // `unavailable`: download failed
975
1177
  * })),
976
1178
  * );
977
1179
  * plugin.windows.create("ui/results.slint").set({ rows });
@@ -1024,7 +1226,14 @@ export interface Ui {
1024
1226
  * `base` is neither an `http(s):` address nor an absolute path.
1025
1227
  * @throws `unsupported` (rejects) The player is older than 0.2.0.
1026
1228
  *
1027
- * @example A chat: one Markdown document per row, with Copy buttons
1229
+ * @example Build a chat with one Markdown document per row
1230
+ * A chat window lists messages as formatted text, with a Copy button on
1231
+ * each code block that puts the code on the clipboard. Each row's `body`
1232
+ * is a document from `ui.markdown`; the whole list is written again on
1233
+ * each change, and the player parses only the documents that changed.
1234
+ * `MarkdownBlocks` with `code-copy` calls `copy-code`, which the plugin
1235
+ * handles with `on("copyCode")`.
1236
+ *
1028
1237
  * `ui/chat.slint`:
1029
1238
  *
1030
1239
  * ```slint
@@ -1051,6 +1260,7 @@ export interface Ui {
1051
1260
  * const chat = plugin.windows.create("ui/chat.slint");
1052
1261
  * const history: { id: string; role: string; text: string }[] = [];
1053
1262
  * chat.on("copyCode", (code: string) => plugin.clipboard.writeText(code));
1263
+ * // Re-send the whole list: unchanged documents cost the player nothing.
1054
1264
  * async function render() {
1055
1265
  * const messages = [];
1056
1266
  * for (const { id, role, text } of history)
@@ -1063,10 +1273,19 @@ export interface Ui {
1063
1273
  * ```
1064
1274
  *
1065
1275
  * @example Show a README from the web
1276
+ * A window shows a README downloaded from the web, its images included,
1277
+ * and clicked links open in the browser. `base` makes relative links and
1278
+ * images resolve against the README's address; `markdown` resolves after
1279
+ * its `https:` images are downloaded. A click on a link makes
1280
+ * `MarkdownView` call `link-clicked`, which `ui/readme.slint` forwards as
1281
+ * `open-link`; the plugin opens the address with `openUrl`.
1066
1282
  * ```ts
1067
1283
  * const readmeUrl = "https://example.com/plugin/README.md";
1068
- * const text = await (await fetch(readmeUrl)).text();
1284
+ * const response = await fetch(readmeUrl);
1285
+ * if (!response.ok) throw new Error(`README: HTTP ${response.status}`);
1286
+ * const text = await response.text();
1069
1287
  * const window = plugin.windows.create("ui/readme.slint");
1288
+ * // Bound before the first `show`, as a tool window requires.
1070
1289
  * window.on("openLink", (url: string) => plugin.system.openUrl(url));
1071
1290
  * window.set({ doc: await plugin.ui.markdown(text, { base: readmeUrl }) });
1072
1291
  * await window.show();
@@ -1109,7 +1328,13 @@ export interface Ui {
1109
1328
  *
1110
1329
  * @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
1111
1330
  *
1112
- * @example A window with a label, two buttons and their callbacks (examples/bookmark)
1331
+ * @example Wire a window's label and buttons to the plugin (examples/bookmark)
1332
+ * A Bookmark window shows the saved position and two buttons that save
1333
+ * it and jump back to it. `bind` keeps the label and the buttons' enabled
1334
+ * state equal to the plugin's state: the SDK evaluates the function again
1335
+ * after every handler and player change and sends only what changed. The
1336
+ * buttons' Slint callbacks reach the plugin through `on`.
1337
+ *
1113
1338
  * {@includeCode ../../examples/bookmark/main.ts#window}
1114
1339
  *
1115
1340
  * `ui/bookmark.slint`:
@@ -1154,10 +1379,16 @@ export interface View {
1154
1379
  * @throws `Error` without a code (throws) On an open {@link Window}, for a
1155
1380
  * name that has no handler yet.
1156
1381
  *
1157
- * @example
1382
+ * @example Keep a slider and the plugin's value in step
1383
+ * Moving the window's brightness slider updates the plugin's value, and
1384
+ * the slider shows it back. The Slint callback `brightness-changed`
1385
+ * reaches the handler with the slider's value as JSON; after the handler
1386
+ * returns, the SDK runs an update pass and `bind` sends the new value.
1387
+ * Callbacks of a tool window must be bound before its first `show`.
1158
1388
  * ```ts
1159
1389
  * const picture = plugin.windows.create("ui/picture.slint");
1160
1390
  * let brightness = 0;
1391
+ * // camelCase here, `brightness-changed` in the Slint file.
1161
1392
  * picture.on("brightnessChanged", (value: number) => {
1162
1393
  * brightness = value;
1163
1394
  * });
@@ -1196,6 +1427,10 @@ export interface View {
1196
1427
  * @param props - Properties by name; see {@link Props}.
1197
1428
  *
1198
1429
  * @example Put a drop-down back when the user picks an entry the plugin refuses
1430
+ * Choosing "Custom" snaps the drop-down back to the last real preset.
1431
+ * The plugin cannot read a property back, only hear the callback; `set`
1432
+ * sends the value even though it equals the one sent before, which is
1433
+ * what pulls a control the user moved back to the plugin's value.
1199
1434
  * ```ts
1200
1435
  * const window = plugin.windows.create("ui/presets.slint");
1201
1436
  * let presetIndex = 0;
@@ -1231,7 +1466,11 @@ export interface View {
1231
1466
  * @param props - Returns the properties, by name.
1232
1467
  * @returns A handle that stops this binding (the values last sent stay).
1233
1468
  *
1234
- * @example
1469
+ * @example Show what is playing and whether it can seek
1470
+ * The status window always shows the current file and enables its seek
1471
+ * controls only for seekable sources. The SDK calls the function right
1472
+ * away and again in every update pass (after player state changes and
1473
+ * after each handler) and sends only the values that changed.
1235
1474
  * ```ts
1236
1475
  * const status = plugin.windows.create("ui/status.slint");
1237
1476
  * status.bind(() => ({
@@ -1281,10 +1520,19 @@ export interface View {
1281
1520
  *
1282
1521
  * @capability ui.window
1283
1522
  *
1284
- * @example A menu command that opens a window (examples/bookmark)
1523
+ * @example Open a window from a menu command (examples/bookmark)
1524
+ * Playback ▸ Bookmark… opens the Bookmark window. `create` only makes a
1525
+ * handle; the command's `run` calls `show`, which compiles the Slint file
1526
+ * and opens the window with the bound properties the first time, and
1527
+ * brings back the same window afterwards.
1528
+ *
1285
1529
  * {@includeCode ../../examples/bookmark/main.ts#window,command}
1286
1530
  *
1287
1531
  * @example Stop work when the user closes the window
1532
+ * A clock window ticks every second while it is open and stops costing
1533
+ * anything once the user closes it. `plugin.update()` makes the SDK
1534
+ * evaluate the bound time again from a timer; `onClose` runs only for the
1535
+ * user's close, so the plugin stops the timer there.
1288
1536
  * ```ts
1289
1537
  * const clock = plugin.windows.create("ui/clock.slint");
1290
1538
  * let timer: NodeJS.Timeout | undefined;
@@ -1298,6 +1546,7 @@ export interface View {
1298
1546
  * path: ["@screen", "Clock…"],
1299
1547
  * run: async () => {
1300
1548
  * await clock.show();
1549
+ * // Re-evaluate `bind` once a second: bound values change only then.
1301
1550
  * timer ??= setInterval(() => plugin.update(), 1000);
1302
1551
  * },
1303
1552
  * });
@@ -1339,12 +1588,17 @@ export interface Window extends View, Disposable {
1339
1588
  * JPEG.
1340
1589
  *
1341
1590
  * @example Tell the user why a window did not open
1591
+ * When the inspector cannot open, the user sees why on the OSD instead
1592
+ * of nothing happening. `show` compiles the Slint file and opens the
1593
+ * window; it rejects with a code, such as `host` for a Slint file that
1594
+ * does not compile or `unavailable` without a user interface.
1342
1595
  * ```ts
1343
1596
  * const window = plugin.windows.create("ui/inspector.slint");
1344
1597
  * try {
1345
1598
  * await window.show();
1346
1599
  * } catch (error) {
1347
- * await plugin.osd.show(`Cannot open the inspector (${(error as PluginError).code})`);
1600
+ * // The code is data; `message` has the details for the log.
1601
+ * await plugin.osd.show("Cannot open the inspector ({})", [(error as PluginError).code]);
1348
1602
  * }
1349
1603
  * ```
1350
1604
  *
@@ -1366,10 +1620,14 @@ export interface Window extends View, Disposable {
1366
1620
  * @throws `missing` (rejects) The user closed the window just before (the
1367
1621
  * close had not reached the plugin yet).
1368
1622
  *
1369
- * @example A command that shows and hides a window
1623
+ * @example Show and hide a window from a checkable menu item
1624
+ * Audio ▸ Levels shows the window with a check mark and hides it again.
1625
+ * `hide` keeps the window's instance and state, so showing it again is
1626
+ * instant; the check mark follows `visible`, and `onClose` resets it when
1627
+ * the user closes the window with its title-bar button.
1370
1628
  * ```ts
1371
1629
  * const levels = plugin.windows.create("ui/levels.slint");
1372
- * let visible = false;
1630
+ * let visible = false; // what the check mark shows
1373
1631
  * plugin.commands.add({
1374
1632
  * id: "levels",
1375
1633
  * path: ["@audio", "Levels"],
@@ -1402,6 +1660,10 @@ export interface Window extends View, Disposable {
1402
1660
  * @throws `missing` (rejects) The user closed the window just before.
1403
1661
  *
1404
1662
  * @example Close the window from its own button
1663
+ * The editor's Done button closes the editor window. `close` destroys
1664
+ * the window instance but keeps the handle, its handlers and bindings,
1665
+ * so the next `show` opens a fresh window; `onClose` is not called for
1666
+ * the plugin's own close.
1405
1667
  * ```ts
1406
1668
  * const editor = plugin.windows.create("ui/editor.slint");
1407
1669
  * editor.on("done", () => editor.close());
@@ -1434,7 +1696,12 @@ export interface Window extends View, Disposable {
1434
1696
  * @param listener - Called with no arguments; may be async.
1435
1697
  * @returns A handle that removes the listener.
1436
1698
  *
1437
- * @example Remember that the user closed it
1699
+ * @example Remember that the user closed the window
1700
+ * The plugin records that the notes window was closed, so it does not
1701
+ * reopen it at the next start. `onClose` runs for the title-bar button,
1702
+ * the player's close keys (Cmd+W, Ctrl+W, Ctrl+F4) and the system's own
1703
+ * close request such as Alt+F4 (not for the plugin's own `close`); the
1704
+ * store writes the change to `window.json` shortly after.
1438
1705
  * ```ts
1439
1706
  * const store = plugin.storage.json("window", { defaults: { open: false } });
1440
1707
  * const window = plugin.windows.create("ui/notes.slint");
@@ -1460,7 +1727,11 @@ export interface Window extends View, Disposable {
1460
1727
  *
1461
1728
  * @capability ui.window
1462
1729
  *
1463
- * @example
1730
+ * @example Create a window and open it from a menu (examples/bookmark)
1731
+ * A Bookmark window opens from Playback ▸ Bookmark…. `create` returns a
1732
+ * handle without opening anything; its bindings and callbacks are set up
1733
+ * first, and `show` opens the window when the command runs.
1734
+ *
1464
1735
  * {@includeCode ../../examples/bookmark/main.ts#window,command}
1465
1736
  *
1466
1737
  * @see {@link Window}, [Windows, panels, settings pages, dialogs and the OSD](../../docs/concepts/ui.md)
@@ -1495,7 +1766,10 @@ export interface Windows {
1495
1766
  * @throws `conflict` (throws) This plugin already has a window with this id
1496
1767
  * (SDK 1.3.0; before, a plain `Error` without `code`).
1497
1768
  *
1498
- * @example One file, two windows
1769
+ * @example Open two windows from one Slint file
1770
+ * Two compare windows open side by side, one labelled A and one B. A
1771
+ * window's id defaults to its file name, so the second window of the
1772
+ * same file needs its own `id`; each handle has its own properties.
1499
1773
  * ```ts
1500
1774
  * const left = plugin.windows.create("ui/compare.slint", { id: "compare-left" });
1501
1775
  * const right = plugin.windows.create("ui/compare.slint", { id: "compare-right" });
@@ -1596,12 +1870,18 @@ export interface PanelState {
1596
1870
  *
1597
1871
  * @capability ui.mount; `events` to follow changes made elsewhere.
1598
1872
  *
1599
- * @example A "Show sidebar" menu item that works with any sidebar panel
1873
+ * @example Add a "Show sidebar" item that works with any sidebar panel
1874
+ * Tools ▸ Show sidebar shows or hides the sidebar, whichever plugin's
1875
+ * panel is in it, with a check mark while it is shown. The position does
1876
+ * not belong to the plugin that mounted the panel: `state` is `null`
1877
+ * while nobody has a panel there (the item is disabled then), and
1878
+ * `toggle` flips the user's remembered preference.
1600
1879
  * ```ts
1601
1880
  * const sidebar = plugin.panels.sidebar;
1602
1881
  * plugin.commands.add({
1603
1882
  * id: "sidebar",
1604
1883
  * path: ["Tools", "Show sidebar"],
1884
+ * // `state` is null while no plugin has a panel in the sidebar.
1605
1885
  * checked: () => sidebar.state?.preferred ?? false,
1606
1886
  * enabled: () => sidebar.state !== null,
1607
1887
  * run: () => sidebar.toggle(),
@@ -1644,9 +1924,14 @@ export interface PanelSlot {
1644
1924
  * @param listener - Receives the new state.
1645
1925
  * @returns A handle that removes the listener.
1646
1926
  *
1647
- * @example
1927
+ * @example Tell the user when the window is too narrow for the sidebar
1928
+ * When the sidebar should show but the window has no room for it, the
1929
+ * OSD says so. With `events`, the listener runs on every change of the
1930
+ * position, whoever caused it. The player reports `reason: "space"` when
1931
+ * the preference is to show the sidebar but the window is too small.
1648
1932
  * ```ts
1649
1933
  * plugin.panels.sidebar.onChange((state) => {
1934
+ * // `null`: no panel is mounted in the sidebar any more.
1650
1935
  * if (state?.reason === "space") void plugin.osd.show("Widen the window to see the playlist");
1651
1936
  * });
1652
1937
  * ```
@@ -1672,9 +1957,15 @@ export interface PanelSlot {
1672
1957
  * @throws `unavailable` (rejects) The player runs without a user interface.
1673
1958
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1674
1959
  *
1675
- * @example
1960
+ * @example Hide the bottom bar for a clean picture
1961
+ * The control bar disappears, as if the user had hidden it from the menu,
1962
+ * and stays hidden at the next start because the preference is
1963
+ * remembered. `setVisible` rejects with `missing` when no panel is in the
1964
+ * bottom bar, which this plugin treats as nothing to hide.
1676
1965
  * ```ts
1677
- * await plugin.panels.bottom.setVisible(false);
1966
+ * await plugin.panels.bottom.setVisible(false).catch((error: PluginError) => {
1967
+ * if (error.code !== "missing") throw error; // no bar mounted: nothing to do
1968
+ * });
1678
1969
  * ```
1679
1970
  *
1680
1971
  * @since 1.0.0
@@ -1695,11 +1986,16 @@ export interface PanelSlot {
1695
1986
  * @throws `unavailable` (rejects) The player runs without a user interface.
1696
1987
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1697
1988
  *
1698
- * @example
1989
+ * @example Toggle the control bar from a checkable menu item
1990
+ * Screen ▸ Control bar shows or hides the bottom bar and carries a check
1991
+ * mark while it is shown. `toggle` reads the player's current preference
1992
+ * first, so it is right even without `events`; `checked` is evaluated
1993
+ * again after the command runs and follows `state`.
1699
1994
  * ```ts
1700
1995
  * plugin.commands.add({
1701
1996
  * id: "toggle-bar",
1702
1997
  * path: ["@screen", "Control bar"],
1998
+ * // The user's preference, remembered across launches.
1703
1999
  * checked: () => plugin.panels.bottom.state?.preferred ?? false,
1704
2000
  * run: () => plugin.panels.bottom.toggle(),
1705
2001
  * });
@@ -1731,7 +2027,11 @@ export interface PanelSlot {
1731
2027
  * @throws `unavailable` (rejects) The player runs without a user interface.
1732
2028
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1733
2029
  *
1734
- * @example A command that shows the playlist's tab
2030
+ * @example Switch the sidebar to the playlist's tab
2031
+ * A command makes the playlist the sidebar's current tab without showing
2032
+ * or hiding the sidebar, like a click on its tab. `activate` takes the
2033
+ * manifest id of the plugin whose tab to show and rejects with
2034
+ * `missing` when that plugin has no panel there.
1735
2035
  * ```ts
1736
2036
  * plugin.commands.add({
1737
2037
  * id: "to-playlist",
@@ -1757,12 +2057,17 @@ export interface PanelSlot {
1757
2057
  *
1758
2058
  * @capability ui.mount
1759
2059
  *
1760
- * @example A "Detach sidebar" menu item
2060
+ * @example Add a "Detach sidebar" item
2061
+ * Tools ▸ Detach sidebar floats the sidebar in its own window and docks
2062
+ * it again, with a check mark while it floats. `setDetached` moves all
2063
+ * the sidebar's tabs; the floating window holds new instances of the
2064
+ * panels, which keep the plugins' properties and callbacks.
1761
2065
  * ```ts
1762
2066
  * const sidebar = plugin.panels.sidebar;
1763
2067
  * plugin.commands.add({
1764
2068
  * id: "detach",
1765
2069
  * path: ["Tools", "Detach sidebar"],
2070
+ * // Detaching moves every tab of the sidebar into the floating window.
1766
2071
  * checked: () => sidebar.state?.detached ?? false,
1767
2072
  * enabled: () => sidebar.state !== null,
1768
2073
  * run: () => sidebar.setDetached(!sidebar.state?.detached),
@@ -1785,7 +2090,10 @@ export interface SidebarSlot extends PanelSlot {
1785
2090
  * @throws `unavailable` (rejects) The player runs without a user interface.
1786
2091
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1787
2092
  *
1788
- * @example
2093
+ * @example Float the sidebar or dock it back
2094
+ * The sidebar moves into its own window, or back into the main window
2095
+ * when it already floats. `state` says whether it is detached now;
2096
+ * `setDetached` resolves once the layout changed, with `state` refreshed.
1789
2097
  * ```ts
1790
2098
  * const sidebar = plugin.panels.sidebar;
1791
2099
  * await sidebar.setDetached(!sidebar.state?.detached);
@@ -1818,7 +2126,13 @@ export interface SidebarSlot extends PanelSlot {
1818
2126
  *
1819
2127
  * @capability ui.mount
1820
2128
  *
1821
- * @example A sidebar list with a right-click menu (examples/panel-menu)
2129
+ * @example Mount a sidebar list with a right-click menu (examples/panel-menu)
2130
+ * The sidebar gets an "Example list" tab whose rows can be selected and
2131
+ * have a context menu. `mount` returns at once and the player adds the
2132
+ * panel when it rebuilds its main window; `bind` keeps the rows and the
2133
+ * selection on the panel's bridge global, and the `context-menu` callback
2134
+ * opens a menu of the plugin's commands where the user clicked.
2135
+ *
1822
2136
  * {@includeCode ../../examples/panel-menu/main.ts#mount,menu}
1823
2137
  *
1824
2138
  * `ui/panel.slint`:
@@ -1893,7 +2207,12 @@ export interface Panel extends View, Disposable {
1893
2207
  * @param listener - Receives the new state.
1894
2208
  * @returns A handle that removes the listener.
1895
2209
  *
1896
- * @example Say when the user switched to another tab
2210
+ * @example Notice when the list moves to a background tab
2211
+ * The plugin logs when the user switches the sidebar to another plugin's
2212
+ * tab, for example to pause work the list does. With `events` the
2213
+ * listener runs on every change. `reason: "otherTab"` exists only in a
2214
+ * panel's own state: the SDK sets it when the sidebar's current tab is
2215
+ * another plugin's (the position's state never has it).
1897
2216
  * ```ts
1898
2217
  * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1899
2218
  * panel.onChange((state) => {
@@ -1923,7 +2242,10 @@ export interface Panel extends View, Disposable {
1923
2242
  * @throws `unavailable` (rejects) The player runs without a user interface.
1924
2243
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
1925
2244
  *
1926
- * @example
2245
+ * @example Bring the plugin's sidebar panel to the front
2246
+ * A command shows the List tab: it becomes the current tab and the
2247
+ * sidebar is shown, and the preference is remembered. `show` waits for
2248
+ * the mount first, so it can run right after `mount`.
1927
2249
  * ```ts
1928
2250
  * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1929
2251
  * plugin.commands.add({ id: "show", title: "Show the list", run: () => list.show() });
@@ -1951,13 +2273,18 @@ export interface Panel extends View, Disposable {
1951
2273
  * @throws `unavailable` (rejects) The player runs without a user interface.
1952
2274
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
1953
2275
  *
1954
- * @example A toggle command for a sidebar panel (the playlist's F6)
2276
+ * @example Toggle a sidebar panel with F6, like the playlist
2277
+ * F6 and Screen ▸ List show the List tab, or hide the sidebar when the
2278
+ * list is already in front. `toggle` reads the player's state first:
2279
+ * it hides the position only when it is shown and this panel's tab is
2280
+ * the current one, otherwise it shows the panel.
1955
2281
  * ```ts
1956
2282
  * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1957
2283
  * plugin.commands.add({
1958
2284
  * id: "list",
1959
2285
  * path: ["@screen", "List"],
1960
2286
  * bindings: ["F6"],
2287
+ * // Checked while the sidebar is shown with this list in front.
1961
2288
  * checked: () => (list.state?.preferred ?? false) && (list.state?.active ?? false),
1962
2289
  * run: () => list.toggle(),
1963
2290
  * });
@@ -1986,7 +2313,12 @@ export interface Panel extends View, Disposable {
1986
2313
  *
1987
2314
  * @param locks - The locks to change; omitted ones keep their value.
1988
2315
  *
1989
- * @example Keep the bar while a search field has focus
2316
+ * @example Keep the bar on screen while a search field has focus
2317
+ * In fullscreen the bottom bar stays visible while the user types in
2318
+ * its search field, instead of hiding when the pointer leaves it. The
2319
+ * Slint side reports the field's focus through a callback, and
2320
+ * `setInteraction` sets or clears the `focused` lock; other locks keep
2321
+ * their values.
1990
2322
  * ```ts
1991
2323
  * const panel = plugin.panels.mount("bottom", "ui/bar.slint");
1992
2324
  * panel.on("searchFocused", (focused: boolean) => panel.setInteraction({ focused }));
@@ -2028,7 +2360,12 @@ export interface Panel extends View, Disposable {
2028
2360
  * finite.
2029
2361
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
2030
2362
  *
2031
- * @example
2363
+ * @example Open a context menu where the user right-clicked (examples/panel-menu)
2364
+ * Right-clicking the list opens a menu with "Select the first item" and
2365
+ * "Remove the selected item". The Slint file passes the pointer position
2366
+ * in window pixels with its `context-menu` callback; `showMenu` opens the
2367
+ * plugin's commands there, and picking one runs that command.
2368
+ *
2032
2369
  * {@includeCode ../../examples/panel-menu/main.ts#menu}
2033
2370
  *
2034
2371
  * @since 1.0.0
@@ -2058,12 +2395,16 @@ export interface Panel extends View, Disposable {
2058
2395
  * @returns A handle that removes the listener.
2059
2396
  *
2060
2397
  * @example Add dropped files to a list
2398
+ * Files dropped on the Files panel are added to its list. While the
2399
+ * listener exists, drops on the panel come here as one group instead of
2400
+ * going to the drop providers; after the listener returns, the SDK runs
2401
+ * an update pass and `bind` sends the longer list.
2061
2402
  * ```ts
2062
2403
  * const files: string[] = [];
2063
2404
  * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "Files" });
2064
2405
  * panel.bind(() => ({ rows: files }));
2065
2406
  * panel.onDrop(({ paths }) => {
2066
- * files.push(...paths);
2407
+ * files.push(...paths); // in the order the file manager gave them
2067
2408
  * });
2068
2409
  * ```
2069
2410
  *
@@ -2109,16 +2450,27 @@ export interface Drops {
2109
2450
  * Take files dropped on the video area. The first provider registered for
2110
2451
  * an extension gets it; files nobody takes are ignored.
2111
2452
  *
2112
- * Paths are grouped by extension (case-insensitive) in the order the file
2453
+ * Paths are grouped by provider (an extension, case-insensitive, belongs
2454
+ * to the provider that registered it first) in the order the file
2113
2455
  * manager gave them, one call per provider. Folders are not expanded.
2114
2456
  *
2115
2457
  * @throws `invalid` synchronously for malformed extensions.
2116
2458
  *
2117
- * @example
2459
+ * @example Load dropped subtitle files
2460
+ * Dropping .srt or .ass files on the video loads each one as a subtitle
2461
+ * track. Each extension belongs to the provider that registered it first;
2462
+ * the player groups the dropped paths by provider and calls each provider
2463
+ * once with all of its paths, so .srt and .ass files dropped together
2464
+ * arrive in one call. (A panel under the pointer that listens with
2465
+ * `onDrop` takes the whole drop first.) The plugin reads each file itself
2466
+ * and uploads its bytes with `subtitles.load`.
2118
2467
  * ```ts
2468
+ * import { readFile } from "node:fs/promises";
2469
+ * // ---cut---
2119
2470
  * plugin.drops.provide({
2120
2471
  * extensions: ["srt", "ass"],
2121
2472
  * drop: async ({ paths }) => {
2473
+ * // Only .srt and .ass files reach this provider.
2122
2474
  * for (const path of paths)
2123
2475
  * await plugin.subtitles.load(await readFile(path), {
2124
2476
  * format: path.endsWith(".ass") ? "ass" : "srt",
@@ -2164,12 +2516,16 @@ export type ThemeId = "vestra" | "vestra-light";
2164
2516
  * @capability None to read `theme`; `events` to follow changes; `ui.theme`
2165
2517
  * to change it.
2166
2518
  *
2167
- * @example A menu item that selects the light palette
2519
+ * @example Offer the light palette as a menu item
2520
+ * Settings ▸ Light colours switches every view of the player to the light
2521
+ * palette and carries a radio mark while it is in use. `set` asks the
2522
+ * player to switch (it saves the choice); `theme` holds the palette in use,
2523
+ * and `checked` reads it again after every handler.
2168
2524
  * ```ts
2169
2525
  * plugin.commands.add({
2170
2526
  * id: "light-colours",
2171
2527
  * path: ["@settings", "Light colours"],
2172
- * radio: true,
2528
+ * radio: true, // a round mark instead of a check mark
2173
2529
  * checked: () => plugin.appearance.theme === "vestra-light",
2174
2530
  * run: () => plugin.appearance.set("vestra-light"),
2175
2531
  * });
@@ -2200,10 +2556,16 @@ export interface Appearance {
2200
2556
  * @param listener - Receives the new palette.
2201
2557
  * @returns A handle that removes the listener.
2202
2558
  *
2203
- * @example
2559
+ * @example Redraw a picture the plugin renders when the palette changes
2560
+ * The plugin renders a chart image in the palette's colours and draws it
2561
+ * again when the user or another plugin switches palettes. Views that use
2562
+ * `Theme` follow on their own; this listener (which needs `events`) is
2563
+ * for what the plugin draws itself.
2204
2564
  * ```ts
2565
+ * declare function renderChart(dark: boolean): Promise<void>;
2566
+ * // ---cut---
2205
2567
  * plugin.appearance.onChange((theme) => {
2206
- * void plugin.osd.show(`Palette: ${theme}`);
2568
+ * void renderChart(theme === "vestra"); // the dark default
2207
2569
  * });
2208
2570
  * ```
2209
2571
  *
@@ -2226,7 +2588,11 @@ export interface Appearance {
2226
2588
  * @throws `invalid` (rejects) `theme` is not a {@link ThemeId}.
2227
2589
  * @throws `capability` (rejects) The manifest does not declare `ui.theme`.
2228
2590
  *
2229
- * @example
2591
+ * @example Switch to the light palette
2592
+ * Every view of the player turns light, and stays light after a restart.
2593
+ * The player applies the palette to every open view and saves the
2594
+ * choice; when the promise resolves, `appearance.theme` already has the
2595
+ * new value. Without `ui.theme` it rejects with `capability`.
2230
2596
  * ```ts
2231
2597
  * await plugin.appearance.set("vestra-light");
2232
2598
  * ```
@@ -2267,10 +2633,14 @@ export interface Appearance {
2267
2633
  * @throws `capability` (rejects) The manifest does not declare `ui.theme`.
2268
2634
  * @throws `unsupported` (rejects) A player before 0.4.3.
2269
2635
  *
2270
- * @example
2636
+ * @example Choose German for the next start
2637
+ * The player will start in German next time, and the user is told so.
2638
+ * `setLanguage` only saves the choice: the language of this run
2639
+ * (`i18n.locale`) never changes while the player runs. Players before
2640
+ * 0.4.3 reject it with `unsupported`.
2271
2641
  * ```ts
2272
2642
  * await plugin.appearance.setLanguage("de");
2273
- * await plugin.osd.show("Takes effect after restart");
2643
+ * await plugin.osd.show("Takes effect after restart"); // translated by the player
2274
2644
  * ```
2275
2645
  *
2276
2646
  * @since 1.5.9
@@ -2417,6 +2787,10 @@ export type TextValue = string | Text | TextList;
2417
2787
  * @capability None.
2418
2788
  *
2419
2789
  * @example Format a date the way the interface language writes it
2790
+ * The OSD shows today's date in the user's language and date format. The
2791
+ * sentence is a template the player translates; only the data in it, the
2792
+ * date, is formatted by the plugin, with `i18n.locale` (the language the
2793
+ * player runs in, fixed for the whole run).
2420
2794
  * ```ts
2421
2795
  * const day = new Date().toLocaleDateString(plugin.i18n.locale);
2422
2796
  * await plugin.osd.show("Today is {}", [day]);
@@ -2450,17 +2824,25 @@ export interface I18n {
2450
2824
  * @param args - Values for its placeholders.
2451
2825
  * @throws `invalid` A malformed or over-limit value (`TEXT_LIMITS`).
2452
2826
  *
2453
- * @example A ComboBox model with fixed words and data
2827
+ * @example Fill a drop-down with fixed words and data
2828
+ * The drop-down lists "No Audio" and one "Track 7 · en" entry per track,
2829
+ * in the user's language. A `[string]` model may hold `text()` values: the
2830
+ * player translates each source text with the plugin's catalog and fills
2831
+ * in its arguments, so the plugin never composes translated strings.
2454
2832
  * ```ts
2455
2833
  * const { text } = plugin.i18n;
2456
2834
  * const picker = plugin.windows.create("ui/clip.slint");
2457
2835
  * const tracks = [{ id: 7, title: "en" }];
2836
+ * // Each entry is translated, then filled in: "Track 7 · en".
2458
2837
  * picker.set({
2459
2838
  * labels: [text("No Audio"), ...tracks.map((t) => text("Track {} · {}", [t.id, t.title]))],
2460
2839
  * });
2461
2840
  * ```
2462
2841
  *
2463
- * @example A message built from translated parts
2842
+ * @example Build a message from translated parts
2843
+ * The OSD says "Download failed: Connection timed out", both parts in the
2844
+ * user's language. An argument made with `text()` is translated too,
2845
+ * while string arguments are shown as they are.
2464
2846
  * ```ts
2465
2847
  * await plugin.osd.show("Download failed: {}", [plugin.i18n.text("Connection timed out")]);
2466
2848
  * ```
@@ -2480,7 +2862,11 @@ export interface I18n {
2480
2862
  * @param style - `"list"` by default.
2481
2863
  * @throws `invalid` A malformed or over-limit value.
2482
2864
  *
2483
- * @example
2865
+ * @example List file names the way the language joins them
2866
+ * The OSD says "Removed missing shaders: a.glsl, b.glsl" in English and
2867
+ * joins the names with "、" in Chinese. `list` makes a value the player
2868
+ * joins per interface language; the names themselves are data and stay
2869
+ * as they are.
2484
2870
  * ```ts
2485
2871
  * const missing = ["a.glsl", "b.glsl"];
2486
2872
  * await plugin.osd.show("Removed missing shaders: {}", [plugin.i18n.list(missing)]);
@@ -2504,13 +2890,25 @@ export interface I18n {
2504
2890
  * @param args - Values for its placeholders.
2505
2891
  * @throws `invalid` A malformed or over-limit value.
2506
2892
  *
2507
- * @example
2893
+ * @example Fail an open with a message the user can read
2894
+ * Opening a `.ch8` file the plugin cannot load shows "The program file
2895
+ * was not found." in the user's language. The player shows the message of
2896
+ * an error thrown from `resolve`; `i18n.error` makes an `Error` whose text
2897
+ * the player translates with this plugin's catalog, while its `message`
2898
+ * stays English for logs and older players.
2508
2899
  * ```ts
2900
+ * import { readFile } from "node:fs/promises";
2901
+ * declare function startEmulator(program: Uint8Array): ResolvedStream;
2902
+ * // ---cut---
2509
2903
  * plugin.resolvers.add({
2510
- * id: "nes",
2511
- * extensions: ["nes"],
2512
- * async resolve() {
2513
- * throw plugin.i18n.error("The ROM file was not found.");
2904
+ * id: "chip8",
2905
+ * extensions: ["ch8"],
2906
+ * async resolve(path, signal) {
2907
+ * const program = await readFile(path, { signal }).catch(() => {
2908
+ * // Shown as the open's failure, translated (locales/<lang>.po).
2909
+ * throw plugin.i18n.error("The program file was not found.");
2910
+ * });
2911
+ * return startEmulator(program);
2514
2912
  * },
2515
2913
  * });
2516
2914
  * ```
@@ -2536,7 +2934,11 @@ export interface I18n {
2536
2934
  * @returns The translated string.
2537
2935
  * @throws `invalid` A malformed value.
2538
2936
  *
2539
- * @example
2937
+ * @example Copy version information in the user's language
2938
+ * "Copy info" puts two translated lines on the clipboard. Text that
2939
+ * leaves the player cannot be translated by it, so `resolve` turns the
2940
+ * values into the string the player would show, using this plugin's
2941
+ * catalog; inside the player, pass the values themselves instead.
2540
2942
  * ```ts
2541
2943
  * const { text, list, resolve } = plugin.i18n;
2542
2944
  * const lines = list([text("Version {}", ["1.0"]), text("Cores: {}", [8])], "lines");
@@ -2589,7 +2991,12 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2589
2991
  *
2590
2992
  * @capability ui.settings
2591
2993
  *
2592
- * @example Two preferences on a page, kept in a store
2994
+ * @example Put two preferences on a page and keep them in a store
2995
+ * Options ▸ Video ▸ Blur shows two check boxes whose values survive
2996
+ * restarts. The checkboxes are bound two-way (`<=>`) to `in-out`
2997
+ * properties of the bridge global, so the user's clicks show at once; the
2998
+ * `toggled` callbacks tell the plugin, which saves the new value, and
2999
+ * `bind` writes the stored values when the page opens.
2593
3000
  * ```ts
2594
3001
  * const store = plugin.storage.json("blur", {
2595
3002
  * defaults: { restoreOnStart: false, messages: true },
@@ -2603,6 +3010,7 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2603
3010
  * restoreOnStart: store.value.restoreOnStart,
2604
3011
  * messages: store.value.messages,
2605
3012
  * }));
3013
+ * // The bridge's `restore-toggled(bool)` callback, in camelCase.
2606
3014
  * page.on("restoreToggled", (on: boolean) =>
2607
3015
  * store.save({ ...store.value, restoreOnStart: on }),
2608
3016
  * );
@@ -2617,6 +3025,7 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2617
3025
  * import { CheckBox } from "std-widgets.slint";
2618
3026
  * import { Theme } from "@vestra/theme.slint";
2619
3027
  *
3028
+ * // The bridge: what the plugin writes and the callbacks it handles.
2620
3029
  * export global BlurPage {
2621
3030
  * in-out property <bool> restore-on-start;
2622
3031
  * in-out property <bool> messages: true;
@@ -2687,7 +3096,11 @@ export type SettingsPage = View & Disposable & {
2687
3096
  *
2688
3097
  * @capability ui.settings
2689
3098
  *
2690
- * @example A page that edits stored settings
3099
+ * @example Add a page that edits a stored setting
3100
+ * Options ▸ Playback ▸ Resume shows the plugin's Resume switch. `page`
3101
+ * registers the page with the Options window (compiled by the player on
3102
+ * its own), `bind` sends the stored value, and the page's callback saves
3103
+ * the user's change.
2691
3104
  * ```ts
2692
3105
  * const store = plugin.storage.json("settings", { defaults: { resume: true } });
2693
3106
  * const page = plugin.settings.page("ui/settings.slint", {
@@ -2696,6 +3109,7 @@ export type SettingsPage = View & Disposable & {
2696
3109
  * title: "Resume",
2697
3110
  * });
2698
3111
  * page.bind(() => ({ resume: store.value.resume }));
3112
+ * // The bridge's `resume-toggled(bool)`: save, and `bind` sends it back.
2699
3113
  * page.on("resumeToggled", (on: boolean) => store.save({ ...store.value, resume: on }));
2700
3114
  * ```
2701
3115
  *
@@ -2732,13 +3146,17 @@ export interface Settings {
2732
3146
  * one component and one global. A file that cannot be read throws Node's
2733
3147
  * file system error (`code` `ENOENT` and so on).
2734
3148
  *
2735
- * @example
3149
+ * @example Register a page and report when the player refuses it
3150
+ * An "My plugin" page appears under Plugins, or the reason it could not
3151
+ * be added is logged. `page` returns at once; the player compiles the
3152
+ * page on its own, and `ready` rejects (for example with `ui-prepare`
3153
+ * and the compiler's diagnostics) when it refuses it.
2736
3154
  * ```ts
2737
3155
  * const page = plugin.settings.page("ui/advanced.slint", {
2738
3156
  * id: "advanced",
2739
3157
  * category: "plugins",
2740
3158
  * title: "My plugin",
2741
- * order: 10,
3159
+ * order: 10, // after pages with a smaller order in the category
2742
3160
  * });
2743
3161
  * page.ready.catch((error: PluginError) => console.error(error.message));
2744
3162
  * ```
@@ -2776,7 +3194,10 @@ export interface Settings {
2776
3194
  * @throws `capability` (rejects) The manifest does not declare
2777
3195
  * `ui.settings`.
2778
3196
  *
2779
- * @example An "Options…" button in a tool window
3197
+ * @example Open the plugin's page from an "Options…" button
3198
+ * The button in the plugin's window opens the Options window at the
3199
+ * plugin's Blur page, or brings it to the front. `show` takes the id the
3200
+ * page was registered with and rejects with `invalid` for any other.
2780
3201
  * ```ts
2781
3202
  * const controls = plugin.windows.create("ui/window.slint");
2782
3203
  * controls.on("openOptions", () => plugin.settings.show("blur"));
@@ -2800,8 +3221,13 @@ export interface Settings {
2800
3221
  * @capability ui.mount; `events` to follow changes.
2801
3222
  *
2802
3223
  * @example React when a video picture appears
3224
+ * The OSD says "Video is playing" when the backdrop gives way to a video
3225
+ * picture. The player decides when the backdrop shows (nothing open,
3226
+ * loading, audio without a picture); `effective` turns false when a video
3227
+ * frame replaces it.
2803
3228
  * ```ts
2804
3229
  * plugin.panels.backdrop.onChange((state) => {
3230
+ * // `null`: no backdrop is mounted.
2805
3231
  * if (state?.effective === false) void plugin.osd.show("Video is playing");
2806
3232
  * });
2807
3233
  * ```
@@ -2827,7 +3253,10 @@ export interface BackdropSlot {
2827
3253
  * @param listener - Receives the new state (`null`: no backdrop).
2828
3254
  * @returns A handle that removes the listener.
2829
3255
  *
2830
- * @example
3256
+ * @example Log when the backdrop shows or hides
3257
+ * The plugin's log records each change of the backdrop position. The
3258
+ * listener runs when the player starts or stops showing the backdrop
3259
+ * and with `null` when it is unmounted.
2831
3260
  * ```ts
2832
3261
  * plugin.panels.backdrop.onChange((state) => console.log("backdrop", state));
2833
3262
  * ```
@@ -2863,7 +3292,13 @@ export interface BackdropSlot {
2863
3292
  *
2864
3293
  * @capability ui.mount
2865
3294
  *
2866
- * @example A sidebar list (examples/panel-menu)
3295
+ * @example Mount a list in the sidebar (examples/panel-menu)
3296
+ * The sidebar gets an "Example list" tab showing three rows. `mount`
3297
+ * returns at once; the SDK sends the mount with the values bound so far,
3298
+ * and the player rebuilds its main window with the panel. The Slint file
3299
+ * exports the component and the bridge global whose properties `bind`
3300
+ * writes.
3301
+ *
2867
3302
  * {@includeCode ../../examples/panel-menu/main.ts#mount}
2868
3303
  *
2869
3304
  * `ui/panel.slint`:
@@ -2925,9 +3360,15 @@ export interface Panels {
2925
3360
  * @throws The errors of {@link Panel.ready} (rejects `ready`) The player
2926
3361
  * refused the mount.
2927
3362
  *
2928
- * @example A bottom bar
3363
+ * @example Mount a bottom bar with a seek slider
3364
+ * The plugin's own control bar appears under the video, its slider
3365
+ * following the playback position and seeking when dragged. Bindings and
3366
+ * handlers made right after `mount` are part of the first values;
3367
+ * `ready` resolves once the main window shows the bar, and rejects with
3368
+ * `conflict` when another plugin's panel holds the bottom bar.
2929
3369
  * ```ts
2930
3370
  * const bar = plugin.panels.mount("bottom", "ui/bar.slint");
3371
+ * // Sent again whenever the position changes.
2931
3372
  * bar.bind(() => ({ position: plugin.player.state.position ?? 0 }));
2932
3373
  * bar.on("seek", (seconds: number) => plugin.player.seek(seconds));
2933
3374
  * await bar.ready;
@@ -2976,10 +3417,16 @@ export interface TimelineLayer extends Disposable {
2976
3417
  * the player translates (`text("Chapter {}", [n])`, SDK 1.5.11); other
2977
3418
  * plugins read the shown string.
2978
3419
  *
2979
- * @example
3420
+ * @example Show the file's bookmarks on the timeline
3421
+ * Bookmark marks appear on the seek bar at their times, titled with the
3422
+ * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
3423
+ * the player drops the write if another file opened since `seen` was
3424
+ * read, so bookmarks of the previous file never land on the new one.
2980
3425
  * ```ts
3426
+ * declare const bookmarks: { time: number; note: string }[];
3427
+ * // ---cut---
2981
3428
  * const layer = plugin.timeline.layer();
2982
- * const seen = plugin.player.state;
3429
+ * const seen = plugin.player.state; // the file the bookmarks belong to
2983
3430
  * layer.set(
2984
3431
  * bookmarks.map((b) => ({ time: b.time, kind: "bookmark", title: b.note })),
2985
3432
  * { ifCurrent: seen },
@@ -3036,7 +3483,13 @@ export interface WindowState {
3036
3483
  * `onChange`; without it, it updates only at start and after this plugin's
3037
3484
  * `setVideoArea`.
3038
3485
  *
3039
- * @example A "1:1 size" command
3486
+ * @example Resize the window to the video's own size
3487
+ * Screen ▸ Original size makes the video area as many logical points as
3488
+ * the video has pixels (one pixel per pixel at a display scale of 100 %).
3489
+ * `stats()` reports the video's size;
3490
+ * `setVideoArea` resizes the window around the video column (bars and a
3491
+ * docked sidebar added), scales it down if it would not fit the screen,
3492
+ * and is refused in fullscreen, hence the `enabled` check.
3040
3493
  * ```ts
3041
3494
  * plugin.commands.add({
3042
3495
  * id: "size-1x",
@@ -3044,6 +3497,7 @@ export interface WindowState {
3044
3497
  * enabled: () => !plugin.window.state.fullscreen,
3045
3498
  * run: async () => {
3046
3499
  * const stats = await plugin.player.stats();
3500
+ * // No video stream (an audio file): nothing to size to.
3047
3501
  * if (stats.video?.width && stats.video.height)
3048
3502
  * await plugin.window.setVideoArea({ width: stats.video.width, height: stats.video.height });
3049
3503
  * },
@@ -3101,7 +3555,11 @@ export interface MainWindow {
3101
3555
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
3102
3556
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
3103
3557
  *
3104
- * @example
3558
+ * @example Toggle picture-in-picture where the system has it
3559
+ * The video moves into a floating picture-in-picture window, or back.
3560
+ * `state.pictureInPicture` says whether the system supports it and
3561
+ * whether it is on; the plugin only calls `setPictureInPicture` where it
3562
+ * is supported.
3105
3563
  * ```ts
3106
3564
  * if (plugin.window.state.pictureInPicture.supported)
3107
3565
  * await plugin.window.setPictureInPicture(!plugin.window.state.pictureInPicture.active);
@@ -3129,7 +3587,12 @@ export interface AppInfo {
3129
3587
  * `plugin.app`: the fixed facts plus the system, read on request. No
3130
3588
  * capability.
3131
3589
  *
3132
- * @example
3590
+ * @example Describe the player and the system for a bug report
3591
+ * The plugin builds a line such as "Vestra 0.5.10 on macOS 26.2" to copy
3592
+ * into a bug report. `app.version` is known from the start; each
3593
+ * `system()` call asks the player, which reads the displays at that moment.
3594
+ * The operating system's name and version it works out once, at the first
3595
+ * call, and reuses them.
3133
3596
  * ```ts
3134
3597
  * const system = await plugin.app.system();
3135
3598
  * const text = `Vestra ${plugin.app.version} on ${system.name} ${system.version ?? ""}`;
@@ -3228,18 +3691,28 @@ export interface ConfirmOptions {
3228
3691
  * at a time: another call meanwhile rejects with `busy`. Playback continues
3229
3692
  * while a dialog is open; command key bindings pause.
3230
3693
  *
3694
+ * **Platforms:** {@link Dialogs.confirm} needs macOS or Windows (other
3695
+ * systems reject it with `unsupported`); the file dialogs work on every
3696
+ * system.
3697
+ *
3231
3698
  * For anything more than a question or a path, use a {@link Windows | tool
3232
3699
  * window}.
3233
3700
  *
3234
3701
  * @capability ui.dialog
3235
3702
  *
3236
3703
  * @example Ask before deleting, remembering "Don't ask again"
3704
+ * Deleting asks for confirmation in a system alert with a "Don't ask
3705
+ * again" box; once the user ticks it, later deletes go ahead without
3706
+ * asking. `confirm` resolves with the button and the checkbox when the
3707
+ * user answers; the texts are templates the player translates, so the
3708
+ * count goes in `args`. Remembering the box is up to the plugin.
3237
3709
  * ```ts
3238
3710
  * const store = plugin.storage.json("confirm", { defaults: { askBeforeDelete: true } });
3239
3711
  * async function deleteAll(count: number) {
3240
3712
  * if (store.value.askBeforeDelete) {
3241
3713
  * const { confirmed, checked } = await plugin.dialogs.confirm({
3242
- * message: `Delete ${count} bookmarks?`,
3714
+ * message: "Delete {} bookmarks?",
3715
+ * args: [count],
3243
3716
  * detail: "This cannot be undone.",
3244
3717
  * confirm: "Delete",
3245
3718
  * destructive: true,
@@ -3286,13 +3759,22 @@ export interface Dialogs {
3286
3759
  * @throws `unsupported` (rejects) A system other than macOS and Windows.
3287
3760
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3288
3761
  *
3289
- * @example
3762
+ * @example Confirm before replacing the playlist
3763
+ * The user sees "Replace the current playlist?" with a Replace button and
3764
+ * the playlist is replaced only after a yes. The system draws the alert
3765
+ * and the promise resolves when the user answers; only one dialog is
3766
+ * open at a time, so a second one meanwhile rejects with `busy`.
3290
3767
  * ```ts
3291
- * const { confirmed } = await plugin.dialogs.confirm({
3292
- * message: "Replace the current playlist?",
3293
- * confirm: "Replace",
3294
- * });
3295
- * if (confirmed) await plugin.osd.show("Playlist replaced");
3768
+ * try {
3769
+ * const { confirmed } = await plugin.dialogs.confirm({
3770
+ * message: "Replace the current playlist?",
3771
+ * confirm: "Replace",
3772
+ * });
3773
+ * if (confirmed) await plugin.osd.show("Playlist replaced");
3774
+ * } catch (error) {
3775
+ * if ((error as PluginError).code !== "busy") throw error;
3776
+ * // Another dialog is already open: let the user finish that one.
3777
+ * }
3296
3778
  * ```
3297
3779
  *
3298
3780
  * @since 1.0.0
@@ -3316,10 +3798,13 @@ export interface Dialogs {
3316
3798
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3317
3799
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3318
3800
  *
3319
- * @example
3801
+ * @example Let the user pick a subtitle file
3802
+ * The system's Open dialog appears titled "Choose a subtitle file" and
3803
+ * the chosen path is shown on the OSD. The promise resolves when the
3804
+ * user answers: with the absolute path, or `null` when they cancelled.
3320
3805
  * ```ts
3321
3806
  * const path = await plugin.dialogs.openFile({ title: "Choose a subtitle file" });
3322
- * if (path !== null) await plugin.osd.show(`Chosen: ${path}`);
3807
+ * if (path !== null) await plugin.osd.show("Chosen: {}", [path]); // a path is data
3323
3808
  * ```
3324
3809
  *
3325
3810
  * @since 1.0.0
@@ -3341,10 +3826,13 @@ export interface Dialogs {
3341
3826
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3342
3827
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3343
3828
  *
3344
- * @example
3829
+ * @example Let the user pick several files at once
3830
+ * The Open dialog allows selecting several files; the OSD says how many
3831
+ * were added. The promise resolves with the absolute paths, or with an
3832
+ * empty array when the user cancelled.
3345
3833
  * ```ts
3346
3834
  * const paths = await plugin.dialogs.openFiles({ title: "Add to the playlist" });
3347
- * await plugin.osd.show(`${paths.length} files added`);
3835
+ * if (paths.length) await plugin.osd.show("{} files added", [paths.length]);
3348
3836
  * ```
3349
3837
  *
3350
3838
  * @since 1.0.0
@@ -3366,7 +3854,10 @@ export interface Dialogs {
3366
3854
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3367
3855
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3368
3856
  *
3369
- * @example
3857
+ * @example Let the user choose a folder for snapshots
3858
+ * The system's folder dialog appears titled "Save snapshots in". It
3859
+ * resolves with the folder's absolute path, or `null` when the user
3860
+ * cancelled, in which case the plugin stops there.
3370
3861
  * ```ts
3371
3862
  * const folder = await plugin.dialogs.openDirectory({ title: "Save snapshots in" });
3372
3863
  * if (folder === null) return; // cancelled
@@ -3397,7 +3888,10 @@ export interface Dialogs {
3397
3888
  * or contains `/` or `\`.
3398
3889
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3399
3890
  *
3400
- * @example
3891
+ * @example Ask where to save a snapshot
3892
+ * The system's Save dialog proposes "frame.png" and returns where the
3893
+ * user wants it. The dialog only returns the path (or `null` when
3894
+ * cancelled); writing the file is up to the plugin afterwards.
3401
3895
  * ```ts
3402
3896
  * const path = await plugin.dialogs.saveFile({ title: "Save snapshot", fileName: "frame.png" });
3403
3897
  * if (path === null) return; // cancelled
@@ -3417,7 +3911,12 @@ export interface Dialogs {
3417
3911
  fileName?: TextValue;
3418
3912
  }): Promise<string | null>;
3419
3913
  }
3420
- /** One audio track of the current media ({@link PlayerState.audioTracks}). */
3914
+ /**
3915
+ * One audio track of the current media ({@link PlayerState.audioTracks}).
3916
+ *
3917
+ * @since 1.0.0
3918
+ * @category Playback
3919
+ */
3421
3920
  export interface AudioTrack {
3422
3921
  /** The backend's track id; pass it to {@link Player.selectAudioTrack}. */
3423
3922
  id: number;
@@ -3439,6 +3938,10 @@ export interface AudioTrack {
3439
3938
  *
3440
3939
  * Without `events`, {@link Player.state} only updates at start and after
3441
3940
  * this plugin's own operations.
3941
+ *
3942
+ * @see {@link Player.state}, [Playback state, controls and sources](../../docs/concepts/playback.md)
3943
+ * @since 1.0.0
3944
+ * @category Playback
3442
3945
  */
3443
3946
  export interface PlayerState {
3444
3947
  /** Changes every time a source is opened, even the same file again. */
@@ -3516,14 +4019,24 @@ export interface PlayerState {
3516
4019
  active: string | null;
3517
4020
  };
3518
4021
  }
3519
- /** An audio output device (`player.audioDevices`). */
4022
+ /**
4023
+ * An audio output device (`player.audioDevices`).
4024
+ *
4025
+ * @since 1.0.0
4026
+ * @category Playback
4027
+ */
3520
4028
  export interface AudioDevice {
3521
4029
  /** Stable id to pass to `setAudioDevice` (e.g. `coreaudio/<UID>`). */
3522
4030
  id: string;
3523
4031
  /** What the system calls it. */
3524
4032
  name: string;
3525
4033
  }
3526
- /** One chapter of the current media. */
4034
+ /**
4035
+ * One chapter of the current media.
4036
+ *
4037
+ * @since 1.0.0
4038
+ * @category Playback
4039
+ */
3527
4040
  export interface Chapter {
3528
4041
  /** Chapter title, `null` without one. */
3529
4042
  title: string | null;
@@ -3540,17 +4053,59 @@ export interface Chapter {
3540
4053
  * consecutive calls do not make each other stale. {@link Player.open} is
3541
4054
  * unconditional unless `ifCurrent` is given.
3542
4055
  *
3543
- * @example
4056
+ * @example Play the next file only if nothing else was opened
4057
+ * When the plugin's file ends, the next one starts, unless the user or
4058
+ * another plugin opened something in the meantime. `onEnd` passes the
4059
+ * state at the end; `open` with `ifCurrent: ended` is refused with `stale`
4060
+ * when the source changed after that state, so nothing is replaced.
3544
4061
  * ```ts
3545
- * // Advance only if nobody changed the source since it ended.
3546
- * media.onEnd((ended) => plugin.player.open(next, { ifCurrent: ended }));
4062
+ * declare const media: Media;
4063
+ * declare const next: string;
4064
+ * // ---cut---
4065
+ * media.onEnd(async (ended) => {
4066
+ * try {
4067
+ * await plugin.player.open(next, { ifCurrent: ended });
4068
+ * } catch (error) {
4069
+ * // Someone opened another file: leave it playing.
4070
+ * if ((error as PluginError).code !== "stale") throw error;
4071
+ * }
4072
+ * });
3547
4073
  * ```
4074
+ *
4075
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md)
4076
+ * @since 1.0.0
4077
+ * @category Playback
3548
4078
  */
3549
4079
  export interface Conditional {
3550
4080
  /** The state the call is conditional on; default: the current `player.state`. */
3551
4081
  ifCurrent?: PlayerState;
3552
4082
  }
3553
- /** A source this plugin opened. */
4083
+ /**
4084
+ * A source this plugin opened.
4085
+ *
4086
+ * @remarks
4087
+ * A source this plugin opened with {@link Player.open}. It does not own
4088
+ * the source: the user or another plugin can replace it at any time, and
4089
+ * `current` then turns false. `ready` and `onEnd` follow the player state:
4090
+ * with `events` they react to the player's events, without it `ready`
4091
+ * polls the state every 100 ms (which needs `playback.read`). Disposing the
4092
+ * scope that opened it does not close the media; call `close` for that.
4093
+ *
4094
+ * @capability That of {@link Player.open} (`playback.control`); `ready` and
4095
+ * `onEnd` follow the state through `events` and `playback.read`.
4096
+ *
4097
+ * @example Open a file and say when it ends
4098
+ * The file opens and plays, and the OSD says "Finished" when it reaches its
4099
+ * end on its own. The handle belongs to this source: `onEnd` fires only
4100
+ * for a natural end of this media, not when someone opens another file.
4101
+ * ```ts
4102
+ * const media = await plugin.player.open("/Movies/clip.mp4");
4103
+ * media.onEnd(() => void plugin.osd.show("Finished")); // natural ends only
4104
+ * ```
4105
+ *
4106
+ * @since 1.0.0
4107
+ * @category Playback
4108
+ */
3554
4109
  export interface Media {
3555
4110
  /** Still the player's source (a restart keeps it current). */
3556
4111
  readonly current: boolean;
@@ -3558,13 +4113,77 @@ export interface Media {
3558
4113
  * Resolves when it is playing. Rejects with code `failed` (load error),
3559
4114
  * `replaced` (another source took over) or `ended`; with `AbortError` when
3560
4115
  * the signal aborts, after closing the media.
4116
+ *
4117
+ * @capability That of {@link Player.open}; `playback.read` or `events` to
4118
+ * see the state change.
4119
+ * @throws `failed` (rejects) The media could not be loaded.
4120
+ * @throws `replaced` (rejects) Another source took over first.
4121
+ * @throws `ended` (rejects) The media already played to the end.
4122
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the media
4123
+ * is closed.
4124
+ *
4125
+ * @example Wait at most ten seconds for a stream to start
4126
+ * A network stream gets ten seconds to start playing; otherwise it is
4127
+ * closed and the user is told. Aborting the signal makes `ready` reject
4128
+ * with an `AbortError` after closing the media.
4129
+ * ```ts
4130
+ * const media = await plugin.player.open("https://example.com/live.m3u8");
4131
+ * // The timeout's abort closes the media and rejects with an AbortError.
4132
+ * try {
4133
+ * await media.ready({ signal: AbortSignal.timeout(10_000) });
4134
+ * } catch (error) {
4135
+ * await plugin.osd.show("Not playing: {}", [(error as Error).message]);
4136
+ * }
4137
+ * ```
4138
+ *
4139
+ * @since 1.0.0
3561
4140
  */
3562
4141
  ready(options?: {
3563
4142
  signal?: AbortSignal;
3564
4143
  }): Promise<void>;
3565
- /** Called once each time a playthrough of this media ends naturally. */
4144
+ /**
4145
+ * Called once each time a playthrough of this media ends naturally.
4146
+ *
4147
+ * @capability That of {@link Player.open}; `events` to hear the end.
4148
+ *
4149
+ * @example Play the next file when this one ends
4150
+ * When the plugin's file ends naturally, the next one opens. The listener
4151
+ * gets the state at the end; passing it as `ifCurrent` makes the next
4152
+ * `open` fail with `stale` (and do nothing) if the user opened something
4153
+ * else in the meantime.
4154
+ * ```ts
4155
+ * declare const media: Media;
4156
+ * declare const next: string;
4157
+ * // ---cut---
4158
+ * media.onEnd((ended) =>
4159
+ * plugin.player.open(next, { ifCurrent: ended }).catch((error: PluginError) => {
4160
+ * if (error.code !== "stale") throw error; // the user opened something else
4161
+ * }),
4162
+ * );
4163
+ * ```
4164
+ *
4165
+ * @since 1.0.0
4166
+ */
3566
4167
  onEnd(listener: (state: PlayerState) => void): Disposable;
3567
- /** Close it if it is still the player's source; otherwise do nothing. */
4168
+ /**
4169
+ * Close it if it is still the player's source; otherwise do nothing.
4170
+ *
4171
+ * @capability That of {@link Player.open} (`playback.control`).
4172
+ * @throws `capability` (rejects) The manifest does not declare
4173
+ * `playback.control`.
4174
+ *
4175
+ * @example Close the preview the plugin opened
4176
+ * The plugin closes the preview it opened, but leaves alone anything the
4177
+ * user opened since. `close` acts only while this media is still the
4178
+ * player's source and does nothing otherwise.
4179
+ * ```ts
4180
+ * declare const preview: Media;
4181
+ * // ---cut---
4182
+ * await preview.close(); // a no-op when something else is playing now
4183
+ * ```
4184
+ *
4185
+ * @since 1.0.0
4186
+ */
3568
4187
  close(): Promise<void>;
3569
4188
  }
3570
4189
  /**
@@ -3572,13 +4191,33 @@ export interface Media {
3572
4191
  * Any outside change (another plugin opens a file, the user pauses) ends
3573
4192
  * the gesture.
3574
4193
  *
3575
- * @example
4194
+ * @example Seek while the user drags a slider
4195
+ * The video follows the slider while it is dragged. `scrub()` starts a
4196
+ * gesture from the current state; each `seek` is conditional on the
4197
+ * previous one and only the newest target is sent while one is running,
4198
+ * so a fast drag does not queue seeks. A change from outside (another file,
4199
+ * the user pausing) ends the gesture and later seeks resolve `false`.
3576
4200
  * ```ts
4201
+ * declare const bar: Panel;
4202
+ * // ---cut---
3577
4203
  * let scrub: Scrub | null = null;
3578
4204
  * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
4205
+ * // Latest value wins: superseded targets resolve false, not an error.
3579
4206
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
3580
4207
  * bar.on("dragEnd", () => scrub?.end());
3581
4208
  * ```
4209
+ *
4210
+ * @remarks
4211
+ * A drag gesture on a seek slider. Each `seek` is conditional on the
4212
+ * previous one and only the newest target is sent while a seek is
4213
+ * running, so a fast drag never queues seeks. Any outside change (another
4214
+ * file, the user pausing) ends the gesture: `active` turns false and later
4215
+ * seeks resolve `false`.
4216
+ *
4217
+ * @capability playback.control
4218
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
4219
+ * @since 1.0.0
4220
+ * @category Playback
3582
4221
  */
3583
4222
  export interface Scrub {
3584
4223
  /**
@@ -3599,18 +4238,52 @@ export interface Scrub {
3599
4238
  * as before. Older players ignore the option and seek exactly: set
3600
4239
  * `"minPlayerVersion": "0.5.3"` when you rely on it.
3601
4240
  *
3602
- * @example
4241
+ * @example Scrub on keyframes for a fast picture
4242
+ * While dragging, the picture jumps to the nearest earlier keyframe at
4243
+ * once instead of decoding up to the exact frame. With `keyframes: true`
4244
+ * the player lands each target on the last keyframe at or before it; the
4245
+ * plugin follows the user's keyframe-seek setting.
3603
4246
  * ```ts
4247
+ * declare const bar: Panel;
4248
+ * declare const scrub: Scrub | null;
4249
+ * // ---cut---
3604
4250
  * const keyframes = true; // the plugin's own setting
3605
4251
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
3606
4252
  * ```
4253
+ *
4254
+ * @capability playback.control
4255
+ * @throws `capability` (rejects) The manifest does not declare
4256
+ * `playback.control`.
4257
+ * @throws `stale` (rejects) The source changed from outside while this
4258
+ * seek was being sent (a superseded target or an ended gesture resolves
4259
+ * `false` instead).
4260
+ * @throws `unsupported` (rejects) The source is a generated stream.
4261
+ * @throws `backend` (rejects) The player failed the seek.
4262
+ * @since 1.0.0
3607
4263
  */
3608
4264
  seek(seconds: number, options?: {
3609
4265
  keyframes?: boolean;
3610
4266
  }): Promise<boolean>;
3611
4267
  /** False once anyone else changed the source; later seeks are ignored. */
3612
4268
  readonly active: boolean;
3613
- /** End the gesture; later seeks resolve `false`. */
4269
+ /**
4270
+ * End the gesture; later seeks resolve `false`.
4271
+ *
4272
+ * @capability playback.control
4273
+ *
4274
+ * @example End the gesture when the user lets go
4275
+ * Releasing the slider ends the gesture, so a late `dragMove` cannot move
4276
+ * playback any more. After `end`, `seek` resolves `false` without doing
4277
+ * anything.
4278
+ * ```ts
4279
+ * declare const bar: Panel;
4280
+ * declare const scrub: Scrub;
4281
+ * // ---cut---
4282
+ * bar.on("dragEnd", () => scrub.end()); // later seeks resolve false
4283
+ * ```
4284
+ *
4285
+ * @since 1.0.0
4286
+ */
3614
4287
  end(): void;
3615
4288
  }
3616
4289
  /**
@@ -3624,6 +4297,9 @@ export interface Scrub {
3624
4297
  *
3625
4298
  * The SDK copies the frame once before `frame()` is called again, so you
3626
4299
  * may reuse the same buffers for every frame.
4300
+ *
4301
+ * @since 1.0.0
4302
+ * @category Playback
3627
4303
  */
3628
4304
  export interface StreamFrame {
3629
4305
  /** `width × height × 4` bytes of RGBA, at most 4 MiB. */
@@ -3635,7 +4311,13 @@ export interface StreamFrame {
3635
4311
  */
3636
4312
  audio: Float32Array;
3637
4313
  }
3638
- /** Options of {@link Player.openStream}. */
4314
+ /**
4315
+ * Options of {@link Player.openStream}.
4316
+ *
4317
+ * @see {@link Player.openStream}
4318
+ * @since 1.0.0
4319
+ * @category Playback
4320
+ */
3639
4321
  export interface StreamOptions {
3640
4322
  /** Frame width in pixels. */
3641
4323
  width: number;
@@ -3650,12 +4332,48 @@ export interface StreamOptions {
3650
4332
  * `openStream` start-up timeouts: code `timeout` with the step that ran out
3651
4333
  * of time, the first buffers (`prefill`) or the host's activation.
3652
4334
  * Both limits are 10 seconds.
4335
+ *
4336
+ * @since 1.0.0
4337
+ * @category Playback
3653
4338
  */
3654
4339
  export type StreamStartError = PluginError & {
3655
4340
  /** The start-up step that timed out. */
3656
4341
  stage: "prefill" | "activation";
3657
4342
  };
3658
- /** A generated source, from {@link Player.openStream}. Disposing it stops it. */
4343
+ /**
4344
+ * A generated source, from {@link Player.openStream}. Disposing it stops it.
4345
+ *
4346
+ * @remarks
4347
+ * A source whose frames this plugin generates, from
4348
+ * {@link Player.openStream}. The SDK pulls frames on the audio clock over
4349
+ * a local connection and handles prefill, activation and pausing; the plugin
4350
+ * only supplies `frame()`. A stream ends when the plugin stops it, when
4351
+ * another source replaces it or the user closes it (`onStop` with `null`),
4352
+ * or when it fails (`onStop` with the error).
4353
+ *
4354
+ * @capability producer.av and playback.control
4355
+ *
4356
+ * @example Stop the stream from a menu item
4357
+ * A menu item ends the plugin's generated stream and closes it as the
4358
+ * player's source. `stop` closes the source first and then the frame
4359
+ * connection, so the user never sees a playback error; `onStop` is not
4360
+ * called for the plugin's own stop.
4361
+ * ```ts
4362
+ * declare const stream: Stream;
4363
+ * // ---cut---
4364
+ * // Enabled while the stream produces frames.
4365
+ * plugin.commands.add({
4366
+ * id: "stop-generator",
4367
+ * path: ["@playback", "Stop generator"],
4368
+ * enabled: () => stream.running,
4369
+ * run: () => stream.stop(),
4370
+ * });
4371
+ * ```
4372
+ *
4373
+ * @see {@link Player.openStream}
4374
+ * @since 1.0.0
4375
+ * @category Playback
4376
+ */
3659
4377
  export interface Stream extends Disposable {
3660
4378
  /** The stream is producing frames. */
3661
4379
  readonly running: boolean;
@@ -3671,6 +4389,23 @@ export interface Stream extends Disposable {
3671
4389
  * (before 1.5.7 a replaced or closed stream could get `unavailable`
3672
4390
  * "producer absent"). Without `playback.read` a failure the SDK has not
3673
4391
  * seen yet is reported as null.
4392
+ *
4393
+ * @capability producer.av
4394
+ *
4395
+ * @example Tell the user why the stream stopped
4396
+ * The OSD explains a failure, and nothing is said when the user simply
4397
+ * opened another file. The listener runs only for stops from outside the
4398
+ * plugin: `null` for a replacement or the user's close, an error (such as
4399
+ * `stream` with the player's text) for a failure.
4400
+ * ```ts
4401
+ * declare const stream: Stream;
4402
+ * // ---cut---
4403
+ * stream.onStop((error) => {
4404
+ * if (error) void plugin.osd.show("Generator stopped: {}", [error.message]);
4405
+ * });
4406
+ * ```
4407
+ *
4408
+ * @since 1.0.0
3674
4409
  */
3675
4410
  onStop(listener: (error: PluginError | null) => void): Disposable;
3676
4411
  /**
@@ -3678,10 +4413,34 @@ export interface Stream extends Disposable {
3678
4413
  * The source closes first, then the frame connection, then the producer,
3679
4414
  * so stopping never shows as a playback failure. `dispose()` and
3680
4415
  * deactivation do the same.
4416
+ *
4417
+ * @capability producer.av and playback.control
4418
+ * @throws Whatever code closing the player's source failed with (the
4419
+ * frame connection and the producer are released either way). A
4420
+ * stream that is no longer the source just resolves.
4421
+ *
4422
+ * @example Stop the stream when the plugin's window closes
4423
+ * Closing the generator window stops the stream it feeds. `stop` closes
4424
+ * the player's source if it is still this stream and then the frame
4425
+ * connection; it resolves once both are closed.
4426
+ * ```ts
4427
+ * declare const stream: Stream;
4428
+ * declare const window: Window;
4429
+ * // ---cut---
4430
+ * window.onClose(() => stream.stop());
4431
+ * ```
4432
+ *
4433
+ * @since 1.0.0
3681
4434
  */
3682
4435
  stop(): Promise<void>;
3683
4436
  }
3684
- /** The plugin that owns "next / previous" (a playlist). */
4437
+ /**
4438
+ * The plugin that owns "next / previous" (a playlist).
4439
+ *
4440
+ * @see {@link Player.provideSequence}
4441
+ * @since 1.0.0
4442
+ * @category Playback
4443
+ */
3685
4444
  export interface SequenceProvider {
3686
4445
  /** Go to the next item (called in the provider's scope). */
3687
4446
  next(): unknown;
@@ -3695,6 +4454,10 @@ export interface SequenceProvider {
3695
4454
  /**
3696
4455
  * What {@link Player.stats} reports. Every field is the backend's own value
3697
4456
  * (libmpv properties), `null` when it has none; nothing is converted.
4457
+ *
4458
+ * @see {@link Player.stats}
4459
+ * @since 1.0.0
4460
+ * @category Playback
3698
4461
  */
3699
4462
  export interface PlaybackStats {
3700
4463
  /**
@@ -3796,23 +4559,103 @@ export interface PlaybackStats {
3796
4559
  * `stale` and do nothing when the source changed since the state you saw.
3797
4560
  *
3798
4561
  * @example Open a file and wait until it plays
4562
+ * The file opens and the plugin learns whether it started playing within
4563
+ * ten seconds. `open` resolves once the player accepted the load and
4564
+ * returns a `Media` handle; `ready` waits for playback and rejects with
4565
+ * `failed` (load error), `replaced` (another source took over) or an
4566
+ * `AbortError` when the timeout fires, which closes the media.
3799
4567
  * ```ts
3800
- * const media = await plugin.player.open("/Movies/clip.mp4");
4568
+ * const media = await plugin.player.open("/Movies/clip.mp4"); // load accepted
3801
4569
  * try {
3802
4570
  * await media.ready({ signal: AbortSignal.timeout(10_000) });
3803
4571
  * } catch (error) {
3804
- * void plugin.osd.show(`Cannot play: ${(error as Error).message}`);
4572
+ * void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
3805
4573
  * }
3806
4574
  * ```
4575
+ *
4576
+ * @remarks
4577
+ * `plugin.player` is the player's one source: what is open, its phase and
4578
+ * position, and the controls. `state` is a snapshot the SDK keeps current
4579
+ * from the player's events (with `events`) and refreshes after this
4580
+ * plugin's own calls; `onChange` runs after it changed. Every control is
4581
+ * conditional: it acts on the state the plugin last saw and fails with
4582
+ * `stale` when the source changed meanwhile: another file, a close, or any
4583
+ * pause, seek or speed change by the user or another plugin (see
4584
+ * {@link Conditional}). The player keeps the source when a plugin stops;
4585
+ * only the plugin's own handles (a stream, its sequence provider, its
4586
+ * output device) are withdrawn.
4587
+ *
4588
+ * @capability `playback.read` to read; `playback.control` to control;
4589
+ * `events` to follow changes made elsewhere; `producer.av` for
4590
+ * {@link Player.openStream}.
4591
+ *
4592
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md), [Control playback](../../docs/how-to/control-playback.md)
4593
+ * @since 1.0.0
4594
+ * @category Playback
3807
4595
  */
3808
4596
  export interface Player {
3809
4597
  /** Latest state; always newer than anything delivered before. */
3810
4598
  readonly state: PlayerState;
3811
- /** The state changed (needs `events` for outside changes). */
4599
+ /**
4600
+ * The state changed (needs `events` for outside changes).
4601
+ *
4602
+ * @capability events (for changes made elsewhere; without it the listener
4603
+ * runs only after this plugin's own calls)
4604
+ *
4605
+ * @example Show the position while it plays
4606
+ * The OSD block shows the position, updated as playback moves. The SDK
4607
+ * updates `player.state` from the player's events and then calls the
4608
+ * listener with the new snapshot; the handler only reads it.
4609
+ * ```ts
4610
+ * const info = plugin.osd.panel();
4611
+ * plugin.player.onChange((state) => {
4612
+ * // `position` is null while nothing is open.
4613
+ * info.set(state.position === null ? null : "{} s", [state.position?.toFixed(0) ?? ""]);
4614
+ * });
4615
+ * ```
4616
+ *
4617
+ * @since 1.0.0
4618
+ */
3812
4619
  onChange(listener: (state: PlayerState) => void): Disposable;
3813
- /** Read the state from the player now (`playback.read`). */
4620
+ /**
4621
+ * Read the state from the player now (`playback.read`).
4622
+ *
4623
+ * @capability playback.read
4624
+ * @throws `capability` (rejects) The manifest does not declare
4625
+ * `playback.read`.
4626
+ *
4627
+ * @example Read the state before deciding
4628
+ * A command reads the newest state from the player before acting, even
4629
+ * without `events`. `refresh` asks the player and resolves with the new
4630
+ * snapshot; `player.state` holds it afterwards.
4631
+ * ```ts
4632
+ * const state = await plugin.player.refresh();
4633
+ * if (state.phase === "ready") await plugin.osd.show("{} s left", [((state.duration ?? 0) - (state.position ?? 0)).toFixed(0)]);
4634
+ * ```
4635
+ *
4636
+ * @since 1.0.0
4637
+ */
3814
4638
  refresh(): Promise<PlayerState>;
3815
- /** True if nothing changed the source since `state` was observed. */
4639
+ /**
4640
+ * True if nothing changed the source since `state` was observed.
4641
+ *
4642
+ * @capability None.
4643
+ *
4644
+ * @example Drop a result that arrives after the file changed
4645
+ * A slow lookup for the current file is shown only if that file is still
4646
+ * open. `isCurrent` compares the snapshot taken before the work with the
4647
+ * SDK's newest state; it is false once the source changed in any way:
4648
+ * another file, a close, or a pause, seek or speed change by anyone.
4649
+ * ```ts
4650
+ * declare function lookUpTitle(path: string): Promise<string>;
4651
+ * // ---cut---
4652
+ * const seen = plugin.player.state;
4653
+ * const title = await lookUpTitle(seen.source ?? "");
4654
+ * if (plugin.player.isCurrent(seen)) await plugin.osd.show("{}", [title]); // still the same file
4655
+ * ```
4656
+ *
4657
+ * @since 1.0.0
4658
+ */
3816
4659
  isCurrent(state: PlayerState): boolean;
3817
4660
  /**
3818
4661
  * Open a path or URL, replacing the current source (unless `ifCurrent` no
@@ -3823,6 +4666,37 @@ export interface Player {
3823
4666
  * {@link Resolver} are resolved first, and so are local files of an
3824
4667
  * extension a file resolver claims (the source is then a stream that
3825
4668
  * plugin generates; `paused` reaches it).
4669
+ *
4670
+ * @capability playback.control
4671
+ * @throws `stale` (rejects) `ifCurrent` no longer holds; nothing was opened.
4672
+ * @throws `replaced` (rejects) Accepted, then another source took over
4673
+ * before it finished (for example while a URL was still being resolved).
4674
+ * @throws `invalid` (rejects) An empty `source`.
4675
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the
4676
+ * source is closed if it was opened.
4677
+ * @throws `busy` (rejects) The address needs a resolver plugin and the
4678
+ * player is already resolving 32 addresses.
4679
+ * @throws `backend` (rejects) A resolver plugin handled the address or
4680
+ * file and failed (its message), or the resolution was cancelled or
4681
+ * expired.
4682
+ * @throws `unsupported` (rejects) The player refused what a resolver
4683
+ * returned (for example split streams with different headers).
4684
+ * @throws `unavailable` (rejects) The stream a file resolver prepared was
4685
+ * not ready to play.
4686
+ * @throws `capability` (rejects) The manifest does not declare
4687
+ * `playback.control`.
4688
+ *
4689
+ * @example Open a file paused
4690
+ * The file opens at its start without playing. `open` resolves once the
4691
+ * player accepted the load and returns a {@link Media} handle for that
4692
+ * source; `ready` then waits until it can play (here: until it is loaded
4693
+ * and paused).
4694
+ * ```ts
4695
+ * const media = await plugin.player.open("/Movies/clip.mp4", { paused: true });
4696
+ * await media.ready(); // rejects `failed` when the file cannot be loaded
4697
+ * ```
4698
+ *
4699
+ * @since 1.0.0
3826
4700
  */
3827
4701
  open(source: string, options?: {
3828
4702
  paused?: boolean;
@@ -3840,74 +4714,395 @@ export interface Player {
3840
4714
  * (`timeout`), `stream`, `device`, or `replaced` when another source
3841
4715
  * replaced it (or it was closed) before it ran.
3842
4716
  *
3843
- * @example
4717
+ * @example Play pictures the plugin draws itself
4718
+ * The player shows a 320×240 picture the plugin renders, with silence as
4719
+ * its sound, as the current source. The SDK pulls frames on the audio
4720
+ * clock: each `frame()` returns one picture (RGBA bytes) and the audio
4721
+ * that plays with it, here 1/60 s, so the picture runs at 60 frames a
4722
+ * second; `frame()` may run several times back to back to refill the
4723
+ * lead. `onStop` tells the plugin when and why the stream ended.
3844
4724
  * ```ts
3845
- * const frame = new Uint8Array(320 * 240 * 4);
3846
- * const silence = new Float32Array(2 * 800); // 1/60 s of stereo
4725
+ * declare function render(into: Uint8Array): Uint8Array;
4726
+ * // ---cut---
4727
+ * const frame = new Uint8Array(320 * 240 * 4); // RGBA, reused per frame
4728
+ * const silence = new Float32Array(2 * 800); // 1/60 s of stereo at 48 kHz
3847
4729
  * const stream = await plugin.player.openStream({
3848
4730
  * width: 320,
3849
4731
  * height: 240,
3850
4732
  * frame: () => ({ video: render(frame), audio: silence }),
3851
4733
  * });
4734
+ * // `error` is null when the stream stopped normally.
3852
4735
  * stream.onStop((error) => error && plugin.osd.show(error.message));
3853
4736
  * ```
4737
+ *
4738
+ * @capability producer.av and playback.control
4739
+ * @throws `timeout` (rejects) A {@link StreamStartError}: the stream did not
4740
+ * start in time.
4741
+ * @throws `stream` (rejects) The player failed to start the stream.
4742
+ * @throws `device` (rejects) The audio device was not ready.
4743
+ * @throws `replaced` (rejects) Another source replaced it, or it was closed,
4744
+ * before it ran.
4745
+ * @throws `invalid` (rejects) Bad options (a size or a frame function).
4746
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed,
4747
+ * while the stream was starting (an `AbortError`).
4748
+ * @since 1.0.0
3854
4749
  */
3855
4750
  openStream(options: StreamOptions): Promise<Stream>;
3856
- /** Pause or resume. */
4751
+ /**
4752
+ * Pause or resume.
4753
+ *
4754
+ * @capability playback.control
4755
+ * @throws `stale` (rejects) The source changed since the state the call is
4756
+ * based on (another file, a close, or any pause, seek
4757
+ * or speed change by the user or another plugin); nothing was done.
4758
+ * @throws `invalid` (rejects) Nothing is open.
4759
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4760
+ * control.
4761
+ * @throws `capability` (rejects) The manifest does not declare
4762
+ * `playback.control`.
4763
+ *
4764
+ * @example Pause, but only the file the user was watching
4765
+ * A "pause on lock" helper pauses the current file and does nothing when
4766
+ * another file was opened in between. The call is conditional on
4767
+ * `player.state` by default; `stale` means the source changed and the
4768
+ * pause was not applied, which is the intended outcome here.
4769
+ * ```ts
4770
+ * try {
4771
+ * await plugin.player.setPaused(true);
4772
+ * } catch (error) {
4773
+ * if ((error as PluginError).code !== "stale") throw error; // another file now
4774
+ * }
4775
+ * ```
4776
+ *
4777
+ * @since 1.0.0
4778
+ */
3857
4779
  setPaused(paused: boolean, options?: Conditional): Promise<void>;
3858
- /** Jump to `seconds`. */
3859
- seek(seconds: number, options?: Conditional): Promise<void>;
3860
- /** Set the playback speed. */
3861
- setRate(rate: number, options?: Conditional): Promise<void>;
3862
- /** Play from the start (the "replay" after the end). */
3863
- restart(options?: Conditional): Promise<void>;
3864
- /** Close the current source. */
3865
- close(options?: Conditional): Promise<void>;
3866
- /** Start a drag gesture from the current state. */
3867
- scrub(): Scrub;
3868
4780
  /**
3869
- * Device output; last write wins and volume is applied before mute.
3870
- * Resolves `true` when this request was applied, `false` if superseded.
3871
- * `volume` is 0–200.
4781
+ * Jump to `seconds`.
4782
+ *
4783
+ * @capability playback.control
4784
+ * @throws `stale` (rejects) The source changed since the state the call is
4785
+ * based on (another file, a close, or any pause, seek
4786
+ * or speed change by the user or another plugin); nothing was done.
4787
+ * @throws `invalid` (rejects) Nothing is open, or `seconds` is negative or
4788
+ * not finite.
4789
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4790
+ * cannot seek.
4791
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4792
+ * control.
4793
+ * @throws `capability` (rejects) The manifest does not declare
4794
+ * `playback.control`.
4795
+ *
4796
+ * @example Jump back ten seconds
4797
+ * Playback jumps ten seconds back, never before the start. The target is
4798
+ * computed from the snapshot the call is conditional on, so if the user
4799
+ * opened another file meanwhile the seek fails with `stale` instead of
4800
+ * landing in the wrong file.
4801
+ * ```ts
4802
+ * const { position } = plugin.player.state;
4803
+ * if (position !== null && plugin.player.state.can.seek)
4804
+ * await plugin.player.seek(Math.max(0, position - 10)); // seconds
4805
+ * ```
4806
+ *
4807
+ * @since 1.0.0
3872
4808
  */
3873
- setOutput(output: {
3874
- volume?: number;
3875
- muted?: boolean;
3876
- }): Promise<boolean>;
4809
+ seek(seconds: number, options?: Conditional): Promise<void>;
3877
4810
  /**
3878
- * Become the sequence provider (one at a time; another provider rejects with
3879
- * `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
4811
+ * Set the playback speed.
4812
+ *
4813
+ * @capability playback.control
4814
+ * @throws `stale` (rejects) The source changed since the state the call is
4815
+ * based on (another file, a close, or any pause, seek
4816
+ * or speed change by the user or another plugin); nothing was done.
4817
+ * @throws `invalid` (rejects) Nothing is open, or `rate` is outside
4818
+ * 0.05–10.
4819
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4820
+ * cannot change speed.
4821
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4822
+ * control.
4823
+ * @throws `capability` (rejects) The manifest does not declare
4824
+ * `playback.control`.
3880
4825
  *
3881
- * @example
4826
+ * @example Play at one and a half times the speed
4827
+ * Playback speeds up to 1.5×, with the speed on the OSD. `setRate` sets
4828
+ * the speed directly (the player's own keys step it by 0.1 within
4829
+ * 0.25–4); `state.can.rate` is false for sources that cannot change speed.
3882
4830
  * ```ts
3883
- * plugin.player.provideSequence({
3884
- * next: () => playAt(index + 1),
3885
- * previous: () => playAt(index - 1),
3886
- * canNext: () => index + 1 < items.length,
3887
- * canPrevious: () => index > 0,
3888
- * });
4831
+ * if (plugin.player.state.can.rate) {
4832
+ * await plugin.player.setRate(1.5);
4833
+ * await plugin.osd.show("Speed {}×", [1.5]);
4834
+ * }
3889
4835
  * ```
4836
+ *
4837
+ * @since 1.0.0
3890
4838
  */
3891
- provideSequence(provider: SequenceProvider): Disposable;
3892
- /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
3893
- next(): Promise<void>;
3894
- /** Ask the provider to go back. Rejects `unavailable` when it cannot. */
3895
- previous(): Promise<void>;
4839
+ setRate(rate: number, options?: Conditional): Promise<void>;
3896
4840
  /**
3897
- * Codec, size, frame counts and backend of the current playback.
3898
- * `playback.read`; `video` and `audio` are `null` without a source.
4841
+ * Play from the start (the "replay" after the end).
4842
+ *
4843
+ * @capability playback.control
4844
+ * @throws `stale` (rejects) The source changed since the state the call is
4845
+ * based on (another file, a close, or any pause, seek
4846
+ * or speed change by the user or another plugin); nothing was done.
4847
+ * @throws `invalid` (rejects) Nothing is open.
4848
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4849
+ * cannot restart.
4850
+ * @throws `capability` (rejects) The manifest does not declare
4851
+ * `playback.control`.
4852
+ *
4853
+ * @example Replay a file after it ended
4854
+ * A Replay button starts the file again from its start once it has ended.
4855
+ * `restart` plays the current source from the beginning and keeps it the
4856
+ * same source, so a {@link Media} handle stays current.
4857
+ * ```ts
4858
+ * if (plugin.player.state.phase === "ended") await plugin.player.restart();
4859
+ * ```
4860
+ *
4861
+ * @since 1.0.0
3899
4862
  */
3900
- stats(): Promise<PlaybackStats>;
4863
+ restart(options?: Conditional): Promise<void>;
4864
+ /**
4865
+ * Close the current source.
4866
+ *
4867
+ * @capability playback.control
4868
+ * @throws `stale` (rejects) The source changed since the state the call is
4869
+ * based on (another file, a close, or any pause, seek
4870
+ * or speed change by the user or another plugin); nothing was done.
4871
+ * @throws `capability` (rejects) The manifest does not declare
4872
+ * `playback.control`.
4873
+ *
4874
+ * @example Close the file when the sleep timer runs out
4875
+ * After 30 minutes the open file is closed, unless the user opened another
4876
+ * one meanwhile. The snapshot taken when the timer started is passed as
4877
+ * `ifCurrent`, so the close applies only to that source; `stale` means it
4878
+ * was replaced.
4879
+ * ```ts
4880
+ * const seen = plugin.player.state;
4881
+ * setTimeout(() => {
4882
+ * // Only the source that was open when the timer started.
4883
+ * plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
4884
+ * if (error.code !== "stale") plugin.osd.show("Could not close: {}", [error.code]);
4885
+ * });
4886
+ * }, 30 * 60 * 1000);
4887
+ * ```
4888
+ *
4889
+ * @since 1.0.0
4890
+ */
4891
+ close(options?: Conditional): Promise<void>;
4892
+ /**
4893
+ * Start a drag gesture from the current state.
4894
+ *
4895
+ * @capability playback.control
4896
+ *
4897
+ * @example Start a gesture when the user grabs the slider
4898
+ * A drag on the plugin's seek slider becomes one gesture whose seeks never
4899
+ * pile up. `scrub()` captures the current state; the gesture's seeks are
4900
+ * conditional on it and on each other, and it ends on its own when
4901
+ * someone else changes the source.
4902
+ * ```ts
4903
+ * declare const bar: Panel;
4904
+ * // ---cut---
4905
+ * let scrub: Scrub | null = null;
4906
+ * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
4907
+ * bar.on("dragEnd", () => scrub?.end());
4908
+ * ```
4909
+ *
4910
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
4911
+ * @since 1.0.0
4912
+ */
4913
+ scrub(): Scrub;
4914
+ /**
4915
+ * Device output; last write wins and volume is applied before mute.
4916
+ * Resolves `true` when this request was applied, `false` if superseded.
4917
+ * `volume` is 0–200.
4918
+ *
4919
+ * @capability playback.control
4920
+ * @throws `invalid` (rejects) `volume` outside 0–200, or `muted` not a
4921
+ * boolean.
4922
+ * @throws `capability` (rejects) The manifest does not declare
4923
+ * `playback.control`.
4924
+ *
4925
+ * @example Duck the volume during an announcement
4926
+ * The volume drops to 30 while the plugin's announcement plays, then
4927
+ * returns. Writes are "latest wins": the promise resolves `true` when this
4928
+ * write was applied and `false` when a newer one replaced it first, which
4929
+ * is not an error.
4930
+ * ```ts
4931
+ * const before = plugin.player.state.volume; // 0–200
4932
+ * await plugin.player.setOutput({ volume: 30 });
4933
+ * // … the announcement …
4934
+ * await plugin.player.setOutput({ volume: before });
4935
+ * ```
4936
+ *
4937
+ * @since 1.0.0
4938
+ */
4939
+ setOutput(output: {
4940
+ volume?: number;
4941
+ muted?: boolean;
4942
+ }): Promise<boolean>;
4943
+ /**
4944
+ * Become the sequence provider, first come first served: while another
4945
+ * plugin holds it (the built-in playlist registers at activation), this
4946
+ * one is refused with `conflict` (`sequence-taken`). That does not fail
4947
+ * activation: the player shows a warning ({@link PluginInfo.warnings});
4948
+ * a refusal after activation goes to the scope's `onError`. Dispose to
4949
+ * stop.
4950
+ *
4951
+ * @example Drive the player's Next and Previous from a playlist
4952
+ * The player's Next and Previous items and keys play the plugin's list.
4953
+ * The player calls `next` and `previous` when the user asks; `canNext`
4954
+ * and `canPrevious` are evaluated again after every handler the SDK runs,
4955
+ * and while they return false the player disables its items ("No next
4956
+ * item").
4957
+ * ```ts
4958
+ * declare const items: string[];
4959
+ * let index = 0;
4960
+ * // ---cut---
4961
+ * const playAt = async (at: number) => {
4962
+ * index = at;
4963
+ * await plugin.player.open(items[at]!);
4964
+ * };
4965
+ * // Called by the player's Next / Previous items, keys and buttons.
4966
+ * plugin.player.provideSequence({
4967
+ * next: () => playAt(index + 1),
4968
+ * previous: () => playAt(index - 1),
4969
+ * canNext: () => index + 1 < items.length,
4970
+ * canPrevious: () => index > 0,
4971
+ * });
4972
+ * ```
4973
+ *
4974
+ * @capability playback.control
4975
+ * @since 1.0.0
4976
+ */
4977
+ provideSequence(provider: SequenceProvider): Disposable;
4978
+ /**
4979
+ * Ask the provider to go forward / back. Rejects `unavailable` when it cannot.
4980
+ *
4981
+ * @capability playback.control
4982
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
4983
+ * no next item.
4984
+ * @throws `capability` (rejects) The manifest does not declare
4985
+ * `playback.control`.
4986
+ *
4987
+ * @example Skip to the next item from a plugin button
4988
+ * The plugin's own Next button plays the next item of whichever plugin
4989
+ * provides the sequence (the playlist, usually). `next` asks the
4990
+ * provider, the same as the player's Next key; `unavailable` means there
4991
+ * is no next item, which the button treats as the end of the list.
4992
+ * ```ts
4993
+ * try {
4994
+ * await plugin.player.next(); // as the player's Next key does
4995
+ * } catch (error) {
4996
+ * if ((error as PluginError).code !== "unavailable") throw error;
4997
+ * await plugin.osd.show("End of the list");
4998
+ * }
4999
+ * ```
5000
+ *
5001
+ * @since 1.0.0
5002
+ */
5003
+ next(): Promise<void>;
5004
+ /**
5005
+ * Ask the provider to go back. Rejects `unavailable` when it cannot.
5006
+ *
5007
+ * @capability playback.control
5008
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
5009
+ * no previous item.
5010
+ * @throws `capability` (rejects) The manifest does not declare
5011
+ * `playback.control`.
5012
+ *
5013
+ * @example Go back to the previous item
5014
+ * The plugin's Previous button asks the sequence provider for the item
5015
+ * before the current one, like the player's PageUp. It is disabled while
5016
+ * `state.can.previous` is false, which the provider's `canPrevious`
5017
+ * decides.
5018
+ * ```ts
5019
+ * plugin.commands.add({
5020
+ * id: "previous",
5021
+ * title: "Previous item",
5022
+ * // The provider's `canPrevious`, as the player reports it.
5023
+ * enabled: () => plugin.player.state.can.previous,
5024
+ * run: () => plugin.player.previous(),
5025
+ * });
5026
+ * ```
5027
+ *
5028
+ * @since 1.0.0
5029
+ */
5030
+ previous(): Promise<void>;
5031
+ /**
5032
+ * Codec, size, frame counts and backend of the current playback.
5033
+ * `playback.read`; `video` and `audio` are `null` without a source.
5034
+ *
5035
+ * @capability playback.read
5036
+ * @throws `capability` (rejects) The manifest does not declare
5037
+ * `playback.read`.
5038
+ *
5039
+ * @example Show the video codec and size
5040
+ * The OSD shows something like "h264 1920×1080". `stats` asks the player
5041
+ * for the backend's own values at that moment; `video` is `null` without
5042
+ * a video stream (nothing open, an audio file).
5043
+ * ```ts
5044
+ * const { video } = await plugin.player.stats();
5045
+ * if (video) await plugin.osd.show("{} {}×{}", [video.codec ?? "?", video.width ?? 0, video.height ?? 0]);
5046
+ * ```
5047
+ *
5048
+ * @since 1.0.0
5049
+ */
5050
+ stats(): Promise<PlaybackStats>;
3901
5051
  /**
3902
5052
  * Add an external audio file (absolute local path) to the current media and,
3903
5053
  * unless `select: false`, select it. Resolves with the new track (`external`,
3904
5054
  * not exportable). It stays until the source changes. Rejects `invalid` for a
3905
5055
  * bad path, `unavailable` without ready media, `backend` when it cannot load.
5056
+ *
5057
+ * @capability playback.control
5058
+ * @throws `invalid` (rejects) Not an absolute path to a file.
5059
+ * @throws `unavailable` (rejects) No ready media.
5060
+ * @throws `backend` (rejects) The player could not load the file.
5061
+ * @throws `stale` (rejects) `ifCurrent` no longer holds.
5062
+ * @throws `capability` (rejects) The manifest does not declare
5063
+ * `playback.control`.
5064
+ *
5065
+ * @example Add a dubbed audio file and switch to it
5066
+ * A separate audio file plays with the film instead of its own sound.
5067
+ * `addAudioTrack` attaches the file to the current media and selects it;
5068
+ * the track stays until another source opens.
5069
+ * ```ts
5070
+ * declare const dubPath: string;
5071
+ * // ---cut---
5072
+ * const track = await plugin.player.addAudioTrack(dubPath); // selected too
5073
+ * await plugin.osd.show("Audio: {}", [track.title ?? dubPath]);
5074
+ * ```
5075
+ *
5076
+ * @since 1.0.0
3906
5077
  */
3907
5078
  addAudioTrack(path: string, options?: {
3908
5079
  select?: boolean;
3909
5080
  } & Conditional): Promise<AudioTrack>;
3910
- /** Select one of `state.audioTracks` by id; `invalid` for an unknown id. */
5081
+ /**
5082
+ * Select one of `state.audioTracks` by id; `invalid` for an unknown id.
5083
+ *
5084
+ * @capability playback.control
5085
+ * @throws `invalid` (rejects) No track with this id.
5086
+ * @throws `unavailable` (rejects) No ready media.
5087
+ * @throws `stale` (rejects) The source changed since the state the call is
5088
+ * based on (another file, a close, or any pause, seek
5089
+ * or speed change by the user or another plugin).
5090
+ * @throws `capability` (rejects) The manifest does not declare
5091
+ * `playback.control`.
5092
+ *
5093
+ * @example Switch to the next audio track
5094
+ * Each press of the plugin's key moves to the next audio track of the
5095
+ * file. The tracks and the selected one are in `state.audioTracks`;
5096
+ * `selectAudioTrack` resolves with the new state.
5097
+ * ```ts
5098
+ * const audioTracks = plugin.player.state.audioTracks ?? []; // null: not known yet
5099
+ * const at = audioTracks.findIndex((track) => track.selected);
5100
+ * const next = audioTracks[(at + 1) % audioTracks.length];
5101
+ * if (next) await plugin.player.selectAudioTrack(next.id);
5102
+ * ```
5103
+ *
5104
+ * @since 1.0.0
5105
+ */
3911
5106
  selectAudioTrack(id: number, options?: Conditional): Promise<PlayerState>;
3912
5107
  /**
3913
5108
  * One frame or to the next / previous keyframe (pauses for frames). Rejects
@@ -3917,6 +5112,29 @@ export interface Player {
3917
5112
  * Resolves once the new position is in effect, so `player.state.position`
3918
5113
  * is current afterwards. One step at a time: a second call while one is
3919
5114
  * in flight rejects `busy`.
5115
+ *
5116
+ * @capability playback.control
5117
+ * @throws `unsupported` (rejects) Nothing playable to step through (no
5118
+ * moving video: audio only, cover art, a generated stream), a backward
5119
+ * frame step on a source that cannot seek, or a keyframe step in either
5120
+ * direction on a source that cannot seek.
5121
+ * @throws `busy` (rejects) A step is still in flight.
5122
+ * @throws `stale` (rejects) The source changed since the state the call is
5123
+ * based on (another file, a close, or any pause, seek
5124
+ * or speed change by the user or another plugin).
5125
+ * @throws `capability` (rejects) The manifest does not declare
5126
+ * `playback.control`.
5127
+ *
5128
+ * @example Step one frame forward for a close look
5129
+ * The picture advances by one frame and playback pauses. `step` resolves
5130
+ * once the new position is in effect, so `state.position` is current
5131
+ * afterwards; a second step while one is running rejects with `busy`.
5132
+ * ```ts
5133
+ * await plugin.player.step("frame", "forward");
5134
+ * await plugin.osd.show("{} s", [plugin.player.state.position?.toFixed(3) ?? "?"]);
5135
+ * ```
5136
+ *
5137
+ * @since 1.0.0
3920
5138
  */
3921
5139
  step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
3922
5140
  /**
@@ -3939,6 +5157,23 @@ export interface Player {
3939
5157
  *
3940
5158
  * Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
3941
5159
  * the manifest when you rely on it.
5160
+ *
5161
+ * @capability playback.control
5162
+ * @throws `invalid` (rejects) `seconds` outside 0.1–3600 or not in tenths,
5163
+ * or `keyframes` not a boolean.
5164
+ * @throws `capability` (rejects) The manifest does not declare
5165
+ * `playback.control`.
5166
+ *
5167
+ * @example Make ← and → jump 10 seconds to keyframes
5168
+ * The arrow keys now jump 10 seconds and land on keyframes, which is fast
5169
+ * on large files. The newest call of any plugin wins and is withdrawn
5170
+ * when that plugin stops, so a plugin offering this as a setting applies
5171
+ * it on activation.
5172
+ * ```ts
5173
+ * await plugin.player.setStepSeek({ seconds: 10, keyframes: true });
5174
+ * ```
5175
+ *
5176
+ * @since 1.0.0
3942
5177
  */
3943
5178
  setStepSeek(options: {
3944
5179
  seconds: number;
@@ -3948,6 +5183,23 @@ export interface Player {
3948
5183
  * The chapters of the current media (empty without media or chapters),
3949
5184
  * read on demand; `sourceId` tells which source they belong to.
3950
5185
  * `playback.read`.
5186
+ *
5187
+ * @capability playback.read
5188
+ * @throws `capability` (rejects) The manifest does not declare
5189
+ * `playback.read`.
5190
+ *
5191
+ * @example List the chapters of the open file
5192
+ * The plugin reads the chapter titles of the current file to show them in
5193
+ * its panel. `chapters` asks the player when called; `sourceId` says which
5194
+ * source they belong to, so a reply for a file that was replaced meanwhile
5195
+ * can be dropped.
5196
+ * ```ts
5197
+ * const { sourceId, chapters } = await plugin.player.chapters();
5198
+ * if (sourceId === plugin.player.state.sourceId) // still the same file
5199
+ * console.log(chapters.map((chapter) => chapter.title));
5200
+ * ```
5201
+ *
5202
+ * @since 1.0.0
3951
5203
  */
3952
5204
  chapters(): Promise<{
3953
5205
  /** The source the chapters belong to (compare with `state.sourceId`). */
@@ -3961,13 +5213,59 @@ export interface Player {
3961
5213
  * stops it. After the last jump back `state.loop` becomes null.
3962
5214
  * `invalid` out of range, `unsupported` for a source that cannot seek,
3963
5215
  * `unavailable` without ready media. A new source clears it.
5216
+ *
5217
+ * @capability playback.control
5218
+ * @throws `invalid` (rejects) `a`/`b` out of range or `count` not a positive
5219
+ * integer.
5220
+ * @throws `unsupported` (rejects) The source cannot seek, or is a generated
5221
+ * stream.
5222
+ * @throws `unavailable` (rejects) No ready media.
5223
+ * @throws `stale` (rejects) The source changed since the state the call is
5224
+ * based on (another file, a close, or any pause, seek
5225
+ * or speed change by the user or another plugin).
5226
+ * @throws `capability` (rejects) The manifest does not declare
5227
+ * `playback.control`.
5228
+ *
5229
+ * @example Loop a passage three times
5230
+ * Playback repeats 1:00–1:30 three times, then goes on. The player jumps
5231
+ * back to `a` each time it reaches `b`; after the last repeat
5232
+ * `state.loop` becomes `null`, and a new source clears the loop.
5233
+ * ```ts
5234
+ * await plugin.player.setLoop({ a: 60, b: 90, count: 3 }); // seconds
5235
+ * ```
5236
+ *
5237
+ * @since 1.0.0
3964
5238
  */
3965
5239
  setLoop(loop: {
3966
5240
  a: number;
3967
5241
  b: number;
3968
5242
  count?: number;
3969
5243
  } | null, options?: Conditional): Promise<void>;
3970
- /** The output devices present now (not the system default entry). `playback.read`. */
5244
+ /**
5245
+ * The output devices present now (not the system default entry). `playback.read`.
5246
+ *
5247
+ * @capability playback.read
5248
+ * @throws `capability` (rejects) The manifest does not declare
5249
+ * `playback.read`.
5250
+ *
5251
+ * @example Offer the audio output devices in a menu
5252
+ * A submenu lists the output devices present now, with a radio mark on the
5253
+ * one in use. `audioDevices` asks the player when called (devices come and
5254
+ * go), and `state.audioOutput` tells which one is active.
5255
+ * ```ts
5256
+ * // The devices present now; build the menu again when they change.
5257
+ * for (const device of await plugin.player.audioDevices())
5258
+ * plugin.commands.add({
5259
+ * id: `device.${device.id}`,
5260
+ * path: ["@audio", "Output", device.name],
5261
+ * radio: true,
5262
+ * checked: () => plugin.player.state.audioOutput?.active === device.id,
5263
+ * run: () => plugin.player.setAudioDevice(device.id),
5264
+ * });
5265
+ * ```
5266
+ *
5267
+ * @since 1.0.0
5268
+ */
3971
5269
  audioDevices(): Promise<AudioDevice[]>;
3972
5270
  /**
3973
5271
  * Play on this device while it is present, else on the system default
@@ -3977,6 +5275,23 @@ export interface Player {
3977
5275
  * Unknown ids are accepted (`state.audioOutput.active` stays `null`); a
3978
5276
  * malformed id is `invalid`. Reverts to the default when this plugin
3979
5277
  * stops; save the choice yourself to restore it on activation.
5278
+ *
5279
+ * @capability playback.control
5280
+ * @throws `invalid` (rejects) A malformed id.
5281
+ * @throws `capability` (rejects) The manifest does not declare
5282
+ * `playback.control`.
5283
+ *
5284
+ * @example Restore the user's output device on start
5285
+ * The plugin plays on the device the user picked last time, or on the
5286
+ * system default when it is not connected. The choice lasts only for
5287
+ * this run and is withdrawn when the plugin stops, so the plugin saves
5288
+ * it itself and sets it again on activation.
5289
+ * ```ts
5290
+ * const store = plugin.storage.json("output", { defaults: { device: null as string | null } });
5291
+ * await plugin.player.setAudioDevice(store.value.device); // null: the system default
5292
+ * ```
5293
+ *
5294
+ * @since 1.0.0
3980
5295
  */
3981
5296
  setAudioDevice(id: string | null): Promise<void>;
3982
5297
  }
@@ -4001,9 +5316,45 @@ export interface Player {
4001
5316
  * (`plugin.window.onChange`), which also changes when the user toggles it.
4002
5317
  * Players before 0.5.2 reject it with `invalid`: set
4003
5318
  * `"minPlayerVersion": "0.5.2"` when you use it.
5319
+ *
5320
+ * @see {@link Host.run}
5321
+ * @since 1.0.0
5322
+ * @category Commands
4004
5323
  */
4005
5324
  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";
4006
- /** `plugin.host`: the player's own core actions. */
5325
+ /**
5326
+ * `plugin.host`: the player's own core actions.
5327
+ *
5328
+ * @remarks
5329
+ * The player's core actions (play/pause, stop, step seeks, speed, volume,
5330
+ * fullscreen…) are commands of the player itself, `host/<action>` in the
5331
+ * binding table. Running them through `host.run` gives a plugin's own
5332
+ * buttons exactly the player's behaviour: the same checks, the same
5333
+ * accumulation of repeated presses and the same on-screen text, instead of
5334
+ * reimplementing them with {@link Player} calls.
5335
+ *
5336
+ * @capability commands
5337
+ *
5338
+ * @example Give a custom bar the player's own Stop and Mute
5339
+ * The plugin's bar buttons stop playback and mute exactly as the player's
5340
+ * own keys do, with the same on-screen text. Each callback runs a core
5341
+ * action; the player applies its checks and rejects with `unavailable`
5342
+ * while the action is disabled, which the bar ignores.
5343
+ * ```ts
5344
+ * declare const bar: Panel;
5345
+ * // ---cut---
5346
+ * const run = (action: HostAction) =>
5347
+ * plugin.host.run(action).catch((error: PluginError) => {
5348
+ * if (error.code !== "unavailable") throw error; // disabled right now
5349
+ * });
5350
+ * bar.on("stop", () => run("stop"));
5351
+ * bar.on("mute", () => run("mute"));
5352
+ * ```
5353
+ *
5354
+ * @see {@link HostAction}, {@link Player}
5355
+ * @since 1.0.0
5356
+ * @category Commands
5357
+ */
4007
5358
  export interface Host {
4008
5359
  /**
4009
5360
  * Run a core action exactly like its menu item or key: same checks, same
@@ -4013,10 +5364,31 @@ export interface Host {
4013
5364
  *
4014
5365
  * Prefer this over reimplementing playback buttons.
4015
5366
  *
4016
- * @example
5367
+ * @example Make a bar button play and pause like Space
5368
+ * The plugin's own Play button behaves exactly like the player's Space
5369
+ * key, including its on-screen text. `host.run` runs the core action
5370
+ * with the player's checks; it resolves when the action finished, and
5371
+ * rejects with `unavailable` while the action is disabled (nothing open).
4017
5372
  * ```ts
4018
- * bar.on("playPause", () => plugin.host.run("play-pause"));
5373
+ * declare const bar: Panel;
5374
+ * // ---cut---
5375
+ * bar.on("playPause", async () => {
5376
+ * try {
5377
+ * await plugin.host.run("play-pause");
5378
+ * } catch (error) {
5379
+ * if ((error as PluginError).code !== "unavailable") throw error;
5380
+ * // Nothing to play: the button simply does nothing.
5381
+ * }
5382
+ * });
4019
5383
  * ```
5384
+ *
5385
+ * @capability commands
5386
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5387
+ * open, no window for `fullscreen`).
5388
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5389
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
5390
+ *
5391
+ * @since 1.0.0
4020
5392
  */
4021
5393
  run(action: HostAction): Promise<void>;
4022
5394
  /**
@@ -4036,10 +5408,22 @@ export interface Host {
4036
5408
  * `seconds` and seek by the `setStepSeek` step)
4037
5409
  * @since 1.5.19
4038
5410
  *
4039
- * @example
5411
+ * @example Add a 30-second skip that adds up with the arrow keys
5412
+ * The plugin's "+30 s" button seeks forward 30 seconds and shows the same
5413
+ * text as →. Passing `seconds` makes the core step seek use that step
5414
+ * with the player's mode (exact or keyframes); quick presses of it and of
5415
+ * → add up into one seek.
4040
5416
  * ```ts
5417
+ * declare const bar: Panel;
5418
+ * // ---cut---
4041
5419
  * bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
4042
5420
  * ```
5421
+ *
5422
+ * @capability commands
5423
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5424
+ * open, no window for `fullscreen`).
5425
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5426
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
4043
5427
  */
4044
5428
  run(action: "seek-backward" | "seek-forward", options: {
4045
5429
  seconds: number;
@@ -4058,11 +5442,16 @@ export interface Host {
4058
5442
  *
4059
5443
  * @capability ui.osd
4060
5444
  *
4061
- * @example Live playback numbers
5445
+ * @example Show live playback numbers over the video
5446
+ * A block over the video shows the position and the duration, updated
5447
+ * as playback moves. `player.onChange` runs after the SDK updated the
5448
+ * state (with `events`); `set` never throws and sends only the newest
5449
+ * text while one is in flight, so calling it on every change is fine.
4062
5450
  * ```ts
4063
5451
  * const info = plugin.osd.panel();
4064
5452
  * plugin.player.onChange((state) =>
4065
- * info.set(`${state.position?.toFixed(1) ?? "–"} / ${state.duration?.toFixed(1) ?? "–"} s`),
5453
+ * // A template with data arguments; the player translates "{} / {} s".
5454
+ * info.set("{} / {} s", [state.position?.toFixed(1) ?? "–", state.duration?.toFixed(1) ?? "–"]),
4066
5455
  * );
4067
5456
  * ```
4068
5457
  *
@@ -4089,7 +5478,10 @@ export interface OsdPanel extends Disposable {
4089
5478
  * omitted for none.
4090
5479
  * @param options - `maxColumns`: cut long lines (SDK 1.5.11).
4091
5480
  *
4092
- * @example
5481
+ * @example Show two loop points, then hide the block
5482
+ * The block shows "A: 12.0 s" and "B: 18.5 s" on two lines, then
5483
+ * disappears. The text is a template the player translates and fills
5484
+ * in; `\n` separates lines, and `null` hides the block.
4093
5485
  * ```ts
4094
5486
  * const info = plugin.osd.panel();
4095
5487
  * info.set("A: {} s\nB: {} s", ["12.0", "18.5"]);
@@ -4148,7 +5540,10 @@ export interface OsdPanelOptions {
4148
5540
  *
4149
5541
  * @capability ui.osd
4150
5542
  *
4151
- * @example On-screen messages that the user can turn off
5543
+ * @example Show on-screen messages the user can turn off
5544
+ * "Blur on" appears over the video unless the user turned messages off in
5545
+ * the plugin's settings. `osd.show` asks the player to draw the text for a
5546
+ * moment; the plugin checks its own stored preference before each call.
4152
5547
  * ```ts
4153
5548
  * const store = plugin.storage.json("prefs", { defaults: { messages: true } });
4154
5549
  * const say = (text: string) => {
@@ -4192,7 +5587,11 @@ export interface Osd {
4192
5587
  * @throws `unavailable` (rejects) The player runs without a user interface.
4193
5588
  * @throws `capability` (rejects) The manifest does not declare `ui.osd`.
4194
5589
  *
4195
- * @example
5590
+ * @example Confirm an action and show a value
5591
+ * The OSD shows "Bookmark added", then "Speed 1.5×" in the user's
5592
+ * language. The text is English source the player translates with the
5593
+ * plugin's catalog; `{}` is filled in with the argument, which stays as
5594
+ * it is because it is data.
4196
5595
  * ```ts
4197
5596
  * await plugin.osd.show("Bookmark added");
4198
5597
  * await plugin.osd.show("Speed {}×", [plugin.player.state.rate]);
@@ -4214,7 +5613,10 @@ export interface Osd {
4214
5613
  *
4215
5614
  * @throws `Error` without a code (throws) This plugin already has a block.
4216
5615
  *
4217
- * @example
5616
+ * @example Show a status block over the video
5617
+ * A small block over the video says "Loop: off" until the plugin changes
5618
+ * or hides it. Each plugin has one block, stacked with other plugins'
5619
+ * blocks; a second `panel()` call throws.
4218
5620
  * ```ts
4219
5621
  * const info = plugin.osd.panel();
4220
5622
  * info.set("Loop: off");
@@ -4292,9 +5694,14 @@ interface Filter<T> extends Disposable {
4292
5694
  * Values out of range fail with `invalid` (see {@link VideoNode}). The
4293
5695
  * same chain applies to processed snapshots and exports.
4294
5696
  *
4295
- * @example
5697
+ * @example Brighten the picture and report when it fails
5698
+ * Every video plays a little brighter with more contrast, and the OSD
5699
+ * says why when the player cannot apply it. `set` records the desired
5700
+ * chain (last write wins, one request in flight); the player reapplies it
5701
+ * for each new source, and `onStatus` reports `applied` or `failed`.
4296
5702
  * ```ts
4297
5703
  * const picture = plugin.video.filter("picture");
5704
+ * // `unsupported` while a generated stream plays: its frames skip filters.
4298
5705
  * picture.onStatus((s) => s.state === "failed" && plugin.osd.show(s.error.message));
4299
5706
  * picture.set([{ kind: "eq", brightness: 0.1, contrast: 1.1, saturation: 1, gamma: 1 }]);
4300
5707
  * ```
@@ -4309,10 +5716,13 @@ export type VideoFilter = Filter<VideoNode[]>;
4309
5716
  * `delayMs` is −10000–10000 in multiples of 10; the positive and the
4310
5717
  * negative delays of all plugins each add up to at most 10000.
4311
5718
  *
4312
- * @example
5719
+ * @example Lower the volume and delay the sound
5720
+ * Every file plays 6 dB quieter, with its sound 120 ms later to match a
5721
+ * slow display. `set` sends the plugin's whole chain and delay; the
5722
+ * player applies it to each source and adds the delays of all plugins.
4313
5723
  * ```ts
4314
5724
  * const audio = plugin.audio.filter("sound");
4315
- * audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 });
5725
+ * audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 }); // 10 ms steps
4316
5726
  * ```
4317
5727
  */
4318
5728
  export type AudioFilter = Filter<{
@@ -4347,7 +5757,11 @@ export interface ViewSettings {
4347
5757
  * Out-of-range values or unknown fields fail with `invalid` without
4348
5758
  * sending anything.
4349
5759
  *
4350
- * @example
5760
+ * @example Fill the window, then zoom in
5761
+ * The video fills the video area (cropped instead of letterboxed), then
5762
+ * zooms to 150 % shifted a little to the right. The first plugin to call
5763
+ * `view()` owns the display layer; each `set` merges into its settings,
5764
+ * which stay across sources until the plugin resets them or stops.
4351
5765
  * ```ts
4352
5766
  * const view = plugin.video.view();
4353
5767
  * view.set({ fit: "fill" });
@@ -4388,13 +5802,20 @@ export interface RenderSettings {
4388
5802
  * `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
4389
5803
  * is accepted but has no effect; output is always SDR.
4390
5804
  *
4391
- * @example
5805
+ * @example Tone-map HDR video and hide the menu where it cannot work
5806
+ * On a GPU-rendering player HDR video is tone-mapped with BT.2390 for a
5807
+ * 250-nit display; under software rendering the plugin hides its HDR menu.
5808
+ * `set` merges the values into the plugin's render options and the
5809
+ * player reports the result through `onStatus`: `failed` with
5810
+ * `unsupported` means the player renders in software (Windows, Linux).
4392
5811
  * ```ts
5812
+ * declare function hideHdrMenu(): void;
5813
+ * // ---cut---
4393
5814
  * const render = plugin.video.render();
4394
5815
  * render.onStatus((s) => {
4395
5816
  * if (s.state === "failed" && s.error.code === "unsupported") hideHdrMenu();
4396
5817
  * });
4397
- * render.set({ toneMapping: "bt.2390", targetPeak: 250 });
5818
+ * render.set({ toneMapping: "bt.2390", targetPeak: 250 }); // nits
4398
5819
  * ```
4399
5820
  */
4400
5821
  export interface RenderOptions extends Disposable {
@@ -4455,7 +5876,10 @@ export interface SubtitlesState {
4455
5876
  /** Font family name for display. */
4456
5877
  family: string;
4457
5878
  }[];
4458
- /** The effective style, from whichever plugin set it last. */
5879
+ /**
5880
+ * The effective style: among the plugins that set a layer, the one whose
5881
+ * manifest id sorts last (not the most recent call); `null` without one.
5882
+ */
4459
5883
  readonly style: SubtitleStyle | null;
4460
5884
  /** Whether the renderer confirmed the selection. */
4461
5885
  readonly trackConfirmation: "waiting" | "applied" | "uncertain";
@@ -4475,12 +5899,23 @@ export interface SubtitleConditional {
4475
5899
  * `subtitles.read`, since they read the current selection first.
4476
5900
  *
4477
5901
  * @example Load an SRT file as the secondary subtitle
5902
+ * An English SRT file shows as a second subtitle line next to the
5903
+ * film's own subtitles, in white with an outline. The plugin reads the
5904
+ * file and uploads its bytes with `load`, which adds a track to the
5905
+ * current source and selects it as the secondary subtitle; `refresh`
5906
+ * reads the subtitle state for its font list, and `setStyle` sends this
5907
+ * plugin's style layer naming one of those fonts.
4478
5908
  * ```ts
5909
+ * import { readFile } from "node:fs/promises";
5910
+ * declare const path: string;
5911
+ * // ---cut---
4479
5912
  * const bytes = await readFile(path);
4480
- * const track = await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
5913
+ * await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
5914
+ * const { fonts } = await plugin.subtitles.refresh();
5915
+ * if (!fonts.length) return;
4481
5916
  * await plugin.subtitles.setStyle({
4482
- * fontId: plugin.subtitles.state!.fonts[0]!.id,
4483
- * fontSize: 40,
5917
+ * fontId: fonts[0]!.id,
5918
+ * fontSize: 40, // at the 1080p reference height
4484
5919
  * color: "white",
4485
5920
  * outline: 2,
4486
5921
  * marginPercent: 5,
@@ -4524,7 +5959,12 @@ export interface Subtitles {
4524
5959
  }): Promise<SubtitleTrack>;
4525
5960
  /** Remove a track this plugin loaded. */
4526
5961
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
4527
- /** This plugin's style layer; `null` removes it. Last write wins. */
5962
+ /**
5963
+ * Set this plugin's style layer; `null` removes it. The newest call of
5964
+ * this plugin replaces its earlier layer. When several plugins have a
5965
+ * layer, the player uses the one whose manifest id sorts last, whatever
5966
+ * the order of the calls.
5967
+ */
4528
5968
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
4529
5969
  }
4530
5970
  /**
@@ -4627,11 +6067,17 @@ export interface ExportJob extends Disposable {
4627
6067
  * `subtitles.read`). Exports decode offline and do not disturb playback.
4628
6068
  *
4629
6069
  * @example Save the current frame as JPEG
6070
+ * The frame on screen, with the plugin's own picture effects, is saved
6071
+ * where the user chooses. `capture` freezes the source as it was in
6072
+ * `seen` (and fails with `stale` if the user opened another file while
6073
+ * the dialog was open); `export` decodes offline without disturbing
6074
+ * playback, and `done` resolves with the job's final status.
4630
6075
  * ```ts
4631
6076
  * const seen = plugin.player.state;
4632
6077
  * const destination = await plugin.dialogs.saveFile({ fileName: "frame.jpg" });
4633
6078
  * if (!destination) return;
4634
6079
  * const snapshot = await plugin.media.capture({ ifCurrent: seen, processed: true });
6080
+ * // Release the frozen frame whatever happens to the export.
4635
6081
  * try {
4636
6082
  * const job = await snapshot.export({ format: "jpeg", destination, quality: 85 });
4637
6083
  * const end = await job.done;
@@ -4691,23 +6137,28 @@ export interface WebResolverOptions {
4691
6137
  * (the player shows a warning). When this plugin stops, its extensions are
4692
6138
  * free again and such files go to the player as before.
4693
6139
  *
4694
- * @example
6140
+ * @example Play CHIP-8 programs with the plugin's own emulator
6141
+ * Opening a `.ch8` file anywhere in the player (Open dialog, playlist,
6142
+ * recent list) runs it in the plugin's emulator instead of failing as an
6143
+ * unknown format. The player calls `resolve` with the path; the plugin
6144
+ * reads the program and returns a stream whose `frame()` the SDK pulls on
6145
+ * the audio clock, one 1/60 s frame per call. `started` runs once the
6146
+ * stream plays; `signal` aborts the read when the open is replaced or
6147
+ * closed.
4695
6148
  * ```ts
4696
- * const { readFile } = await import("node:fs/promises");
4697
- * // The plugin's own emulator: one frame per call.
4698
- * const emulator = (rom: Uint8Array): (() => StreamFrame) => {
4699
- * const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
4700
- * return () => ({ video, audio: new Float32Array(1600) });
4701
- * };
6149
+ * import { readFile } from "node:fs/promises";
6150
+ * // The plugin's own emulator (made up here): one frame per call.
6151
+ * declare function emulator(program: Uint8Array): () => StreamFrame;
6152
+ * // ---cut---
4702
6153
  * plugin.resolvers.add({
4703
- * id: "nes",
4704
- * extensions: ["nes"],
6154
+ * id: "chip8",
6155
+ * extensions: ["ch8"], // lower case, no dot; matches Game.CH8 too
4705
6156
  * async resolve(path, signal) {
4706
6157
  * const frame = emulator(await readFile(path, { signal }));
4707
6158
  * return {
4708
6159
  * kind: "stream",
4709
- * width: 256,
4710
- * height: 240,
6160
+ * width: 64, // CHIP-8's display
6161
+ * height: 32,
4711
6162
  * frame,
4712
6163
  * started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
4713
6164
  * };
@@ -4772,14 +6223,20 @@ export interface Resolver extends Disposable {
4772
6223
  * files into streams this plugin generates. Needs `source.resolve` (file
4773
6224
  * resolvers also `producer.av`).
4774
6225
  *
4775
- * @example
6226
+ * @example Play pages of a video site
6227
+ * Opening a `https://video.example.com/…` page plays its video. When the
6228
+ * user opens a URL whose scheme and host this resolver declares, the
6229
+ * player asks the plugin first; `resolve` returns the stream's address,
6230
+ * and `signal` aborts the lookup when the open is replaced or closed.
4776
6231
  * ```ts
4777
6232
  * plugin.resolvers.add({
4778
6233
  * id: "example",
4779
6234
  * schemes: ["https"],
4780
6235
  * hosts: ["video.example.com"],
4781
6236
  * async resolve(url, signal) {
4782
- * const page = await (await fetch(url, { signal })).json();
6237
+ * const response = await fetch(url, { signal }); // the site's JSON for the page
6238
+ * if (!response.ok) throw new Error(`video.example.com answered ${response.status}`);
6239
+ * const page = await response.json();
4783
6240
  * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
4784
6241
  * },
4785
6242
  * });
@@ -4802,9 +6259,13 @@ export interface Resolvers {
4802
6259
  * Use it to accept such files beside the player's own media types, for
4803
6260
  * example in a playlist's drop or folder scan.
4804
6261
  *
4805
- * @example
6262
+ * @example Accept files that a resolver plugin can play
6263
+ * A plugin's own file filter also accepts types other plugins turn into
6264
+ * streams. `fileTypes` lists the extensions every running file resolver
6265
+ * claims (lower case, no dot); the SDK keeps it current with `events`.
4806
6266
  * ```ts
4807
6267
  * const MEDIA = ["mp4", "mkv", "mp3"];
6268
+ * // Extensions compare in lower case, without the dot.
4808
6269
  * const accepted = (path: string) => {
4809
6270
  * const extension = path.split(".").pop()?.toLowerCase() ?? "";
4810
6271
  * return MEDIA.includes(extension) || plugin.resolvers.fileTypes.includes(extension);
@@ -4823,10 +6284,13 @@ export interface Resolvers {
4823
6284
  * @param listener - Receives the new list.
4824
6285
  * @returns A handle that removes the listener.
4825
6286
  *
4826
- * @example
6287
+ * @example Say which file types became playable
6288
+ * When a plugin with a file resolver starts or stops, the OSD lists the
6289
+ * extra file types now accepted. The listener (which needs `events`)
6290
+ * receives the new list each time it changes.
4827
6291
  * ```ts
4828
6292
  * plugin.resolvers.onFileTypesChange((types) => {
4829
- * void plugin.osd.show(`Also accepted: ${types.join(", ")}`);
6293
+ * void plugin.osd.show("Also accepted: {}", [plugin.i18n.list(types)]);
4830
6294
  * });
4831
6295
  * ```
4832
6296
  *
@@ -5049,10 +6513,19 @@ export interface PluginWarning {
5049
6513
  * `plugin.packages`: the plugins the player knows. `list` and `onChange`
5050
6514
  * need no capability; everything else needs `plugins.manage`.
5051
6515
  *
5052
- * @example
6516
+ * @example Install a plugin from an unpacked package
6517
+ * The plugin installs a package directory it downloaded and unpacked, and
6518
+ * reports why when it does not work. `validate` asks the player to check
6519
+ * the package and returns a candidate; `install` starts the change and
6520
+ * `done` resolves with its final status: `committed` only once the new
6521
+ * plugin reported that it is running, otherwise `failed`, `cancelled`,
6522
+ * `failedDegraded` or `tooLate`.
5053
6523
  * ```ts
6524
+ * declare const unpackedDirectory: string;
6525
+ * // ---cut---
5054
6526
  * const candidate = await plugin.packages.validate(unpackedDirectory);
5055
6527
  * const change = await candidate.install();
6528
+ * // `message` says why a change did not commit.
5056
6529
  * if ((await change.done) !== "committed") throw new Error(change.message ?? "install failed");
5057
6530
  * ```
5058
6531
  */
@@ -5165,7 +6638,11 @@ export interface BindingInfo extends BindingRef {
5165
6638
  * `plugin.bindings`: list and override every command's bindings, including
5166
6639
  * the player's. Needs `bindings.manage`.
5167
6640
  *
5168
- * @example "Ignore the default shortcut" for one command
6641
+ * @example Unbind the player's Mute key, then restore it
6642
+ * The player's Mute command loses its default key, and later gets it
6643
+ * back. `set` with `[]` saves an override that unbinds the command
6644
+ * (remembered across launches); `reset` removes the override, so the
6645
+ * command's own default bindings apply again.
5169
6646
  * ```ts
5170
6647
  * await plugin.bindings.set({ plugin: "host", command: "mute" }, []);
5171
6648
  * // later
@@ -5205,20 +6682,122 @@ export interface Bindings {
5205
6682
  * PNG. Other systems: `unsupported`. Failures: `invalid`, `missing`,
5206
6683
  * `unavailable`, `failed` (the system refused).
5207
6684
  *
5208
- * @example
6685
+ * @example Copy the current file and position
6686
+ * The clipboard holds "path @ seconds" for pasting into a note. The
6687
+ * player writes the system clipboard; it rejects with `unsupported` on
6688
+ * systems other than macOS and Windows and `failed` when the system
6689
+ * refuses, which the plugin reports.
5209
6690
  * ```ts
5210
- * await plugin.clipboard.writeText(`${plugin.player.state.source} @ ${plugin.player.state.position}`);
6691
+ * const { source, position } = plugin.player.state;
6692
+ * try {
6693
+ * await plugin.clipboard.writeText(`${source} @ ${position}`); // data, not shown
6694
+ * } catch (error) {
6695
+ * void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
6696
+ * }
5211
6697
  * ```
6698
+ *
6699
+ * @remarks
6700
+ * The player reads and writes the system clipboard on the plugin's behalf.
6701
+ * Text is plain text; images are written from a PNG or JPEG file.
6702
+ *
6703
+ * @capability clipboard
6704
+ * @since 1.0.0
6705
+ * @category Services
5212
6706
  */
5213
6707
  export interface Clipboard {
5214
- /** Plain text on the clipboard; `null` when it holds none. */
6708
+ /**
6709
+ * Plain text on the clipboard; `null` when it holds none.
6710
+ *
6711
+ * @capability clipboard
6712
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6713
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6714
+ * @throws `failed` (rejects) The system refused (Windows).
6715
+ * @throws `capability` (rejects) The manifest does not declare
6716
+ * `clipboard`.
6717
+ *
6718
+ * @example Open a URL the user copied
6719
+ * A "Paste and play" command opens the address on the clipboard. `readText`
6720
+ * resolves with the clipboard's text, or `null` when it holds none (an
6721
+ * image, nothing).
6722
+ * ```ts
6723
+ * const text = await plugin.clipboard.readText();
6724
+ * if (text?.startsWith("https://")) await plugin.player.open(text.trim());
6725
+ * ```
6726
+ *
6727
+ * @since 1.0.0
6728
+ */
5215
6729
  readText(): Promise<string | null>;
5216
- /** Replace the clipboard with text (at most 1 MiB of UTF-8). */
6730
+ /**
6731
+ * Replace the clipboard with text (at most 1 MiB of UTF-8).
6732
+ *
6733
+ * @capability clipboard
6734
+ * @throws `invalid` (rejects) More than 1 MiB of UTF-8.
6735
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6736
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6737
+ * @throws `failed` (rejects) The system refused.
6738
+ * @throws `capability` (rejects) The manifest does not declare
6739
+ * `clipboard`.
6740
+ *
6741
+ * @example Copy the playing file's path
6742
+ * The path of the playing file is put on the clipboard, replacing what was
6743
+ * there. It resolves once the system has it.
6744
+ * ```ts
6745
+ * const { source } = plugin.player.state;
6746
+ * if (source) await plugin.clipboard.writeText(source);
6747
+ * ```
6748
+ *
6749
+ * @since 1.0.0
6750
+ */
5217
6751
  writeText(text: string): Promise<void>;
5218
- /** Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB). */
6752
+ /**
6753
+ * Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB).
6754
+ *
6755
+ * @capability clipboard
6756
+ * @throws `missing` (rejects) The file does not exist.
6757
+ * @throws `invalid` (rejects) Not an absolute path, not PNG or JPEG, over
6758
+ * 16 MB, or (Windows) an image the system cannot decode.
6759
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6760
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6761
+ * @throws `failed` (rejects) The system refused.
6762
+ * @throws `capability` (rejects) The manifest does not declare
6763
+ * `clipboard`.
6764
+ *
6765
+ * @example Copy a snapshot to the clipboard
6766
+ * A frame the plugin saved as PNG is put on the clipboard, ready to paste
6767
+ * into another application. The player reads the file and writes the
6768
+ * image (macOS: its original bytes; Windows: a bitmap plus PNG).
6769
+ * ```ts
6770
+ * declare const pngPath: string;
6771
+ * // ---cut---
6772
+ * await plugin.clipboard.writeImage(pngPath); // absolute path, at most 16 MB
6773
+ * await plugin.osd.show("Snapshot copied");
6774
+ * ```
6775
+ *
6776
+ * @since 1.0.0
6777
+ */
5219
6778
  writeImage(path: string): Promise<void>;
5220
6779
  }
5221
- /** Keeping the computer awake (`playback.control`). */
6780
+ /**
6781
+ * Keeping the computer awake (`playback.control`).
6782
+ *
6783
+ * @remarks
6784
+ * A request, not a command: the player keeps the system awake while any
6785
+ * plugin asks for it and a source plays, and withdraws a plugin's request
6786
+ * when it stops.
6787
+ *
6788
+ * @capability playback.control
6789
+ *
6790
+ * @example Keep the display on while playing
6791
+ * The screen does not dim or sleep while a video plays, and may again when
6792
+ * it is paused or ends. The player takes the system's assertion only while
6793
+ * a source is ready and not paused.
6794
+ * ```ts
6795
+ * await plugin.power.preventSleep("playing"); // "off" withdraws the request
6796
+ * ```
6797
+ *
6798
+ * @since 1.0.0
6799
+ * @category Services
6800
+ */
5222
6801
  export interface Power {
5223
6802
  /**
5224
6803
  * `"playing"`: while a source is ready and not paused, keep the display
@@ -5229,10 +6808,32 @@ export interface Power {
5229
6808
  * take the system assertion are logged by the player, not reported.
5230
6809
  * macOS uses `caffeinate`, Windows the thread execution state; elsewhere
5231
6810
  * it has no effect.
6811
+ *
6812
+ * @capability playback.control
6813
+ * @throws `invalid` (rejects) A mode other than `"off"` or `"playing"`.
6814
+ * @throws `capability` (rejects) The manifest does not declare
6815
+ * `playback.control`.
6816
+ *
6817
+ * @example Follow the plugin's own "Keep awake" setting
6818
+ * The plugin keeps the display awake during playback only while its
6819
+ * setting is on. Each call replaces this plugin's request; `"off"`
6820
+ * withdraws it, and other plugins' requests still count.
6821
+ * ```ts
6822
+ * const store = plugin.storage.json("power", { defaults: { keepAwake: true } });
6823
+ * await plugin.power.preventSleep(store.value.keepAwake ? "playing" : "off");
6824
+ * ```
6825
+ *
6826
+ * @since 1.0.0
5232
6827
  */
5233
6828
  preventSleep(mode: "off" | "playing"): Promise<void>;
5234
6829
  }
5235
- /** The output of {@link Tools.run}. */
6830
+ /**
6831
+ * The output of {@link Tools.run}.
6832
+ *
6833
+ * @see {@link Tools.run}
6834
+ * @since 1.0.0
6835
+ * @category Services
6836
+ */
5236
6837
  export interface ToolResult {
5237
6838
  /** Standard output (at most `maxOutput` bytes). */
5238
6839
  stdout: Uint8Array;
@@ -5243,22 +6844,42 @@ export interface ToolResult {
5243
6844
  * `plugin.tools`: ffmpeg and ffprobe, run in this plugin's process. No
5244
6845
  * capability.
5245
6846
  *
5246
- * @example
6847
+ * @example Read a file's format with ffprobe
6848
+ * The plugin gets a media file's container details as JSON. `available`
6849
+ * says whether ffprobe can be found (`VESTRA_FFPROBE`, then `PATH`); `run`
6850
+ * starts it from the plugin's own process without a shell and resolves
6851
+ * with its output as bytes once it exits, or rejects when it fails
6852
+ * (`failed`, with the last stderr line) or takes longer than `timeoutMs`.
5247
6853
  * ```ts
6854
+ * declare const path: string;
6855
+ * // ---cut---
5248
6856
  * if (await plugin.tools.available("ffprobe")) {
5249
6857
  * const { stdout } = await plugin.tools.run(
5250
6858
  * "ffprobe",
5251
6859
  * ["-v", "error", "-show_format", "-of", "json", path],
5252
- * { timeoutMs: 10_000 },
6860
+ * { timeoutMs: 10_000 }, // give up on a stuck file after 10 s
5253
6861
  * );
5254
6862
  * const info = JSON.parse(new TextDecoder().decode(stdout));
6863
+ * void info.format; // container, duration, bit rate…
5255
6864
  * }
5256
6865
  * ```
6866
+ *
6867
+ * @remarks
6868
+ * ffmpeg and ffprobe run as child processes of the plugin. When
6869
+ * `VESTRA_FFMPEG` / `VESTRA_FFPROBE` is set, only that path is used (an
6870
+ * invalid one means the tool is not found); otherwise the tool is searched
6871
+ * on `PATH`. A found path is kept for the plugin's run, a miss is looked up
6872
+ * again next time. Disposing the
6873
+ * scope ends a running process.
6874
+ *
6875
+ * @capability None.
6876
+ * @since 1.0.0
6877
+ * @category Services
5257
6878
  */
5258
6879
  export interface Tools {
5259
6880
  /**
5260
- * Run ffmpeg or ffprobe (found through `VESTRA_FFMPEG` / `VESTRA_FFPROBE`,
5261
- * then `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
6881
+ * Run ffmpeg or ffprobe (`VESTRA_FFMPEG` / `VESTRA_FFPROBE` when set, else
6882
+ * found on `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
5262
6883
  * `failed` (non-zero exit; `message` is the last stderr line), `invalid`
5263
6884
  * (output over `maxOutput`, default 16 MiB), `aborted` (`signal`). Timeout,
5264
6885
  * `signal` and disposing the scope end the process.
@@ -5266,28 +6887,171 @@ export interface Tools {
5266
6887
  * Arguments are passed as-is (no quoting needed). On Windows `PATHEXT` is
5267
6888
  * honoured. Ending sends SIGTERM, then SIGKILL after 300 ms (immediate on
5268
6889
  * Windows).
6890
+ *
6891
+ * @capability None.
6892
+ * @throws `unavailable` (rejects) The tool was not found.
6893
+ * @throws `timeout` (rejects) It ran longer than `timeoutMs`; the process is
6894
+ * ended.
6895
+ * @throws `failed` (rejects) A non-zero exit; `message` is the last stderr
6896
+ * line.
6897
+ * @throws `invalid` (rejects) Bad arguments or options, or output over
6898
+ * `maxOutput`.
6899
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed.
6900
+ *
6901
+ * @example Cut a clip without re-encoding
6902
+ * The plugin writes the first ten seconds of the file to a new file. `run`
6903
+ * starts ffmpeg without a shell (arguments pass as they are, no quoting)
6904
+ * and resolves with its output once it exits with 0; a failure rejects
6905
+ * with the last line ffmpeg printed.
6906
+ * ```ts
6907
+ * declare const input: string;
6908
+ * declare const output: string;
6909
+ * // ---cut---
6910
+ * await plugin.tools.run(
6911
+ * "ffmpeg",
6912
+ * ["-y", "-i", input, "-t", "10", "-c", "copy", output],
6913
+ * { timeoutMs: 60_000 }, // give up after a minute
6914
+ * );
6915
+ * ```
6916
+ *
6917
+ * @since 1.0.0
5269
6918
  */
5270
6919
  run(tool: "ffmpeg" | "ffprobe", args: string[], options: {
5271
6920
  timeoutMs: number;
5272
6921
  maxOutput?: number;
5273
6922
  signal?: AbortSignal;
5274
6923
  }): Promise<ToolResult>;
5275
- /** Whether the tool can be found. */
6924
+ /**
6925
+ * Whether the tool can be found.
6926
+ *
6927
+ * @capability None.
6928
+ * @throws Never; it resolves `false` when the tool cannot be found.
6929
+ *
6930
+ * @example Grey out a command when ffmpeg is missing
6931
+ * The Export item is disabled with a reason when ffmpeg is not installed.
6932
+ * `available` looks the tool up (a found path is kept, a miss is looked
6933
+ * up again on the next call, so installing it later is noticed).
6934
+ * ```ts
6935
+ * const hasFfmpeg = await plugin.tools.available("ffmpeg");
6936
+ * plugin.commands.add({
6937
+ * id: "export",
6938
+ * path: ["@video", "Export…"],
6939
+ * enabled: hasFfmpeg,
6940
+ * // Shown with the greyed-out item.
6941
+ * reason: hasFfmpeg ? null : "Install ffmpeg to export",
6942
+ * run() {},
6943
+ * });
6944
+ * ```
6945
+ *
6946
+ * @since 1.0.0
6947
+ */
5276
6948
  available(tool: "ffmpeg" | "ffprobe"): Promise<boolean>;
5277
6949
  }
5278
- /** A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced. */
6950
+ /**
6951
+ * A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
6952
+ *
6953
+ * @remarks
6954
+ * One JSON file, `<dataDirectory>/<name>.json`, made by
6955
+ * {@link Storage.json}. `value` is read once when the store is made;
6956
+ * `save` replaces it in memory at once and writes the file `debounceMs`
6957
+ * later (300 by default) through a temporary file and a rename, so a
6958
+ * crash never leaves half a file. Writes to one file run one at a time,
6959
+ * newest last. Disposing the store, its scope or stopping the plugin
6960
+ * writes a pending save first.
6961
+ *
6962
+ * @capability None.
6963
+ *
6964
+ * @example Count how often the plugin was started
6965
+ * The counter grows by one at every start and is kept in `stats.json`. The
6966
+ * store reads the file when it is made (the defaults when there is none),
6967
+ * and `save` writes the new count a moment later.
6968
+ * ```ts
6969
+ * const stats = plugin.storage.json("stats", { defaults: { starts: 0 } });
6970
+ * stats.save({ starts: stats.value.starts + 1 }); // written 300 ms later
6971
+ * ```
6972
+ *
6973
+ * @since 1.0.0
6974
+ * @category Services
6975
+ */
5279
6976
  export interface JsonStore<T> extends Disposable {
5280
6977
  /** The loaded value, or `defaults` when the file was missing or broken. */
5281
6978
  readonly value: T;
5282
6979
  /**
5283
6980
  * Replace the value now and write it after `debounceMs`.
5284
6981
  * Only `save` writes: mutating `value` in place is not saved.
6982
+ *
6983
+ * @capability None.
6984
+ *
6985
+ * @example Save a slider value without writing on every move
6986
+ * Dragging the plugin's volume slider updates the stored value at once but
6987
+ * writes the file only once the user stops for 300 ms. Each `save`
6988
+ * replaces the value and restarts the debounce timer.
6989
+ * ```ts
6990
+ * declare const window: Window;
6991
+ * // ---cut---
6992
+ * const store = plugin.storage.json("prefs", { defaults: { volume: 100 } });
6993
+ * window.on("volumeChanged", (volume: number) => store.save({ ...store.value, volume }));
6994
+ * ```
6995
+ *
6996
+ * @since 1.0.0
5285
6997
  */
5286
6998
  save(next: T): void;
5287
- /** Write a pending save now. */
6999
+ /**
7000
+ * Write a pending save now.
7001
+ *
7002
+ * @capability None.
7003
+ * @throws `Error` (rejects) Node's file system error when the file cannot
7004
+ * be written; its `code` is Node's (`ENOSPC`, `EACCES` and so on), not a
7005
+ * player error code.
7006
+ *
7007
+ * @example Write the file now before copying it
7008
+ * Before exporting its settings, the plugin makes sure the file on disk has
7009
+ * the latest value. `flush` writes a pending save at once (or waits for
7010
+ * the write in progress) and resolves when the file is written.
7011
+ * ```ts
7012
+ * import { copyFile } from "node:fs/promises";
7013
+ * import { join } from "node:path";
7014
+ * declare const exportPath: string;
7015
+ * // ---cut---
7016
+ * const store = plugin.storage.json("settings", { defaults: { step: 5 } });
7017
+ * await store.flush(); // the file now has the newest value
7018
+ * await copyFile(join(plugin.dataDirectory, "settings.json"), exportPath);
7019
+ * ```
7020
+ *
7021
+ * @since 1.0.0
7022
+ */
5288
7023
  flush(): Promise<void>;
5289
7024
  }
5290
- /** `plugin.storage`: JSON settings files. No capability. */
7025
+ /**
7026
+ * `plugin.storage`: JSON settings files. No capability.
7027
+ *
7028
+ * @remarks
7029
+ * Two kinds of storage, both private to the plugin and needing no
7030
+ * capability. {@link Storage.json} keeps settings in JSON files in the
7031
+ * plugin's data directory: they last across player restarts and upgrades,
7032
+ * and are written by the plugin's own process. {@link Storage.session}
7033
+ * keeps values in the player's memory for one player run: they survive a
7034
+ * restart of the plugin's process but not of the player. Secrets belong in
7035
+ * {@link Secrets}, never in either.
7036
+ *
7037
+ * @capability None.
7038
+ *
7039
+ * @example Keep a setting across restarts and a queue for this run
7040
+ * The plugin's step size survives a restart of the player; its work queue
7041
+ * survives a reload of the plugin but starts empty with the next player
7042
+ * run. The JSON store reads its file at once and writes it shortly after
7043
+ * each `save`; session values live in the player process.
7044
+ * ```ts
7045
+ * const settings = plugin.storage.json("settings", { defaults: { step: 5 } });
7046
+ * const queue = (await plugin.storage.session.get<string[]>("queue")) ?? [];
7047
+ * void settings.value.step; // read synchronously, from the file or the defaults
7048
+ * void queue;
7049
+ * ```
7050
+ *
7051
+ * @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)
7052
+ * @since 1.0.0
7053
+ * @category Services
7054
+ */
5291
7055
  export interface Storage {
5292
7056
  /**
5293
7057
  * Open `<name>.json` (`name`: 1–64 of `a-z 0-9 -`). A file that cannot be
@@ -5298,16 +7062,29 @@ export interface Storage {
5298
7062
  * The file is read synchronously on creation; `debounceMs` defaults to
5299
7063
  * 300. Files are created with mode 0600. `invalid` for a bad name.
5300
7064
  *
5301
- * @example
7065
+ * @example Keep settings and a recent list in a JSON file
7066
+ * The plugin remembers whether to resume and the last ten files across
7067
+ * restarts. `json` reads `settings.json` from the data directory at once
7068
+ * (or uses `defaults` when it is missing or fails `validate`); `save`
7069
+ * replaces the value and writes the file 300 ms later, and stopping the
7070
+ * plugin writes a pending save first.
5302
7071
  * ```ts
7072
+ * declare const path: string;
7073
+ * // ---cut---
5303
7074
  * type Settings = { resume: boolean; recent: string[] };
5304
7075
  * const store = plugin.storage.json<Settings>("settings", {
5305
7076
  * defaults: { resume: true, recent: [] },
5306
7077
  * validate: (v): v is Settings =>
5307
7078
  * typeof v === "object" && v !== null && typeof (v as Settings).resume === "boolean",
5308
7079
  * });
7080
+ * // Only `save` writes: changing `store.value` in place is not saved.
5309
7081
  * store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
5310
7082
  * ```
7083
+ *
7084
+ * @capability None.
7085
+ * @throws `invalid` (throws) A bad `name`, or a `debounceMs` that is not a
7086
+ * non-negative number.
7087
+ * @since 1.0.0
5311
7088
  */
5312
7089
  json<T>(name: string, options: {
5313
7090
  defaults: T;
@@ -5367,7 +7144,11 @@ export interface Storage {
5367
7144
  * resolves, so a crash right after loses nothing; there is no debounce.
5368
7145
  * Calls answer in order.
5369
7146
  *
5370
- * @example
7147
+ * @example Keep a work queue across a plugin restart
7148
+ * A download queue survives the plugin being reloaded or crashing, and is
7149
+ * empty again when the player starts next time. Session storage lives in
7150
+ * the player process for one player run; each `set` is kept once its
7151
+ * promise resolves, so nothing is lost if the plugin dies right after.
5371
7152
  * ```ts
5372
7153
  * // A queue the plugin is working through: after a reload or a crash it
5373
7154
  * // goes on where it stopped, after the player quits it starts afresh.
@@ -5377,30 +7158,128 @@ export interface Storage {
5377
7158
  * ```
5378
7159
  *
5379
7160
  * @since 1.5.25
7161
+ *
7162
+ * @remarks
7163
+ * The player keeps the values in its own memory, per plugin, until it
7164
+ * quits; the plugin's process may restart in between. Each call is one
7165
+ * request to the player, answered in order, with no debounce.
7166
+ *
7167
+ * @capability None.
7168
+ * @category Services
5380
7169
  */
5381
7170
  export interface SessionStorage {
5382
- /** The value stored under `key`, or `undefined` when there is none. */
7171
+ /**
7172
+ * The value stored under `key`, or `undefined` when there is none.
7173
+ *
7174
+ * @capability None.
7175
+ * @throws `invalid` (rejects) A bad key (empty, over 1024 bytes, with NUL
7176
+ * or a lone surrogate).
7177
+ * @throws `disabled` (rejects) The plugin is stopping.
7178
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7179
+ *
7180
+ * @example Pick up where the plugin stopped after a reload
7181
+ * After a reload the plugin resumes the item it was working on. `get`
7182
+ * asks the player and resolves with the value parsed from JSON, or
7183
+ * `undefined` when nothing is stored; the value is not checked, so its
7184
+ * shape is checked here.
7185
+ * ```ts
7186
+ * const current = await plugin.storage.session.get<{ url: string }>("current");
7187
+ * if (typeof current?.url === "string") await plugin.osd.show("Resuming {}", [current.url]);
7188
+ * ```
7189
+ *
7190
+ * @since 1.5.25
7191
+ */
5383
7192
  get<T = unknown>(key: string): Promise<T | undefined>;
5384
7193
  /**
5385
7194
  * Store or replace `value` under `key`. `invalid` for a bad key or a value
5386
7195
  * that is not JSON, `limit` when the plugin's values would be over 1 MiB
5387
7196
  * or the request over the 1 MiB message limit.
7197
+ *
7198
+ * @capability None.
7199
+ * @throws `invalid` (rejects) A bad key, or a value JSON cannot hold (or a
7200
+ * string with a lone surrogate).
7201
+ * @throws `disabled` (rejects) The plugin is stopping.
7202
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7203
+ * @throws `limit` (rejects) The plugin's values would pass 1 MiB, or the
7204
+ * request the 1 MiB message limit; nothing changes.
7205
+ *
7206
+ * @example Remember the item being worked on
7207
+ * The plugin records the URL it is processing, so a reload can continue
7208
+ * with it. The value is stored as `JSON.stringify` writes it, and is kept
7209
+ * once the promise resolves.
7210
+ * ```ts
7211
+ * await plugin.storage.session.set("current", { url: "https://example.com/a.mkv" });
7212
+ * ```
7213
+ *
7214
+ * @since 1.5.25
5388
7215
  */
5389
7216
  set(key: string, value: unknown): Promise<void>;
5390
- /** Remove a key; removing a missing one is fine. */
7217
+ /**
7218
+ * Remove a key; removing a missing one is fine.
7219
+ *
7220
+ * @capability None.
7221
+ * @throws `invalid` (rejects) A bad key.
7222
+ * @throws `disabled` (rejects) The plugin is stopping.
7223
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7224
+ *
7225
+ * @example Forget the item once it is done
7226
+ * When the work finishes, the plugin removes the entry so that a later
7227
+ * reload does not resume it. Removing a key that is not there is fine.
7228
+ * ```ts
7229
+ * await plugin.storage.session.delete("current");
7230
+ * ```
7231
+ *
7232
+ * @since 1.5.25
7233
+ */
5391
7234
  delete(key: string): Promise<void>;
5392
- /** The keys stored, in code point order. */
7235
+ /**
7236
+ * The keys stored, in code point order.
7237
+ *
7238
+ * @capability None.
7239
+ * @throws `disabled` (rejects) The plugin is stopping.
7240
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7241
+ *
7242
+ * @example Clear everything the plugin stored for this run
7243
+ * The plugin starts its session state over. `keys` lists the plugin's own
7244
+ * keys in code point order; each `delete` is a request of its own.
7245
+ * ```ts
7246
+ * const session = plugin.storage.session;
7247
+ * for (const key of await session.keys()) await session.delete(key);
7248
+ * ```
7249
+ *
7250
+ * @since 1.5.25
7251
+ */
5393
7252
  keys(): Promise<string[]>;
5394
7253
  }
5395
7254
  /**
5396
7255
  * `plugin.system`: files and links, executed by the player. No capability.
5397
7256
  * Other systems than macOS and Windows: `unsupported`.
5398
7257
  *
5399
- * @example
7258
+ * @example Show an exported file and open the help page
7259
+ * The file manager opens with the exported file selected, then the
7260
+ * browser opens the plugin's help page. The player performs both on the
7261
+ * plugin's behalf; they reject with `unsupported` on systems other than
7262
+ * macOS and Windows, which the plugin turns into a message.
5400
7263
  * ```ts
5401
- * await plugin.system.reveal(exportedFile);
5402
- * await plugin.system.openUrl("https://example.com/help");
7264
+ * declare const exportedFile: string;
7265
+ * // ---cut---
7266
+ * try {
7267
+ * await plugin.system.reveal(exportedFile);
7268
+ * await plugin.system.openUrl("https://example.com/help");
7269
+ * } catch (error) {
7270
+ * if ((error as PluginError).code !== "unsupported") throw error;
7271
+ * await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
7272
+ * }
5403
7273
  * ```
7274
+ *
7275
+ * @remarks
7276
+ * The player opens files, folders and links with the system's own
7277
+ * applications for the plugin, so the plugin never runs `open`,
7278
+ * `explorer` or a shell itself.
7279
+ *
7280
+ * @capability None.
7281
+ * @since 1.0.0
7282
+ * @category Services
5404
7283
  */
5405
7284
  export interface System {
5406
7285
  /**
@@ -5408,13 +7287,66 @@ export interface System {
5408
7287
  * for all, so several files of one folder are all highlighted; Windows:
5409
7288
  * one Explorer window per folder, selecting its first path). Paths that do
5410
7289
  * not exist are skipped; `missing` when none exists; at most 256.
7290
+ *
7291
+ * @capability None.
7292
+ * @throws `missing` (rejects) None of the paths exists.
7293
+ * @throws `invalid` (rejects) No paths, or more than 256.
7294
+ * @throws `failed` (rejects) The system refused.
7295
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
7296
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7297
+ *
7298
+ * @example Show the exported files in the file manager
7299
+ * After an export, the file manager opens with the new files selected.
7300
+ * Paths that no longer exist are skipped; the call rejects with `missing`
7301
+ * only when none of them exists.
7302
+ * ```ts
7303
+ * declare const exported: string[];
7304
+ * // ---cut---
7305
+ * await plugin.system.reveal(exported); // macOS: one Finder window for all
7306
+ * ```
7307
+ *
7308
+ * @since 1.0.0
5411
7309
  */
5412
7310
  reveal(paths: string | string[]): Promise<void>;
5413
- /** Open the file or folder with its default application. `missing` if absent. */
7311
+ /**
7312
+ * Open the file or folder with its default application. `missing` if absent.
7313
+ *
7314
+ * @capability None.
7315
+ * @throws `missing` (rejects) The path does not exist.
7316
+ * @throws `invalid` (rejects) An empty path.
7317
+ * @throws `failed` (rejects) The system refused.
7318
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
7319
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7320
+ *
7321
+ * @example Open the plugin's data folder
7322
+ * A "Show data folder" command opens the plugin's data directory in the
7323
+ * file manager, which is the default application for a folder.
7324
+ * ```ts
7325
+ * await plugin.system.openPath(plugin.dataDirectory);
7326
+ * ```
7327
+ *
7328
+ * @since 1.0.0
7329
+ */
5414
7330
  openPath(path: string): Promise<void>;
5415
7331
  /**
5416
7332
  * Open an http(s) address in the default browser. `invalid` for another
5417
7333
  * protocol or a malformed address; `timeout` after 10 s, `failed` otherwise.
7334
+ *
7335
+ * @capability None.
7336
+ * @throws `invalid` (rejects) Not an `http:` or `https:` address.
7337
+ * @throws `timeout` (rejects) macOS did not hand it to the browser within
7338
+ * 10 s.
7339
+ * @throws `failed` (rejects) The system refused.
7340
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7341
+ *
7342
+ * @example Open the plugin's help page
7343
+ * A Help command opens the plugin's documentation in the default browser.
7344
+ * Only `http:` and `https:` addresses are opened.
7345
+ * ```ts
7346
+ * await plugin.system.openUrl("https://example.com/plugin/help");
7347
+ * ```
7348
+ *
7349
+ * @since 1.0.0
5418
7350
  */
5419
7351
  openUrl(url: string): Promise<void>;
5420
7352
  }
@@ -5422,29 +7354,123 @@ export interface System {
5422
7354
  * Per-plugin secrets (API keys, passwords) in the system's secret store:
5423
7355
  * the login keychain on macOS, the Credential Manager on Windows. Needs
5424
7356
  * `secrets`. `unsupported` on systems without a store, `unavailable` when
5425
- * the store refuses access: keep a fallback (or ask again).
7357
+ * the store cannot be reached (no keychain, a read-only store, no logged-in
7358
+ * session on Windows): keep a fallback (or ask again).
5426
7359
  *
5427
7360
  * The user denying access and other system errors reject `failed`. Never
5428
7361
  * write secrets to settings files.
5429
7362
  *
5430
- * @example
7363
+ * @example Ask for an API token once and keep it in the keychain
7364
+ * The user types the token the first time; later runs read it from the
7365
+ * system's secret store. `get` resolves `null` when nothing is stored; a
7366
+ * store that is missing or refuses access rejects (`unsupported`,
7367
+ * `unavailable`, `failed`), and then the plugin keeps the token for this
7368
+ * run only and asks again next time.
5431
7369
  * ```ts
5432
- * let token = await plugin.secrets.get("api-token");
7370
+ * declare function askUser(): Promise<string | null>;
7371
+ * // ---cut---
7372
+ * let token: string | null = null;
7373
+ * try {
7374
+ * token = await plugin.secrets.get("api-token");
7375
+ * } catch {
7376
+ * // No usable store: fall through and ask.
7377
+ * }
5433
7378
  * if (token === null) {
5434
7379
  * token = await askUser();
5435
- * await plugin.secrets.set("api-token", token);
7380
+ * if (token)
7381
+ * await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
5436
7382
  * }
5437
7383
  * ```
7384
+ *
7385
+ * @remarks
7386
+ * Values are stored by the player in the system's secret store under this
7387
+ * plugin's id: other plugins cannot read them. Names are 1–64 of
7388
+ * `a-z 0-9 . _ -`, values 1–1024 bytes of text, at most 256 names per
7389
+ * plugin (`SECRET_LIMITS`).
7390
+ *
7391
+ * @capability secrets
7392
+ * @since 1.0.0
7393
+ * @category Services
5438
7394
  */
5439
7395
  export interface Secrets {
5440
- /** The stored value, `null` when there is none. */
7396
+ /**
7397
+ * The stored value, `null` when there is none.
7398
+ *
7399
+ * @capability secrets
7400
+ * @throws `unsupported` (rejects) The system has no secret store.
7401
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7402
+ * a read-only store, no logged-in session on Windows).
7403
+ * @throws `failed` (rejects) The user denied access, or another system
7404
+ * error.
7405
+ * @throws `capability` (rejects) The manifest does not declare
7406
+ * `secrets`.
7407
+ * @throws `invalid` (rejects) A bad name.
7408
+ *
7409
+ * @example Read a stored token
7410
+ * The plugin uses the token the user saved earlier, or asks for one when
7411
+ * there is none. `get` resolves with the stored text or `null`.
7412
+ * ```ts
7413
+ * const token = await plugin.secrets.get("api-token");
7414
+ * if (token === null) await plugin.osd.show("Sign in first");
7415
+ * ```
7416
+ *
7417
+ * @since 1.0.0
7418
+ */
5441
7419
  get(name: string): Promise<string | null>;
5442
7420
  /**
5443
7421
  * Store or replace (`name`: 1–64 of `a-z 0-9 . _ -`; value 1–1024 bytes).
5444
7422
  * At most 256 different names per plugin: a new name beyond that rejects
5445
7423
  * `limit`. Uninstalling with `removeData` deletes them.
7424
+ *
7425
+ * @capability secrets
7426
+ * @throws `invalid` (rejects) A bad name, or a value that is empty, has NUL
7427
+ * or is over 1024 bytes.
7428
+ * @throws `limit` (rejects) A new name beyond 256.
7429
+ * @throws `unsupported` (rejects) The system has no secret store.
7430
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7431
+ * a read-only store, no logged-in session on Windows).
7432
+ * @throws `failed` (rejects) The user denied access, or another system
7433
+ * error.
7434
+ * @throws `capability` (rejects) The manifest does not declare
7435
+ * `secrets`.
7436
+ *
7437
+ * @example Save the token the user entered
7438
+ * The token the user typed is kept in the system's secret store for later
7439
+ * runs. `set` stores or replaces it; the store can refuse, in which case
7440
+ * the plugin keeps the token for this run only.
7441
+ * ```ts
7442
+ * declare const token: string;
7443
+ * // ---cut---
7444
+ * await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
7445
+ * // No store, or access denied: the token is not kept for next time.
7446
+ * void plugin.osd.show("The token could not be saved ({})", [error.code]);
7447
+ * });
7448
+ * ```
7449
+ *
7450
+ * @since 1.0.0
5446
7451
  */
5447
7452
  set(name: string, value: string): Promise<void>;
5448
- /** Remove; removing a missing secret is fine. */
7453
+ /**
7454
+ * Remove; removing a missing secret is fine.
7455
+ *
7456
+ * @capability secrets
7457
+ * @throws `invalid` (rejects) A bad name.
7458
+ * @throws `unsupported` (rejects) The system has no secret store.
7459
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7460
+ * a read-only store, no logged-in session on Windows).
7461
+ * @throws `failed` (rejects) The user denied access, or another system
7462
+ * error.
7463
+ * @throws `capability` (rejects) The manifest does not declare
7464
+ * `secrets`.
7465
+ *
7466
+ * @example Sign out by removing the token
7467
+ * Signing out removes the stored token, so the next start asks again.
7468
+ * Removing a name that is not stored is fine.
7469
+ * ```ts
7470
+ * await plugin.secrets.delete("api-token");
7471
+ * ```
7472
+ *
7473
+ * @since 1.0.0
7474
+ */
5449
7475
  delete(name: string): Promise<void>;
5450
7476
  }