@vestra-player/sdk 1.5.30 → 1.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -372,7 +372,13 @@ export interface Plugin extends Scope {
372
372
  */
373
373
  readonly dataDirectory: string;
374
374
  }
375
- /** The state of a command's menu item, as the player shows it. */
375
+ /**
376
+ * The state of a command's menu item, as the player shows it.
377
+ *
378
+ * @see {@link Command.set}, {@link CommandOptions}
379
+ * @since 1.0.0
380
+ * @category Commands
381
+ */
376
382
  export interface CommandState {
377
383
  /** False greys the item out; the player then never calls `run` (bound keys are swallowed). Default true. */
378
384
  enabled: boolean;
@@ -404,6 +410,9 @@ export interface CommandState {
404
410
  * run: () => plugin.player.setLoop(null),
405
411
  * });
406
412
  * ```
413
+ *
414
+ * @since 1.0.0
415
+ * @category Commands
407
416
  */
408
417
  export type Bindable<T> = T | (() => T);
409
418
  /**
@@ -411,6 +420,9 @@ export type Bindable<T> = T | (() => T);
411
420
  * `@<id>` or by its English key (see {@link CommandOptions.path}): Open,
412
421
  * Playback, Subtitles, Video, Audio, Screen, Settings. The list is
413
422
  * `MENU_IDS` in `@vestra-player/sdk/testing`.
423
+ *
424
+ * @since 1.3.0
425
+ * @category Commands
414
426
  */
415
427
  export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "screen" | "settings";
416
428
  /**
@@ -419,6 +431,8 @@ export type MenuId = "open" | "playback" | "subtitles" | "video" | "audio" | "sc
419
431
  * `@vestra-player/sdk/testing`.
420
432
  *
421
433
  * @since 1.5.29
434
+ *
435
+ * @category Commands
422
436
  */
423
437
  export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "open" | "volume" | "mute" | "playlist" | "detach" | "attach" | "close" | "failed" | "move-top" | "move-up" | "move-down" | "move-bottom" | "search" | "chapter" | "bookmark" | "check-task" | "check-box" | "menu-check" | "submenu" | "scroll-up" | "scroll-down" | "caret-down" | "sort-asc" | "sort-desc" | "tag-remove" | "preview-play" | "add" | "remove" | "sort" | "fullscreen" | "exit-fullscreen" | "store" | "ai";
424
438
  /**
@@ -470,6 +484,8 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
470
484
  * @player 0.5.10; older players show the menu entry without a button
471
485
  * (declare `"minPlayerVersion": "0.5.10"` when the button matters).
472
486
  * @since 1.5.29
487
+ *
488
+ * @category Commands
473
489
  */
474
490
  export interface CommandButton {
475
491
  /**
@@ -518,6 +534,10 @@ export interface CommandButton {
518
534
  * {@link Commands.add} throws `invalid` synchronously for a bad path,
519
535
  * order, group or binding. The same path registered twice, or a path that
520
536
  * is both an item and a submenu, fails with `conflict` (through `ready`).
537
+ *
538
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
539
+ * @since 1.0.0
540
+ * @category Commands
521
541
  */
522
542
  export interface CommandOptions {
523
543
  /** Local id, unique within the plugin (the global id is `<manifest id>/<id>`). */
@@ -644,6 +664,38 @@ export interface CommandOptions {
644
664
  /**
645
665
  * A registered command, from {@link Commands.add}. Disposing it removes it
646
666
  * from the menu and the binding table; the id can be added again right away.
667
+ *
668
+ * @remarks
669
+ * The handle of one menu item and its keys. Its state (`enabled`, `checked`,
670
+ * `busy`, `reason`) is bound: functions given to {@link Commands.add} or
671
+ * {@link Command.set} are evaluated again in every update pass and only
672
+ * changes reach the player. Its title, path, bindings and button cannot
673
+ * change in place; dispose the command (or a child {@link Scope}) and add it
674
+ * again. Disposing it, its scope or stopping the plugin removes the menu
675
+ * item, its keys and its bar button.
676
+ *
677
+ * @capability commands
678
+ *
679
+ * @example Enable an item only while a file is open
680
+ * Video ▸ Export… is greyed out until a file is open. `set` replaces the
681
+ * `enabled` field with a function; the SDK evaluates it again after every
682
+ * player state change and sends the new state when it changes, so the
683
+ * item follows the player without further code.
684
+ * ```ts
685
+ * declare function runExport(): Promise<void>;
686
+ * // ---cut---
687
+ * const exportCommand = plugin.commands.add({
688
+ * id: "export",
689
+ * path: ["@video", "Export…"],
690
+ * run: runExport,
691
+ * });
692
+ * // A function: re-evaluated in every update pass.
693
+ * exportCommand.set({ enabled: () => plugin.player.state.phase === "ready" });
694
+ * ```
695
+ *
696
+ * @see {@link Commands.add}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
697
+ * @since 1.0.0
698
+ * @category Commands
647
699
  */
648
700
  export interface Command extends Disposable {
649
701
  /** The local id given in {@link CommandOptions.id}. */
@@ -672,6 +724,9 @@ export interface Command extends Disposable {
672
724
  * },
673
725
  * });
674
726
  * ```
727
+ *
728
+ * @capability commands
729
+ * @since 1.0.0
675
730
  */
676
731
  set(state: Partial<{
677
732
  [K in keyof CommandState]: Bindable<CommandState[K]>;
@@ -682,10 +737,49 @@ export interface Command extends Disposable {
682
737
  * commands in this plugin, or 1024 in the whole player). You rarely need
683
738
  * to await it: during activation a failure fails activation, later it
684
739
  * goes to {@link Scope.onError}.
740
+ *
741
+ * @throws `conflict` (rejects) Another command already has this menu path,
742
+ * or the path would be both a menu item and a submenu.
743
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
744
+ * @throws `invalid` (rejects) The player refused the declaration.
745
+ * @throws `limit` (rejects) More than 160 commands in this plugin, or 1024 in
746
+ * the player.
685
747
  */
686
748
  readonly ready: Promise<void>;
687
749
  }
688
- /** `plugin.commands`: menu commands with default bindings. Needs `commands`. */
750
+ /**
751
+ * `plugin.commands`: menu commands with default bindings. Needs `commands`.
752
+ *
753
+ * @remarks
754
+ * A command is one action of the plugin with a menu item (`path`), default
755
+ * keys (`bindings`), optionally a bottom-bar button (`button`) and a state
756
+ * the menu shows (`enabled`, `checked`, `busy`, `reason`). The player owns
757
+ * the menu, the binding table and the user's key overrides: it calls `run`
758
+ * when the user picks the item, presses a key or clicks the button.
759
+ * Commands of all plugins merge into the player's menus by their English
760
+ * path keys. Registration is asynchronous: `add` returns at once, and
761
+ * {@link Command.ready} reports the player's answer (during activation a
762
+ * refusal fails activation).
763
+ *
764
+ * @capability commands
765
+ *
766
+ * @example Add a menu item with a default key
767
+ * Tools ▸ Say hello, or Ctrl+H, shows a greeting. `add` declares the item;
768
+ * the player calls `run` when the user picks it or presses its key, and the
769
+ * user can change the key in the player's key settings.
770
+ * ```ts
771
+ * plugin.commands.add({
772
+ * id: "hello",
773
+ * path: ["Tools", "Say hello"],
774
+ * bindings: ["Ctrl+H"], // a default; the user may override it
775
+ * run: () => plugin.osd.show("Hello"),
776
+ * });
777
+ * ```
778
+ *
779
+ * @see {@link Command}, {@link CommandOptions}, [Commands, menus and key bindings](../../docs/concepts/commands.md)
780
+ * @since 1.0.0
781
+ * @category Commands
782
+ */
689
783
  export interface Commands {
690
784
  /**
691
785
  * Declare a command. It is registered and kept in sync; `dispose()` removes
@@ -715,6 +809,9 @@ export interface Commands {
715
809
  * run: () => plugin.player.setPaused(!plugin.player.state.paused),
716
810
  * });
717
811
  * ```
812
+ *
813
+ * @capability commands
814
+ * @since 1.0.0
718
815
  */
719
816
  add(options: CommandOptions): Command;
720
817
  }
@@ -3814,7 +3911,12 @@ export interface Dialogs {
3814
3911
  fileName?: TextValue;
3815
3912
  }): Promise<string | null>;
3816
3913
  }
3817
- /** One audio track of the current media ({@link PlayerState.audioTracks}). */
3914
+ /**
3915
+ * One audio track of the current media ({@link PlayerState.audioTracks}).
3916
+ *
3917
+ * @since 1.0.0
3918
+ * @category Playback
3919
+ */
3818
3920
  export interface AudioTrack {
3819
3921
  /** The backend's track id; pass it to {@link Player.selectAudioTrack}. */
3820
3922
  id: number;
@@ -3836,6 +3938,10 @@ export interface AudioTrack {
3836
3938
  *
3837
3939
  * Without `events`, {@link Player.state} only updates at start and after
3838
3940
  * this plugin's own operations.
3941
+ *
3942
+ * @see {@link Player.state}, [Playback state, controls and sources](../../docs/concepts/playback.md)
3943
+ * @since 1.0.0
3944
+ * @category Playback
3839
3945
  */
3840
3946
  export interface PlayerState {
3841
3947
  /** Changes every time a source is opened, even the same file again. */
@@ -3913,14 +4019,24 @@ export interface PlayerState {
3913
4019
  active: string | null;
3914
4020
  };
3915
4021
  }
3916
- /** An audio output device (`player.audioDevices`). */
4022
+ /**
4023
+ * An audio output device (`player.audioDevices`).
4024
+ *
4025
+ * @since 1.0.0
4026
+ * @category Playback
4027
+ */
3917
4028
  export interface AudioDevice {
3918
4029
  /** Stable id to pass to `setAudioDevice` (e.g. `coreaudio/<UID>`). */
3919
4030
  id: string;
3920
4031
  /** What the system calls it. */
3921
4032
  name: string;
3922
4033
  }
3923
- /** One chapter of the current media. */
4034
+ /**
4035
+ * One chapter of the current media.
4036
+ *
4037
+ * @since 1.0.0
4038
+ * @category Playback
4039
+ */
3924
4040
  export interface Chapter {
3925
4041
  /** Chapter title, `null` without one. */
3926
4042
  title: string | null;
@@ -3955,12 +4071,41 @@ export interface Chapter {
3955
4071
  * }
3956
4072
  * });
3957
4073
  * ```
4074
+ *
4075
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md)
4076
+ * @since 1.0.0
4077
+ * @category Playback
3958
4078
  */
3959
4079
  export interface Conditional {
3960
4080
  /** The state the call is conditional on; default: the current `player.state`. */
3961
4081
  ifCurrent?: PlayerState;
3962
4082
  }
3963
- /** A source this plugin opened. */
4083
+ /**
4084
+ * A source this plugin opened.
4085
+ *
4086
+ * @remarks
4087
+ * A source this plugin opened with {@link Player.open}. It does not own
4088
+ * the source: the user or another plugin can replace it at any time, and
4089
+ * `current` then turns false. `ready` and `onEnd` follow the player state:
4090
+ * with `events` they react to the player's events, without it `ready`
4091
+ * polls the state every 100 ms (which needs `playback.read`). Disposing the
4092
+ * scope that opened it does not close the media; call `close` for that.
4093
+ *
4094
+ * @capability That of {@link Player.open} (`playback.control`); `ready` and
4095
+ * `onEnd` follow the state through `events` and `playback.read`.
4096
+ *
4097
+ * @example Open a file and say when it ends
4098
+ * The file opens and plays, and the OSD says "Finished" when it reaches its
4099
+ * end on its own. The handle belongs to this source: `onEnd` fires only
4100
+ * for a natural end of this media, not when someone opens another file.
4101
+ * ```ts
4102
+ * const media = await plugin.player.open("/Movies/clip.mp4");
4103
+ * media.onEnd(() => void plugin.osd.show("Finished")); // natural ends only
4104
+ * ```
4105
+ *
4106
+ * @since 1.0.0
4107
+ * @category Playback
4108
+ */
3964
4109
  export interface Media {
3965
4110
  /** Still the player's source (a restart keeps it current). */
3966
4111
  readonly current: boolean;
@@ -3968,13 +4113,77 @@ export interface Media {
3968
4113
  * Resolves when it is playing. Rejects with code `failed` (load error),
3969
4114
  * `replaced` (another source took over) or `ended`; with `AbortError` when
3970
4115
  * the signal aborts, after closing the media.
4116
+ *
4117
+ * @capability That of {@link Player.open}; `playback.read` or `events` to
4118
+ * see the state change.
4119
+ * @throws `failed` (rejects) The media could not be loaded.
4120
+ * @throws `replaced` (rejects) Another source took over first.
4121
+ * @throws `ended` (rejects) The media already played to the end.
4122
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the media
4123
+ * is closed.
4124
+ *
4125
+ * @example Wait at most ten seconds for a stream to start
4126
+ * A network stream gets ten seconds to start playing; otherwise it is
4127
+ * closed and the user is told. Aborting the signal makes `ready` reject
4128
+ * with an `AbortError` after closing the media.
4129
+ * ```ts
4130
+ * const media = await plugin.player.open("https://example.com/live.m3u8");
4131
+ * // The timeout's abort closes the media and rejects with an AbortError.
4132
+ * try {
4133
+ * await media.ready({ signal: AbortSignal.timeout(10_000) });
4134
+ * } catch (error) {
4135
+ * await plugin.osd.show("Not playing: {}", [(error as Error).message]);
4136
+ * }
4137
+ * ```
4138
+ *
4139
+ * @since 1.0.0
3971
4140
  */
3972
4141
  ready(options?: {
3973
4142
  signal?: AbortSignal;
3974
4143
  }): Promise<void>;
3975
- /** Called once each time a playthrough of this media ends naturally. */
4144
+ /**
4145
+ * Called once each time a playthrough of this media ends naturally.
4146
+ *
4147
+ * @capability That of {@link Player.open}; `events` to hear the end.
4148
+ *
4149
+ * @example Play the next file when this one ends
4150
+ * When the plugin's file ends naturally, the next one opens. The listener
4151
+ * gets the state at the end; passing it as `ifCurrent` makes the next
4152
+ * `open` fail with `stale` (and do nothing) if the user opened something
4153
+ * else in the meantime.
4154
+ * ```ts
4155
+ * declare const media: Media;
4156
+ * declare const next: string;
4157
+ * // ---cut---
4158
+ * media.onEnd((ended) =>
4159
+ * plugin.player.open(next, { ifCurrent: ended }).catch((error: PluginError) => {
4160
+ * if (error.code !== "stale") throw error; // the user opened something else
4161
+ * }),
4162
+ * );
4163
+ * ```
4164
+ *
4165
+ * @since 1.0.0
4166
+ */
3976
4167
  onEnd(listener: (state: PlayerState) => void): Disposable;
3977
- /** Close it if it is still the player's source; otherwise do nothing. */
4168
+ /**
4169
+ * Close it if it is still the player's source; otherwise do nothing.
4170
+ *
4171
+ * @capability That of {@link Player.open} (`playback.control`).
4172
+ * @throws `capability` (rejects) The manifest does not declare
4173
+ * `playback.control`.
4174
+ *
4175
+ * @example Close the preview the plugin opened
4176
+ * The plugin closes the preview it opened, but leaves alone anything the
4177
+ * user opened since. `close` acts only while this media is still the
4178
+ * player's source and does nothing otherwise.
4179
+ * ```ts
4180
+ * declare const preview: Media;
4181
+ * // ---cut---
4182
+ * await preview.close(); // a no-op when something else is playing now
4183
+ * ```
4184
+ *
4185
+ * @since 1.0.0
4186
+ */
3978
4187
  close(): Promise<void>;
3979
4188
  }
3980
4189
  /**
@@ -3997,6 +4206,18 @@ export interface Media {
3997
4206
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
3998
4207
  * bar.on("dragEnd", () => scrub?.end());
3999
4208
  * ```
4209
+ *
4210
+ * @remarks
4211
+ * A drag gesture on a seek slider. Each `seek` is conditional on the
4212
+ * previous one and only the newest target is sent while a seek is
4213
+ * running, so a fast drag never queues seeks. Any outside change (another
4214
+ * file, the user pausing) ends the gesture: `active` turns false and later
4215
+ * seeks resolve `false`.
4216
+ *
4217
+ * @capability playback.control
4218
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
4219
+ * @since 1.0.0
4220
+ * @category Playback
4000
4221
  */
4001
4222
  export interface Scrub {
4002
4223
  /**
@@ -4029,13 +4250,40 @@ export interface Scrub {
4029
4250
  * const keyframes = true; // the plugin's own setting
4030
4251
  * bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
4031
4252
  * ```
4253
+ *
4254
+ * @capability playback.control
4255
+ * @throws `capability` (rejects) The manifest does not declare
4256
+ * `playback.control`.
4257
+ * @throws `stale` (rejects) The source changed from outside while this
4258
+ * seek was being sent (a superseded target or an ended gesture resolves
4259
+ * `false` instead).
4260
+ * @throws `unsupported` (rejects) The source is a generated stream.
4261
+ * @throws `backend` (rejects) The player failed the seek.
4262
+ * @since 1.0.0
4032
4263
  */
4033
4264
  seek(seconds: number, options?: {
4034
4265
  keyframes?: boolean;
4035
4266
  }): Promise<boolean>;
4036
4267
  /** False once anyone else changed the source; later seeks are ignored. */
4037
4268
  readonly active: boolean;
4038
- /** End the gesture; later seeks resolve `false`. */
4269
+ /**
4270
+ * End the gesture; later seeks resolve `false`.
4271
+ *
4272
+ * @capability playback.control
4273
+ *
4274
+ * @example End the gesture when the user lets go
4275
+ * Releasing the slider ends the gesture, so a late `dragMove` cannot move
4276
+ * playback any more. After `end`, `seek` resolves `false` without doing
4277
+ * anything.
4278
+ * ```ts
4279
+ * declare const bar: Panel;
4280
+ * declare const scrub: Scrub;
4281
+ * // ---cut---
4282
+ * bar.on("dragEnd", () => scrub.end()); // later seeks resolve false
4283
+ * ```
4284
+ *
4285
+ * @since 1.0.0
4286
+ */
4039
4287
  end(): void;
4040
4288
  }
4041
4289
  /**
@@ -4049,6 +4297,9 @@ export interface Scrub {
4049
4297
  *
4050
4298
  * The SDK copies the frame once before `frame()` is called again, so you
4051
4299
  * may reuse the same buffers for every frame.
4300
+ *
4301
+ * @since 1.0.0
4302
+ * @category Playback
4052
4303
  */
4053
4304
  export interface StreamFrame {
4054
4305
  /** `width × height × 4` bytes of RGBA, at most 4 MiB. */
@@ -4060,7 +4311,13 @@ export interface StreamFrame {
4060
4311
  */
4061
4312
  audio: Float32Array;
4062
4313
  }
4063
- /** Options of {@link Player.openStream}. */
4314
+ /**
4315
+ * Options of {@link Player.openStream}.
4316
+ *
4317
+ * @see {@link Player.openStream}
4318
+ * @since 1.0.0
4319
+ * @category Playback
4320
+ */
4064
4321
  export interface StreamOptions {
4065
4322
  /** Frame width in pixels. */
4066
4323
  width: number;
@@ -4075,12 +4332,48 @@ export interface StreamOptions {
4075
4332
  * `openStream` start-up timeouts: code `timeout` with the step that ran out
4076
4333
  * of time, the first buffers (`prefill`) or the host's activation.
4077
4334
  * Both limits are 10 seconds.
4335
+ *
4336
+ * @since 1.0.0
4337
+ * @category Playback
4078
4338
  */
4079
4339
  export type StreamStartError = PluginError & {
4080
4340
  /** The start-up step that timed out. */
4081
4341
  stage: "prefill" | "activation";
4082
4342
  };
4083
- /** A generated source, from {@link Player.openStream}. Disposing it stops it. */
4343
+ /**
4344
+ * A generated source, from {@link Player.openStream}. Disposing it stops it.
4345
+ *
4346
+ * @remarks
4347
+ * A source whose frames this plugin generates, from
4348
+ * {@link Player.openStream}. The SDK pulls frames on the audio clock over
4349
+ * a local connection and handles prefill, activation and pausing; the plugin
4350
+ * only supplies `frame()`. A stream ends when the plugin stops it, when
4351
+ * another source replaces it or the user closes it (`onStop` with `null`),
4352
+ * or when it fails (`onStop` with the error).
4353
+ *
4354
+ * @capability producer.av and playback.control
4355
+ *
4356
+ * @example Stop the stream from a menu item
4357
+ * A menu item ends the plugin's generated stream and closes it as the
4358
+ * player's source. `stop` closes the source first and then the frame
4359
+ * connection, so the user never sees a playback error; `onStop` is not
4360
+ * called for the plugin's own stop.
4361
+ * ```ts
4362
+ * declare const stream: Stream;
4363
+ * // ---cut---
4364
+ * // Enabled while the stream produces frames.
4365
+ * plugin.commands.add({
4366
+ * id: "stop-generator",
4367
+ * path: ["@playback", "Stop generator"],
4368
+ * enabled: () => stream.running,
4369
+ * run: () => stream.stop(),
4370
+ * });
4371
+ * ```
4372
+ *
4373
+ * @see {@link Player.openStream}
4374
+ * @since 1.0.0
4375
+ * @category Playback
4376
+ */
4084
4377
  export interface Stream extends Disposable {
4085
4378
  /** The stream is producing frames. */
4086
4379
  readonly running: boolean;
@@ -4096,6 +4389,23 @@ export interface Stream extends Disposable {
4096
4389
  * (before 1.5.7 a replaced or closed stream could get `unavailable`
4097
4390
  * "producer absent"). Without `playback.read` a failure the SDK has not
4098
4391
  * seen yet is reported as null.
4392
+ *
4393
+ * @capability producer.av
4394
+ *
4395
+ * @example Tell the user why the stream stopped
4396
+ * The OSD explains a failure, and nothing is said when the user simply
4397
+ * opened another file. The listener runs only for stops from outside the
4398
+ * plugin: `null` for a replacement or the user's close, an error (such as
4399
+ * `stream` with the player's text) for a failure.
4400
+ * ```ts
4401
+ * declare const stream: Stream;
4402
+ * // ---cut---
4403
+ * stream.onStop((error) => {
4404
+ * if (error) void plugin.osd.show("Generator stopped: {}", [error.message]);
4405
+ * });
4406
+ * ```
4407
+ *
4408
+ * @since 1.0.0
4099
4409
  */
4100
4410
  onStop(listener: (error: PluginError | null) => void): Disposable;
4101
4411
  /**
@@ -4103,10 +4413,34 @@ export interface Stream extends Disposable {
4103
4413
  * The source closes first, then the frame connection, then the producer,
4104
4414
  * so stopping never shows as a playback failure. `dispose()` and
4105
4415
  * deactivation do the same.
4416
+ *
4417
+ * @capability producer.av and playback.control
4418
+ * @throws Whatever code closing the player's source failed with (the
4419
+ * frame connection and the producer are released either way). A
4420
+ * stream that is no longer the source just resolves.
4421
+ *
4422
+ * @example Stop the stream when the plugin's window closes
4423
+ * Closing the generator window stops the stream it feeds. `stop` closes
4424
+ * the player's source if it is still this stream and then the frame
4425
+ * connection; it resolves once both are closed.
4426
+ * ```ts
4427
+ * declare const stream: Stream;
4428
+ * declare const window: Window;
4429
+ * // ---cut---
4430
+ * window.onClose(() => stream.stop());
4431
+ * ```
4432
+ *
4433
+ * @since 1.0.0
4106
4434
  */
4107
4435
  stop(): Promise<void>;
4108
4436
  }
4109
- /** The plugin that owns "next / previous" (a playlist). */
4437
+ /**
4438
+ * The plugin that owns "next / previous" (a playlist).
4439
+ *
4440
+ * @see {@link Player.provideSequence}
4441
+ * @since 1.0.0
4442
+ * @category Playback
4443
+ */
4110
4444
  export interface SequenceProvider {
4111
4445
  /** Go to the next item (called in the provider's scope). */
4112
4446
  next(): unknown;
@@ -4120,6 +4454,10 @@ export interface SequenceProvider {
4120
4454
  /**
4121
4455
  * What {@link Player.stats} reports. Every field is the backend's own value
4122
4456
  * (libmpv properties), `null` when it has none; nothing is converted.
4457
+ *
4458
+ * @see {@link Player.stats}
4459
+ * @since 1.0.0
4460
+ * @category Playback
4123
4461
  */
4124
4462
  export interface PlaybackStats {
4125
4463
  /**
@@ -4234,15 +4572,90 @@ export interface PlaybackStats {
4234
4572
  * void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
4235
4573
  * }
4236
4574
  * ```
4575
+ *
4576
+ * @remarks
4577
+ * `plugin.player` is the player's one source: what is open, its phase and
4578
+ * position, and the controls. `state` is a snapshot the SDK keeps current
4579
+ * from the player's events (with `events`) and refreshes after this
4580
+ * plugin's own calls; `onChange` runs after it changed. Every control is
4581
+ * conditional: it acts on the state the plugin last saw and fails with
4582
+ * `stale` when the source changed meanwhile: another file, a close, or any
4583
+ * pause, seek or speed change by the user or another plugin (see
4584
+ * {@link Conditional}). The player keeps the source when a plugin stops;
4585
+ * only the plugin's own handles (a stream, its sequence provider, its
4586
+ * output device) are withdrawn.
4587
+ *
4588
+ * @capability `playback.read` to read; `playback.control` to control;
4589
+ * `events` to follow changes made elsewhere; `producer.av` for
4590
+ * {@link Player.openStream}.
4591
+ *
4592
+ * @see [Playback state, controls and sources](../../docs/concepts/playback.md), [Control playback](../../docs/how-to/control-playback.md)
4593
+ * @since 1.0.0
4594
+ * @category Playback
4237
4595
  */
4238
4596
  export interface Player {
4239
4597
  /** Latest state; always newer than anything delivered before. */
4240
4598
  readonly state: PlayerState;
4241
- /** The state changed (needs `events` for outside changes). */
4599
+ /**
4600
+ * The state changed (needs `events` for outside changes).
4601
+ *
4602
+ * @capability events (for changes made elsewhere; without it the listener
4603
+ * runs only after this plugin's own calls)
4604
+ *
4605
+ * @example Show the position while it plays
4606
+ * The OSD block shows the position, updated as playback moves. The SDK
4607
+ * updates `player.state` from the player's events and then calls the
4608
+ * listener with the new snapshot; the handler only reads it.
4609
+ * ```ts
4610
+ * const info = plugin.osd.panel();
4611
+ * plugin.player.onChange((state) => {
4612
+ * // `position` is null while nothing is open.
4613
+ * info.set(state.position === null ? null : "{} s", [state.position?.toFixed(0) ?? ""]);
4614
+ * });
4615
+ * ```
4616
+ *
4617
+ * @since 1.0.0
4618
+ */
4242
4619
  onChange(listener: (state: PlayerState) => void): Disposable;
4243
- /** Read the state from the player now (`playback.read`). */
4620
+ /**
4621
+ * Read the state from the player now (`playback.read`).
4622
+ *
4623
+ * @capability playback.read
4624
+ * @throws `capability` (rejects) The manifest does not declare
4625
+ * `playback.read`.
4626
+ *
4627
+ * @example Read the state before deciding
4628
+ * A command reads the newest state from the player before acting, even
4629
+ * without `events`. `refresh` asks the player and resolves with the new
4630
+ * snapshot; `player.state` holds it afterwards.
4631
+ * ```ts
4632
+ * const state = await plugin.player.refresh();
4633
+ * if (state.phase === "ready") await plugin.osd.show("{} s left", [((state.duration ?? 0) - (state.position ?? 0)).toFixed(0)]);
4634
+ * ```
4635
+ *
4636
+ * @since 1.0.0
4637
+ */
4244
4638
  refresh(): Promise<PlayerState>;
4245
- /** True if nothing changed the source since `state` was observed. */
4639
+ /**
4640
+ * True if nothing changed the source since `state` was observed.
4641
+ *
4642
+ * @capability None.
4643
+ *
4644
+ * @example Drop a result that arrives after the file changed
4645
+ * A slow lookup for the current file is shown only if that file is still
4646
+ * open. `isCurrent` compares the snapshot taken before the work with the
4647
+ * SDK's newest state; it is false once the source changed in any way:
4648
+ * another file, a close, or a pause, seek or speed change by anyone.
4649
+ * ```ts
4650
+ * declare function lookUpTitle(path: string): Promise<string>;
4651
+ * // ---cut---
4652
+ * const seen = plugin.player.state;
4653
+ * const title = await lookUpTitle(seen.source ?? "");
4654
+ * if (plugin.player.isCurrent(seen)) await plugin.osd.show("{}", [title]); // still the same file
4655
+ * ```
4656
+ *
4657
+ * @since 1.0.0
4658
+ */
4246
4659
  isCurrent(state: PlayerState): boolean;
4247
4660
  /**
4248
4661
  * Open a path or URL, replacing the current source (unless `ifCurrent` no
@@ -4253,6 +4666,37 @@ export interface Player {
4253
4666
  * {@link Resolver} are resolved first, and so are local files of an
4254
4667
  * extension a file resolver claims (the source is then a stream that
4255
4668
  * plugin generates; `paused` reaches it).
4669
+ *
4670
+ * @capability playback.control
4671
+ * @throws `stale` (rejects) `ifCurrent` no longer holds; nothing was opened.
4672
+ * @throws `replaced` (rejects) Accepted, then another source took over
4673
+ * before it finished (for example while a URL was still being resolved).
4674
+ * @throws `invalid` (rejects) An empty `source`.
4675
+ * @throws `aborted` (rejects) `signal` aborted (an `AbortError`); the
4676
+ * source is closed if it was opened.
4677
+ * @throws `busy` (rejects) The address needs a resolver plugin and the
4678
+ * player is already resolving 32 addresses.
4679
+ * @throws `backend` (rejects) A resolver plugin handled the address or
4680
+ * file and failed (its message), or the resolution was cancelled or
4681
+ * expired.
4682
+ * @throws `unsupported` (rejects) The player refused what a resolver
4683
+ * returned (for example split streams with different headers).
4684
+ * @throws `unavailable` (rejects) The stream a file resolver prepared was
4685
+ * not ready to play.
4686
+ * @throws `capability` (rejects) The manifest does not declare
4687
+ * `playback.control`.
4688
+ *
4689
+ * @example Open a file paused
4690
+ * The file opens at its start without playing. `open` resolves once the
4691
+ * player accepted the load and returns a {@link Media} handle for that
4692
+ * source; `ready` then waits until it can play (here: until it is loaded
4693
+ * and paused).
4694
+ * ```ts
4695
+ * const media = await plugin.player.open("/Movies/clip.mp4", { paused: true });
4696
+ * await media.ready(); // rejects `failed` when the file cannot be loaded
4697
+ * ```
4698
+ *
4699
+ * @since 1.0.0
4256
4700
  */
4257
4701
  open(source: string, options?: {
4258
4702
  paused?: boolean;
@@ -4290,32 +4734,219 @@ export interface Player {
4290
4734
  * // `error` is null when the stream stopped normally.
4291
4735
  * stream.onStop((error) => error && plugin.osd.show(error.message));
4292
4736
  * ```
4737
+ *
4738
+ * @capability producer.av and playback.control
4739
+ * @throws `timeout` (rejects) A {@link StreamStartError}: the stream did not
4740
+ * start in time.
4741
+ * @throws `stream` (rejects) The player failed to start the stream.
4742
+ * @throws `device` (rejects) The audio device was not ready.
4743
+ * @throws `replaced` (rejects) Another source replaced it, or it was closed,
4744
+ * before it ran.
4745
+ * @throws `invalid` (rejects) Bad options (a size or a frame function).
4746
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed,
4747
+ * while the stream was starting (an `AbortError`).
4748
+ * @since 1.0.0
4293
4749
  */
4294
4750
  openStream(options: StreamOptions): Promise<Stream>;
4295
- /** Pause or resume. */
4751
+ /**
4752
+ * Pause or resume.
4753
+ *
4754
+ * @capability playback.control
4755
+ * @throws `stale` (rejects) The source changed since the state the call is
4756
+ * based on (another file, a close, or any pause, seek
4757
+ * or speed change by the user or another plugin); nothing was done.
4758
+ * @throws `invalid` (rejects) Nothing is open.
4759
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4760
+ * control.
4761
+ * @throws `capability` (rejects) The manifest does not declare
4762
+ * `playback.control`.
4763
+ *
4764
+ * @example Pause, but only the file the user was watching
4765
+ * A "pause on lock" helper pauses the current file and does nothing when
4766
+ * another file was opened in between. The call is conditional on
4767
+ * `player.state` by default; `stale` means the source changed and the
4768
+ * pause was not applied, which is the intended outcome here.
4769
+ * ```ts
4770
+ * try {
4771
+ * await plugin.player.setPaused(true);
4772
+ * } catch (error) {
4773
+ * if ((error as PluginError).code !== "stale") throw error; // another file now
4774
+ * }
4775
+ * ```
4776
+ *
4777
+ * @since 1.0.0
4778
+ */
4296
4779
  setPaused(paused: boolean, options?: Conditional): Promise<void>;
4297
- /** Jump to `seconds`. */
4780
+ /**
4781
+ * Jump to `seconds`.
4782
+ *
4783
+ * @capability playback.control
4784
+ * @throws `stale` (rejects) The source changed since the state the call is
4785
+ * based on (another file, a close, or any pause, seek
4786
+ * or speed change by the user or another plugin); nothing was done.
4787
+ * @throws `invalid` (rejects) Nothing is open, or `seconds` is negative or
4788
+ * not finite.
4789
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4790
+ * cannot seek.
4791
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4792
+ * control.
4793
+ * @throws `capability` (rejects) The manifest does not declare
4794
+ * `playback.control`.
4795
+ *
4796
+ * @example Jump back ten seconds
4797
+ * Playback jumps ten seconds back, never before the start. The target is
4798
+ * computed from the snapshot the call is conditional on, so if the user
4799
+ * opened another file meanwhile the seek fails with `stale` instead of
4800
+ * landing in the wrong file.
4801
+ * ```ts
4802
+ * const { position } = plugin.player.state;
4803
+ * if (position !== null && plugin.player.state.can.seek)
4804
+ * await plugin.player.seek(Math.max(0, position - 10)); // seconds
4805
+ * ```
4806
+ *
4807
+ * @since 1.0.0
4808
+ */
4298
4809
  seek(seconds: number, options?: Conditional): Promise<void>;
4299
- /** Set the playback speed. */
4810
+ /**
4811
+ * Set the playback speed.
4812
+ *
4813
+ * @capability playback.control
4814
+ * @throws `stale` (rejects) The source changed since the state the call is
4815
+ * based on (another file, a close, or any pause, seek
4816
+ * or speed change by the user or another plugin); nothing was done.
4817
+ * @throws `invalid` (rejects) Nothing is open, or `rate` is outside
4818
+ * 0.05–10.
4819
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4820
+ * cannot change speed.
4821
+ * @throws `busy` (rejects) A generated stream is still handling a previous
4822
+ * control.
4823
+ * @throws `capability` (rejects) The manifest does not declare
4824
+ * `playback.control`.
4825
+ *
4826
+ * @example Play at one and a half times the speed
4827
+ * Playback speeds up to 1.5×, with the speed on the OSD. `setRate` sets
4828
+ * the speed directly (the player's own keys step it by 0.1 within
4829
+ * 0.25–4); `state.can.rate` is false for sources that cannot change speed.
4830
+ * ```ts
4831
+ * if (plugin.player.state.can.rate) {
4832
+ * await plugin.player.setRate(1.5);
4833
+ * await plugin.osd.show("Speed {}×", [1.5]);
4834
+ * }
4835
+ * ```
4836
+ *
4837
+ * @since 1.0.0
4838
+ */
4300
4839
  setRate(rate: number, options?: Conditional): Promise<void>;
4301
- /** Play from the start (the "replay" after the end). */
4840
+ /**
4841
+ * Play from the start (the "replay" after the end).
4842
+ *
4843
+ * @capability playback.control
4844
+ * @throws `stale` (rejects) The source changed since the state the call is
4845
+ * based on (another file, a close, or any pause, seek
4846
+ * or speed change by the user or another plugin); nothing was done.
4847
+ * @throws `invalid` (rejects) Nothing is open.
4848
+ * @throws `unsupported` (rejects) The source is a generated stream, which
4849
+ * cannot restart.
4850
+ * @throws `capability` (rejects) The manifest does not declare
4851
+ * `playback.control`.
4852
+ *
4853
+ * @example Replay a file after it ended
4854
+ * A Replay button starts the file again from its start once it has ended.
4855
+ * `restart` plays the current source from the beginning and keeps it the
4856
+ * same source, so a {@link Media} handle stays current.
4857
+ * ```ts
4858
+ * if (plugin.player.state.phase === "ended") await plugin.player.restart();
4859
+ * ```
4860
+ *
4861
+ * @since 1.0.0
4862
+ */
4302
4863
  restart(options?: Conditional): Promise<void>;
4303
- /** Close the current source. */
4864
+ /**
4865
+ * Close the current source.
4866
+ *
4867
+ * @capability playback.control
4868
+ * @throws `stale` (rejects) The source changed since the state the call is
4869
+ * based on (another file, a close, or any pause, seek
4870
+ * or speed change by the user or another plugin); nothing was done.
4871
+ * @throws `capability` (rejects) The manifest does not declare
4872
+ * `playback.control`.
4873
+ *
4874
+ * @example Close the file when the sleep timer runs out
4875
+ * After 30 minutes the open file is closed, unless the user opened another
4876
+ * one meanwhile. The snapshot taken when the timer started is passed as
4877
+ * `ifCurrent`, so the close applies only to that source; `stale` means it
4878
+ * was replaced.
4879
+ * ```ts
4880
+ * const seen = plugin.player.state;
4881
+ * setTimeout(() => {
4882
+ * // Only the source that was open when the timer started.
4883
+ * plugin.player.close({ ifCurrent: seen }).catch((error: PluginError) => {
4884
+ * if (error.code !== "stale") plugin.osd.show("Could not close: {}", [error.code]);
4885
+ * });
4886
+ * }, 30 * 60 * 1000);
4887
+ * ```
4888
+ *
4889
+ * @since 1.0.0
4890
+ */
4304
4891
  close(options?: Conditional): Promise<void>;
4305
- /** Start a drag gesture from the current state. */
4892
+ /**
4893
+ * Start a drag gesture from the current state.
4894
+ *
4895
+ * @capability playback.control
4896
+ *
4897
+ * @example Start a gesture when the user grabs the slider
4898
+ * A drag on the plugin's seek slider becomes one gesture whose seeks never
4899
+ * pile up. `scrub()` captures the current state; the gesture's seeks are
4900
+ * conditional on it and on each other, and it ends on its own when
4901
+ * someone else changes the source.
4902
+ * ```ts
4903
+ * declare const bar: Panel;
4904
+ * // ---cut---
4905
+ * let scrub: Scrub | null = null;
4906
+ * bar.on("dragStart", () => (scrub = plugin.player.scrub()));
4907
+ * bar.on("dragEnd", () => scrub?.end());
4908
+ * ```
4909
+ *
4910
+ * @see [Keep a slider responsive while dragging](../../docs/how-to/slider.md)
4911
+ * @since 1.0.0
4912
+ */
4306
4913
  scrub(): Scrub;
4307
4914
  /**
4308
4915
  * Device output; last write wins and volume is applied before mute.
4309
4916
  * Resolves `true` when this request was applied, `false` if superseded.
4310
4917
  * `volume` is 0–200.
4918
+ *
4919
+ * @capability playback.control
4920
+ * @throws `invalid` (rejects) `volume` outside 0–200, or `muted` not a
4921
+ * boolean.
4922
+ * @throws `capability` (rejects) The manifest does not declare
4923
+ * `playback.control`.
4924
+ *
4925
+ * @example Duck the volume during an announcement
4926
+ * The volume drops to 30 while the plugin's announcement plays, then
4927
+ * returns. Writes are "latest wins": the promise resolves `true` when this
4928
+ * write was applied and `false` when a newer one replaced it first, which
4929
+ * is not an error.
4930
+ * ```ts
4931
+ * const before = plugin.player.state.volume; // 0–200
4932
+ * await plugin.player.setOutput({ volume: 30 });
4933
+ * // … the announcement …
4934
+ * await plugin.player.setOutput({ volume: before });
4935
+ * ```
4936
+ *
4937
+ * @since 1.0.0
4311
4938
  */
4312
4939
  setOutput(output: {
4313
4940
  volume?: number;
4314
4941
  muted?: boolean;
4315
4942
  }): Promise<boolean>;
4316
4943
  /**
4317
- * Become the sequence provider (one at a time; another provider rejects with
4318
- * `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
4944
+ * Become the sequence provider, first come first served: while another
4945
+ * plugin holds it (the built-in playlist registers at activation), this
4946
+ * one is refused with `conflict` (`sequence-taken`). That does not fail
4947
+ * activation: the player shows a warning ({@link PluginInfo.warnings});
4948
+ * a refusal after activation goes to the scope's `onError`. Dispose to
4949
+ * stop.
4319
4950
  *
4320
4951
  * @example Drive the player's Next and Previous from a playlist
4321
4952
  * The player's Next and Previous items and keys play the plugin's list.
@@ -4339,15 +4970,82 @@ export interface Player {
4339
4970
  * canPrevious: () => index > 0,
4340
4971
  * });
4341
4972
  * ```
4973
+ *
4974
+ * @capability playback.control
4975
+ * @since 1.0.0
4342
4976
  */
4343
4977
  provideSequence(provider: SequenceProvider): Disposable;
4344
- /** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
4345
- next(): Promise<void>;
4346
- /** Ask the provider to go back. Rejects `unavailable` when it cannot. */
4347
- previous(): Promise<void>;
4348
4978
  /**
4349
- * Codec, size, frame counts and backend of the current playback.
4979
+ * Ask the provider to go forward / back. Rejects `unavailable` when it cannot.
4980
+ *
4981
+ * @capability playback.control
4982
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
4983
+ * no next item.
4984
+ * @throws `capability` (rejects) The manifest does not declare
4985
+ * `playback.control`.
4986
+ *
4987
+ * @example Skip to the next item from a plugin button
4988
+ * The plugin's own Next button plays the next item of whichever plugin
4989
+ * provides the sequence (the playlist, usually). `next` asks the
4990
+ * provider, the same as the player's Next key; `unavailable` means there
4991
+ * is no next item, which the button treats as the end of the list.
4992
+ * ```ts
4993
+ * try {
4994
+ * await plugin.player.next(); // as the player's Next key does
4995
+ * } catch (error) {
4996
+ * if ((error as PluginError).code !== "unavailable") throw error;
4997
+ * await plugin.osd.show("End of the list");
4998
+ * }
4999
+ * ```
5000
+ *
5001
+ * @since 1.0.0
5002
+ */
5003
+ next(): Promise<void>;
5004
+ /**
5005
+ * Ask the provider to go back. Rejects `unavailable` when it cannot.
5006
+ *
5007
+ * @capability playback.control
5008
+ * @throws `unavailable` (rejects) There is no sequence provider, or it has
5009
+ * no previous item.
5010
+ * @throws `capability` (rejects) The manifest does not declare
5011
+ * `playback.control`.
5012
+ *
5013
+ * @example Go back to the previous item
5014
+ * The plugin's Previous button asks the sequence provider for the item
5015
+ * before the current one, like the player's PageUp. It is disabled while
5016
+ * `state.can.previous` is false, which the provider's `canPrevious`
5017
+ * decides.
5018
+ * ```ts
5019
+ * plugin.commands.add({
5020
+ * id: "previous",
5021
+ * title: "Previous item",
5022
+ * // The provider's `canPrevious`, as the player reports it.
5023
+ * enabled: () => plugin.player.state.can.previous,
5024
+ * run: () => plugin.player.previous(),
5025
+ * });
5026
+ * ```
5027
+ *
5028
+ * @since 1.0.0
5029
+ */
5030
+ previous(): Promise<void>;
5031
+ /**
5032
+ * Codec, size, frame counts and backend of the current playback.
4350
5033
  * `playback.read`; `video` and `audio` are `null` without a source.
5034
+ *
5035
+ * @capability playback.read
5036
+ * @throws `capability` (rejects) The manifest does not declare
5037
+ * `playback.read`.
5038
+ *
5039
+ * @example Show the video codec and size
5040
+ * The OSD shows something like "h264 1920×1080". `stats` asks the player
5041
+ * for the backend's own values at that moment; `video` is `null` without
5042
+ * a video stream (nothing open, an audio file).
5043
+ * ```ts
5044
+ * const { video } = await plugin.player.stats();
5045
+ * if (video) await plugin.osd.show("{} {}×{}", [video.codec ?? "?", video.width ?? 0, video.height ?? 0]);
5046
+ * ```
5047
+ *
5048
+ * @since 1.0.0
4351
5049
  */
4352
5050
  stats(): Promise<PlaybackStats>;
4353
5051
  /**
@@ -4355,11 +5053,56 @@ export interface Player {
4355
5053
  * unless `select: false`, select it. Resolves with the new track (`external`,
4356
5054
  * not exportable). It stays until the source changes. Rejects `invalid` for a
4357
5055
  * bad path, `unavailable` without ready media, `backend` when it cannot load.
5056
+ *
5057
+ * @capability playback.control
5058
+ * @throws `invalid` (rejects) Not an absolute path to a file.
5059
+ * @throws `unavailable` (rejects) No ready media.
5060
+ * @throws `backend` (rejects) The player could not load the file.
5061
+ * @throws `stale` (rejects) `ifCurrent` no longer holds.
5062
+ * @throws `capability` (rejects) The manifest does not declare
5063
+ * `playback.control`.
5064
+ *
5065
+ * @example Add a dubbed audio file and switch to it
5066
+ * A separate audio file plays with the film instead of its own sound.
5067
+ * `addAudioTrack` attaches the file to the current media and selects it;
5068
+ * the track stays until another source opens.
5069
+ * ```ts
5070
+ * declare const dubPath: string;
5071
+ * // ---cut---
5072
+ * const track = await plugin.player.addAudioTrack(dubPath); // selected too
5073
+ * await plugin.osd.show("Audio: {}", [track.title ?? dubPath]);
5074
+ * ```
5075
+ *
5076
+ * @since 1.0.0
4358
5077
  */
4359
5078
  addAudioTrack(path: string, options?: {
4360
5079
  select?: boolean;
4361
5080
  } & Conditional): Promise<AudioTrack>;
4362
- /** Select one of `state.audioTracks` by id; `invalid` for an unknown id. */
5081
+ /**
5082
+ * Select one of `state.audioTracks` by id; `invalid` for an unknown id.
5083
+ *
5084
+ * @capability playback.control
5085
+ * @throws `invalid` (rejects) No track with this id.
5086
+ * @throws `unavailable` (rejects) No ready media.
5087
+ * @throws `stale` (rejects) The source changed since the state the call is
5088
+ * based on (another file, a close, or any pause, seek
5089
+ * or speed change by the user or another plugin).
5090
+ * @throws `capability` (rejects) The manifest does not declare
5091
+ * `playback.control`.
5092
+ *
5093
+ * @example Switch to the next audio track
5094
+ * Each press of the plugin's key moves to the next audio track of the
5095
+ * file. The tracks and the selected one are in `state.audioTracks`;
5096
+ * `selectAudioTrack` resolves with the new state.
5097
+ * ```ts
5098
+ * const audioTracks = plugin.player.state.audioTracks ?? []; // null: not known yet
5099
+ * const at = audioTracks.findIndex((track) => track.selected);
5100
+ * const next = audioTracks[(at + 1) % audioTracks.length];
5101
+ * if (next) await plugin.player.selectAudioTrack(next.id);
5102
+ * ```
5103
+ *
5104
+ * @since 1.0.0
5105
+ */
4363
5106
  selectAudioTrack(id: number, options?: Conditional): Promise<PlayerState>;
4364
5107
  /**
4365
5108
  * One frame or to the next / previous keyframe (pauses for frames). Rejects
@@ -4369,6 +5112,29 @@ export interface Player {
4369
5112
  * Resolves once the new position is in effect, so `player.state.position`
4370
5113
  * is current afterwards. One step at a time: a second call while one is
4371
5114
  * in flight rejects `busy`.
5115
+ *
5116
+ * @capability playback.control
5117
+ * @throws `unsupported` (rejects) Nothing playable to step through (no
5118
+ * moving video: audio only, cover art, a generated stream), a backward
5119
+ * frame step on a source that cannot seek, or a keyframe step in either
5120
+ * direction on a source that cannot seek.
5121
+ * @throws `busy` (rejects) A step is still in flight.
5122
+ * @throws `stale` (rejects) The source changed since the state the call is
5123
+ * based on (another file, a close, or any pause, seek
5124
+ * or speed change by the user or another plugin).
5125
+ * @throws `capability` (rejects) The manifest does not declare
5126
+ * `playback.control`.
5127
+ *
5128
+ * @example Step one frame forward for a close look
5129
+ * The picture advances by one frame and playback pauses. `step` resolves
5130
+ * once the new position is in effect, so `state.position` is current
5131
+ * afterwards; a second step while one is running rejects with `busy`.
5132
+ * ```ts
5133
+ * await plugin.player.step("frame", "forward");
5134
+ * await plugin.osd.show("{} s", [plugin.player.state.position?.toFixed(3) ?? "?"]);
5135
+ * ```
5136
+ *
5137
+ * @since 1.0.0
4372
5138
  */
4373
5139
  step(unit: "frame" | "keyframe", direction: "backward" | "forward", options?: Conditional): Promise<void>;
4374
5140
  /**
@@ -4391,6 +5157,23 @@ export interface Player {
4391
5157
  *
4392
5158
  * Older players ignore `keyframes`: set `"minPlayerVersion": "0.5.1"` in
4393
5159
  * the manifest when you rely on it.
5160
+ *
5161
+ * @capability playback.control
5162
+ * @throws `invalid` (rejects) `seconds` outside 0.1–3600 or not in tenths,
5163
+ * or `keyframes` not a boolean.
5164
+ * @throws `capability` (rejects) The manifest does not declare
5165
+ * `playback.control`.
5166
+ *
5167
+ * @example Make ← and → jump 10 seconds to keyframes
5168
+ * The arrow keys now jump 10 seconds and land on keyframes, which is fast
5169
+ * on large files. The newest call of any plugin wins and is withdrawn
5170
+ * when that plugin stops, so a plugin offering this as a setting applies
5171
+ * it on activation.
5172
+ * ```ts
5173
+ * await plugin.player.setStepSeek({ seconds: 10, keyframes: true });
5174
+ * ```
5175
+ *
5176
+ * @since 1.0.0
4394
5177
  */
4395
5178
  setStepSeek(options: {
4396
5179
  seconds: number;
@@ -4400,6 +5183,23 @@ export interface Player {
4400
5183
  * The chapters of the current media (empty without media or chapters),
4401
5184
  * read on demand; `sourceId` tells which source they belong to.
4402
5185
  * `playback.read`.
5186
+ *
5187
+ * @capability playback.read
5188
+ * @throws `capability` (rejects) The manifest does not declare
5189
+ * `playback.read`.
5190
+ *
5191
+ * @example List the chapters of the open file
5192
+ * The plugin reads the chapter titles of the current file to show them in
5193
+ * its panel. `chapters` asks the player when called; `sourceId` says which
5194
+ * source they belong to, so a reply for a file that was replaced meanwhile
5195
+ * can be dropped.
5196
+ * ```ts
5197
+ * const { sourceId, chapters } = await plugin.player.chapters();
5198
+ * if (sourceId === plugin.player.state.sourceId) // still the same file
5199
+ * console.log(chapters.map((chapter) => chapter.title));
5200
+ * ```
5201
+ *
5202
+ * @since 1.0.0
4403
5203
  */
4404
5204
  chapters(): Promise<{
4405
5205
  /** The source the chapters belong to (compare with `state.sourceId`). */
@@ -4413,13 +5213,59 @@ export interface Player {
4413
5213
  * stops it. After the last jump back `state.loop` becomes null.
4414
5214
  * `invalid` out of range, `unsupported` for a source that cannot seek,
4415
5215
  * `unavailable` without ready media. A new source clears it.
5216
+ *
5217
+ * @capability playback.control
5218
+ * @throws `invalid` (rejects) `a`/`b` out of range or `count` not a positive
5219
+ * integer.
5220
+ * @throws `unsupported` (rejects) The source cannot seek, or is a generated
5221
+ * stream.
5222
+ * @throws `unavailable` (rejects) No ready media.
5223
+ * @throws `stale` (rejects) The source changed since the state the call is
5224
+ * based on (another file, a close, or any pause, seek
5225
+ * or speed change by the user or another plugin).
5226
+ * @throws `capability` (rejects) The manifest does not declare
5227
+ * `playback.control`.
5228
+ *
5229
+ * @example Loop a passage three times
5230
+ * Playback repeats 1:00–1:30 three times, then goes on. The player jumps
5231
+ * back to `a` each time it reaches `b`; after the last repeat
5232
+ * `state.loop` becomes `null`, and a new source clears the loop.
5233
+ * ```ts
5234
+ * await plugin.player.setLoop({ a: 60, b: 90, count: 3 }); // seconds
5235
+ * ```
5236
+ *
5237
+ * @since 1.0.0
4416
5238
  */
4417
5239
  setLoop(loop: {
4418
5240
  a: number;
4419
5241
  b: number;
4420
5242
  count?: number;
4421
5243
  } | null, options?: Conditional): Promise<void>;
4422
- /** The output devices present now (not the system default entry). `playback.read`. */
5244
+ /**
5245
+ * The output devices present now (not the system default entry). `playback.read`.
5246
+ *
5247
+ * @capability playback.read
5248
+ * @throws `capability` (rejects) The manifest does not declare
5249
+ * `playback.read`.
5250
+ *
5251
+ * @example Offer the audio output devices in a menu
5252
+ * A submenu lists the output devices present now, with a radio mark on the
5253
+ * one in use. `audioDevices` asks the player when called (devices come and
5254
+ * go), and `state.audioOutput` tells which one is active.
5255
+ * ```ts
5256
+ * // The devices present now; build the menu again when they change.
5257
+ * for (const device of await plugin.player.audioDevices())
5258
+ * plugin.commands.add({
5259
+ * id: `device.${device.id}`,
5260
+ * path: ["@audio", "Output", device.name],
5261
+ * radio: true,
5262
+ * checked: () => plugin.player.state.audioOutput?.active === device.id,
5263
+ * run: () => plugin.player.setAudioDevice(device.id),
5264
+ * });
5265
+ * ```
5266
+ *
5267
+ * @since 1.0.0
5268
+ */
4423
5269
  audioDevices(): Promise<AudioDevice[]>;
4424
5270
  /**
4425
5271
  * Play on this device while it is present, else on the system default
@@ -4429,6 +5275,23 @@ export interface Player {
4429
5275
  * Unknown ids are accepted (`state.audioOutput.active` stays `null`); a
4430
5276
  * malformed id is `invalid`. Reverts to the default when this plugin
4431
5277
  * stops; save the choice yourself to restore it on activation.
5278
+ *
5279
+ * @capability playback.control
5280
+ * @throws `invalid` (rejects) A malformed id.
5281
+ * @throws `capability` (rejects) The manifest does not declare
5282
+ * `playback.control`.
5283
+ *
5284
+ * @example Restore the user's output device on start
5285
+ * The plugin plays on the device the user picked last time, or on the
5286
+ * system default when it is not connected. The choice lasts only for
5287
+ * this run and is withdrawn when the plugin stops, so the plugin saves
5288
+ * it itself and sets it again on activation.
5289
+ * ```ts
5290
+ * const store = plugin.storage.json("output", { defaults: { device: null as string | null } });
5291
+ * await plugin.player.setAudioDevice(store.value.device); // null: the system default
5292
+ * ```
5293
+ *
5294
+ * @since 1.0.0
4432
5295
  */
4433
5296
  setAudioDevice(id: string | null): Promise<void>;
4434
5297
  }
@@ -4453,9 +5316,45 @@ export interface Player {
4453
5316
  * (`plugin.window.onChange`), which also changes when the user toggles it.
4454
5317
  * Players before 0.5.2 reject it with `invalid`: set
4455
5318
  * `"minPlayerVersion": "0.5.2"` when you use it.
5319
+ *
5320
+ * @see {@link Host.run}
5321
+ * @since 1.0.0
5322
+ * @category Commands
4456
5323
  */
4457
5324
  export type HostAction = "play-pause" | "stop" | "previous" | "next" | "frame-back" | "frame-forward" | "seek-backward" | "seek-forward" | "rate-down" | "rate-up" | "rate-normal" | "volume-up" | "volume-down" | "mute" | "fullscreen";
4458
- /** `plugin.host`: the player's own core actions. */
5325
+ /**
5326
+ * `plugin.host`: the player's own core actions.
5327
+ *
5328
+ * @remarks
5329
+ * The player's core actions (play/pause, stop, step seeks, speed, volume,
5330
+ * fullscreen…) are commands of the player itself, `host/<action>` in the
5331
+ * binding table. Running them through `host.run` gives a plugin's own
5332
+ * buttons exactly the player's behaviour: the same checks, the same
5333
+ * accumulation of repeated presses and the same on-screen text, instead of
5334
+ * reimplementing them with {@link Player} calls.
5335
+ *
5336
+ * @capability commands
5337
+ *
5338
+ * @example Give a custom bar the player's own Stop and Mute
5339
+ * The plugin's bar buttons stop playback and mute exactly as the player's
5340
+ * own keys do, with the same on-screen text. Each callback runs a core
5341
+ * action; the player applies its checks and rejects with `unavailable`
5342
+ * while the action is disabled, which the bar ignores.
5343
+ * ```ts
5344
+ * declare const bar: Panel;
5345
+ * // ---cut---
5346
+ * const run = (action: HostAction) =>
5347
+ * plugin.host.run(action).catch((error: PluginError) => {
5348
+ * if (error.code !== "unavailable") throw error; // disabled right now
5349
+ * });
5350
+ * bar.on("stop", () => run("stop"));
5351
+ * bar.on("mute", () => run("mute"));
5352
+ * ```
5353
+ *
5354
+ * @see {@link HostAction}, {@link Player}
5355
+ * @since 1.0.0
5356
+ * @category Commands
5357
+ */
4459
5358
  export interface Host {
4460
5359
  /**
4461
5360
  * Run a core action exactly like its menu item or key: same checks, same
@@ -4482,6 +5381,14 @@ export interface Host {
4482
5381
  * }
4483
5382
  * });
4484
5383
  * ```
5384
+ *
5385
+ * @capability commands
5386
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5387
+ * open, no window for `fullscreen`).
5388
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5389
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
5390
+ *
5391
+ * @since 1.0.0
4485
5392
  */
4486
5393
  run(action: HostAction): Promise<void>;
4487
5394
  /**
@@ -4511,6 +5418,12 @@ export interface Host {
4511
5418
  * // ---cut---
4512
5419
  * bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
4513
5420
  * ```
5421
+ *
5422
+ * @capability commands
5423
+ * @throws `unavailable` (rejects) The action is disabled right now (nothing
5424
+ * open, no window for `fullscreen`).
5425
+ * @throws `invalid` (rejects) An unknown action, or `seconds` out of range.
5426
+ * @throws `capability` (rejects) The manifest does not declare `commands`.
4514
5427
  */
4515
5428
  run(action: "seek-backward" | "seek-forward", options: {
4516
5429
  seconds: number;
@@ -5782,16 +6695,109 @@ export interface Bindings {
5782
6695
  * void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
5783
6696
  * }
5784
6697
  * ```
6698
+ *
6699
+ * @remarks
6700
+ * The player reads and writes the system clipboard on the plugin's behalf.
6701
+ * Text is plain text; images are written from a PNG or JPEG file.
6702
+ *
6703
+ * @capability clipboard
6704
+ * @since 1.0.0
6705
+ * @category Services
5785
6706
  */
5786
6707
  export interface Clipboard {
5787
- /** Plain text on the clipboard; `null` when it holds none. */
6708
+ /**
6709
+ * Plain text on the clipboard; `null` when it holds none.
6710
+ *
6711
+ * @capability clipboard
6712
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6713
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6714
+ * @throws `failed` (rejects) The system refused (Windows).
6715
+ * @throws `capability` (rejects) The manifest does not declare
6716
+ * `clipboard`.
6717
+ *
6718
+ * @example Open a URL the user copied
6719
+ * A "Paste and play" command opens the address on the clipboard. `readText`
6720
+ * resolves with the clipboard's text, or `null` when it holds none (an
6721
+ * image, nothing).
6722
+ * ```ts
6723
+ * const text = await plugin.clipboard.readText();
6724
+ * if (text?.startsWith("https://")) await plugin.player.open(text.trim());
6725
+ * ```
6726
+ *
6727
+ * @since 1.0.0
6728
+ */
5788
6729
  readText(): Promise<string | null>;
5789
- /** Replace the clipboard with text (at most 1 MiB of UTF-8). */
6730
+ /**
6731
+ * Replace the clipboard with text (at most 1 MiB of UTF-8).
6732
+ *
6733
+ * @capability clipboard
6734
+ * @throws `invalid` (rejects) More than 1 MiB of UTF-8.
6735
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6736
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6737
+ * @throws `failed` (rejects) The system refused.
6738
+ * @throws `capability` (rejects) The manifest does not declare
6739
+ * `clipboard`.
6740
+ *
6741
+ * @example Copy the playing file's path
6742
+ * The path of the playing file is put on the clipboard, replacing what was
6743
+ * there. It resolves once the system has it.
6744
+ * ```ts
6745
+ * const { source } = plugin.player.state;
6746
+ * if (source) await plugin.clipboard.writeText(source);
6747
+ * ```
6748
+ *
6749
+ * @since 1.0.0
6750
+ */
5790
6751
  writeText(text: string): Promise<void>;
5791
- /** Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB). */
6752
+ /**
6753
+ * Replace the clipboard with a PNG or JPEG file (absolute path, at most 16 MB).
6754
+ *
6755
+ * @capability clipboard
6756
+ * @throws `missing` (rejects) The file does not exist.
6757
+ * @throws `invalid` (rejects) Not an absolute path, not PNG or JPEG, over
6758
+ * 16 MB, or (Windows) an image the system cannot decode.
6759
+ * @throws `unavailable` (rejects) The player runs without a user interface.
6760
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
6761
+ * @throws `failed` (rejects) The system refused.
6762
+ * @throws `capability` (rejects) The manifest does not declare
6763
+ * `clipboard`.
6764
+ *
6765
+ * @example Copy a snapshot to the clipboard
6766
+ * A frame the plugin saved as PNG is put on the clipboard, ready to paste
6767
+ * into another application. The player reads the file and writes the
6768
+ * image (macOS: its original bytes; Windows: a bitmap plus PNG).
6769
+ * ```ts
6770
+ * declare const pngPath: string;
6771
+ * // ---cut---
6772
+ * await plugin.clipboard.writeImage(pngPath); // absolute path, at most 16 MB
6773
+ * await plugin.osd.show("Snapshot copied");
6774
+ * ```
6775
+ *
6776
+ * @since 1.0.0
6777
+ */
5792
6778
  writeImage(path: string): Promise<void>;
5793
6779
  }
5794
- /** Keeping the computer awake (`playback.control`). */
6780
+ /**
6781
+ * Keeping the computer awake (`playback.control`).
6782
+ *
6783
+ * @remarks
6784
+ * A request, not a command: the player keeps the system awake while any
6785
+ * plugin asks for it and a source plays, and withdraws a plugin's request
6786
+ * when it stops.
6787
+ *
6788
+ * @capability playback.control
6789
+ *
6790
+ * @example Keep the display on while playing
6791
+ * The screen does not dim or sleep while a video plays, and may again when
6792
+ * it is paused or ends. The player takes the system's assertion only while
6793
+ * a source is ready and not paused.
6794
+ * ```ts
6795
+ * await plugin.power.preventSleep("playing"); // "off" withdraws the request
6796
+ * ```
6797
+ *
6798
+ * @since 1.0.0
6799
+ * @category Services
6800
+ */
5795
6801
  export interface Power {
5796
6802
  /**
5797
6803
  * `"playing"`: while a source is ready and not paused, keep the display
@@ -5802,10 +6808,32 @@ export interface Power {
5802
6808
  * take the system assertion are logged by the player, not reported.
5803
6809
  * macOS uses `caffeinate`, Windows the thread execution state; elsewhere
5804
6810
  * it has no effect.
6811
+ *
6812
+ * @capability playback.control
6813
+ * @throws `invalid` (rejects) A mode other than `"off"` or `"playing"`.
6814
+ * @throws `capability` (rejects) The manifest does not declare
6815
+ * `playback.control`.
6816
+ *
6817
+ * @example Follow the plugin's own "Keep awake" setting
6818
+ * The plugin keeps the display awake during playback only while its
6819
+ * setting is on. Each call replaces this plugin's request; `"off"`
6820
+ * withdraws it, and other plugins' requests still count.
6821
+ * ```ts
6822
+ * const store = plugin.storage.json("power", { defaults: { keepAwake: true } });
6823
+ * await plugin.power.preventSleep(store.value.keepAwake ? "playing" : "off");
6824
+ * ```
6825
+ *
6826
+ * @since 1.0.0
5805
6827
  */
5806
6828
  preventSleep(mode: "off" | "playing"): Promise<void>;
5807
6829
  }
5808
- /** The output of {@link Tools.run}. */
6830
+ /**
6831
+ * The output of {@link Tools.run}.
6832
+ *
6833
+ * @see {@link Tools.run}
6834
+ * @since 1.0.0
6835
+ * @category Services
6836
+ */
5809
6837
  export interface ToolResult {
5810
6838
  /** Standard output (at most `maxOutput` bytes). */
5811
6839
  stdout: Uint8Array;
@@ -5835,11 +6863,23 @@ export interface ToolResult {
5835
6863
  * void info.format; // container, duration, bit rate…
5836
6864
  * }
5837
6865
  * ```
6866
+ *
6867
+ * @remarks
6868
+ * ffmpeg and ffprobe run as child processes of the plugin. When
6869
+ * `VESTRA_FFMPEG` / `VESTRA_FFPROBE` is set, only that path is used (an
6870
+ * invalid one means the tool is not found); otherwise the tool is searched
6871
+ * on `PATH`. A found path is kept for the plugin's run, a miss is looked up
6872
+ * again next time. Disposing the
6873
+ * scope ends a running process.
6874
+ *
6875
+ * @capability None.
6876
+ * @since 1.0.0
6877
+ * @category Services
5838
6878
  */
5839
6879
  export interface Tools {
5840
6880
  /**
5841
- * Run ffmpeg or ffprobe (found through `VESTRA_FFMPEG` / `VESTRA_FFPROBE`,
5842
- * then `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
6881
+ * Run ffmpeg or ffprobe (`VESTRA_FFMPEG` / `VESTRA_FFPROBE` when set, else
6882
+ * found on `PATH`) without a shell. Rejects `unavailable` (not found), `timeout`,
5843
6883
  * `failed` (non-zero exit; `message` is the last stderr line), `invalid`
5844
6884
  * (output over `maxOutput`, default 16 MiB), `aborted` (`signal`). Timeout,
5845
6885
  * `signal` and disposing the scope end the process.
@@ -5847,28 +6887,171 @@ export interface Tools {
5847
6887
  * Arguments are passed as-is (no quoting needed). On Windows `PATHEXT` is
5848
6888
  * honoured. Ending sends SIGTERM, then SIGKILL after 300 ms (immediate on
5849
6889
  * Windows).
6890
+ *
6891
+ * @capability None.
6892
+ * @throws `unavailable` (rejects) The tool was not found.
6893
+ * @throws `timeout` (rejects) It ran longer than `timeoutMs`; the process is
6894
+ * ended.
6895
+ * @throws `failed` (rejects) A non-zero exit; `message` is the last stderr
6896
+ * line.
6897
+ * @throws `invalid` (rejects) Bad arguments or options, or output over
6898
+ * `maxOutput`.
6899
+ * @throws `aborted` (rejects) `signal` aborted, or the scope was disposed.
6900
+ *
6901
+ * @example Cut a clip without re-encoding
6902
+ * The plugin writes the first ten seconds of the file to a new file. `run`
6903
+ * starts ffmpeg without a shell (arguments pass as they are, no quoting)
6904
+ * and resolves with its output once it exits with 0; a failure rejects
6905
+ * with the last line ffmpeg printed.
6906
+ * ```ts
6907
+ * declare const input: string;
6908
+ * declare const output: string;
6909
+ * // ---cut---
6910
+ * await plugin.tools.run(
6911
+ * "ffmpeg",
6912
+ * ["-y", "-i", input, "-t", "10", "-c", "copy", output],
6913
+ * { timeoutMs: 60_000 }, // give up after a minute
6914
+ * );
6915
+ * ```
6916
+ *
6917
+ * @since 1.0.0
5850
6918
  */
5851
6919
  run(tool: "ffmpeg" | "ffprobe", args: string[], options: {
5852
6920
  timeoutMs: number;
5853
6921
  maxOutput?: number;
5854
6922
  signal?: AbortSignal;
5855
6923
  }): Promise<ToolResult>;
5856
- /** Whether the tool can be found. */
6924
+ /**
6925
+ * Whether the tool can be found.
6926
+ *
6927
+ * @capability None.
6928
+ * @throws Never; it resolves `false` when the tool cannot be found.
6929
+ *
6930
+ * @example Grey out a command when ffmpeg is missing
6931
+ * The Export item is disabled with a reason when ffmpeg is not installed.
6932
+ * `available` looks the tool up (a found path is kept, a miss is looked
6933
+ * up again on the next call, so installing it later is noticed).
6934
+ * ```ts
6935
+ * const hasFfmpeg = await plugin.tools.available("ffmpeg");
6936
+ * plugin.commands.add({
6937
+ * id: "export",
6938
+ * path: ["@video", "Export…"],
6939
+ * enabled: hasFfmpeg,
6940
+ * // Shown with the greyed-out item.
6941
+ * reason: hasFfmpeg ? null : "Install ffmpeg to export",
6942
+ * run() {},
6943
+ * });
6944
+ * ```
6945
+ *
6946
+ * @since 1.0.0
6947
+ */
5857
6948
  available(tool: "ffmpeg" | "ffprobe"): Promise<boolean>;
5858
6949
  }
5859
- /** A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced. */
6950
+ /**
6951
+ * A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
6952
+ *
6953
+ * @remarks
6954
+ * One JSON file, `<dataDirectory>/<name>.json`, made by
6955
+ * {@link Storage.json}. `value` is read once when the store is made;
6956
+ * `save` replaces it in memory at once and writes the file `debounceMs`
6957
+ * later (300 by default) through a temporary file and a rename, so a
6958
+ * crash never leaves half a file. Writes to one file run one at a time,
6959
+ * newest last. Disposing the store, its scope or stopping the plugin
6960
+ * writes a pending save first.
6961
+ *
6962
+ * @capability None.
6963
+ *
6964
+ * @example Count how often the plugin was started
6965
+ * The counter grows by one at every start and is kept in `stats.json`. The
6966
+ * store reads the file when it is made (the defaults when there is none),
6967
+ * and `save` writes the new count a moment later.
6968
+ * ```ts
6969
+ * const stats = plugin.storage.json("stats", { defaults: { starts: 0 } });
6970
+ * stats.save({ starts: stats.value.starts + 1 }); // written 300 ms later
6971
+ * ```
6972
+ *
6973
+ * @since 1.0.0
6974
+ * @category Services
6975
+ */
5860
6976
  export interface JsonStore<T> extends Disposable {
5861
6977
  /** The loaded value, or `defaults` when the file was missing or broken. */
5862
6978
  readonly value: T;
5863
6979
  /**
5864
6980
  * Replace the value now and write it after `debounceMs`.
5865
6981
  * Only `save` writes: mutating `value` in place is not saved.
6982
+ *
6983
+ * @capability None.
6984
+ *
6985
+ * @example Save a slider value without writing on every move
6986
+ * Dragging the plugin's volume slider updates the stored value at once but
6987
+ * writes the file only once the user stops for 300 ms. Each `save`
6988
+ * replaces the value and restarts the debounce timer.
6989
+ * ```ts
6990
+ * declare const window: Window;
6991
+ * // ---cut---
6992
+ * const store = plugin.storage.json("prefs", { defaults: { volume: 100 } });
6993
+ * window.on("volumeChanged", (volume: number) => store.save({ ...store.value, volume }));
6994
+ * ```
6995
+ *
6996
+ * @since 1.0.0
5866
6997
  */
5867
6998
  save(next: T): void;
5868
- /** Write a pending save now. */
6999
+ /**
7000
+ * Write a pending save now.
7001
+ *
7002
+ * @capability None.
7003
+ * @throws `Error` (rejects) Node's file system error when the file cannot
7004
+ * be written; its `code` is Node's (`ENOSPC`, `EACCES` and so on), not a
7005
+ * player error code.
7006
+ *
7007
+ * @example Write the file now before copying it
7008
+ * Before exporting its settings, the plugin makes sure the file on disk has
7009
+ * the latest value. `flush` writes a pending save at once (or waits for
7010
+ * the write in progress) and resolves when the file is written.
7011
+ * ```ts
7012
+ * import { copyFile } from "node:fs/promises";
7013
+ * import { join } from "node:path";
7014
+ * declare const exportPath: string;
7015
+ * // ---cut---
7016
+ * const store = plugin.storage.json("settings", { defaults: { step: 5 } });
7017
+ * await store.flush(); // the file now has the newest value
7018
+ * await copyFile(join(plugin.dataDirectory, "settings.json"), exportPath);
7019
+ * ```
7020
+ *
7021
+ * @since 1.0.0
7022
+ */
5869
7023
  flush(): Promise<void>;
5870
7024
  }
5871
- /** `plugin.storage`: JSON settings files. No capability. */
7025
+ /**
7026
+ * `plugin.storage`: JSON settings files. No capability.
7027
+ *
7028
+ * @remarks
7029
+ * Two kinds of storage, both private to the plugin and needing no
7030
+ * capability. {@link Storage.json} keeps settings in JSON files in the
7031
+ * plugin's data directory: they last across player restarts and upgrades,
7032
+ * and are written by the plugin's own process. {@link Storage.session}
7033
+ * keeps values in the player's memory for one player run: they survive a
7034
+ * restart of the plugin's process but not of the player. Secrets belong in
7035
+ * {@link Secrets}, never in either.
7036
+ *
7037
+ * @capability None.
7038
+ *
7039
+ * @example Keep a setting across restarts and a queue for this run
7040
+ * The plugin's step size survives a restart of the player; its work queue
7041
+ * survives a reload of the plugin but starts empty with the next player
7042
+ * run. The JSON store reads its file at once and writes it shortly after
7043
+ * each `save`; session values live in the player process.
7044
+ * ```ts
7045
+ * const settings = plugin.storage.json("settings", { defaults: { step: 5 } });
7046
+ * const queue = (await plugin.storage.session.get<string[]>("queue")) ?? [];
7047
+ * void settings.value.step; // read synchronously, from the file or the defaults
7048
+ * void queue;
7049
+ * ```
7050
+ *
7051
+ * @see [Store a plugin's data](../../docs/how-to/store-data.md), [Remember a window's state between runs](../../docs/how-to/window-state.md)
7052
+ * @since 1.0.0
7053
+ * @category Services
7054
+ */
5872
7055
  export interface Storage {
5873
7056
  /**
5874
7057
  * Open `<name>.json` (`name`: 1–64 of `a-z 0-9 -`). A file that cannot be
@@ -5897,6 +7080,11 @@ export interface Storage {
5897
7080
  * // Only `save` writes: changing `store.value` in place is not saved.
5898
7081
  * store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
5899
7082
  * ```
7083
+ *
7084
+ * @capability None.
7085
+ * @throws `invalid` (throws) A bad `name`, or a `debounceMs` that is not a
7086
+ * non-negative number.
7087
+ * @since 1.0.0
5900
7088
  */
5901
7089
  json<T>(name: string, options: {
5902
7090
  defaults: T;
@@ -5970,19 +7158,97 @@ export interface Storage {
5970
7158
  * ```
5971
7159
  *
5972
7160
  * @since 1.5.25
7161
+ *
7162
+ * @remarks
7163
+ * The player keeps the values in its own memory, per plugin, until it
7164
+ * quits; the plugin's process may restart in between. Each call is one
7165
+ * request to the player, answered in order, with no debounce.
7166
+ *
7167
+ * @capability None.
7168
+ * @category Services
5973
7169
  */
5974
7170
  export interface SessionStorage {
5975
- /** The value stored under `key`, or `undefined` when there is none. */
7171
+ /**
7172
+ * The value stored under `key`, or `undefined` when there is none.
7173
+ *
7174
+ * @capability None.
7175
+ * @throws `invalid` (rejects) A bad key (empty, over 1024 bytes, with NUL
7176
+ * or a lone surrogate).
7177
+ * @throws `disabled` (rejects) The plugin is stopping.
7178
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7179
+ *
7180
+ * @example Pick up where the plugin stopped after a reload
7181
+ * After a reload the plugin resumes the item it was working on. `get`
7182
+ * asks the player and resolves with the value parsed from JSON, or
7183
+ * `undefined` when nothing is stored; the value is not checked, so its
7184
+ * shape is checked here.
7185
+ * ```ts
7186
+ * const current = await plugin.storage.session.get<{ url: string }>("current");
7187
+ * if (typeof current?.url === "string") await plugin.osd.show("Resuming {}", [current.url]);
7188
+ * ```
7189
+ *
7190
+ * @since 1.5.25
7191
+ */
5976
7192
  get<T = unknown>(key: string): Promise<T | undefined>;
5977
7193
  /**
5978
7194
  * Store or replace `value` under `key`. `invalid` for a bad key or a value
5979
7195
  * that is not JSON, `limit` when the plugin's values would be over 1 MiB
5980
7196
  * or the request over the 1 MiB message limit.
7197
+ *
7198
+ * @capability None.
7199
+ * @throws `invalid` (rejects) A bad key, or a value JSON cannot hold (or a
7200
+ * string with a lone surrogate).
7201
+ * @throws `disabled` (rejects) The plugin is stopping.
7202
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7203
+ * @throws `limit` (rejects) The plugin's values would pass 1 MiB, or the
7204
+ * request the 1 MiB message limit; nothing changes.
7205
+ *
7206
+ * @example Remember the item being worked on
7207
+ * The plugin records the URL it is processing, so a reload can continue
7208
+ * with it. The value is stored as `JSON.stringify` writes it, and is kept
7209
+ * once the promise resolves.
7210
+ * ```ts
7211
+ * await plugin.storage.session.set("current", { url: "https://example.com/a.mkv" });
7212
+ * ```
7213
+ *
7214
+ * @since 1.5.25
5981
7215
  */
5982
7216
  set(key: string, value: unknown): Promise<void>;
5983
- /** Remove a key; removing a missing one is fine. */
7217
+ /**
7218
+ * Remove a key; removing a missing one is fine.
7219
+ *
7220
+ * @capability None.
7221
+ * @throws `invalid` (rejects) A bad key.
7222
+ * @throws `disabled` (rejects) The plugin is stopping.
7223
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7224
+ *
7225
+ * @example Forget the item once it is done
7226
+ * When the work finishes, the plugin removes the entry so that a later
7227
+ * reload does not resume it. Removing a key that is not there is fine.
7228
+ * ```ts
7229
+ * await plugin.storage.session.delete("current");
7230
+ * ```
7231
+ *
7232
+ * @since 1.5.25
7233
+ */
5984
7234
  delete(key: string): Promise<void>;
5985
- /** The keys stored, in code point order. */
7235
+ /**
7236
+ * The keys stored, in code point order.
7237
+ *
7238
+ * @capability None.
7239
+ * @throws `disabled` (rejects) The plugin is stopping.
7240
+ * @throws `unsupported` (rejects) A player before 0.5.5.
7241
+ *
7242
+ * @example Clear everything the plugin stored for this run
7243
+ * The plugin starts its session state over. `keys` lists the plugin's own
7244
+ * keys in code point order; each `delete` is a request of its own.
7245
+ * ```ts
7246
+ * const session = plugin.storage.session;
7247
+ * for (const key of await session.keys()) await session.delete(key);
7248
+ * ```
7249
+ *
7250
+ * @since 1.5.25
7251
+ */
5986
7252
  keys(): Promise<string[]>;
5987
7253
  }
5988
7254
  /**
@@ -6005,6 +7271,15 @@ export interface SessionStorage {
6005
7271
  * await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
6006
7272
  * }
6007
7273
  * ```
7274
+ *
7275
+ * @remarks
7276
+ * The player opens files, folders and links with the system's own
7277
+ * applications for the plugin, so the plugin never runs `open`,
7278
+ * `explorer` or a shell itself.
7279
+ *
7280
+ * @capability None.
7281
+ * @since 1.0.0
7282
+ * @category Services
6008
7283
  */
6009
7284
  export interface System {
6010
7285
  /**
@@ -6012,13 +7287,66 @@ export interface System {
6012
7287
  * for all, so several files of one folder are all highlighted; Windows:
6013
7288
  * one Explorer window per folder, selecting its first path). Paths that do
6014
7289
  * not exist are skipped; `missing` when none exists; at most 256.
7290
+ *
7291
+ * @capability None.
7292
+ * @throws `missing` (rejects) None of the paths exists.
7293
+ * @throws `invalid` (rejects) No paths, or more than 256.
7294
+ * @throws `failed` (rejects) The system refused.
7295
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
7296
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7297
+ *
7298
+ * @example Show the exported files in the file manager
7299
+ * After an export, the file manager opens with the new files selected.
7300
+ * Paths that no longer exist are skipped; the call rejects with `missing`
7301
+ * only when none of them exists.
7302
+ * ```ts
7303
+ * declare const exported: string[];
7304
+ * // ---cut---
7305
+ * await plugin.system.reveal(exported); // macOS: one Finder window for all
7306
+ * ```
7307
+ *
7308
+ * @since 1.0.0
6015
7309
  */
6016
7310
  reveal(paths: string | string[]): Promise<void>;
6017
- /** Open the file or folder with its default application. `missing` if absent. */
7311
+ /**
7312
+ * Open the file or folder with its default application. `missing` if absent.
7313
+ *
7314
+ * @capability None.
7315
+ * @throws `missing` (rejects) The path does not exist.
7316
+ * @throws `invalid` (rejects) An empty path.
7317
+ * @throws `failed` (rejects) The system refused.
7318
+ * @throws `timeout` (rejects) macOS did not answer within 10 s.
7319
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7320
+ *
7321
+ * @example Open the plugin's data folder
7322
+ * A "Show data folder" command opens the plugin's data directory in the
7323
+ * file manager, which is the default application for a folder.
7324
+ * ```ts
7325
+ * await plugin.system.openPath(plugin.dataDirectory);
7326
+ * ```
7327
+ *
7328
+ * @since 1.0.0
7329
+ */
6018
7330
  openPath(path: string): Promise<void>;
6019
7331
  /**
6020
7332
  * Open an http(s) address in the default browser. `invalid` for another
6021
7333
  * protocol or a malformed address; `timeout` after 10 s, `failed` otherwise.
7334
+ *
7335
+ * @capability None.
7336
+ * @throws `invalid` (rejects) Not an `http:` or `https:` address.
7337
+ * @throws `timeout` (rejects) macOS did not hand it to the browser within
7338
+ * 10 s.
7339
+ * @throws `failed` (rejects) The system refused.
7340
+ * @throws `unsupported` (rejects) A system other than macOS and Windows.
7341
+ *
7342
+ * @example Open the plugin's help page
7343
+ * A Help command opens the plugin's documentation in the default browser.
7344
+ * Only `http:` and `https:` addresses are opened.
7345
+ * ```ts
7346
+ * await plugin.system.openUrl("https://example.com/plugin/help");
7347
+ * ```
7348
+ *
7349
+ * @since 1.0.0
6022
7350
  */
6023
7351
  openUrl(url: string): Promise<void>;
6024
7352
  }
@@ -6026,7 +7354,8 @@ export interface System {
6026
7354
  * Per-plugin secrets (API keys, passwords) in the system's secret store:
6027
7355
  * the login keychain on macOS, the Credential Manager on Windows. Needs
6028
7356
  * `secrets`. `unsupported` on systems without a store, `unavailable` when
6029
- * the store refuses access: keep a fallback (or ask again).
7357
+ * the store cannot be reached (no keychain, a read-only store, no logged-in
7358
+ * session on Windows): keep a fallback (or ask again).
6030
7359
  *
6031
7360
  * The user denying access and other system errors reject `failed`. Never
6032
7361
  * write secrets to settings files.
@@ -6052,16 +7381,96 @@ export interface System {
6052
7381
  * await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
6053
7382
  * }
6054
7383
  * ```
7384
+ *
7385
+ * @remarks
7386
+ * Values are stored by the player in the system's secret store under this
7387
+ * plugin's id: other plugins cannot read them. Names are 1–64 of
7388
+ * `a-z 0-9 . _ -`, values 1–1024 bytes of text, at most 256 names per
7389
+ * plugin (`SECRET_LIMITS`).
7390
+ *
7391
+ * @capability secrets
7392
+ * @since 1.0.0
7393
+ * @category Services
6055
7394
  */
6056
7395
  export interface Secrets {
6057
- /** The stored value, `null` when there is none. */
7396
+ /**
7397
+ * The stored value, `null` when there is none.
7398
+ *
7399
+ * @capability secrets
7400
+ * @throws `unsupported` (rejects) The system has no secret store.
7401
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7402
+ * a read-only store, no logged-in session on Windows).
7403
+ * @throws `failed` (rejects) The user denied access, or another system
7404
+ * error.
7405
+ * @throws `capability` (rejects) The manifest does not declare
7406
+ * `secrets`.
7407
+ * @throws `invalid` (rejects) A bad name.
7408
+ *
7409
+ * @example Read a stored token
7410
+ * The plugin uses the token the user saved earlier, or asks for one when
7411
+ * there is none. `get` resolves with the stored text or `null`.
7412
+ * ```ts
7413
+ * const token = await plugin.secrets.get("api-token");
7414
+ * if (token === null) await plugin.osd.show("Sign in first");
7415
+ * ```
7416
+ *
7417
+ * @since 1.0.0
7418
+ */
6058
7419
  get(name: string): Promise<string | null>;
6059
7420
  /**
6060
7421
  * Store or replace (`name`: 1–64 of `a-z 0-9 . _ -`; value 1–1024 bytes).
6061
7422
  * At most 256 different names per plugin: a new name beyond that rejects
6062
7423
  * `limit`. Uninstalling with `removeData` deletes them.
7424
+ *
7425
+ * @capability secrets
7426
+ * @throws `invalid` (rejects) A bad name, or a value that is empty, has NUL
7427
+ * or is over 1024 bytes.
7428
+ * @throws `limit` (rejects) A new name beyond 256.
7429
+ * @throws `unsupported` (rejects) The system has no secret store.
7430
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7431
+ * a read-only store, no logged-in session on Windows).
7432
+ * @throws `failed` (rejects) The user denied access, or another system
7433
+ * error.
7434
+ * @throws `capability` (rejects) The manifest does not declare
7435
+ * `secrets`.
7436
+ *
7437
+ * @example Save the token the user entered
7438
+ * The token the user typed is kept in the system's secret store for later
7439
+ * runs. `set` stores or replaces it; the store can refuse, in which case
7440
+ * the plugin keeps the token for this run only.
7441
+ * ```ts
7442
+ * declare const token: string;
7443
+ * // ---cut---
7444
+ * await plugin.secrets.set("api-token", token).catch((error: PluginError) => {
7445
+ * // No store, or access denied: the token is not kept for next time.
7446
+ * void plugin.osd.show("The token could not be saved ({})", [error.code]);
7447
+ * });
7448
+ * ```
7449
+ *
7450
+ * @since 1.0.0
6063
7451
  */
6064
7452
  set(name: string, value: string): Promise<void>;
6065
- /** Remove; removing a missing secret is fine. */
7453
+ /**
7454
+ * Remove; removing a missing secret is fine.
7455
+ *
7456
+ * @capability secrets
7457
+ * @throws `invalid` (rejects) A bad name.
7458
+ * @throws `unsupported` (rejects) The system has no secret store.
7459
+ * @throws `unavailable` (rejects) The store cannot be reached (no keychain,
7460
+ * a read-only store, no logged-in session on Windows).
7461
+ * @throws `failed` (rejects) The user denied access, or another system
7462
+ * error.
7463
+ * @throws `capability` (rejects) The manifest does not declare
7464
+ * `secrets`.
7465
+ *
7466
+ * @example Sign out by removing the token
7467
+ * Signing out removes the stored token, so the next start asks again.
7468
+ * Removing a name that is not stored is fine.
7469
+ * ```ts
7470
+ * await plugin.secrets.delete("api-token");
7471
+ * ```
7472
+ *
7473
+ * @since 1.0.0
7474
+ */
6066
7475
  delete(name: string): Promise<void>;
6067
7476
  }