@vestra-player/sdk 1.5.28 → 1.5.30

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
  * ```
@@ -367,7 +390,11 @@ export interface CommandState {
367
390
  * A value, or a function the SDK re-evaluates on every update pass (see
368
391
  * {@link Scope.update}); only changes are sent to the player.
369
392
  *
370
- * @example
393
+ * @example Mix a computed state with a fixed one
394
+ * The Loop item carries a check mark while a loop is set and is always
395
+ * enabled. A function is evaluated again in every update pass (after each
396
+ * handler and player state change) and only changes are sent; a plain
397
+ * value never changes.
371
398
  * ```ts
372
399
  * plugin.commands.add({
373
400
  * id: "loop",
@@ -386,6 +413,82 @@ export type Bindable<T> = T | (() => T);
386
413
  * `MENU_IDS` in `@vestra-player/sdk/testing`.
387
414
  */
388
415
  export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings";
416
+ /**
417
+ * An icon of the player's V-Line set (`@vestra/icons.slint`), by name: what
418
+ * a bottom-bar button may show. The list is `BAR_BUTTON_ICONS` in
419
+ * `@vestra-player/sdk/testing`.
420
+ *
421
+ * @since 1.5.29
422
+ */
423
+ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "open" | "volume" | "mute" | "playlist" | "detach" | "attach" | "close" | "failed" | "move-top" | "move-up" | "move-down" | "move-bottom" | "search" | "chapter" | "bookmark" | "check-task" | "check-box" | "menu-check" | "submenu" | "scroll-up" | "scroll-down" | "caret-down" | "sort-asc" | "sort-desc" | "tag-remove" | "preview-play" | "add" | "remove" | "sort" | "fullscreen" | "exit-fullscreen" | "store" | "ai";
424
+ /**
425
+ * A command's button on the bottom bar: {@link CommandOptions.button}.
426
+ *
427
+ * @remarks
428
+ * **Where it shows.** The bottom bar places the player's strip of command
429
+ * buttons, `BarButtons` from `@vestra/bar-buttons.slint`, at its right end;
430
+ * the player fills it with the buttons of every plugin, left to right by
431
+ * `order`, and its own sidebar toggle rightmost. When the window is narrow
432
+ * the strip loses cells from its outer (left) end inward: the lowest order
433
+ * goes first, the sidebar toggle last. A
434
+ * bottom bar that does not place `BarButtons` shows no buttons: the
435
+ * command's menu entry stays, and the player logs the buttons it cannot
436
+ * show and whose bar hides them. The built-in bar (`vestra.playback`) places
437
+ * it; see
438
+ * [Writing a bottom bar](../../docs/guidelines/interface.md#writing-a-bottom-bar).
439
+ *
440
+ * **What it shows.** The icon, the command's title (translated, a trailing
441
+ * "…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
442
+ * `enabled`, `busy` and `reason` as the menu item has them: a disabled or
443
+ * busy command's button is drawn disabled, its tooltip adds the reason, and
444
+ * a click runs nothing. A command with `checked` shows its icon in the
445
+ * accent colour while checked. A click runs the command exactly like a
446
+ * click on its menu item.
447
+ *
448
+ * **Limits.** One button per plugin and eight in the player
449
+ * (`BAR_BUTTON_LIMITS`): a button past them is left out (its command and
450
+ * menu entry stay) and the player logs it with `limit`. The button goes
451
+ * with its command: disposing the command, or disabling, stopping or
452
+ * uninstalling the plugin, removes it.
453
+ *
454
+ * @example Give a command a button in the bottom bar
455
+ * A Store icon appears in the bottom bar's button strip; a click opens
456
+ * the store window, exactly like the menu item. The player draws the
457
+ * button from the command's title, keys and state, and places it by
458
+ * `order` (200 is where the player's own Plugin Store button sits); a
459
+ * bar that does not place `BarButtons` shows only the menu item.
460
+ * ```ts
461
+ * const store = plugin.windows.create("ui/store.slint");
462
+ * plugin.commands.add({
463
+ * id: "open",
464
+ * path: ["Plugin Store…"], // a button needs a menu path
465
+ * button: { icon: "store", order: 200 },
466
+ * run: () => store.show(),
467
+ * });
468
+ * ```
469
+ *
470
+ * @player 0.5.10; older players show the menu entry without a button
471
+ * (declare `"minPlayerVersion": "0.5.10"` when the button matters).
472
+ * @since 1.5.29
473
+ */
474
+ export interface CommandButton {
475
+ /**
476
+ * A name of the player's icon set ({@link BarButtonIcon}), or an image
477
+ * made with {@link Ui.image} (a PNG or JPEG in your package or data
478
+ * directory), drawn in the 14×12 icon box, aspect kept, coloured like the
479
+ * player's line icons: only its alpha counts, so draw it as a dark shape
480
+ * on a transparent background. An unknown name fails with `invalid`.
481
+ */
482
+ icon: BarButtonIcon | ImageRef;
483
+ /**
484
+ * Place in the strip, left to right ascending: an integer 0–999. It is
485
+ * position only: a lower order sits further left, and in a narrow window
486
+ * (or past the limit of eight) it is the first to go. Equal orders sort
487
+ * by plugin id, then declaration order. The player's own buttons use AI
488
+ * 100, Plugin Store 200.
489
+ */
490
+ order: number;
491
+ }
389
492
  /**
390
493
  * The declaration passed to {@link Commands.add}.
391
494
  *
@@ -444,10 +547,19 @@ export interface CommandOptions {
444
547
  * ({})", [n, codec])`. The menu shows it translated and filled in; it
445
548
  * merges by its English rendering ("Track 2 (opus)").
446
549
  *
447
- * @example
550
+ * @example Put items into built-in menus, one with a track number
551
+ * "Blur" appears in the Video menu and "Track 2 (opus)" under Audio ▸
552
+ * Audio Track, both in the user's language. `@video` and `@audio` name
553
+ * the built-in menus by id; "Audio Track" is a submenu key that merges
554
+ * with any other plugin's same key; the `text()` segment is translated
555
+ * with the plugin's catalog and then filled in with the number and codec.
448
556
  * ```ts
557
+ * declare function toggle(): void;
558
+ * declare function select(): void;
559
+ * // ---cut---
449
560
  * plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run: toggle });
450
561
  * const { text } = plugin.i18n;
562
+ * // A segment with data: translated as "Track {} ({})", then filled in.
451
563
  * plugin.commands.add({
452
564
  * id: "track-2",
453
565
  * path: ["@audio", "Audio Track", text("Track {} ({})", [2, "opus"])],
@@ -500,6 +612,16 @@ export interface CommandOptions {
500
612
  focus?: "panel";
501
613
  /** Fire again on keyboard auto-repeat while the key is held. Default false. */
502
614
  repeat?: boolean;
615
+ /**
616
+ * A button for this command on the bottom bar (SDK 1.5.29, player 0.5.10):
617
+ * an icon and a place, `{ icon: "store", order: 200 }`. Needs a `path`: the
618
+ * menu entry is where the command stays reachable when the bar shows no
619
+ * button (a bar without `BarButtons`, a narrow window, a limit). See
620
+ * {@link CommandButton} for where it shows, what it shows and the limits.
621
+ * Fails with `invalid` for an unknown icon name, an order outside 0–999 or
622
+ * a button without a path.
623
+ */
624
+ button?: CommandButton;
503
625
  /** Whether the command can run (default true). */
504
626
  enabled?: Bindable<boolean>;
505
627
  /** Check mark (or radio dot with `radio`); `null` (default) draws none. */
@@ -529,9 +651,26 @@ export interface Command extends Disposable {
529
651
  /**
530
652
  * Replace some state fields (values or functions). Last write wins.
531
653
  *
532
- * @example
654
+ * @example Show a command as busy while it works
655
+ * The Export item shows as busy with "Exporting…" as its reason while the
656
+ * export runs, then returns to normal. `set` replaces only the fields it
657
+ * names and the SDK sends the new state to the player; a busy command's
658
+ * menu item and key do not run it again until it is set back.
533
659
  * ```ts
534
- * exportCommand.set({ busy: true, reason: "Exporting…" });
660
+ * declare function runExport(): Promise<void>;
661
+ * // ---cut---
662
+ * const exportCommand = plugin.commands.add({
663
+ * id: "export",
664
+ * path: ["@video", "Export…"],
665
+ * run: async () => {
666
+ * exportCommand.set({ busy: true, reason: "Exporting…" });
667
+ * try {
668
+ * await runExport();
669
+ * } finally {
670
+ * exportCommand.set({ busy: false, reason: null }); // also on failure
671
+ * }
672
+ * },
673
+ * });
535
674
  * ```
536
675
  */
537
676
  set(state: Partial<{
@@ -559,12 +698,17 @@ export interface Commands {
559
698
  * unknown `@` menu id), order, group or binding; `conflict` for an id this
560
699
  * plugin has already declared and not disposed.
561
700
  *
562
- * @example
701
+ * @example Pause playback from a menu item
702
+ * Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
703
+ * while paused and is greyed out when nothing can be paused. The player
704
+ * calls `run` when the user picks the item or presses its key; `enabled`
705
+ * and `checked` are functions the SDK evaluates again after every
706
+ * handler and player state change, sending only what changed.
563
707
  * ```ts
564
708
  * const toggle = plugin.commands.add({
565
709
  * id: "toggle-pause",
566
710
  * path: ["Playback", "Pause"],
567
- * order: 210,
711
+ * order: 210, // position among the Playback menu's items
568
712
  * bindings: ["Ctrl+P"],
569
713
  * enabled: () => plugin.player.state.can.pause,
570
714
  * checked: () => plugin.player.state.paused,
@@ -582,8 +726,8 @@ export interface Commands {
582
726
  * {@link View.bind}: the whole value of an `image` property or, from SDK
583
727
  * 1.5.19 (player 0.5.1), an `image` field at any depth, such as the logo of
584
728
  * each row of a list. Making the reference does not read the file: the
585
- * player checks and decodes it each time a view uses it ({@link Ui.image}
586
- * has the rules and limits).
729
+ * player checks and decodes it each time it receives the value (a property
730
+ * write, a window opening; {@link Ui.image} has the rules and limits).
587
731
  *
588
732
  * A reference belongs to the {@link Scope} that created it. Once that scope
589
733
  * is disposed, writing the reference fails with `invalid` (reported to
@@ -591,13 +735,24 @@ export interface Commands {
591
735
  * property changes or the view closes.
592
736
  *
593
737
  * @example Show a cover image from the data directory
738
+ * A window opens showing `cover.png`, which the plugin saved in its data
739
+ * directory. `ui.image` only makes a reference, without reading the file;
740
+ * the player checks and decodes the file when it receives the value, so a
741
+ * missing file fails that write with `missing` (here: `show` rejects, as
742
+ * it is an initial property). A missing image nested inside a value is
743
+ * drawn empty instead.
594
744
  * ```ts
595
745
  * const cover = plugin.windows.create("ui/cover.slint");
746
+ * // `art` is an `image` property in ui/cover.slint.
596
747
  * cover.set({ art: plugin.ui.image(`${plugin.dataDirectory}/cover.png`) });
597
748
  * await cover.show();
598
749
  * ```
599
750
  *
600
- * @example A logo in each row
751
+ * @example Show a logo in each row of a list
752
+ * Each row of the list draws its own small logo next to its name. An
753
+ * `ImageRef` may sit in any field of a property value (SDK 1.5.19, player
754
+ * 0.5.1), so the rows carry their icons; `maxSide: 64` decodes each logo
755
+ * at most 64 px wide, which keeps the view's image memory small.
601
756
  * ```slint
602
757
  * export struct Row { key: string, name: string, icon: image }
603
758
  * ```
@@ -608,6 +763,7 @@ export interface Commands {
608
763
  * rows: items.map((item) => ({
609
764
  * key: item.id,
610
765
  * name: item.name,
766
+ * // About twice the drawn size (32 px) stays sharp on dense screens.
611
767
  * icon: plugin.ui.image(item.iconPath, { maxSide: 64 }),
612
768
  * })),
613
769
  * }));
@@ -658,7 +814,11 @@ export interface ImageOptions {
658
814
  * scope that created it: once that scope is disposed, writing it fails with
659
815
  * `invalid`, wherever it is in the value.
660
816
  *
661
- * @example Chat messages, one document per row
817
+ * @example Show chat messages, one document per row
818
+ * The chat window shows a formatted assistant message. `ui.markdown`
819
+ * resolves once the document is ready (here without downloading images);
820
+ * the reference goes into the `body` field of a row, whose Slint type is
821
+ * `[MarkdownBlock]`, and the player parses and draws it.
662
822
  * ```ts
663
823
  * const chat = plugin.windows.create("ui/chat.slint");
664
824
  * const body = await plugin.ui.markdown("**Hello**", { remoteImages: false });
@@ -752,7 +912,11 @@ export type PropValue = PropData | ImageRef;
752
912
  * JSON whose values may also be Markdown documents and (SDK 1.5.19) images,
753
913
  * at any depth: the part of {@link PropValue} that nests.
754
914
  *
755
- * @example A message list whose rows carry a document
915
+ * @example Type a message list whose rows carry a document
916
+ * A property value for a chat's message list, typed so that TypeScript
917
+ * accepts a Markdown document inside each row. `PropData` is the JSON a
918
+ * property takes plus documents and images at any depth; the player turns
919
+ * each object into a Slint struct by its field names.
756
920
  * ```ts
757
921
  * const messages: PropData = [
758
922
  * { id: "m1", role: "user", body: await plugin.ui.markdown("Hi") },
@@ -774,7 +938,11 @@ export type PropData = null | boolean | number | string | MarkdownRef | ImageRef
774
938
  * kebab-case are kept. Only the property names are converted, not the
775
939
  * fields of struct values.
776
940
  *
777
- * @example
941
+ * @example Prepare a window's initial properties
942
+ * An object of property values by name, as `windows.create` and
943
+ * `View.set` take them. Names match the Slint file (`canMark` for
944
+ * `can-mark`), and each value becomes the Slint type of its property: a
945
+ * boolean, and an array of structs for a model.
778
946
  * ```ts
779
947
  * const props: Props = { canMark: true, rows: [{ rowId: "a", title: "First" }] };
780
948
  * ```
@@ -798,7 +966,13 @@ export type Props = Record<string, PropValue>;
798
966
  *
799
967
  * @capability None.
800
968
  *
801
- * @example A window that shows a Markdown file (examples/markdown)
969
+ * @example Show a Markdown file in a window (examples/markdown)
970
+ * A menu item opens a window with a formatted Markdown file that ships
971
+ * with the plugin. The plugin reads the file, turns it into a document
972
+ * with `ui.markdown` (relative images resolve against the plugin's
973
+ * directory through `base`) and writes it to the window's `doc` property,
974
+ * which the Slint file hands to `MarkdownView`.
975
+ *
802
976
  * {@includeCode ../../examples/markdown/main.ts#document}
803
977
  *
804
978
  * `ui/document.slint`:
@@ -815,8 +989,8 @@ export interface Ui {
815
989
  *
816
990
  * @remarks
817
991
  * Returns at once without touching the file. The player checks the file
818
- * each time a view uses the reference (a property write, a window opening,
819
- * the main window being rebuilt): it must be inside this plugin's package
992
+ * each time it receives the reference (a property write, a window or
993
+ * settings page opening, the main window being rebuilt): it must be inside this plugin's package
820
994
  * directory or {@link Plugin.dataDirectory} after following links, at most
821
995
  * 16 MB and 4096×4096 pixels, and PNG or JPEG by content (the extension
822
996
  * does not matter). A failed check of a whole-value image is a failed
@@ -828,9 +1002,10 @@ export interface Ui {
828
1002
  * the value is shown, as a Markdown image shows its alt text.
829
1003
  *
830
1004
  * The player caches decoded images per plugin (up to 64 MB, least recently
831
- * used first) and does not watch files. Every call returns a new value, so
832
- * after rewriting a file under the same path, call `image()` again and
833
- * write the new reference.
1005
+ * used first) by path, size and modification time, and does not watch
1006
+ * files. After rewriting a file under the same path, call `image()` again
1007
+ * and write the new reference: the SDK does not send a value that did not
1008
+ * change, so the player would not look at the file again.
834
1009
  *
835
1010
  * @capability None.
836
1011
  *
@@ -844,6 +1019,12 @@ export interface Ui {
844
1019
  * `maxSide` is not an integer 1–4096.
845
1020
  *
846
1021
  * @example Show a thumbnail the plugin rendered
1022
+ * A window shows a PNG the plugin wrote into its data directory. The file
1023
+ * must be inside the package or data directory; `image` returns at once.
1024
+ * The player decodes the file when it receives the value and notices a
1025
+ * rewritten file by its size and modification time; the plugin still
1026
+ * calls `image()` again after rewriting it, because the SDK does not send
1027
+ * a value that did not change.
847
1028
  * ```ts
848
1029
  * const window = plugin.windows.create("ui/thumbnail.slint");
849
1030
  * const file = `${plugin.dataDirectory}/thumbnail.png`;
@@ -881,7 +1062,12 @@ export interface Ui {
881
1062
  * large, or is not a PNG or JPEG image; or the plugin has no data
882
1063
  * directory.
883
1064
  *
884
- * @example Search results with thumbnails
1065
+ * @example Show search results with downloaded thumbnails
1066
+ * A results window lists each hit with its thumbnail, or a placeholder
1067
+ * when a thumbnail cannot be fetched. `remoteImage` downloads the image
1068
+ * in the plugin's process into the data directory's cache (at most 4 at a
1069
+ * time) and resolves with a local reference; a failed download rejects
1070
+ * with `unavailable`, which the `catch` turns into the placeholder.
885
1071
  * ```ts
886
1072
  * const results = [{ title: "A", thumbnailUrl: "https://example.com/a.jpg" }];
887
1073
  * const placeholder = plugin.ui.image(`${plugin.dataDirectory}/blank.png`);
@@ -890,7 +1076,7 @@ export interface Ui {
890
1076
  * title: r.title,
891
1077
  * thumb: await plugin.ui
892
1078
  * .remoteImage(r.thumbnailUrl, { maxSide: 320 })
893
- * .catch(() => placeholder),
1079
+ * .catch(() => placeholder), // `unavailable`: download failed
894
1080
  * })),
895
1081
  * );
896
1082
  * plugin.windows.create("ui/results.slint").set({ rows });
@@ -943,7 +1129,14 @@ export interface Ui {
943
1129
  * `base` is neither an `http(s):` address nor an absolute path.
944
1130
  * @throws `unsupported` (rejects) The player is older than 0.2.0.
945
1131
  *
946
- * @example A chat: one Markdown document per row, with Copy buttons
1132
+ * @example Build a chat with one Markdown document per row
1133
+ * A chat window lists messages as formatted text, with a Copy button on
1134
+ * each code block that puts the code on the clipboard. Each row's `body`
1135
+ * is a document from `ui.markdown`; the whole list is written again on
1136
+ * each change, and the player parses only the documents that changed.
1137
+ * `MarkdownBlocks` with `code-copy` calls `copy-code`, which the plugin
1138
+ * handles with `on("copyCode")`.
1139
+ *
947
1140
  * `ui/chat.slint`:
948
1141
  *
949
1142
  * ```slint
@@ -970,6 +1163,7 @@ export interface Ui {
970
1163
  * const chat = plugin.windows.create("ui/chat.slint");
971
1164
  * const history: { id: string; role: string; text: string }[] = [];
972
1165
  * chat.on("copyCode", (code: string) => plugin.clipboard.writeText(code));
1166
+ * // Re-send the whole list: unchanged documents cost the player nothing.
973
1167
  * async function render() {
974
1168
  * const messages = [];
975
1169
  * for (const { id, role, text } of history)
@@ -982,10 +1176,19 @@ export interface Ui {
982
1176
  * ```
983
1177
  *
984
1178
  * @example Show a README from the web
1179
+ * A window shows a README downloaded from the web, its images included,
1180
+ * and clicked links open in the browser. `base` makes relative links and
1181
+ * images resolve against the README's address; `markdown` resolves after
1182
+ * its `https:` images are downloaded. A click on a link makes
1183
+ * `MarkdownView` call `link-clicked`, which `ui/readme.slint` forwards as
1184
+ * `open-link`; the plugin opens the address with `openUrl`.
985
1185
  * ```ts
986
1186
  * const readmeUrl = "https://example.com/plugin/README.md";
987
- * const text = await (await fetch(readmeUrl)).text();
1187
+ * const response = await fetch(readmeUrl);
1188
+ * if (!response.ok) throw new Error(`README: HTTP ${response.status}`);
1189
+ * const text = await response.text();
988
1190
  * const window = plugin.windows.create("ui/readme.slint");
1191
+ * // Bound before the first `show`, as a tool window requires.
989
1192
  * window.on("openLink", (url: string) => plugin.system.openUrl(url));
990
1193
  * window.set({ doc: await plugin.ui.markdown(text, { base: readmeUrl }) });
991
1194
  * await window.show();
@@ -1028,7 +1231,13 @@ export interface Ui {
1028
1231
  *
1029
1232
  * @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
1030
1233
  *
1031
- * @example A window with a label, two buttons and their callbacks (examples/bookmark)
1234
+ * @example Wire a window's label and buttons to the plugin (examples/bookmark)
1235
+ * A Bookmark window shows the saved position and two buttons that save
1236
+ * it and jump back to it. `bind` keeps the label and the buttons' enabled
1237
+ * state equal to the plugin's state: the SDK evaluates the function again
1238
+ * after every handler and player change and sends only what changed. The
1239
+ * buttons' Slint callbacks reach the plugin through `on`.
1240
+ *
1032
1241
  * {@includeCode ../../examples/bookmark/main.ts#window}
1033
1242
  *
1034
1243
  * `ui/bookmark.slint`:
@@ -1073,10 +1282,16 @@ export interface View {
1073
1282
  * @throws `Error` without a code (throws) On an open {@link Window}, for a
1074
1283
  * name that has no handler yet.
1075
1284
  *
1076
- * @example
1285
+ * @example Keep a slider and the plugin's value in step
1286
+ * Moving the window's brightness slider updates the plugin's value, and
1287
+ * the slider shows it back. The Slint callback `brightness-changed`
1288
+ * reaches the handler with the slider's value as JSON; after the handler
1289
+ * returns, the SDK runs an update pass and `bind` sends the new value.
1290
+ * Callbacks of a tool window must be bound before its first `show`.
1077
1291
  * ```ts
1078
1292
  * const picture = plugin.windows.create("ui/picture.slint");
1079
1293
  * let brightness = 0;
1294
+ * // camelCase here, `brightness-changed` in the Slint file.
1080
1295
  * picture.on("brightnessChanged", (value: number) => {
1081
1296
  * brightness = value;
1082
1297
  * });
@@ -1115,6 +1330,10 @@ export interface View {
1115
1330
  * @param props - Properties by name; see {@link Props}.
1116
1331
  *
1117
1332
  * @example Put a drop-down back when the user picks an entry the plugin refuses
1333
+ * Choosing "Custom" snaps the drop-down back to the last real preset.
1334
+ * The plugin cannot read a property back, only hear the callback; `set`
1335
+ * sends the value even though it equals the one sent before, which is
1336
+ * what pulls a control the user moved back to the plugin's value.
1118
1337
  * ```ts
1119
1338
  * const window = plugin.windows.create("ui/presets.slint");
1120
1339
  * let presetIndex = 0;
@@ -1150,7 +1369,11 @@ export interface View {
1150
1369
  * @param props - Returns the properties, by name.
1151
1370
  * @returns A handle that stops this binding (the values last sent stay).
1152
1371
  *
1153
- * @example
1372
+ * @example Show what is playing and whether it can seek
1373
+ * The status window always shows the current file and enables its seek
1374
+ * controls only for seekable sources. The SDK calls the function right
1375
+ * away and again in every update pass (after player state changes and
1376
+ * after each handler) and sends only the values that changed.
1154
1377
  * ```ts
1155
1378
  * const status = plugin.windows.create("ui/status.slint");
1156
1379
  * status.bind(() => ({
@@ -1200,10 +1423,19 @@ export interface View {
1200
1423
  *
1201
1424
  * @capability ui.window
1202
1425
  *
1203
- * @example A menu command that opens a window (examples/bookmark)
1426
+ * @example Open a window from a menu command (examples/bookmark)
1427
+ * Playback ▸ Bookmark… opens the Bookmark window. `create` only makes a
1428
+ * handle; the command's `run` calls `show`, which compiles the Slint file
1429
+ * and opens the window with the bound properties the first time, and
1430
+ * brings back the same window afterwards.
1431
+ *
1204
1432
  * {@includeCode ../../examples/bookmark/main.ts#window,command}
1205
1433
  *
1206
1434
  * @example Stop work when the user closes the window
1435
+ * A clock window ticks every second while it is open and stops costing
1436
+ * anything once the user closes it. `plugin.update()` makes the SDK
1437
+ * evaluate the bound time again from a timer; `onClose` runs only for the
1438
+ * user's close, so the plugin stops the timer there.
1207
1439
  * ```ts
1208
1440
  * const clock = plugin.windows.create("ui/clock.slint");
1209
1441
  * let timer: NodeJS.Timeout | undefined;
@@ -1217,6 +1449,7 @@ export interface View {
1217
1449
  * path: ["@screen", "Clock…"],
1218
1450
  * run: async () => {
1219
1451
  * await clock.show();
1452
+ * // Re-evaluate `bind` once a second: bound values change only then.
1220
1453
  * timer ??= setInterval(() => plugin.update(), 1000);
1221
1454
  * },
1222
1455
  * });
@@ -1258,12 +1491,17 @@ export interface Window extends View, Disposable {
1258
1491
  * JPEG.
1259
1492
  *
1260
1493
  * @example Tell the user why a window did not open
1494
+ * When the inspector cannot open, the user sees why on the OSD instead
1495
+ * of nothing happening. `show` compiles the Slint file and opens the
1496
+ * window; it rejects with a code, such as `host` for a Slint file that
1497
+ * does not compile or `unavailable` without a user interface.
1261
1498
  * ```ts
1262
1499
  * const window = plugin.windows.create("ui/inspector.slint");
1263
1500
  * try {
1264
1501
  * await window.show();
1265
1502
  * } catch (error) {
1266
- * await plugin.osd.show(`Cannot open the inspector (${(error as PluginError).code})`);
1503
+ * // The code is data; `message` has the details for the log.
1504
+ * await plugin.osd.show("Cannot open the inspector ({})", [(error as PluginError).code]);
1267
1505
  * }
1268
1506
  * ```
1269
1507
  *
@@ -1285,10 +1523,14 @@ export interface Window extends View, Disposable {
1285
1523
  * @throws `missing` (rejects) The user closed the window just before (the
1286
1524
  * close had not reached the plugin yet).
1287
1525
  *
1288
- * @example A command that shows and hides a window
1526
+ * @example Show and hide a window from a checkable menu item
1527
+ * Audio ▸ Levels shows the window with a check mark and hides it again.
1528
+ * `hide` keeps the window's instance and state, so showing it again is
1529
+ * instant; the check mark follows `visible`, and `onClose` resets it when
1530
+ * the user closes the window with its title-bar button.
1289
1531
  * ```ts
1290
1532
  * const levels = plugin.windows.create("ui/levels.slint");
1291
- * let visible = false;
1533
+ * let visible = false; // what the check mark shows
1292
1534
  * plugin.commands.add({
1293
1535
  * id: "levels",
1294
1536
  * path: ["@audio", "Levels"],
@@ -1321,6 +1563,10 @@ export interface Window extends View, Disposable {
1321
1563
  * @throws `missing` (rejects) The user closed the window just before.
1322
1564
  *
1323
1565
  * @example Close the window from its own button
1566
+ * The editor's Done button closes the editor window. `close` destroys
1567
+ * the window instance but keeps the handle, its handlers and bindings,
1568
+ * so the next `show` opens a fresh window; `onClose` is not called for
1569
+ * the plugin's own close.
1324
1570
  * ```ts
1325
1571
  * const editor = plugin.windows.create("ui/editor.slint");
1326
1572
  * editor.on("done", () => editor.close());
@@ -1353,7 +1599,12 @@ export interface Window extends View, Disposable {
1353
1599
  * @param listener - Called with no arguments; may be async.
1354
1600
  * @returns A handle that removes the listener.
1355
1601
  *
1356
- * @example Remember that the user closed it
1602
+ * @example Remember that the user closed the window
1603
+ * The plugin records that the notes window was closed, so it does not
1604
+ * reopen it at the next start. `onClose` runs for the title-bar button,
1605
+ * the player's close keys (Cmd+W, Ctrl+W, Ctrl+F4) and the system's own
1606
+ * close request such as Alt+F4 (not for the plugin's own `close`); the
1607
+ * store writes the change to `window.json` shortly after.
1357
1608
  * ```ts
1358
1609
  * const store = plugin.storage.json("window", { defaults: { open: false } });
1359
1610
  * const window = plugin.windows.create("ui/notes.slint");
@@ -1379,7 +1630,11 @@ export interface Window extends View, Disposable {
1379
1630
  *
1380
1631
  * @capability ui.window
1381
1632
  *
1382
- * @example
1633
+ * @example Create a window and open it from a menu (examples/bookmark)
1634
+ * A Bookmark window opens from Playback ▸ Bookmark…. `create` returns a
1635
+ * handle without opening anything; its bindings and callbacks are set up
1636
+ * first, and `show` opens the window when the command runs.
1637
+ *
1383
1638
  * {@includeCode ../../examples/bookmark/main.ts#window,command}
1384
1639
  *
1385
1640
  * @see {@link Window}, [Windows, panels, settings pages, dialogs and the OSD](../../docs/concepts/ui.md)
@@ -1414,7 +1669,10 @@ export interface Windows {
1414
1669
  * @throws `conflict` (throws) This plugin already has a window with this id
1415
1670
  * (SDK 1.3.0; before, a plain `Error` without `code`).
1416
1671
  *
1417
- * @example One file, two windows
1672
+ * @example Open two windows from one Slint file
1673
+ * Two compare windows open side by side, one labelled A and one B. A
1674
+ * window's id defaults to its file name, so the second window of the
1675
+ * same file needs its own `id`; each handle has its own properties.
1418
1676
  * ```ts
1419
1677
  * const left = plugin.windows.create("ui/compare.slint", { id: "compare-left" });
1420
1678
  * const right = plugin.windows.create("ui/compare.slint", { id: "compare-right" });
@@ -1515,12 +1773,18 @@ export interface PanelState {
1515
1773
  *
1516
1774
  * @capability ui.mount; `events` to follow changes made elsewhere.
1517
1775
  *
1518
- * @example A "Show sidebar" menu item that works with any sidebar panel
1776
+ * @example Add a "Show sidebar" item that works with any sidebar panel
1777
+ * Tools ▸ Show sidebar shows or hides the sidebar, whichever plugin's
1778
+ * panel is in it, with a check mark while it is shown. The position does
1779
+ * not belong to the plugin that mounted the panel: `state` is `null`
1780
+ * while nobody has a panel there (the item is disabled then), and
1781
+ * `toggle` flips the user's remembered preference.
1519
1782
  * ```ts
1520
1783
  * const sidebar = plugin.panels.sidebar;
1521
1784
  * plugin.commands.add({
1522
1785
  * id: "sidebar",
1523
1786
  * path: ["Tools", "Show sidebar"],
1787
+ * // `state` is null while no plugin has a panel in the sidebar.
1524
1788
  * checked: () => sidebar.state?.preferred ?? false,
1525
1789
  * enabled: () => sidebar.state !== null,
1526
1790
  * run: () => sidebar.toggle(),
@@ -1563,9 +1827,14 @@ export interface PanelSlot {
1563
1827
  * @param listener - Receives the new state.
1564
1828
  * @returns A handle that removes the listener.
1565
1829
  *
1566
- * @example
1830
+ * @example Tell the user when the window is too narrow for the sidebar
1831
+ * When the sidebar should show but the window has no room for it, the
1832
+ * OSD says so. With `events`, the listener runs on every change of the
1833
+ * position, whoever caused it. The player reports `reason: "space"` when
1834
+ * the preference is to show the sidebar but the window is too small.
1567
1835
  * ```ts
1568
1836
  * plugin.panels.sidebar.onChange((state) => {
1837
+ * // `null`: no panel is mounted in the sidebar any more.
1569
1838
  * if (state?.reason === "space") void plugin.osd.show("Widen the window to see the playlist");
1570
1839
  * });
1571
1840
  * ```
@@ -1591,9 +1860,15 @@ export interface PanelSlot {
1591
1860
  * @throws `unavailable` (rejects) The player runs without a user interface.
1592
1861
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1593
1862
  *
1594
- * @example
1863
+ * @example Hide the bottom bar for a clean picture
1864
+ * The control bar disappears, as if the user had hidden it from the menu,
1865
+ * and stays hidden at the next start because the preference is
1866
+ * remembered. `setVisible` rejects with `missing` when no panel is in the
1867
+ * bottom bar, which this plugin treats as nothing to hide.
1595
1868
  * ```ts
1596
- * await plugin.panels.bottom.setVisible(false);
1869
+ * await plugin.panels.bottom.setVisible(false).catch((error: PluginError) => {
1870
+ * if (error.code !== "missing") throw error; // no bar mounted: nothing to do
1871
+ * });
1597
1872
  * ```
1598
1873
  *
1599
1874
  * @since 1.0.0
@@ -1614,11 +1889,16 @@ export interface PanelSlot {
1614
1889
  * @throws `unavailable` (rejects) The player runs without a user interface.
1615
1890
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1616
1891
  *
1617
- * @example
1892
+ * @example Toggle the control bar from a checkable menu item
1893
+ * Screen ▸ Control bar shows or hides the bottom bar and carries a check
1894
+ * mark while it is shown. `toggle` reads the player's current preference
1895
+ * first, so it is right even without `events`; `checked` is evaluated
1896
+ * again after the command runs and follows `state`.
1618
1897
  * ```ts
1619
1898
  * plugin.commands.add({
1620
1899
  * id: "toggle-bar",
1621
1900
  * path: ["@screen", "Control bar"],
1901
+ * // The user's preference, remembered across launches.
1622
1902
  * checked: () => plugin.panels.bottom.state?.preferred ?? false,
1623
1903
  * run: () => plugin.panels.bottom.toggle(),
1624
1904
  * });
@@ -1650,7 +1930,11 @@ export interface PanelSlot {
1650
1930
  * @throws `unavailable` (rejects) The player runs without a user interface.
1651
1931
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1652
1932
  *
1653
- * @example A command that shows the playlist's tab
1933
+ * @example Switch the sidebar to the playlist's tab
1934
+ * A command makes the playlist the sidebar's current tab without showing
1935
+ * or hiding the sidebar, like a click on its tab. `activate` takes the
1936
+ * manifest id of the plugin whose tab to show and rejects with
1937
+ * `missing` when that plugin has no panel there.
1654
1938
  * ```ts
1655
1939
  * plugin.commands.add({
1656
1940
  * id: "to-playlist",
@@ -1676,12 +1960,17 @@ export interface PanelSlot {
1676
1960
  *
1677
1961
  * @capability ui.mount
1678
1962
  *
1679
- * @example A "Detach sidebar" menu item
1963
+ * @example Add a "Detach sidebar" item
1964
+ * Tools ▸ Detach sidebar floats the sidebar in its own window and docks
1965
+ * it again, with a check mark while it floats. `setDetached` moves all
1966
+ * the sidebar's tabs; the floating window holds new instances of the
1967
+ * panels, which keep the plugins' properties and callbacks.
1680
1968
  * ```ts
1681
1969
  * const sidebar = plugin.panels.sidebar;
1682
1970
  * plugin.commands.add({
1683
1971
  * id: "detach",
1684
1972
  * path: ["Tools", "Detach sidebar"],
1973
+ * // Detaching moves every tab of the sidebar into the floating window.
1685
1974
  * checked: () => sidebar.state?.detached ?? false,
1686
1975
  * enabled: () => sidebar.state !== null,
1687
1976
  * run: () => sidebar.setDetached(!sidebar.state?.detached),
@@ -1704,7 +1993,10 @@ export interface SidebarSlot extends PanelSlot {
1704
1993
  * @throws `unavailable` (rejects) The player runs without a user interface.
1705
1994
  * @throws `capability` (rejects) The manifest does not declare `ui.mount`.
1706
1995
  *
1707
- * @example
1996
+ * @example Float the sidebar or dock it back
1997
+ * The sidebar moves into its own window, or back into the main window
1998
+ * when it already floats. `state` says whether it is detached now;
1999
+ * `setDetached` resolves once the layout changed, with `state` refreshed.
1708
2000
  * ```ts
1709
2001
  * const sidebar = plugin.panels.sidebar;
1710
2002
  * await sidebar.setDetached(!sidebar.state?.detached);
@@ -1737,7 +2029,13 @@ export interface SidebarSlot extends PanelSlot {
1737
2029
  *
1738
2030
  * @capability ui.mount
1739
2031
  *
1740
- * @example A sidebar list with a right-click menu (examples/panel-menu)
2032
+ * @example Mount a sidebar list with a right-click menu (examples/panel-menu)
2033
+ * The sidebar gets an "Example list" tab whose rows can be selected and
2034
+ * have a context menu. `mount` returns at once and the player adds the
2035
+ * panel when it rebuilds its main window; `bind` keeps the rows and the
2036
+ * selection on the panel's bridge global, and the `context-menu` callback
2037
+ * opens a menu of the plugin's commands where the user clicked.
2038
+ *
1741
2039
  * {@includeCode ../../examples/panel-menu/main.ts#mount,menu}
1742
2040
  *
1743
2041
  * `ui/panel.slint`:
@@ -1812,7 +2110,12 @@ export interface Panel extends View, Disposable {
1812
2110
  * @param listener - Receives the new state.
1813
2111
  * @returns A handle that removes the listener.
1814
2112
  *
1815
- * @example Say when the user switched to another tab
2113
+ * @example Notice when the list moves to a background tab
2114
+ * The plugin logs when the user switches the sidebar to another plugin's
2115
+ * tab, for example to pause work the list does. With `events` the
2116
+ * listener runs on every change. `reason: "otherTab"` exists only in a
2117
+ * panel's own state: the SDK sets it when the sidebar's current tab is
2118
+ * another plugin's (the position's state never has it).
1816
2119
  * ```ts
1817
2120
  * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1818
2121
  * panel.onChange((state) => {
@@ -1842,7 +2145,10 @@ export interface Panel extends View, Disposable {
1842
2145
  * @throws `unavailable` (rejects) The player runs without a user interface.
1843
2146
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
1844
2147
  *
1845
- * @example
2148
+ * @example Bring the plugin's sidebar panel to the front
2149
+ * A command shows the List tab: it becomes the current tab and the
2150
+ * sidebar is shown, and the preference is remembered. `show` waits for
2151
+ * the mount first, so it can run right after `mount`.
1846
2152
  * ```ts
1847
2153
  * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1848
2154
  * plugin.commands.add({ id: "show", title: "Show the list", run: () => list.show() });
@@ -1870,13 +2176,18 @@ export interface Panel extends View, Disposable {
1870
2176
  * @throws `unavailable` (rejects) The player runs without a user interface.
1871
2177
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
1872
2178
  *
1873
- * @example A toggle command for a sidebar panel (the playlist's F6)
2179
+ * @example Toggle a sidebar panel with F6, like the playlist
2180
+ * F6 and Screen ▸ List show the List tab, or hide the sidebar when the
2181
+ * list is already in front. `toggle` reads the player's state first:
2182
+ * it hides the position only when it is shown and this panel's tab is
2183
+ * the current one, otherwise it shows the panel.
1874
2184
  * ```ts
1875
2185
  * const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
1876
2186
  * plugin.commands.add({
1877
2187
  * id: "list",
1878
2188
  * path: ["@screen", "List"],
1879
2189
  * bindings: ["F6"],
2190
+ * // Checked while the sidebar is shown with this list in front.
1880
2191
  * checked: () => (list.state?.preferred ?? false) && (list.state?.active ?? false),
1881
2192
  * run: () => list.toggle(),
1882
2193
  * });
@@ -1905,7 +2216,12 @@ export interface Panel extends View, Disposable {
1905
2216
  *
1906
2217
  * @param locks - The locks to change; omitted ones keep their value.
1907
2218
  *
1908
- * @example Keep the bar while a search field has focus
2219
+ * @example Keep the bar on screen while a search field has focus
2220
+ * In fullscreen the bottom bar stays visible while the user types in
2221
+ * its search field, instead of hiding when the pointer leaves it. The
2222
+ * Slint side reports the field's focus through a callback, and
2223
+ * `setInteraction` sets or clears the `focused` lock; other locks keep
2224
+ * their values.
1909
2225
  * ```ts
1910
2226
  * const panel = plugin.panels.mount("bottom", "ui/bar.slint");
1911
2227
  * panel.on("searchFocused", (focused: boolean) => panel.setInteraction({ focused }));
@@ -1947,7 +2263,12 @@ export interface Panel extends View, Disposable {
1947
2263
  * finite.
1948
2264
  * @throws The errors of {@link Panel.ready} (rejects) The mount failed.
1949
2265
  *
1950
- * @example
2266
+ * @example Open a context menu where the user right-clicked (examples/panel-menu)
2267
+ * Right-clicking the list opens a menu with "Select the first item" and
2268
+ * "Remove the selected item". The Slint file passes the pointer position
2269
+ * in window pixels with its `context-menu` callback; `showMenu` opens the
2270
+ * plugin's commands there, and picking one runs that command.
2271
+ *
1951
2272
  * {@includeCode ../../examples/panel-menu/main.ts#menu}
1952
2273
  *
1953
2274
  * @since 1.0.0
@@ -1977,12 +2298,16 @@ export interface Panel extends View, Disposable {
1977
2298
  * @returns A handle that removes the listener.
1978
2299
  *
1979
2300
  * @example Add dropped files to a list
2301
+ * Files dropped on the Files panel are added to its list. While the
2302
+ * listener exists, drops on the panel come here as one group instead of
2303
+ * going to the drop providers; after the listener returns, the SDK runs
2304
+ * an update pass and `bind` sends the longer list.
1980
2305
  * ```ts
1981
2306
  * const files: string[] = [];
1982
2307
  * const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "Files" });
1983
2308
  * panel.bind(() => ({ rows: files }));
1984
2309
  * panel.onDrop(({ paths }) => {
1985
- * files.push(...paths);
2310
+ * files.push(...paths); // in the order the file manager gave them
1986
2311
  * });
1987
2312
  * ```
1988
2313
  *
@@ -2028,16 +2353,27 @@ export interface Drops {
2028
2353
  * Take files dropped on the video area. The first provider registered for
2029
2354
  * an extension gets it; files nobody takes are ignored.
2030
2355
  *
2031
- * Paths are grouped by extension (case-insensitive) in the order the file
2356
+ * Paths are grouped by provider (an extension, case-insensitive, belongs
2357
+ * to the provider that registered it first) in the order the file
2032
2358
  * manager gave them, one call per provider. Folders are not expanded.
2033
2359
  *
2034
2360
  * @throws `invalid` synchronously for malformed extensions.
2035
2361
  *
2036
- * @example
2362
+ * @example Load dropped subtitle files
2363
+ * Dropping .srt or .ass files on the video loads each one as a subtitle
2364
+ * track. Each extension belongs to the provider that registered it first;
2365
+ * the player groups the dropped paths by provider and calls each provider
2366
+ * once with all of its paths, so .srt and .ass files dropped together
2367
+ * arrive in one call. (A panel under the pointer that listens with
2368
+ * `onDrop` takes the whole drop first.) The plugin reads each file itself
2369
+ * and uploads its bytes with `subtitles.load`.
2037
2370
  * ```ts
2371
+ * import { readFile } from "node:fs/promises";
2372
+ * // ---cut---
2038
2373
  * plugin.drops.provide({
2039
2374
  * extensions: ["srt", "ass"],
2040
2375
  * drop: async ({ paths }) => {
2376
+ * // Only .srt and .ass files reach this provider.
2041
2377
  * for (const path of paths)
2042
2378
  * await plugin.subtitles.load(await readFile(path), {
2043
2379
  * format: path.endsWith(".ass") ? "ass" : "srt",
@@ -2083,12 +2419,16 @@ export type ThemeId = "vestra" | "vestra-light";
2083
2419
  * @capability None to read `theme`; `events` to follow changes; `ui.theme`
2084
2420
  * to change it.
2085
2421
  *
2086
- * @example A menu item that selects the light palette
2422
+ * @example Offer the light palette as a menu item
2423
+ * Settings ▸ Light colours switches every view of the player to the light
2424
+ * palette and carries a radio mark while it is in use. `set` asks the
2425
+ * player to switch (it saves the choice); `theme` holds the palette in use,
2426
+ * and `checked` reads it again after every handler.
2087
2427
  * ```ts
2088
2428
  * plugin.commands.add({
2089
2429
  * id: "light-colours",
2090
2430
  * path: ["@settings", "Light colours"],
2091
- * radio: true,
2431
+ * radio: true, // a round mark instead of a check mark
2092
2432
  * checked: () => plugin.appearance.theme === "vestra-light",
2093
2433
  * run: () => plugin.appearance.set("vestra-light"),
2094
2434
  * });
@@ -2119,10 +2459,16 @@ export interface Appearance {
2119
2459
  * @param listener - Receives the new palette.
2120
2460
  * @returns A handle that removes the listener.
2121
2461
  *
2122
- * @example
2462
+ * @example Redraw a picture the plugin renders when the palette changes
2463
+ * The plugin renders a chart image in the palette's colours and draws it
2464
+ * again when the user or another plugin switches palettes. Views that use
2465
+ * `Theme` follow on their own; this listener (which needs `events`) is
2466
+ * for what the plugin draws itself.
2123
2467
  * ```ts
2468
+ * declare function renderChart(dark: boolean): Promise<void>;
2469
+ * // ---cut---
2124
2470
  * plugin.appearance.onChange((theme) => {
2125
- * void plugin.osd.show(`Palette: ${theme}`);
2471
+ * void renderChart(theme === "vestra"); // the dark default
2126
2472
  * });
2127
2473
  * ```
2128
2474
  *
@@ -2145,7 +2491,11 @@ export interface Appearance {
2145
2491
  * @throws `invalid` (rejects) `theme` is not a {@link ThemeId}.
2146
2492
  * @throws `capability` (rejects) The manifest does not declare `ui.theme`.
2147
2493
  *
2148
- * @example
2494
+ * @example Switch to the light palette
2495
+ * Every view of the player turns light, and stays light after a restart.
2496
+ * The player applies the palette to every open view and saves the
2497
+ * choice; when the promise resolves, `appearance.theme` already has the
2498
+ * new value. Without `ui.theme` it rejects with `capability`.
2149
2499
  * ```ts
2150
2500
  * await plugin.appearance.set("vestra-light");
2151
2501
  * ```
@@ -2186,10 +2536,14 @@ export interface Appearance {
2186
2536
  * @throws `capability` (rejects) The manifest does not declare `ui.theme`.
2187
2537
  * @throws `unsupported` (rejects) A player before 0.4.3.
2188
2538
  *
2189
- * @example
2539
+ * @example Choose German for the next start
2540
+ * The player will start in German next time, and the user is told so.
2541
+ * `setLanguage` only saves the choice: the language of this run
2542
+ * (`i18n.locale`) never changes while the player runs. Players before
2543
+ * 0.4.3 reject it with `unsupported`.
2190
2544
  * ```ts
2191
2545
  * await plugin.appearance.setLanguage("de");
2192
- * await plugin.osd.show("Takes effect after restart");
2546
+ * await plugin.osd.show("Takes effect after restart"); // translated by the player
2193
2547
  * ```
2194
2548
  *
2195
2549
  * @since 1.5.9
@@ -2336,6 +2690,10 @@ export type TextValue = string | Text | TextList;
2336
2690
  * @capability None.
2337
2691
  *
2338
2692
  * @example Format a date the way the interface language writes it
2693
+ * The OSD shows today's date in the user's language and date format. The
2694
+ * sentence is a template the player translates; only the data in it, the
2695
+ * date, is formatted by the plugin, with `i18n.locale` (the language the
2696
+ * player runs in, fixed for the whole run).
2339
2697
  * ```ts
2340
2698
  * const day = new Date().toLocaleDateString(plugin.i18n.locale);
2341
2699
  * await plugin.osd.show("Today is {}", [day]);
@@ -2369,17 +2727,25 @@ export interface I18n {
2369
2727
  * @param args - Values for its placeholders.
2370
2728
  * @throws `invalid` A malformed or over-limit value (`TEXT_LIMITS`).
2371
2729
  *
2372
- * @example A ComboBox model with fixed words and data
2730
+ * @example Fill a drop-down with fixed words and data
2731
+ * The drop-down lists "No Audio" and one "Track 7 · en" entry per track,
2732
+ * in the user's language. A `[string]` model may hold `text()` values: the
2733
+ * player translates each source text with the plugin's catalog and fills
2734
+ * in its arguments, so the plugin never composes translated strings.
2373
2735
  * ```ts
2374
2736
  * const { text } = plugin.i18n;
2375
2737
  * const picker = plugin.windows.create("ui/clip.slint");
2376
2738
  * const tracks = [{ id: 7, title: "en" }];
2739
+ * // Each entry is translated, then filled in: "Track 7 · en".
2377
2740
  * picker.set({
2378
2741
  * labels: [text("No Audio"), ...tracks.map((t) => text("Track {} · {}", [t.id, t.title]))],
2379
2742
  * });
2380
2743
  * ```
2381
2744
  *
2382
- * @example A message built from translated parts
2745
+ * @example Build a message from translated parts
2746
+ * The OSD says "Download failed: Connection timed out", both parts in the
2747
+ * user's language. An argument made with `text()` is translated too,
2748
+ * while string arguments are shown as they are.
2383
2749
  * ```ts
2384
2750
  * await plugin.osd.show("Download failed: {}", [plugin.i18n.text("Connection timed out")]);
2385
2751
  * ```
@@ -2399,7 +2765,11 @@ export interface I18n {
2399
2765
  * @param style - `"list"` by default.
2400
2766
  * @throws `invalid` A malformed or over-limit value.
2401
2767
  *
2402
- * @example
2768
+ * @example List file names the way the language joins them
2769
+ * The OSD says "Removed missing shaders: a.glsl, b.glsl" in English and
2770
+ * joins the names with "、" in Chinese. `list` makes a value the player
2771
+ * joins per interface language; the names themselves are data and stay
2772
+ * as they are.
2403
2773
  * ```ts
2404
2774
  * const missing = ["a.glsl", "b.glsl"];
2405
2775
  * await plugin.osd.show("Removed missing shaders: {}", [plugin.i18n.list(missing)]);
@@ -2423,13 +2793,25 @@ export interface I18n {
2423
2793
  * @param args - Values for its placeholders.
2424
2794
  * @throws `invalid` A malformed or over-limit value.
2425
2795
  *
2426
- * @example
2796
+ * @example Fail an open with a message the user can read
2797
+ * Opening a `.ch8` file the plugin cannot load shows "The program file
2798
+ * was not found." in the user's language. The player shows the message of
2799
+ * an error thrown from `resolve`; `i18n.error` makes an `Error` whose text
2800
+ * the player translates with this plugin's catalog, while its `message`
2801
+ * stays English for logs and older players.
2427
2802
  * ```ts
2803
+ * import { readFile } from "node:fs/promises";
2804
+ * declare function startEmulator(program: Uint8Array): ResolvedStream;
2805
+ * // ---cut---
2428
2806
  * plugin.resolvers.add({
2429
- * id: "nes",
2430
- * extensions: ["nes"],
2431
- * async resolve() {
2432
- * throw plugin.i18n.error("The ROM file was not found.");
2807
+ * id: "chip8",
2808
+ * extensions: ["ch8"],
2809
+ * async resolve(path, signal) {
2810
+ * const program = await readFile(path, { signal }).catch(() => {
2811
+ * // Shown as the open's failure, translated (locales/<lang>.po).
2812
+ * throw plugin.i18n.error("The program file was not found.");
2813
+ * });
2814
+ * return startEmulator(program);
2433
2815
  * },
2434
2816
  * });
2435
2817
  * ```
@@ -2455,7 +2837,11 @@ export interface I18n {
2455
2837
  * @returns The translated string.
2456
2838
  * @throws `invalid` A malformed value.
2457
2839
  *
2458
- * @example
2840
+ * @example Copy version information in the user's language
2841
+ * "Copy info" puts two translated lines on the clipboard. Text that
2842
+ * leaves the player cannot be translated by it, so `resolve` turns the
2843
+ * values into the string the player would show, using this plugin's
2844
+ * catalog; inside the player, pass the values themselves instead.
2459
2845
  * ```ts
2460
2846
  * const { text, list, resolve } = plugin.i18n;
2461
2847
  * const lines = list([text("Version {}", ["1.0"]), text("Cores: {}", [8])], "lines");
@@ -2508,7 +2894,12 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2508
2894
  *
2509
2895
  * @capability ui.settings
2510
2896
  *
2511
- * @example Two preferences on a page, kept in a store
2897
+ * @example Put two preferences on a page and keep them in a store
2898
+ * Options ▸ Video ▸ Blur shows two check boxes whose values survive
2899
+ * restarts. The checkboxes are bound two-way (`<=>`) to `in-out`
2900
+ * properties of the bridge global, so the user's clicks show at once; the
2901
+ * `toggled` callbacks tell the plugin, which saves the new value, and
2902
+ * `bind` writes the stored values when the page opens.
2512
2903
  * ```ts
2513
2904
  * const store = plugin.storage.json("blur", {
2514
2905
  * defaults: { restoreOnStart: false, messages: true },
@@ -2522,6 +2913,7 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2522
2913
  * restoreOnStart: store.value.restoreOnStart,
2523
2914
  * messages: store.value.messages,
2524
2915
  * }));
2916
+ * // The bridge's `restore-toggled(bool)` callback, in camelCase.
2525
2917
  * page.on("restoreToggled", (on: boolean) =>
2526
2918
  * store.save({ ...store.value, restoreOnStart: on }),
2527
2919
  * );
@@ -2536,6 +2928,7 @@ export type SettingsCategory = "general" | "playback" | "subtitles" | "video" |
2536
2928
  * import { CheckBox } from "std-widgets.slint";
2537
2929
  * import { Theme } from "@vestra/theme.slint";
2538
2930
  *
2931
+ * // The bridge: what the plugin writes and the callbacks it handles.
2539
2932
  * export global BlurPage {
2540
2933
  * in-out property <bool> restore-on-start;
2541
2934
  * in-out property <bool> messages: true;
@@ -2606,7 +2999,11 @@ export type SettingsPage = View & Disposable & {
2606
2999
  *
2607
3000
  * @capability ui.settings
2608
3001
  *
2609
- * @example A page that edits stored settings
3002
+ * @example Add a page that edits a stored setting
3003
+ * Options ▸ Playback ▸ Resume shows the plugin's Resume switch. `page`
3004
+ * registers the page with the Options window (compiled by the player on
3005
+ * its own), `bind` sends the stored value, and the page's callback saves
3006
+ * the user's change.
2610
3007
  * ```ts
2611
3008
  * const store = plugin.storage.json("settings", { defaults: { resume: true } });
2612
3009
  * const page = plugin.settings.page("ui/settings.slint", {
@@ -2615,6 +3012,7 @@ export type SettingsPage = View & Disposable & {
2615
3012
  * title: "Resume",
2616
3013
  * });
2617
3014
  * page.bind(() => ({ resume: store.value.resume }));
3015
+ * // The bridge's `resume-toggled(bool)`: save, and `bind` sends it back.
2618
3016
  * page.on("resumeToggled", (on: boolean) => store.save({ ...store.value, resume: on }));
2619
3017
  * ```
2620
3018
  *
@@ -2651,13 +3049,17 @@ export interface Settings {
2651
3049
  * one component and one global. A file that cannot be read throws Node's
2652
3050
  * file system error (`code` `ENOENT` and so on).
2653
3051
  *
2654
- * @example
3052
+ * @example Register a page and report when the player refuses it
3053
+ * An "My plugin" page appears under Plugins, or the reason it could not
3054
+ * be added is logged. `page` returns at once; the player compiles the
3055
+ * page on its own, and `ready` rejects (for example with `ui-prepare`
3056
+ * and the compiler's diagnostics) when it refuses it.
2655
3057
  * ```ts
2656
3058
  * const page = plugin.settings.page("ui/advanced.slint", {
2657
3059
  * id: "advanced",
2658
3060
  * category: "plugins",
2659
3061
  * title: "My plugin",
2660
- * order: 10,
3062
+ * order: 10, // after pages with a smaller order in the category
2661
3063
  * });
2662
3064
  * page.ready.catch((error: PluginError) => console.error(error.message));
2663
3065
  * ```
@@ -2695,7 +3097,10 @@ export interface Settings {
2695
3097
  * @throws `capability` (rejects) The manifest does not declare
2696
3098
  * `ui.settings`.
2697
3099
  *
2698
- * @example An "Options…" button in a tool window
3100
+ * @example Open the plugin's page from an "Options…" button
3101
+ * The button in the plugin's window opens the Options window at the
3102
+ * plugin's Blur page, or brings it to the front. `show` takes the id the
3103
+ * page was registered with and rejects with `invalid` for any other.
2699
3104
  * ```ts
2700
3105
  * const controls = plugin.windows.create("ui/window.slint");
2701
3106
  * controls.on("openOptions", () => plugin.settings.show("blur"));
@@ -2719,8 +3124,13 @@ export interface Settings {
2719
3124
  * @capability ui.mount; `events` to follow changes.
2720
3125
  *
2721
3126
  * @example React when a video picture appears
3127
+ * The OSD says "Video is playing" when the backdrop gives way to a video
3128
+ * picture. The player decides when the backdrop shows (nothing open,
3129
+ * loading, audio without a picture); `effective` turns false when a video
3130
+ * frame replaces it.
2722
3131
  * ```ts
2723
3132
  * plugin.panels.backdrop.onChange((state) => {
3133
+ * // `null`: no backdrop is mounted.
2724
3134
  * if (state?.effective === false) void plugin.osd.show("Video is playing");
2725
3135
  * });
2726
3136
  * ```
@@ -2746,7 +3156,10 @@ export interface BackdropSlot {
2746
3156
  * @param listener - Receives the new state (`null`: no backdrop).
2747
3157
  * @returns A handle that removes the listener.
2748
3158
  *
2749
- * @example
3159
+ * @example Log when the backdrop shows or hides
3160
+ * The plugin's log records each change of the backdrop position. The
3161
+ * listener runs when the player starts or stops showing the backdrop
3162
+ * and with `null` when it is unmounted.
2750
3163
  * ```ts
2751
3164
  * plugin.panels.backdrop.onChange((state) => console.log("backdrop", state));
2752
3165
  * ```
@@ -2782,7 +3195,13 @@ export interface BackdropSlot {
2782
3195
  *
2783
3196
  * @capability ui.mount
2784
3197
  *
2785
- * @example A sidebar list (examples/panel-menu)
3198
+ * @example Mount a list in the sidebar (examples/panel-menu)
3199
+ * The sidebar gets an "Example list" tab showing three rows. `mount`
3200
+ * returns at once; the SDK sends the mount with the values bound so far,
3201
+ * and the player rebuilds its main window with the panel. The Slint file
3202
+ * exports the component and the bridge global whose properties `bind`
3203
+ * writes.
3204
+ *
2786
3205
  * {@includeCode ../../examples/panel-menu/main.ts#mount}
2787
3206
  *
2788
3207
  * `ui/panel.slint`:
@@ -2844,9 +3263,15 @@ export interface Panels {
2844
3263
  * @throws The errors of {@link Panel.ready} (rejects `ready`) The player
2845
3264
  * refused the mount.
2846
3265
  *
2847
- * @example A bottom bar
3266
+ * @example Mount a bottom bar with a seek slider
3267
+ * The plugin's own control bar appears under the video, its slider
3268
+ * following the playback position and seeking when dragged. Bindings and
3269
+ * handlers made right after `mount` are part of the first values;
3270
+ * `ready` resolves once the main window shows the bar, and rejects with
3271
+ * `conflict` when another plugin's panel holds the bottom bar.
2848
3272
  * ```ts
2849
3273
  * const bar = plugin.panels.mount("bottom", "ui/bar.slint");
3274
+ * // Sent again whenever the position changes.
2850
3275
  * bar.bind(() => ({ position: plugin.player.state.position ?? 0 }));
2851
3276
  * bar.on("seek", (seconds: number) => plugin.player.seek(seconds));
2852
3277
  * await bar.ready;
@@ -2895,10 +3320,16 @@ export interface TimelineLayer extends Disposable {
2895
3320
  * the player translates (`text("Chapter {}", [n])`, SDK 1.5.11); other
2896
3321
  * plugins read the shown string.
2897
3322
  *
2898
- * @example
3323
+ * @example Show the file's bookmarks on the timeline
3324
+ * Bookmark marks appear on the seek bar at their times, titled with the
3325
+ * user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
3326
+ * the player drops the write if another file opened since `seen` was
3327
+ * read, so bookmarks of the previous file never land on the new one.
2899
3328
  * ```ts
3329
+ * declare const bookmarks: { time: number; note: string }[];
3330
+ * // ---cut---
2900
3331
  * const layer = plugin.timeline.layer();
2901
- * const seen = plugin.player.state;
3332
+ * const seen = plugin.player.state; // the file the bookmarks belong to
2902
3333
  * layer.set(
2903
3334
  * bookmarks.map((b) => ({ time: b.time, kind: "bookmark", title: b.note })),
2904
3335
  * { ifCurrent: seen },
@@ -2955,7 +3386,13 @@ export interface WindowState {
2955
3386
  * `onChange`; without it, it updates only at start and after this plugin's
2956
3387
  * `setVideoArea`.
2957
3388
  *
2958
- * @example A "1:1 size" command
3389
+ * @example Resize the window to the video's own size
3390
+ * Screen ▸ Original size makes the video area as many logical points as
3391
+ * the video has pixels (one pixel per pixel at a display scale of 100 %).
3392
+ * `stats()` reports the video's size;
3393
+ * `setVideoArea` resizes the window around the video column (bars and a
3394
+ * docked sidebar added), scales it down if it would not fit the screen,
3395
+ * and is refused in fullscreen, hence the `enabled` check.
2959
3396
  * ```ts
2960
3397
  * plugin.commands.add({
2961
3398
  * id: "size-1x",
@@ -2963,6 +3400,7 @@ export interface WindowState {
2963
3400
  * enabled: () => !plugin.window.state.fullscreen,
2964
3401
  * run: async () => {
2965
3402
  * const stats = await plugin.player.stats();
3403
+ * // No video stream (an audio file): nothing to size to.
2966
3404
  * if (stats.video?.width && stats.video.height)
2967
3405
  * await plugin.window.setVideoArea({ width: stats.video.width, height: stats.video.height });
2968
3406
  * },
@@ -3020,7 +3458,11 @@ export interface MainWindow {
3020
3458
  * Where it is unsupported (Windows, Linux), `setVideoArea` plus
3021
3459
  * `setAlwaysOnTop` make a reasonable "mini player" fallback.
3022
3460
  *
3023
- * @example
3461
+ * @example Toggle picture-in-picture where the system has it
3462
+ * The video moves into a floating picture-in-picture window, or back.
3463
+ * `state.pictureInPicture` says whether the system supports it and
3464
+ * whether it is on; the plugin only calls `setPictureInPicture` where it
3465
+ * is supported.
3024
3466
  * ```ts
3025
3467
  * if (plugin.window.state.pictureInPicture.supported)
3026
3468
  * await plugin.window.setPictureInPicture(!plugin.window.state.pictureInPicture.active);
@@ -3048,7 +3490,12 @@ export interface AppInfo {
3048
3490
  * `plugin.app`: the fixed facts plus the system, read on request. No
3049
3491
  * capability.
3050
3492
  *
3051
- * @example
3493
+ * @example Describe the player and the system for a bug report
3494
+ * The plugin builds a line such as "Vestra 0.5.10 on macOS 26.2" to copy
3495
+ * into a bug report. `app.version` is known from the start; each
3496
+ * `system()` call asks the player, which reads the displays at that moment.
3497
+ * The operating system's name and version it works out once, at the first
3498
+ * call, and reuses them.
3052
3499
  * ```ts
3053
3500
  * const system = await plugin.app.system();
3054
3501
  * const text = `Vestra ${plugin.app.version} on ${system.name} ${system.version ?? ""}`;
@@ -3147,18 +3594,28 @@ export interface ConfirmOptions {
3147
3594
  * at a time: another call meanwhile rejects with `busy`. Playback continues
3148
3595
  * while a dialog is open; command key bindings pause.
3149
3596
  *
3597
+ * **Platforms:** {@link Dialogs.confirm} needs macOS or Windows (other
3598
+ * systems reject it with `unsupported`); the file dialogs work on every
3599
+ * system.
3600
+ *
3150
3601
  * For anything more than a question or a path, use a {@link Windows | tool
3151
3602
  * window}.
3152
3603
  *
3153
3604
  * @capability ui.dialog
3154
3605
  *
3155
3606
  * @example Ask before deleting, remembering "Don't ask again"
3607
+ * Deleting asks for confirmation in a system alert with a "Don't ask
3608
+ * again" box; once the user ticks it, later deletes go ahead without
3609
+ * asking. `confirm` resolves with the button and the checkbox when the
3610
+ * user answers; the texts are templates the player translates, so the
3611
+ * count goes in `args`. Remembering the box is up to the plugin.
3156
3612
  * ```ts
3157
3613
  * const store = plugin.storage.json("confirm", { defaults: { askBeforeDelete: true } });
3158
3614
  * async function deleteAll(count: number) {
3159
3615
  * if (store.value.askBeforeDelete) {
3160
3616
  * const { confirmed, checked } = await plugin.dialogs.confirm({
3161
- * message: `Delete ${count} bookmarks?`,
3617
+ * message: "Delete {} bookmarks?",
3618
+ * args: [count],
3162
3619
  * detail: "This cannot be undone.",
3163
3620
  * confirm: "Delete",
3164
3621
  * destructive: true,
@@ -3205,13 +3662,22 @@ export interface Dialogs {
3205
3662
  * @throws `unsupported` (rejects) A system other than macOS and Windows.
3206
3663
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3207
3664
  *
3208
- * @example
3665
+ * @example Confirm before replacing the playlist
3666
+ * The user sees "Replace the current playlist?" with a Replace button and
3667
+ * the playlist is replaced only after a yes. The system draws the alert
3668
+ * and the promise resolves when the user answers; only one dialog is
3669
+ * open at a time, so a second one meanwhile rejects with `busy`.
3209
3670
  * ```ts
3210
- * const { confirmed } = await plugin.dialogs.confirm({
3211
- * message: "Replace the current playlist?",
3212
- * confirm: "Replace",
3213
- * });
3214
- * if (confirmed) await plugin.osd.show("Playlist replaced");
3671
+ * try {
3672
+ * const { confirmed } = await plugin.dialogs.confirm({
3673
+ * message: "Replace the current playlist?",
3674
+ * confirm: "Replace",
3675
+ * });
3676
+ * if (confirmed) await plugin.osd.show("Playlist replaced");
3677
+ * } catch (error) {
3678
+ * if ((error as PluginError).code !== "busy") throw error;
3679
+ * // Another dialog is already open: let the user finish that one.
3680
+ * }
3215
3681
  * ```
3216
3682
  *
3217
3683
  * @since 1.0.0
@@ -3235,10 +3701,13 @@ export interface Dialogs {
3235
3701
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3236
3702
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3237
3703
  *
3238
- * @example
3704
+ * @example Let the user pick a subtitle file
3705
+ * The system's Open dialog appears titled "Choose a subtitle file" and
3706
+ * the chosen path is shown on the OSD. The promise resolves when the
3707
+ * user answers: with the absolute path, or `null` when they cancelled.
3239
3708
  * ```ts
3240
3709
  * const path = await plugin.dialogs.openFile({ title: "Choose a subtitle file" });
3241
- * if (path !== null) await plugin.osd.show(`Chosen: ${path}`);
3710
+ * if (path !== null) await plugin.osd.show("Chosen: {}", [path]); // a path is data
3242
3711
  * ```
3243
3712
  *
3244
3713
  * @since 1.0.0
@@ -3260,10 +3729,13 @@ export interface Dialogs {
3260
3729
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3261
3730
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3262
3731
  *
3263
- * @example
3732
+ * @example Let the user pick several files at once
3733
+ * The Open dialog allows selecting several files; the OSD says how many
3734
+ * were added. The promise resolves with the absolute paths, or with an
3735
+ * empty array when the user cancelled.
3264
3736
  * ```ts
3265
3737
  * const paths = await plugin.dialogs.openFiles({ title: "Add to the playlist" });
3266
- * await plugin.osd.show(`${paths.length} files added`);
3738
+ * if (paths.length) await plugin.osd.show("{} files added", [paths.length]);
3267
3739
  * ```
3268
3740
  *
3269
3741
  * @since 1.0.0
@@ -3285,7 +3757,10 @@ export interface Dialogs {
3285
3757
  * @throws `invalid` (rejects) `title` is longer than 256 bytes.
3286
3758
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3287
3759
  *
3288
- * @example
3760
+ * @example Let the user choose a folder for snapshots
3761
+ * The system's folder dialog appears titled "Save snapshots in". It
3762
+ * resolves with the folder's absolute path, or `null` when the user
3763
+ * cancelled, in which case the plugin stops there.
3289
3764
  * ```ts
3290
3765
  * const folder = await plugin.dialogs.openDirectory({ title: "Save snapshots in" });
3291
3766
  * if (folder === null) return; // cancelled
@@ -3316,7 +3791,10 @@ export interface Dialogs {
3316
3791
  * or contains `/` or `\`.
3317
3792
  * @throws `capability` (rejects) The manifest does not declare `ui.dialog`.
3318
3793
  *
3319
- * @example
3794
+ * @example Ask where to save a snapshot
3795
+ * The system's Save dialog proposes "frame.png" and returns where the
3796
+ * user wants it. The dialog only returns the path (or `null` when
3797
+ * cancelled); writing the file is up to the plugin afterwards.
3320
3798
  * ```ts
3321
3799
  * const path = await plugin.dialogs.saveFile({ title: "Save snapshot", fileName: "frame.png" });
3322
3800
  * if (path === null) return; // cancelled
@@ -3459,10 +3937,23 @@ export interface Chapter {
3459
3937
  * consecutive calls do not make each other stale. {@link Player.open} is
3460
3938
  * unconditional unless `ifCurrent` is given.
3461
3939
  *
3462
- * @example
3940
+ * @example Play the next file only if nothing else was opened
3941
+ * When the plugin's file ends, the next one starts, unless the user or
3942
+ * another plugin opened something in the meantime. `onEnd` passes the
3943
+ * state at the end; `open` with `ifCurrent: ended` is refused with `stale`
3944
+ * when the source changed after that state, so nothing is replaced.
3463
3945
  * ```ts
3464
- * // Advance only if nobody changed the source since it ended.
3465
- * media.onEnd((ended) => plugin.player.open(next, { ifCurrent: ended }));
3946
+ * declare const media: Media;
3947
+ * declare const next: string;
3948
+ * // ---cut---
3949
+ * media.onEnd(async (ended) => {
3950
+ * try {
3951
+ * await plugin.player.open(next, { ifCurrent: ended });
3952
+ * } catch (error) {
3953
+ * // Someone opened another file: leave it playing.
3954
+ * if ((error as PluginError).code !== "stale") throw error;
3955
+ * }
3956
+ * });
3466
3957
  * ```
3467
3958
  */
3468
3959
  export interface Conditional {
@@ -3491,10 +3982,18 @@ export interface Media {
3491
3982
  * Any outside change (another plugin opens a file, the user pauses) ends
3492
3983
  * the gesture.
3493
3984
  *
3494
- * @example
3985
+ * @example Seek while the user drags a slider
3986
+ * The video follows the slider while it is dragged. `scrub()` starts a
3987
+ * gesture from the current state; each `seek` is conditional on the
3988
+ * previous one and only the newest target is sent while one is running,
3989
+ * so a fast drag does not queue seeks. A change from outside (another file,
3990
+ * the user pausing) ends the gesture and later seeks resolve `false`.
3495
3991
  * ```ts
3992
+ * declare const bar: Panel;
3993
+ * // ---cut---
3496
3994
  * let scrub: Scrub | null = null;
3497
3995
  * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
3996
+ * // Latest value wins: superseded targets resolve false, not an error.
3498
3997
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
3499
3998
  * bar.on("dragEnd", () => scrub?.end());
3500
3999
  * ```
@@ -3518,8 +4017,15 @@ export interface Scrub {
3518
4017
  * as before. Older players ignore the option and seek exactly: set
3519
4018
  * `"minPlayerVersion": "0.5.3"` when you rely on it.
3520
4019
  *
3521
- * @example
4020
+ * @example Scrub on keyframes for a fast picture
4021
+ * While dragging, the picture jumps to the nearest earlier keyframe at
4022
+ * once instead of decoding up to the exact frame. With `keyframes: true`
4023
+ * the player lands each target on the last keyframe at or before it; the
4024
+ * plugin follows the user's keyframe-seek setting.
3522
4025
  * ```ts
4026
+ * declare const bar: Panel;
4027
+ * declare const scrub: Scrub | null;
4028
+ * // ---cut---
3523
4029
  * const keyframes = true; // the plugin's own setting
3524
4030
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
3525
4031
  * ```
@@ -3715,12 +4221,17 @@ export interface PlaybackStats {
3715
4221
  * `stale` and do nothing when the source changed since the state you saw.
3716
4222
  *
3717
4223
  * @example Open a file and wait until it plays
4224
+ * The file opens and the plugin learns whether it started playing within
4225
+ * ten seconds. `open` resolves once the player accepted the load and
4226
+ * returns a `Media` handle; `ready` waits for playback and rejects with
4227
+ * `failed` (load error), `replaced` (another source took over) or an
4228
+ * `AbortError` when the timeout fires, which closes the media.
3718
4229
  * ```ts
3719
- * const media = await plugin.player.open("/Movies/clip.mp4");
4230
+ * const media = await plugin.player.open("/Movies/clip.mp4"); // load accepted
3720
4231
  * try {
3721
4232
  * await media.ready({ signal: AbortSignal.timeout(10_000) });
3722
4233
  * } catch (error) {
3723
- * void plugin.osd.show(`Cannot play: ${(error as Error).message}`);
4234
+ * void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
3724
4235
  * }
3725
4236
  * ```
3726
4237
  */
@@ -3759,15 +4270,24 @@ export interface Player {
3759
4270
  * (`timeout`), `stream`, `device`, or `replaced` when another source
3760
4271
  * replaced it (or it was closed) before it ran.
3761
4272
  *
3762
- * @example
4273
+ * @example Play pictures the plugin draws itself
4274
+ * The player shows a 320×240 picture the plugin renders, with silence as
4275
+ * its sound, as the current source. The SDK pulls frames on the audio
4276
+ * clock: each `frame()` returns one picture (RGBA bytes) and the audio
4277
+ * that plays with it, here 1/60 s, so the picture runs at 60 frames a
4278
+ * second; `frame()` may run several times back to back to refill the
4279
+ * lead. `onStop` tells the plugin when and why the stream ended.
3763
4280
  * ```ts
3764
- * const frame = new Uint8Array(320 * 240 * 4);
3765
- * const silence = new Float32Array(2 * 800); // 1/60 s of stereo
4281
+ * declare function render(into: Uint8Array): Uint8Array;
4282
+ * // ---cut---
4283
+ * const frame = new Uint8Array(320 * 240 * 4); // RGBA, reused per frame
4284
+ * const silence = new Float32Array(2 * 800); // 1/60 s of stereo at 48 kHz
3766
4285
  * const stream = await plugin.player.openStream({
3767
4286
  * width: 320,
3768
4287
  * height: 240,
3769
4288
  * frame: () => ({ video: render(frame), audio: silence }),
3770
4289
  * });
4290
+ * // `error` is null when the stream stopped normally.
3771
4291
  * stream.onStop((error) => error && plugin.osd.show(error.message));
3772
4292
  * ```
3773
4293
  */
@@ -3797,8 +4317,21 @@ export interface Player {
3797
4317
  * Become the sequence provider (one at a time; another provider rejects with
3798
4318
  * `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
3799
4319
  *
3800
- * @example
4320
+ * @example Drive the player's Next and Previous from a playlist
4321
+ * The player's Next and Previous items and keys play the plugin's list.
4322
+ * The player calls `next` and `previous` when the user asks; `canNext`
4323
+ * and `canPrevious` are evaluated again after every handler the SDK runs,
4324
+ * and while they return false the player disables its items ("No next
4325
+ * item").
3801
4326
  * ```ts
4327
+ * declare const items: string[];
4328
+ * let index = 0;
4329
+ * // ---cut---
4330
+ * const playAt = async (at: number) => {
4331
+ * index = at;
4332
+ * await plugin.player.open(items[at]!);
4333
+ * };
4334
+ * // Called by the player's Next / Previous items, keys and buttons.
3802
4335
  * plugin.player.provideSequence({
3803
4336
  * next: () => playAt(index + 1),
3804
4337
  * previous: () => playAt(index - 1),
@@ -3932,9 +4465,22 @@ export interface Host {
3932
4465
  *
3933
4466
  * Prefer this over reimplementing playback buttons.
3934
4467
  *
3935
- * @example
4468
+ * @example Make a bar button play and pause like Space
4469
+ * The plugin's own Play button behaves exactly like the player's Space
4470
+ * key, including its on-screen text. `host.run` runs the core action
4471
+ * with the player's checks; it resolves when the action finished, and
4472
+ * rejects with `unavailable` while the action is disabled (nothing open).
3936
4473
  * ```ts
3937
- * bar.on("playPause", () => plugin.host.run("play-pause"));
4474
+ * declare const bar: Panel;
4475
+ * // ---cut---
4476
+ * bar.on("playPause", async () => {
4477
+ * try {
4478
+ * await plugin.host.run("play-pause");
4479
+ * } catch (error) {
4480
+ * if ((error as PluginError).code !== "unavailable") throw error;
4481
+ * // Nothing to play: the button simply does nothing.
4482
+ * }
4483
+ * });
3938
4484
  * ```
3939
4485
  */
3940
4486
  run(action: HostAction): Promise<void>;
@@ -3955,8 +4501,14 @@ export interface Host {
3955
4501
  * `seconds` and seek by the `setStepSeek` step)
3956
4502
  * @since 1.5.19
3957
4503
  *
3958
- * @example
4504
+ * @example Add a 30-second skip that adds up with the arrow keys
4505
+ * The plugin's "+30 s" button seeks forward 30 seconds and shows the same
4506
+ * text as →. Passing `seconds` makes the core step seek use that step
4507
+ * with the player's mode (exact or keyframes); quick presses of it and of
4508
+ * → add up into one seek.
3959
4509
  * ```ts
4510
+ * declare const bar: Panel;
4511
+ * // ---cut---
3960
4512
  * bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
3961
4513
  * ```
3962
4514
  */
@@ -3977,11 +4529,16 @@ export interface Host {
3977
4529
  *
3978
4530
  * @capability ui.osd
3979
4531
  *
3980
- * @example Live playback numbers
4532
+ * @example Show live playback numbers over the video
4533
+ * A block over the video shows the position and the duration, updated
4534
+ * as playback moves. `player.onChange` runs after the SDK updated the
4535
+ * state (with `events`); `set` never throws and sends only the newest
4536
+ * text while one is in flight, so calling it on every change is fine.
3981
4537
  * ```ts
3982
4538
  * const info = plugin.osd.panel();
3983
4539
  * plugin.player.onChange((state) =>
3984
- * info.set(`${state.position?.toFixed(1) ?? "–"} / ${state.duration?.toFixed(1) ?? "–"} s`),
4540
+ * // A template with data arguments; the player translates "{} / {} s".
4541
+ * info.set("{} / {} s", [state.position?.toFixed(1) ?? "–", state.duration?.toFixed(1) ?? "–"]),
3985
4542
  * );
3986
4543
  * ```
3987
4544
  *
@@ -4008,7 +4565,10 @@ export interface OsdPanel extends Disposable {
4008
4565
  * omitted for none.
4009
4566
  * @param options - `maxColumns`: cut long lines (SDK 1.5.11).
4010
4567
  *
4011
- * @example
4568
+ * @example Show two loop points, then hide the block
4569
+ * The block shows "A: 12.0 s" and "B: 18.5 s" on two lines, then
4570
+ * disappears. The text is a template the player translates and fills
4571
+ * in; `\n` separates lines, and `null` hides the block.
4012
4572
  * ```ts
4013
4573
  * const info = plugin.osd.panel();
4014
4574
  * info.set("A: {} s\nB: {} s", ["12.0", "18.5"]);
@@ -4067,7 +4627,10 @@ export interface OsdPanelOptions {
4067
4627
  *
4068
4628
  * @capability ui.osd
4069
4629
  *
4070
- * @example On-screen messages that the user can turn off
4630
+ * @example Show on-screen messages the user can turn off
4631
+ * "Blur on" appears over the video unless the user turned messages off in
4632
+ * the plugin's settings. `osd.show` asks the player to draw the text for a
4633
+ * moment; the plugin checks its own stored preference before each call.
4071
4634
  * ```ts
4072
4635
  * const store = plugin.storage.json("prefs", { defaults: { messages: true } });
4073
4636
  * const say = (text: string) => {
@@ -4111,7 +4674,11 @@ export interface Osd {
4111
4674
  * @throws `unavailable` (rejects) The player runs without a user interface.
4112
4675
  * @throws `capability` (rejects) The manifest does not declare `ui.osd`.
4113
4676
  *
4114
- * @example
4677
+ * @example Confirm an action and show a value
4678
+ * The OSD shows "Bookmark added", then "Speed 1.5×" in the user's
4679
+ * language. The text is English source the player translates with the
4680
+ * plugin's catalog; `{}` is filled in with the argument, which stays as
4681
+ * it is because it is data.
4115
4682
  * ```ts
4116
4683
  * await plugin.osd.show("Bookmark added");
4117
4684
  * await plugin.osd.show("Speed {}×", [plugin.player.state.rate]);
@@ -4133,7 +4700,10 @@ export interface Osd {
4133
4700
  *
4134
4701
  * @throws `Error` without a code (throws) This plugin already has a block.
4135
4702
  *
4136
- * @example
4703
+ * @example Show a status block over the video
4704
+ * A small block over the video says "Loop: off" until the plugin changes
4705
+ * or hides it. Each plugin has one block, stacked with other plugins'
4706
+ * blocks; a second `panel()` call throws.
4137
4707
  * ```ts
4138
4708
  * const info = plugin.osd.panel();
4139
4709
  * info.set("Loop: off");
@@ -4211,9 +4781,14 @@ interface Filter<T> extends Disposable {
4211
4781
  * Values out of range fail with `invalid` (see {@link VideoNode}). The
4212
4782
  * same chain applies to processed snapshots and exports.
4213
4783
  *
4214
- * @example
4784
+ * @example Brighten the picture and report when it fails
4785
+ * Every video plays a little brighter with more contrast, and the OSD
4786
+ * says why when the player cannot apply it. `set` records the desired
4787
+ * chain (last write wins, one request in flight); the player reapplies it
4788
+ * for each new source, and `onStatus` reports `applied` or `failed`.
4215
4789
  * ```ts
4216
4790
  * const picture = plugin.video.filter("picture");
4791
+ * // `unsupported` while a generated stream plays: its frames skip filters.
4217
4792
  * picture.onStatus((s) => s.state === "failed" && plugin.osd.show(s.error.message));
4218
4793
  * picture.set([{ kind: "eq", brightness: 0.1, contrast: 1.1, saturation: 1, gamma: 1 }]);
4219
4794
  * ```
@@ -4228,10 +4803,13 @@ export type VideoFilter = Filter<VideoNode[]>;
4228
4803
  * `delayMs` is −10000–10000 in multiples of 10; the positive and the
4229
4804
  * negative delays of all plugins each add up to at most 10000.
4230
4805
  *
4231
- * @example
4806
+ * @example Lower the volume and delay the sound
4807
+ * Every file plays 6 dB quieter, with its sound 120 ms later to match a
4808
+ * slow display. `set` sends the plugin's whole chain and delay; the
4809
+ * player applies it to each source and adds the delays of all plugins.
4232
4810
  * ```ts
4233
4811
  * const audio = plugin.audio.filter("sound");
4234
- * audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 });
4812
+ * audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 }); // 10 ms steps
4235
4813
  * ```
4236
4814
  */
4237
4815
  export type AudioFilter = Filter<{
@@ -4266,7 +4844,11 @@ export interface ViewSettings {
4266
4844
  * Out-of-range values or unknown fields fail with `invalid` without
4267
4845
  * sending anything.
4268
4846
  *
4269
- * @example
4847
+ * @example Fill the window, then zoom in
4848
+ * The video fills the video area (cropped instead of letterboxed), then
4849
+ * zooms to 150 % shifted a little to the right. The first plugin to call
4850
+ * `view()` owns the display layer; each `set` merges into its settings,
4851
+ * which stay across sources until the plugin resets them or stops.
4270
4852
  * ```ts
4271
4853
  * const view = plugin.video.view();
4272
4854
  * view.set({ fit: "fill" });
@@ -4307,13 +4889,20 @@ export interface RenderSettings {
4307
4889
  * `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
4308
4890
  * is accepted but has no effect; output is always SDR.
4309
4891
  *
4310
- * @example
4892
+ * @example Tone-map HDR video and hide the menu where it cannot work
4893
+ * On a GPU-rendering player HDR video is tone-mapped with BT.2390 for a
4894
+ * 250-nit display; under software rendering the plugin hides its HDR menu.
4895
+ * `set` merges the values into the plugin's render options and the
4896
+ * player reports the result through `onStatus`: `failed` with
4897
+ * `unsupported` means the player renders in software (Windows, Linux).
4311
4898
  * ```ts
4899
+ * declare function hideHdrMenu(): void;
4900
+ * // ---cut---
4312
4901
  * const render = plugin.video.render();
4313
4902
  * render.onStatus((s) => {
4314
4903
  * if (s.state === "failed" && s.error.code === "unsupported") hideHdrMenu();
4315
4904
  * });
4316
- * render.set({ toneMapping: "bt.2390", targetPeak: 250 });
4905
+ * render.set({ toneMapping: "bt.2390", targetPeak: 250 }); // nits
4317
4906
  * ```
4318
4907
  */
4319
4908
  export interface RenderOptions extends Disposable {
@@ -4374,7 +4963,10 @@ export interface SubtitlesState {
4374
4963
  /** Font family name for display. */
4375
4964
  family: string;
4376
4965
  }[];
4377
- /** The effective style, from whichever plugin set it last. */
4966
+ /**
4967
+ * The effective style: among the plugins that set a layer, the one whose
4968
+ * manifest id sorts last (not the most recent call); `null` without one.
4969
+ */
4378
4970
  readonly style: SubtitleStyle | null;
4379
4971
  /** Whether the renderer confirmed the selection. */
4380
4972
  readonly trackConfirmation: "waiting" | "applied" | "uncertain";
@@ -4394,12 +4986,23 @@ export interface SubtitleConditional {
4394
4986
  * `subtitles.read`, since they read the current selection first.
4395
4987
  *
4396
4988
  * @example Load an SRT file as the secondary subtitle
4989
+ * An English SRT file shows as a second subtitle line next to the
4990
+ * film's own subtitles, in white with an outline. The plugin reads the
4991
+ * file and uploads its bytes with `load`, which adds a track to the
4992
+ * current source and selects it as the secondary subtitle; `refresh`
4993
+ * reads the subtitle state for its font list, and `setStyle` sends this
4994
+ * plugin's style layer naming one of those fonts.
4397
4995
  * ```ts
4996
+ * import { readFile } from "node:fs/promises";
4997
+ * declare const path: string;
4998
+ * // ---cut---
4398
4999
  * const bytes = await readFile(path);
4399
- * const track = await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
5000
+ * await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
5001
+ * const { fonts } = await plugin.subtitles.refresh();
5002
+ * if (!fonts.length) return;
4400
5003
  * await plugin.subtitles.setStyle({
4401
- * fontId: plugin.subtitles.state!.fonts[0]!.id,
4402
- * fontSize: 40,
5004
+ * fontId: fonts[0]!.id,
5005
+ * fontSize: 40, // at the 1080p reference height
4403
5006
  * color: "white",
4404
5007
  * outline: 2,
4405
5008
  * marginPercent: 5,
@@ -4443,7 +5046,12 @@ export interface Subtitles {
4443
5046
  }): Promise<SubtitleTrack>;
4444
5047
  /** Remove a track this plugin loaded. */
4445
5048
  remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
4446
- /** This plugin's style layer; `null` removes it. Last write wins. */
5049
+ /**
5050
+ * Set this plugin's style layer; `null` removes it. The newest call of
5051
+ * this plugin replaces its earlier layer. When several plugins have a
5052
+ * layer, the player uses the one whose manifest id sorts last, whatever
5053
+ * the order of the calls.
5054
+ */
4447
5055
  setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
4448
5056
  }
4449
5057
  /**
@@ -4546,11 +5154,17 @@ export interface ExportJob extends Disposable {
4546
5154
  * `subtitles.read`). Exports decode offline and do not disturb playback.
4547
5155
  *
4548
5156
  * @example Save the current frame as JPEG
5157
+ * The frame on screen, with the plugin's own picture effects, is saved
5158
+ * where the user chooses. `capture` freezes the source as it was in
5159
+ * `seen` (and fails with `stale` if the user opened another file while
5160
+ * the dialog was open); `export` decodes offline without disturbing
5161
+ * playback, and `done` resolves with the job's final status.
4549
5162
  * ```ts
4550
5163
  * const seen = plugin.player.state;
4551
5164
  * const destination = await plugin.dialogs.saveFile({ fileName: "frame.jpg" });
4552
5165
  * if (!destination) return;
4553
5166
  * const snapshot = await plugin.media.capture({ ifCurrent: seen, processed: true });
5167
+ * // Release the frozen frame whatever happens to the export.
4554
5168
  * try {
4555
5169
  * const job = await snapshot.export({ format: "jpeg", destination, quality: 85 });
4556
5170
  * const end = await job.done;
@@ -4610,23 +5224,28 @@ export interface WebResolverOptions {
4610
5224
  * (the player shows a warning). When this plugin stops, its extensions are
4611
5225
  * free again and such files go to the player as before.
4612
5226
  *
4613
- * @example
5227
+ * @example Play CHIP-8 programs with the plugin's own emulator
5228
+ * Opening a `.ch8` file anywhere in the player (Open dialog, playlist,
5229
+ * recent list) runs it in the plugin's emulator instead of failing as an
5230
+ * unknown format. The player calls `resolve` with the path; the plugin
5231
+ * reads the program and returns a stream whose `frame()` the SDK pulls on
5232
+ * the audio clock, one 1/60 s frame per call. `started` runs once the
5233
+ * stream plays; `signal` aborts the read when the open is replaced or
5234
+ * closed.
4614
5235
  * ```ts
4615
- * const { readFile } = await import("node:fs/promises");
4616
- * // The plugin's own emulator: one frame per call.
4617
- * const emulator = (rom: Uint8Array): (() => StreamFrame) => {
4618
- * const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
4619
- * return () => ({ video, audio: new Float32Array(1600) });
4620
- * };
5236
+ * import { readFile } from "node:fs/promises";
5237
+ * // The plugin's own emulator (made up here): one frame per call.
5238
+ * declare function emulator(program: Uint8Array): () => StreamFrame;
5239
+ * // ---cut---
4621
5240
  * plugin.resolvers.add({
4622
- * id: "nes",
4623
- * extensions: ["nes"],
5241
+ * id: "chip8",
5242
+ * extensions: ["ch8"], // lower case, no dot; matches Game.CH8 too
4624
5243
  * async resolve(path, signal) {
4625
5244
  * const frame = emulator(await readFile(path, { signal }));
4626
5245
  * return {
4627
5246
  * kind: "stream",
4628
- * width: 256,
4629
- * height: 240,
5247
+ * width: 64, // CHIP-8's display
5248
+ * height: 32,
4630
5249
  * frame,
4631
5250
  * started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
4632
5251
  * };
@@ -4691,14 +5310,20 @@ export interface Resolver extends Disposable {
4691
5310
  * files into streams this plugin generates. Needs `source.resolve` (file
4692
5311
  * resolvers also `producer.av`).
4693
5312
  *
4694
- * @example
5313
+ * @example Play pages of a video site
5314
+ * Opening a `https://video.example.com/…` page plays its video. When the
5315
+ * user opens a URL whose scheme and host this resolver declares, the
5316
+ * player asks the plugin first; `resolve` returns the stream's address,
5317
+ * and `signal` aborts the lookup when the open is replaced or closed.
4695
5318
  * ```ts
4696
5319
  * plugin.resolvers.add({
4697
5320
  * id: "example",
4698
5321
  * schemes: ["https"],
4699
5322
  * hosts: ["video.example.com"],
4700
5323
  * async resolve(url, signal) {
4701
- * const page = await (await fetch(url, { signal })).json();
5324
+ * const response = await fetch(url, { signal }); // the site's JSON for the page
5325
+ * if (!response.ok) throw new Error(`video.example.com answered ${response.status}`);
5326
+ * const page = await response.json();
4702
5327
  * return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
4703
5328
  * },
4704
5329
  * });
@@ -4721,9 +5346,13 @@ export interface Resolvers {
4721
5346
  * Use it to accept such files beside the player's own media types, for
4722
5347
  * example in a playlist's drop or folder scan.
4723
5348
  *
4724
- * @example
5349
+ * @example Accept files that a resolver plugin can play
5350
+ * A plugin's own file filter also accepts types other plugins turn into
5351
+ * streams. `fileTypes` lists the extensions every running file resolver
5352
+ * claims (lower case, no dot); the SDK keeps it current with `events`.
4725
5353
  * ```ts
4726
5354
  * const MEDIA = ["mp4", "mkv", "mp3"];
5355
+ * // Extensions compare in lower case, without the dot.
4727
5356
  * const accepted = (path: string) => {
4728
5357
  * const extension = path.split(".").pop()?.toLowerCase() ?? "";
4729
5358
  * return MEDIA.includes(extension) || plugin.resolvers.fileTypes.includes(extension);
@@ -4742,10 +5371,13 @@ export interface Resolvers {
4742
5371
  * @param listener - Receives the new list.
4743
5372
  * @returns A handle that removes the listener.
4744
5373
  *
4745
- * @example
5374
+ * @example Say which file types became playable
5375
+ * When a plugin with a file resolver starts or stops, the OSD lists the
5376
+ * extra file types now accepted. The listener (which needs `events`)
5377
+ * receives the new list each time it changes.
4746
5378
  * ```ts
4747
5379
  * plugin.resolvers.onFileTypesChange((types) => {
4748
- * void plugin.osd.show(`Also accepted: ${types.join(", ")}`);
5380
+ * void plugin.osd.show("Also accepted: {}", [plugin.i18n.list(types)]);
4749
5381
  * });
4750
5382
  * ```
4751
5383
  *
@@ -4968,10 +5600,19 @@ export interface PluginWarning {
4968
5600
  * `plugin.packages`: the plugins the player knows. `list` and `onChange`
4969
5601
  * need no capability; everything else needs `plugins.manage`.
4970
5602
  *
4971
- * @example
5603
+ * @example Install a plugin from an unpacked package
5604
+ * The plugin installs a package directory it downloaded and unpacked, and
5605
+ * reports why when it does not work. `validate` asks the player to check
5606
+ * the package and returns a candidate; `install` starts the change and
5607
+ * `done` resolves with its final status: `committed` only once the new
5608
+ * plugin reported that it is running, otherwise `failed`, `cancelled`,
5609
+ * `failedDegraded` or `tooLate`.
4972
5610
  * ```ts
5611
+ * declare const unpackedDirectory: string;
5612
+ * // ---cut---
4973
5613
  * const candidate = await plugin.packages.validate(unpackedDirectory);
4974
5614
  * const change = await candidate.install();
5615
+ * // `message` says why a change did not commit.
4975
5616
  * if ((await change.done) !== "committed") throw new Error(change.message ?? "install failed");
4976
5617
  * ```
4977
5618
  */
@@ -5084,7 +5725,11 @@ export interface BindingInfo extends BindingRef {
5084
5725
  * `plugin.bindings`: list and override every command's bindings, including
5085
5726
  * the player's. Needs `bindings.manage`.
5086
5727
  *
5087
- * @example "Ignore the default shortcut" for one command
5728
+ * @example Unbind the player's Mute key, then restore it
5729
+ * The player's Mute command loses its default key, and later gets it
5730
+ * back. `set` with `[]` saves an override that unbinds the command
5731
+ * (remembered across launches); `reset` removes the override, so the
5732
+ * command's own default bindings apply again.
5088
5733
  * ```ts
5089
5734
  * await plugin.bindings.set({ plugin: "host", command: "mute" }, []);
5090
5735
  * // later
@@ -5124,9 +5769,18 @@ export interface Bindings {
5124
5769
  * PNG. Other systems: `unsupported`. Failures: `invalid`, `missing`,
5125
5770
  * `unavailable`, `failed` (the system refused).
5126
5771
  *
5127
- * @example
5772
+ * @example Copy the current file and position
5773
+ * The clipboard holds "path @ seconds" for pasting into a note. The
5774
+ * player writes the system clipboard; it rejects with `unsupported` on
5775
+ * systems other than macOS and Windows and `failed` when the system
5776
+ * refuses, which the plugin reports.
5128
5777
  * ```ts
5129
- * await plugin.clipboard.writeText(`${plugin.player.state.source} @ ${plugin.player.state.position}`);
5778
+ * const { source, position } = plugin.player.state;
5779
+ * try {
5780
+ * await plugin.clipboard.writeText(`${source} @ ${position}`); // data, not shown
5781
+ * } catch (error) {
5782
+ * void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
5783
+ * }
5130
5784
  * ```
5131
5785
  */
5132
5786
  export interface Clipboard {
@@ -5162,15 +5816,23 @@ export interface ToolResult {
5162
5816
  * `plugin.tools`: ffmpeg and ffprobe, run in this plugin's process. No
5163
5817
  * capability.
5164
5818
  *
5165
- * @example
5819
+ * @example Read a file's format with ffprobe
5820
+ * The plugin gets a media file's container details as JSON. `available`
5821
+ * says whether ffprobe can be found (`VESTRA_FFPROBE`, then `PATH`); `run`
5822
+ * starts it from the plugin's own process without a shell and resolves
5823
+ * with its output as bytes once it exits, or rejects when it fails
5824
+ * (`failed`, with the last stderr line) or takes longer than `timeoutMs`.
5166
5825
  * ```ts
5826
+ * declare const path: string;
5827
+ * // ---cut---
5167
5828
  * if (await plugin.tools.available("ffprobe")) {
5168
5829
  * const { stdout } = await plugin.tools.run(
5169
5830
  * "ffprobe",
5170
5831
  * ["-v", "error", "-show_format", "-of", "json", path],
5171
- * { timeoutMs: 10_000 },
5832
+ * { timeoutMs: 10_000 }, // give up on a stuck file after 10 s
5172
5833
  * );
5173
5834
  * const info = JSON.parse(new TextDecoder().decode(stdout));
5835
+ * void info.format; // container, duration, bit rate…
5174
5836
  * }
5175
5837
  * ```
5176
5838
  */
@@ -5217,14 +5879,22 @@ export interface Storage {
5217
5879
  * The file is read synchronously on creation; `debounceMs` defaults to
5218
5880
  * 300. Files are created with mode 0600. `invalid` for a bad name.
5219
5881
  *
5220
- * @example
5882
+ * @example Keep settings and a recent list in a JSON file
5883
+ * The plugin remembers whether to resume and the last ten files across
5884
+ * restarts. `json` reads `settings.json` from the data directory at once
5885
+ * (or uses `defaults` when it is missing or fails `validate`); `save`
5886
+ * replaces the value and writes the file 300 ms later, and stopping the
5887
+ * plugin writes a pending save first.
5221
5888
  * ```ts
5889
+ * declare const path: string;
5890
+ * // ---cut---
5222
5891
  * type Settings = { resume: boolean; recent: string[] };
5223
5892
  * const store = plugin.storage.json<Settings>("settings", {
5224
5893
  * defaults: { resume: true, recent: [] },
5225
5894
  * validate: (v): v is Settings =>
5226
5895
  * typeof v === "object" && v !== null && typeof (v as Settings).resume === "boolean",
5227
5896
  * });
5897
+ * // Only `save` writes: changing `store.value` in place is not saved.
5228
5898
  * store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
5229
5899
  * ```
5230
5900
  */
@@ -5286,7 +5956,11 @@ export interface Storage {
5286
5956
  * resolves, so a crash right after loses nothing; there is no debounce.
5287
5957
  * Calls answer in order.
5288
5958
  *
5289
- * @example
5959
+ * @example Keep a work queue across a plugin restart
5960
+ * A download queue survives the plugin being reloaded or crashing, and is
5961
+ * empty again when the player starts next time. Session storage lives in
5962
+ * the player process for one player run; each `set` is kept once its
5963
+ * promise resolves, so nothing is lost if the plugin dies right after.
5290
5964
  * ```ts
5291
5965
  * // A queue the plugin is working through: after a reload or a crash it
5292
5966
  * // goes on where it stopped, after the player quits it starts afresh.
@@ -5315,10 +5989,21 @@ export interface SessionStorage {
5315
5989
  * `plugin.system`: files and links, executed by the player. No capability.
5316
5990
  * Other systems than macOS and Windows: `unsupported`.
5317
5991
  *
5318
- * @example
5992
+ * @example Show an exported file and open the help page
5993
+ * The file manager opens with the exported file selected, then the
5994
+ * browser opens the plugin's help page. The player performs both on the
5995
+ * plugin's behalf; they reject with `unsupported` on systems other than
5996
+ * macOS and Windows, which the plugin turns into a message.
5319
5997
  * ```ts
5320
- * await plugin.system.reveal(exportedFile);
5321
- * await plugin.system.openUrl("https://example.com/help");
5998
+ * declare const exportedFile: string;
5999
+ * // ---cut---
6000
+ * try {
6001
+ * await plugin.system.reveal(exportedFile);
6002
+ * await plugin.system.openUrl("https://example.com/help");
6003
+ * } catch (error) {
6004
+ * if ((error as PluginError).code !== "unsupported") throw error;
6005
+ * await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
6006
+ * }
5322
6007
  * ```
5323
6008
  */
5324
6009
  export interface System {
@@ -5346,12 +6031,25 @@ export interface System {
5346
6031
  * The user denying access and other system errors reject `failed`. Never
5347
6032
  * write secrets to settings files.
5348
6033
  *
5349
- * @example
6034
+ * @example Ask for an API token once and keep it in the keychain
6035
+ * The user types the token the first time; later runs read it from the
6036
+ * system's secret store. `get` resolves `null` when nothing is stored; a
6037
+ * store that is missing or refuses access rejects (`unsupported`,
6038
+ * `unavailable`, `failed`), and then the plugin keeps the token for this
6039
+ * run only and asks again next time.
5350
6040
  * ```ts
5351
- * let token = await plugin.secrets.get("api-token");
6041
+ * declare function askUser(): Promise<string | null>;
6042
+ * // ---cut---
6043
+ * let token: string | null = null;
6044
+ * try {
6045
+ * token = await plugin.secrets.get("api-token");
6046
+ * } catch {
6047
+ * // No usable store: fall through and ask.
6048
+ * }
5352
6049
  * if (token === null) {
5353
6050
  * token = await askUser();
5354
- * await plugin.secrets.set("api-token", token);
6051
+ * if (token)
6052
+ * await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
5355
6053
  * }
5356
6054
  * ```
5357
6055
  */