@vestra-player/sdk 1.5.33 → 1.5.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.33: full reference
1
+ # @vestra-player/sdk 1.5.35: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
@@ -740,7 +740,7 @@ plugin.video.view() returns the VideoView: aspect override, fit (keep with bars,
740
740
 
741
741
  plugin.video.render() returns the RenderOptions of the GPU renderer: HDR to SDR tone mapping and the display's brightness, user shaders (`.glsl` files) and motion interpolation. Like the display layer they act at display time only, have one owner (independent of the display layer's owner) and merge writes.
742
742
 
743
- - **Only the GPU renderer has them.** Under software rendering every `set` fails with `unsupported`; see Reference: platforms for where the player renders on the GPU. Treat `unsupported` as "hide these settings", not as an error.
743
+ - **Only the GPU renderer has them.** Under software rendering every `set` fails with `unsupported`; see Reference: platforms for where the player renders on the GPU. Treat `unsupported` as "hide these settings", not as an error. To know before creating the handle, read rendering from `plugin.app.system()` (player 0.5.12, no capability): `"gpu"` or `"software"`.
744
744
  - A shader that does not compile is reported later: the status first becomes `applied`, then `failed` with code `failed` and the compiler's first error line once mpv first uses the shader.
745
745
 
746
746
  ## Concept: Windows, panels, settings pages, dialogs and the OSD
@@ -1272,7 +1272,7 @@ if (english)
1272
1272
 
1273
1273
  #### Upload a file
1274
1274
 
1275
- `load` takes the file's bytes: UTF-8 SRT, or ASS/SSA with `format: "ass"`, at most 2 MiB (SRT up to 10,000 cues, ASS up to 20,000 `Dialogue:` lines). Convert other formats (WebVTT, SAMI) to SRT first. The player adds an `external` track to the current source only, selects it unless `select: "none"`, and resolves with the track once the upload is committed.
1275
+ `load` takes the file's bytes: UTF-8 SRT, or ASS/SSA with `format: "ass"`, at most 2 MiB (SRT up to 10,000 cues, ASS up to 20,000 `Dialogue:` lines). Convert other formats (WebVTT, SAMI) to SRT first. The player adds an `external` track to the current source only, selects it unless `select: "none"`, and resolves with the track once the upload is committed. A plugin has at most 4 such tracks on a source at once and loads at most 16 files into it (removed ones count); past that `load` fails with `busy` (Limits).
1276
1276
 
1277
1277
  ```ts
1278
1278
  import { readFile } from "node:fs/promises";
@@ -1613,6 +1613,127 @@ export default async function activate(plugin: Plugin) {
1613
1613
  - **Preferences rather than state:** a choice such as "reopen my windows at startup" belongs on a settings page (plugin.settings.page) that reads and writes the same store (the `SettingsPage` reference has an example).
1614
1614
  - **Large or binary data:** write your own files in plugin.dataDirectory (to a temporary name, then rename) and keep only small settings in `storage.json`.
1615
1615
 
1616
+ ## How-to: Offer a GPU rendering switch
1617
+
1618
+ **Goal:** a settings menu item "GPU Rendering (Experimental)" that shows whether the player will draw on the GPU, is greyed out with the player's reason where the GPU cannot be chosen, saves the user's choice, says "(Restart to Apply)" while the choice waits for a restart, and offers that restart.
1619
+
1620
+ The player keeps the choice itself, like a browser's hardware acceleration switch: setPreference saves it in the player's settings at once, the running player keeps its renderer, and the next start uses the choice **whether or not the user restarts now**. Your plugin stores nothing. Reading the state needs no capability; saving a choice and restarting need `rendering.manage`, which needs player 0.5.13.
1621
+
1622
+ #### The complete example
1623
+
1624
+ `manifest.json`:
1625
+
1626
+ ```json
1627
+ {
1628
+ "id": "examples.gpu-switch",
1629
+ "version": "1.0.0",
1630
+ "apiVersion": "vestra/1",
1631
+ "entry": "main.ts",
1632
+ "minPlayerVersion": "0.5.13",
1633
+ "capabilities": ["commands", "rendering.manage"]
1634
+ }
1635
+ ```
1636
+
1637
+ `main.ts`:
1638
+
1639
+ ```ts
1640
+ import type { Plugin, PluginError, RenderingState, Scope } from "@vestra-player/sdk";
1641
+
1642
+ const TITLE = "GPU Rendering (Experimental)";
1643
+
1644
+ export default async function activate(plugin: Plugin) {
1645
+ let state: RenderingState = await plugin.rendering.state();
1646
+ let menu: Scope | null = null;
1647
+ let shownPending: boolean | null = null;
1648
+
1649
+ // A command's title cannot change in place, so the items live in a child
1650
+ // scope that is rebuilt when `restartPending` flips.
1651
+ async function build() {
1652
+ if (state.restartPending === shownPending) return;
1653
+ shownPending = state.restartPending;
1654
+ await menu?.dispose();
1655
+ menu = plugin.scope();
1656
+ menu.commands.add({
1657
+ id: "gpu-rendering",
1658
+ path: ["@settings", state.restartPending ? `${TITLE} (Restart to Apply)` : TITLE],
1659
+ // Checked when the next start draws on the GPU (also for "auto").
1660
+ checked: () => state.nextMode === "gpu",
1661
+ // Greyed out with the player's reason where "gpu" cannot be chosen.
1662
+ enabled: () => state.gpu.available,
1663
+ reason: () => state.gpu.reason?.message ?? null,
1664
+ run: async () => {
1665
+ const choice = state.nextMode === "gpu" ? "software" : "gpu";
1666
+ try {
1667
+ state = await plugin.rendering.setPreference(choice);
1668
+ } catch (error) {
1669
+ // `unavailable`: the GPU was ruled out after the state was read.
1670
+ state = await plugin.rendering.state(); // the item now says why
1671
+ if ((error as PluginError).code !== "unavailable") throw error; // `failed`: not saved
1672
+ } finally {
1673
+ await build();
1674
+ }
1675
+ },
1676
+ });
1677
+ if (state.restartPending)
1678
+ menu.commands.add({
1679
+ id: "restart",
1680
+ path: ["@settings", "Restart Player to Apply"],
1681
+ run: async () => {
1682
+ // Resolves once the player accepted; it then quits and starts again.
1683
+ await plugin.rendering.restartPlayer();
1684
+ },
1685
+ });
1686
+ }
1687
+
1688
+ await build();
1689
+ }
1690
+ ```
1691
+
1692
+ #### How it works
1693
+
1694
+ - **`state()` describes now and the next start.** `mode` and `reason` are the renderer drawing now and why; `preference` is the saved choice (`"auto"`, `"gpu"` or `"software"`); `nextMode` and `nextReason` are what the next start will use as far as the player knows, after the environment, the driver denylist and an earlier GPU crash. The item is checked by `nextMode`, so with `"auto"` on a system where the GPU is the default it shows checked.
1695
+ - **`gpu.available` decides whether the item can be used.** Where this player has no GPU renderer (`no-gpu-backend`, Windows today), without a user interface (`headless`), for a graphics driver on the player's denylist (`denylist`) or after a GPU crash at an earlier start (`previous-crash`), it is `false` and `gpu.reason.message` explains why; the item shows that as its disabled reason. `"software"` and `"auto"` are always accepted, so a user can always leave the GPU.
1696
+ - **`restartPending`** is true while the next start's mode differs from the mode this run started with. When the user changes the choice back, it turns false again and the "(Restart to Apply)" title and the restart item go away.
1697
+ - **`restartPlayer()`** resolves once the player has accepted. The player then quits normally, which stops every plugin, yours included, and starts again with the same plugins; a local file continues at its position, paused or playing as it was (a stream is not reopened). It rejects with `busy` while a restart is under way and with `failed` when the new process could not start (the player keeps running).
1698
+
1699
+ The restart is only a shortcut. A user who ignores it gets the new renderer the next time they start the player.
1700
+
1701
+ #### Older players
1702
+
1703
+ A player before 0.5.13 does not load a plugin that declares `rendering.manage`, so the manifest above sets `minPlayerVersion`. A plugin that only reads the state, without the capability, gets `unsupported` from `state()` on an older player:
1704
+
1705
+ ```ts
1706
+ const state = await plugin.rendering.state().catch((error: PluginError) => {
1707
+ if (error.code === "unsupported") return null; // a player before 0.5.13
1708
+ throw error;
1709
+ });
1710
+ ```
1711
+
1712
+ To know only whether render options work now, read rendering from `plugin.app.system()` instead; `renderingReason` there is the same reason as `state().reason`.
1713
+
1714
+ #### Test it
1715
+
1716
+ TestHost.rendering models the player: preset `gpu`, `reason` and `startedWith` before the plugin starts (`videoRender.gpu` is the mode drawing now), then drive the item and check the saved choice.
1717
+
1718
+ ```ts
1719
+ import assert from "node:assert/strict";
1720
+ import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1721
+ import activate from "../main.ts";
1722
+
1723
+ const host = new TestHost(); // a GPU player, "auto", started on the GPU
1724
+ const { stop } = await startPlugin(activate, {
1725
+ directory: new URL("..", import.meta.url),
1726
+ host,
1727
+ });
1728
+ await host.command("gpu-rendering").run(); // unchecks: saves "software"
1729
+ assert.equal(host.rendering.preference, "software");
1730
+ await host.command("restart").run(); // offered while the restart is pending
1731
+ assert.equal(host.rendering.restarts, 1);
1732
+ await stop();
1733
+ ```
1734
+
1735
+ Set `host.rendering.gpu = { available: false, reason: { code: "denylist", message: "…" } }` to see the item greyed out, and refuse a call with `host.handle("rendering.restart", () => { throw new HostError("failed", "…"); })`.
1736
+
1616
1737
  ## How-to: Translate a plugin
1617
1738
 
1618
1739
  Vestra Player (0.4.3 or later) shows its interface in one of seven languages. The user picks one in **Settings ▸ Language** (or follows the system); the choice takes effect at the next start, and the language stays the same for the whole run. A plugin joins in by shipping one gettext file per language. It writes **no translation code**: the player translates.
@@ -2044,7 +2165,7 @@ The test decides what the player is doing:
2044
2165
  - `host.openElsewhere(path, phase?)`: the user or another plugin opened a source (a new source id, then `phase`, `"ready"` by default). Calls the plugin made against the old state now fail with `stale`.
2045
2166
  - `host.setSource({ phase, position, duration, paused, audioTracks, … })`: the state changed (the user paused, playback reached the end). Before `startPlugin`, it presets the source the plugin finds at activation.
2046
2167
  - `host.setMainWindow({ fullscreen, videoArea })`, `host.setAppearance(theme)`, `host.setPackages(list)`: the main window, the colour scheme, the installed plugins changed.
2047
- - Preset data the player would return: `host.stats` (`player.stats()`), `host.chapters`, `host.clipboard.text`, `host.secrets`, `host.systemInfo`; `host.tools.handle("ffprobe", (args) => ({ stdout }))` makes a tool available and answers its runs.
2168
+ - Preset data the player would return: `host.stats` (`player.stats()`), `host.chapters`, `host.clipboard.text`, `host.secrets`, `host.systemInfo`, `host.rendering` (the GPU or software choice, see Offer a GPU rendering switch); `host.tools.handle("ffprobe", (args) => ({ stdout }))` makes a tool available and answers its runs.
2048
2169
 
2049
2170
  #### Inject failures
2050
2171
 
@@ -2567,6 +2688,8 @@ The table lists the API whose documentation names a player version, newest last:
2567
2688
  | 0.5.3 | Scrub.seek |
2568
2689
  | 0.5.5 | SessionStorage |
2569
2690
  | 0.5.10 | CommandButton, CommandOptions.button |
2691
+ | 0.5.12 | SystemInfo.rendering |
2692
+ | 0.5.13 | SystemInfo.renderingReason, RenderingMode, RenderingPreference, RenderingReasonCode, RenderingReason, RenderingState, Rendering |
2570
2693
 
2571
2694
  The Slint library carries no version tags: `Theme.danger` needs player 0.3.0; `@vestra/icons.slint` and the palette `vestra-light` need 0.4.0 (Theme).
2572
2695
 
@@ -2603,6 +2726,7 @@ A plugin lists what it may do in the manifest's `capabilities` (Manifest). A cal
2603
2726
  | `ui.settings` | Settings.page, Settings.show | — | Add pages to the Options window | |
2604
2727
  | `ui.theme` | Appearance.set, Appearance.setLanguage | — | Change the player's palette | |
2605
2728
  | `secrets` | Secrets.get, Secrets.set, Secrets.delete | — | Store sign-in details in the system keychain | yes |
2729
+ | `rendering.manage` | Rendering.setPreference, Rendering.restartPlayer | — | Choose GPU or software rendering and restart the player | yes |
2606
2730
 
2607
2731
  **Also needs** applies to every member of the row: declare those capabilities as well. A few members or options need one more, documented at the member:
2608
2732
 
@@ -2614,7 +2738,7 @@ A plugin lists what it may do in the manifest's `capabilities` (Manifest). A cal
2614
2738
 
2615
2739
  #### Without a capability
2616
2740
 
2617
- A member not in the table needs no capability: `plugin.tools`, `plugin.storage`, `plugin.system`, `plugin.app`, `plugin.ui` (images and Markdown), `plugin.i18n`, reading window.state, appearance.theme and packages.list. The table is `CAPABILITY_INFO`, which tests can import from `@vestra-player/sdk/testing` to check a manifest (CAPABILITY_INFO).
2741
+ A member not in the table needs no capability: `plugin.tools`, `plugin.storage`, `plugin.system`, `plugin.app`, `plugin.ui` (images and Markdown), `plugin.i18n`, reading window.state, appearance.theme, packages.list and rendering.state. The table is `CAPABILITY_INFO`, which tests can import from `@vestra-player/sdk/testing` to check a manifest (CAPABILITY_INFO).
2618
2742
 
2619
2743
  #### Catching a missing capability
2620
2744
 
@@ -2724,6 +2848,21 @@ plugin.storage.session (player 0.5.5). A key out of its rule, or a value that is
2724
2848
  | `SESSION_STORAGE_LIMITS.bytes` | 1048576 (1 MiB) | Bytes one plugin may hold at the same time: for every key, its UTF-8 bytes, its value written as compact JSON (`JSON.stringify(value)`) and `entryBytes`. |
2725
2849
  | `SESSION_STORAGE_LIMITS.entryBytes` | 128 | What each key costs on top of its text and value (128): the player's own bookkeeping, so that many tiny entries stay within `bytes` of memory. |
2726
2850
 
2851
+ #### Subtitle files
2852
+
2853
+ Subtitles.load uploads a file as a track of the current source. A file over `fileBytes` fails with `invalid`; every other limit fails with `busy` and nothing is loaded. The counts are per source, so another file starts from zero. Removing a track frees its place among the tracks and their bytes, but a load stays counted until the source changes.
2854
+
2855
+ | Limit | Value | Meaning |
2856
+ | --- | --- | --- |
2857
+ | `SUBTITLE_LIMITS.fileBytes` | 2097152 (2 MiB) | UTF-8 bytes of one file; a larger one fails with `invalid`. |
2858
+ | `SUBTITLE_LIMITS.tracksPerPlugin` | 4 | Tracks one plugin has loaded into the current source at the same time. |
2859
+ | `SUBTITLE_LIMITS.tracks` | 16 | Loaded tracks of every plugin in the current source at the same time. |
2860
+ | `SUBTITLE_LIMITS.totalBytes` | 16777216 (16 MiB) | Bytes of the loaded tracks of every plugin in the current source together. |
2861
+ | `SUBTITLE_LIMITS.loadsPerPlugin` | 16 | Loads by one plugin into the current source, removed tracks included. |
2862
+ | `SUBTITLE_LIMITS.loads` | 128 | Loads by every plugin into the current source, removed tracks included. |
2863
+ | `SUBTITLE_LIMITS.uploadsPerPlugin` | 2 | Loads of one plugin in progress at the same time. |
2864
+ | `SUBTITLE_LIMITS.uploads` | 8 | Loads of every plugin in progress at the same time. |
2865
+
2727
2866
  #### Messages
2728
2867
 
2729
2868
  Every request a plugin sends travels as one protocol line. The plugin's side refuses a longer one with code `limit` before sending it; property values meet their own limits first.
@@ -3244,10 +3383,11 @@ A plugin uses the same API on macOS and Windows; the player does the system-spec
3244
3383
  | DropModifiers | The pointer and ⌥ ⇧ ⌘ (`cmd` is ⌘). | The pointer and Alt, Shift, Ctrl (`cmd` is Ctrl). |
3245
3384
  | plugin.system (open, reveal) | Opened by the system; revealing several files selects all of them in one Finder window. | Opened by the shell; one Explorer window per folder. |
3246
3385
  | plugin.secrets | The login keychain. | The Credential Manager. |
3247
- | App.system() | Version, build, model and displays. | Version, build, model and displays. |
3386
+ | App.system() | Version, build, model and displays; `rendering` is `"gpu"`, or `"software"` after a fallback (a window that cannot show GPU frames). | Version, build, model and displays; `rendering` is `"software"`. |
3248
3387
  | Snapshots and exports (MediaCapture) | Available. | Available; needs FFmpeg 6.1 or newer. |
3249
3388
  | Generated streams (Player.openStream) | A Unix socket. | Loopback TCP with a token. |
3250
3389
  | Rendering | On the GPU (OpenGL), with hardware decoding. | In software, with hardware decoding copied back (`d3d11va-copy` and similar). |
3390
+ | plugin.rendering (player 0.5.13) | `"gpu"` can be chosen unless the driver is ruled out (`denylist`, `previous-crash`). | `gpu.available` is `false` (`no-gpu-backend`); `setPreference("gpu")` fails with `unsupported`. |
3251
3391
  | Render options (RenderOptions) | Available; `peakDetect` has no effect. | `unsupported` (software rendering). |
3252
3392
  | Bindings | `Ctrl` also matches ⌘; `Cmd` is ⌘. | `Cmd` is the Windows logo key; `Cmd+Q` does not quit. |
3253
3393
  | Keys taken first, standard close keys | See Keys the system takes first. | See Keys the system takes first. |
@@ -3786,6 +3926,8 @@ interface Scope extends Disposable {
3786
3926
  readonly window: MainWindow;
3787
3927
  /** The player's version and components, and the system it runs on. */
3788
3928
  readonly app: App;
3929
+ /** GPU or software rendering: read the choice, change it (`rendering.manage`). */
3930
+ readonly rendering: Rendering;
3789
3931
  /** Every command's bindings and the user's overrides (`bindings.manage`). */
3790
3932
  readonly bindings: Bindings;
3791
3933
  /** Runtime images for Slint `image` properties. */
@@ -7227,7 +7369,7 @@ worked out once, at the first call, and reused.
7227
7369
  ```ts
7228
7370
  interface App extends AppInfo {
7229
7371
  /**
7230
- * The operating system and the displays, read when called (displays come and go). No capability. CPU and memory: Node's `os` module.
7372
+ * The operating system, the displays and the renderer, read when called (displays come and go; the renderer can fall back from GPU to software once). No capability. CPU and memory: Node's `os` module.
7231
7373
  * @capability None.
7232
7374
  * @throws busy (rejects) The player could not start the request (no thread available).
7233
7375
  * @example Choose a size for the primary display
@@ -7276,6 +7418,63 @@ interface SystemInfo {
7276
7418
  readonly arch: string;
7277
7419
  /** Primary display first; empty without a user interface. */
7278
7420
  readonly displays: readonly DisplayInfo[];
7421
+ /**
7422
+ * Which renderer draws the video now: `"gpu"` (the GPU renderer, so
7423
+ * render options apply) or `"software"` (they
7424
+ * fail with `unsupported`). Read at each call. It changes at most once
7425
+ * while the player runs, from `"gpu"` to `"software"` when a window
7426
+ * cannot show GPU frames; the owner of the render options hears that
7427
+ * switch through RenderOptions.onStatus.
7428
+ *
7429
+ * Absent (`undefined`) on players before 0.5.12, which do not report it:
7430
+ * the renderer is then unknown, not software.
7431
+ *
7432
+ * Today the player renders on the GPU on macOS and in software on
7433
+ * Windows, Linux and without a user interface (see
7434
+ * Platforms); test this field
7435
+ * rather than SystemInfo.os.
7436
+ * @player 0.5.12; older players leave it out (declare `"minPlayerVersion": "0.5.12"` when the plugin relies on it).
7437
+ * @example Offer the HDR menu only where render options work
7438
+ * The plugin adds its HDR command only when the player renders on the
7439
+ * GPU, instead of creating the render options just to find out from a
7440
+ * failed status. `system()` asks the player, which reports the renderer
7441
+ * drawing at that moment; no capability is needed to ask. A player before
7442
+ * 0.5.12 leaves the field out, so the test is for `"gpu"` exactly (an
7443
+ * older player gets no HDR command).
7444
+ * ```ts
7445
+ * const { rendering } = await plugin.app.system();
7446
+ * if (rendering === "gpu") {
7447
+ * const render = plugin.video.render(); // one handle, kept
7448
+ * plugin.commands.add({
7449
+ * id: "hdr",
7450
+ * path: ["@video", "HDR Tone Mapping"],
7451
+ * run: () => render.set({ toneMapping: "bt.2390", targetPeak: 250 }),
7452
+ * });
7453
+ * }
7454
+ * ```
7455
+ */
7456
+ readonly rendering?: "gpu" | "software";
7457
+ /**
7458
+ * Why SystemInfo.rendering is what it is now: the same reason as
7459
+ * RenderingState.reason (`default`, `no-gpu-backend`,
7460
+ * `preference`, `denylist`, `window-fallback`…, see
7461
+ * RenderingReasonCode). Read at each call, like `rendering`.
7462
+ *
7463
+ * Absent (`undefined`) on players before 0.5.13.
7464
+ * @player 0.5.13; older players leave it out (declare `"minPlayerVersion": "0.5.13"` when the plugin relies on it).
7465
+ * @example Say why the video is drawn in software
7466
+ * An About page explains a software renderer with the player's own words,
7467
+ * for example that the graphics driver is on the player's denylist.
7468
+ * `system()` reports the renderer and its reason together, with no
7469
+ * capability; the message is English text that may name the adapter and
7470
+ * the driver version.
7471
+ * ```ts
7472
+ * const { rendering, renderingReason } = await plugin.app.system();
7473
+ * if (rendering === "software" && renderingReason)
7474
+ * console.log(`Rendering: software (${renderingReason.message})`);
7475
+ * ```
7476
+ */
7477
+ readonly renderingReason?: RenderingReason;
7279
7478
  }
7280
7479
  ```
7281
7480
 
@@ -9142,8 +9341,10 @@ independent handles.
9142
9341
 
9143
9342
  Platforms: only macOS renders on the GPU. Under software rendering
9144
9343
  (Windows, Linux, headless, fallback) every `set` fails with
9145
- `unsupported`. macOS OpenGL 4.1 has no compute shaders, so `peakDetect`
9146
- is accepted but has no effect; output is always SDR.
9344
+ `unsupported`; SystemInfo.rendering tells which renderer draws
9345
+ without creating the handle. macOS OpenGL 4.1 has no
9346
+ compute shaders, so `peakDetect` is accepted but has no effect; output
9347
+ is always SDR.
9147
9348
 
9148
9349
  GPU renderer options (tone mapping, peak detection, shaders,
9149
9350
  interpolation). Only the GPU renderer has them, which today means macOS;
@@ -9373,13 +9574,16 @@ interface Subtitles {
9373
9574
  *
9374
9575
  * Limits: SRT ≤ 2 MiB and ≤ 10,000 cues; ASS ≤ 2 MiB with `[Script Info]`
9375
9576
  * and `[Events]` and 1–20,000 `Dialogue:` lines. Convert other formats
9376
- * (VTT, SAMI) to SRT first.
9577
+ * (VTT, SAMI) to SRT first. Per source, a plugin has at most 4 loaded
9578
+ * tracks at once and loads at most 16 files (removed ones count), and
9579
+ * every plugin together at most 16 tracks; see `SUBTITLE_LIMITS` in
9580
+ * `@vestra-player/sdk/testing`.
9377
9581
  * @capability subtitles.control and subtitles.read
9378
9582
  * @throws invalid (rejects) A bad `format` or `title`, or a file over the limits.
9379
9583
  * @throws stale (rejects) With `ifCurrent`: the source changed since that state.
9380
9584
  * @throws unavailable (rejects) Nothing loaded, or the source is still loading.
9381
9585
  * @throws aborted (rejects) `signal` aborted before the commit (an `AbortError`); the upload is discarded.
9382
- * @throws busy (rejects) Too many subtitle operations at once.
9586
+ * @throws busy (rejects) Too many subtitle operations at once, or a limit of `SUBTITLE_LIMITS` is reached (4 tracks of this plugin, 16 loads of this plugin or 16 tracks in the source, or another of its limits); nothing is loaded.
9383
9587
  * @throws capability (rejects) The manifest does not declare `subtitles.control` and `subtitles.read`.
9384
9588
  * @example Load a downloaded SRT and select it
9385
9589
  * A subtitle file the plugin downloaded becomes a track of the current file
@@ -11311,6 +11515,298 @@ if (token === null) {
11311
11515
  }
11312
11516
  ```
11313
11517
 
11518
+ ### RenderingMode
11519
+
11520
+ Which renderer draws the video: `"gpu"` (the GPU renderer, where
11521
+ render options apply) or `"software"` (they fail
11522
+ with `unsupported`). The same values as SystemInfo.rendering.
11523
+
11524
+ - @player 0.5.13
11525
+
11526
+ ```ts
11527
+ type RenderingMode = "gpu" | "software";
11528
+ ```
11529
+
11530
+ Example: Tell the user which renderer draws the video
11531
+
11532
+ The plugin shows "Rendering: GPU" or "Rendering: software" on the
11533
+ video. `state()` asks the player; `mode` is the renderer drawing now,
11534
+ which can differ from the saved choice until the next start.
11535
+ ```ts
11536
+ const { mode } = await plugin.rendering.state();
11537
+ const label: Record<RenderingMode, string> = { gpu: "GPU", software: "software" };
11538
+ await plugin.osd.show("Rendering: {}", [label[mode]]);
11539
+ ```
11540
+
11541
+ ### RenderingPreference
11542
+
11543
+ The user's saved rendering choice: `"auto"` (the player decides: the GPU
11544
+ where this system has a GPU renderer that is not ruled out, software
11545
+ elsewhere), `"gpu"` or `"software"`. It applies from the next start.
11546
+
11547
+ - @player 0.5.13
11548
+
11549
+ ```ts
11550
+ type RenderingPreference = "auto" | "gpu" | "software";
11551
+ ```
11552
+
11553
+ Example: Describe the saved choice on a settings page
11554
+
11555
+ The plugin turns the saved choice into the words its settings page
11556
+ shows. `preference` is what the user chose, not what draws now:
11557
+ RenderingState.nextMode says what the next start will use.
11558
+ ```ts
11559
+ const { preference } = await plugin.rendering.state();
11560
+ // One word per choice; the Record type makes sure none is missing.
11561
+ const words: Record<RenderingPreference, string> = {
11562
+ auto: "Automatic",
11563
+ gpu: "GPU",
11564
+ software: "Software",
11565
+ };
11566
+ console.log(words[preference]);
11567
+ ```
11568
+
11569
+ ### RenderingReasonCode
11570
+
11571
+ Why the player renders the way it does, as a stable kebab-case code. A
11572
+ newer player may add codes: compare with the ones you handle and show
11573
+ RenderingReason.message for the rest. The codes of player 0.5.13:
11574
+
11575
+ | Code | Meaning |
11576
+ | --- | --- |
11577
+ | `default` | Nothing ruled the GPU out; the platform's default. |
11578
+ | `pending` | GPU rendering is starting. |
11579
+ | `headless` | The player runs without a user interface. |
11580
+ | `no-gpu-backend` | This player has no GPU renderer on this system (Windows today). |
11581
+ | `env` | The environment chose it (`VESTRA_RENDER`, `VESTRA_SOFTWARE_RENDER`). |
11582
+ | `preference` | The user chose software rendering (Rendering.setPreference); GPU chosen and available reports `default`. |
11583
+ | `denylist` | The graphics driver is on the player's denylist. |
11584
+ | `previous-crash` | GPU rendering crashed at an earlier start; it is tried again after a driver or player update, or after 7 days. |
11585
+ | `init-failed` | GPU rendering could not start. |
11586
+ | `window-fallback` | A window could not show GPU frames; the player switched to software during the run. |
11587
+
11588
+ - @player 0.5.13
11589
+
11590
+ ```ts
11591
+ type RenderingReasonCode = "default" | "pending" | "headless" | "no-gpu-backend" | "env" | "preference" | "denylist" | "previous-crash" | "init-failed" | "window-fallback" | (string & {});
11592
+ ```
11593
+
11594
+ Example: Explain a driver problem differently from a user choice
11595
+
11596
+ The plugin adds advice only when the graphics driver is the cause.
11597
+ `reason.code` is looked up among the codes the plugin knows; an unknown
11598
+ code from a newer player falls through to the player's own message.
11599
+ ```ts
11600
+ const { reason } = await plugin.rendering.state();
11601
+ // Advice for the codes this plugin knows; any other code shows the message.
11602
+ const advice: Partial<Record<RenderingReasonCode, string>> = {
11603
+ denylist: "Update the graphics driver to use the GPU.",
11604
+ "previous-crash": "The GPU renderer crashed earlier; it is retried after an update.",
11605
+ };
11606
+ console.log(advice[reason.code] ?? reason.message);
11607
+ ```
11608
+
11609
+ ### RenderingReason
11610
+
11611
+ Why the player renders, or will render, the way it does: a stable
11612
+ RenderingReasonCode to branch on and a sentence to show.
11613
+
11614
+ - @player 0.5.13
11615
+
11616
+ ```ts
11617
+ interface RenderingReason {
11618
+ /** The reason as a stable kebab-case code; branch on this. */
11619
+ readonly code: RenderingReasonCode;
11620
+ /** The reason as English text for people, for example with the graphics adapter's name and driver version; never branch on it. */
11621
+ readonly message: string;
11622
+ }
11623
+ ```
11624
+
11625
+ Example: Show why GPU rendering is off
11626
+
11627
+ The plugin logs the player's explanation when the video is drawn in
11628
+ software. `message` is the player's English text and may name the
11629
+ graphics adapter and driver version; `code` is for decisions.
11630
+ ```ts
11631
+ const { mode, reason } = await plugin.rendering.state();
11632
+ if (mode === "software") console.log(`${reason.code}: ${reason.message}`);
11633
+ ```
11634
+
11635
+ ### RenderingState
11636
+
11637
+ What Rendering.state and Rendering.setPreference report:
11638
+ the renderer drawing now and why, the saved choice, what the next start
11639
+ will use, and whether `"gpu"` can be chosen.
11640
+
11641
+ - @player 0.5.13
11642
+
11643
+ ```ts
11644
+ interface RenderingState {
11645
+ /** The renderer drawing the video now; the same as SystemInfo.rendering. */
11646
+ readonly mode: RenderingMode;
11647
+ /** Why RenderingState.mode is what it is now. */
11648
+ readonly reason: RenderingReason;
11649
+ /** The saved choice; it applies from the next start. */
11650
+ readonly preference: RenderingPreference;
11651
+ /** The renderer the next start will use, as far as the player knows now: from the saved choice, the environment, the driver denylist and an earlier GPU crash. */
11652
+ readonly nextMode: RenderingMode;
11653
+ /** Why the next start will use RenderingState.nextMode. */
11654
+ readonly nextReason: RenderingReason;
11655
+ /**
11656
+ * `true` while RenderingState.nextMode differs from the mode this run started with, so a restart would change the renderer; `false` again when the user changes the choice back.
11657
+ */
11658
+ readonly restartPending: boolean;
11659
+ /** Whether the user can choose `"gpu"` now. */
11660
+ readonly gpu: {
11661
+ /** `true` when `setPreference("gpu")` would be accepted. */
11662
+ readonly available: boolean;
11663
+ /** Why `"gpu"` cannot be chosen (`no-gpu-backend`, `headless`, `denylist`, `previous-crash`…); `null` while it is available. */
11664
+ readonly reason: RenderingReason | null;
11665
+ };
11666
+ }
11667
+ ```
11668
+
11669
+ Example: Label a menu item while a change waits for a restart
11670
+
11671
+ The plugin adds "(Restart to Apply)" to its GPU item after the user
11672
+ changed the choice. `restartPending` compares the mode the next start
11673
+ will use with the mode this run started with, so it turns false again
11674
+ when the user changes the choice back.
11675
+ ```ts
11676
+ const state = await plugin.rendering.state();
11677
+ const title = state.restartPending
11678
+ ? "GPU Rendering (Experimental) (Restart to Apply)"
11679
+ : "GPU Rendering (Experimental)";
11680
+ console.log(title, state.nextMode, state.nextReason.message);
11681
+ ```
11682
+
11683
+ ### Rendering
11684
+
11685
+ `plugin.rendering`: the player's rendering choice, GPU or software.
11686
+ Reading it needs no capability; changing the saved choice and restarting
11687
+ the player need `rendering.manage`.
11688
+
11689
+ The choice works like a browser's hardware acceleration switch: the
11690
+ player saves it in its own settings at once, and the running player
11691
+ keeps its renderer. The next start uses it, whether the user restarts
11692
+ now through Rendering.restartPlayer or quits and starts the
11693
+ player later. The player can still rule the GPU out at a start (an
11694
+ environment variable, a graphics driver on its denylist, a GPU crash at
11695
+ an earlier start); RenderingState.nextMode and
11696
+ RenderingState.nextReason say what the next start will do.
11697
+
11698
+ The renderer is a choice for the whole player and a restart stops every
11699
+ plugin, so change it only when the user asks. A plugin that only needs
11700
+ to know whether render options work reads SystemInfo.rendering.
11701
+
11702
+ Players before 0.5.13 do not know these requests: `state()` rejects with
11703
+ `unsupported`, and they do not load a plugin that declares
11704
+ `rendering.manage` (an unknown capability). Declare
11705
+ `"minPlayerVersion": "0.5.13"` when the plugin needs this.
11706
+
11707
+ - @capability None to read the state; `rendering.manage` to change the choice or restart the player.
11708
+ - @player 0.5.13; older players reject the requests as unknown (`unsupported`).
11709
+
11710
+ ```ts
11711
+ interface Rendering {
11712
+ /**
11713
+ * Read the renderer drawing now, the saved choice, what the next start will use and whether `"gpu"` can be chosen. No capability.
11714
+ * @capability None.
11715
+ * @throws unsupported (rejects) A player before 0.5.13, which does not know the request.
11716
+ * @example Find out whether the GPU can be chosen
11717
+ * The plugin reads the state once while it starts and keeps it for its
11718
+ * menu. Each call asks the player; `gpu.available` is false, with a
11719
+ * reason, where this player has no GPU renderer, without a user
11720
+ * interface, or when the driver is ruled out. An older player rejects
11721
+ * with `unsupported`, and the plugin then offers nothing.
11722
+ * ```ts
11723
+ * const state = await plugin.rendering.state().catch((error: PluginError) => {
11724
+ * if (error.code === "unsupported") return null; // a player before 0.5.13
11725
+ * throw error;
11726
+ * });
11727
+ * if (state && !state.gpu.available) console.log(state.gpu.reason?.message);
11728
+ * ```
11729
+ */
11730
+ state(): Promise<RenderingState>;
11731
+ /**
11732
+ * Save the user's rendering choice. The player writes it to its settings at once; the running player keeps its renderer, and the next start uses the choice whether or not the user restarts now. `"auto"` and `"software"` are always accepted.
11733
+ * @param preference - `"auto"`, `"gpu"` or `"software"`.
11734
+ * @returns The new state; RenderingState.restartPending tells whether a restart would change the renderer.
11735
+ * @capability rendering.manage
11736
+ * @throws invalid (rejects) `preference` is not `"auto"`, `"gpu"` or `"software"`.
11737
+ * @throws unsupported (rejects) `"gpu"` where this player has no GPU renderer on this system, or runs without a user interface; also any value on a player before 0.5.13.
11738
+ * @throws unavailable (rejects) `"gpu"` while it is ruled out: the graphics driver is on the player's denylist, or GPU rendering crashed at an earlier start. The message says which, with the adapter's name and the driver version.
11739
+ * @throws failed (rejects) The player could not write its settings file.
11740
+ * @throws capability (rejects) The manifest does not declare `rendering.manage`.
11741
+ * @example Switch back to the automatic choice
11742
+ * A Reset item returns the renderer to the player's own choice and tells
11743
+ * the user when it takes effect. `setPreference` saves the choice and
11744
+ * resolves with the new state; `restartPending` is true when the next
11745
+ * start will draw differently from this run. A refused save leaves the
11746
+ * old choice in place.
11747
+ * ```ts
11748
+ * try {
11749
+ * const state = await plugin.rendering.setPreference("auto");
11750
+ * if (state.restartPending) await plugin.osd.show("Applies after a restart");
11751
+ * } catch (error) {
11752
+ * // `failed`: the player's settings file could not be written.
11753
+ * await plugin.osd.show("Not saved ({})", [(error as PluginError).code]);
11754
+ * }
11755
+ * ```
11756
+ */
11757
+ setPreference(preference: RenderingPreference): Promise<RenderingState>;
11758
+ /**
11759
+ * Restart the player so that a changed choice applies now. Resolves once the player has accepted; the player then quits normally, which stops every plugin (this one included), and starts again with the same plugins. A local file that was open continues at its position, paused or playing as it was; a stream or another source that is not a file is not reopened.
11760
+ * @capability rendering.manage
11761
+ * @throws unsupported (rejects) The player runs without a user interface; also a player before 0.5.13.
11762
+ * @throws busy (rejects) A restart is already under way, or this player was itself started by a restart less than 30 seconds ago (so a plugin that restarts on activation cannot make a loop).
11763
+ * @throws failed (rejects) The new player process could not be started; the player keeps running.
11764
+ * @throws capability (rejects) The manifest does not declare `rendering.manage`.
11765
+ * @example Restart after asking the user
11766
+ * After the user changed the renderer, the plugin offers to restart now.
11767
+ * `confirm` asks first, because a restart closes every window (it needs
11768
+ * `ui.dialog`); once `restartPlayer` resolves, the player is quitting and
11769
+ * will stop this plugin, so nothing else is started after it.
11770
+ * ```ts
11771
+ * const { confirmed } = await plugin.dialogs.confirm({
11772
+ * message: "Restart the player now?",
11773
+ * detail: "The new renderer is used from the restart on. The video continues where it is.",
11774
+ * confirm: "Restart",
11775
+ * });
11776
+ * if (confirmed)
11777
+ * await plugin.rendering.restartPlayer().catch((error: PluginError) => {
11778
+ * // `failed`: the new player could not start; this one keeps running.
11779
+ * void plugin.osd.show("Could not restart ({})", [error.code]);
11780
+ * });
11781
+ * ```
11782
+ */
11783
+ restartPlayer(): Promise<void>;
11784
+ }
11785
+ ```
11786
+
11787
+ Example: Offer GPU rendering as a menu item
11788
+
11789
+ Settings ▸ GPU Rendering (Experimental) carries a check mark while the
11790
+ next start will draw on the GPU, is greyed out with the player's reason
11791
+ where the GPU cannot be chosen, and saves the other choice when picked.
11792
+ `state()` reads the player's view once; `setPreference` saves the choice
11793
+ and resolves with the new state, which the item's bound values read
11794
+ again after the handler. The how-to guide adds the restart.
11795
+ ```ts
11796
+ let state = await plugin.rendering.state();
11797
+ plugin.commands.add({
11798
+ id: "gpu-rendering",
11799
+ path: ["@settings", "GPU Rendering (Experimental)"],
11800
+ checked: () => state.nextMode === "gpu", // also for "auto" on a GPU system
11801
+ enabled: () => state.gpu.available,
11802
+ reason: () => state.gpu.reason?.message ?? null, // shown while disabled
11803
+ run: async () => {
11804
+ const choice = state.nextMode === "gpu" ? "software" : "gpu";
11805
+ state = await plugin.rendering.setPreference(choice);
11806
+ },
11807
+ });
11808
+ ```
11809
+
11314
11810
  ### VideoNode
11315
11811
 
11316
11812
  One node of a VideoFilter chain, by `kind`. Values outside the
@@ -12007,7 +12503,7 @@ Members fall into a few groups:
12007
12503
  TestHost.app, TestHost.systemInfo,
12008
12504
  TestHost.chapters, TestHost.audioDevices,
12009
12505
  TestHost.secrets, TestHost.clipboard,
12010
- TestHost.tools.
12506
+ TestHost.tools, TestHost.rendering.
12011
12507
  - **Assert** on records: TestHost.osd, TestHost.calls,
12012
12508
  TestHost.sent, TestHost.conflicts,
12013
12509
  TestHost.layout, TestHost.menus, and many more.
@@ -12582,7 +13078,7 @@ class TestHost implements Transport {
12582
13078
  owner: string | null;
12583
13079
  /** The values the player holds. */
12584
13080
  values: RenderSettings;
12585
- /** `true`: GPU rendering; `false`: software rendering (`unsupported`). */
13081
+ /** `true`: GPU rendering; `false`: software rendering (`unsupported`). `plugin.app.system()` reports it as `rendering`. */
12586
13082
  gpu: boolean;
12587
13083
  };
12588
13084
  /**
@@ -12601,7 +13097,7 @@ class TestHost implements Transport {
12601
13097
  */
12602
13098
  setVideoRenderOwner(pluginId: string | null): void;
12603
13099
  /**
12604
- * What the player pushes when mpv cannot compile a shader (code `failed`) or switches to software rendering (`unsupported`, which also sets `videoRender.gpu` to false). Pushed only when this plugin is the owner.
13100
+ * What the player pushes when mpv cannot compile a shader (code `failed`) or switches to software rendering (`unsupported`, which also sets `videoRender.gpu` to false and `rendering.reason` to `window-fallback` with `message`). Pushed only when this plugin is the owner.
12605
13101
  * @capability None.
12606
13102
  * @example Make a shader fail to compile
12607
13103
  * The plugin's render options report `failed`, as when mpv rejects a
@@ -12671,6 +13167,8 @@ class TestHost implements Transport {
12671
13167
  bytes: Buffer[];
12672
13168
  /** The announced total size in bytes. */
12673
13169
  expected: number;
13170
+ /** The source generation it was begun for; like the player, a new source drops the uploads of the old one. */
13171
+ generation?: number;
12674
13172
  }>;
12675
13173
  /** Snapshots taken with `media.capture`, by snapshot id. Wire-level. */
12676
13174
  readonly snapshots: Map<number, Json>;
@@ -12824,8 +13322,10 @@ class TestHost implements Transport {
12824
13322
  * `plugin.storage.session`: key → value, as the player keeps them. Preset entries to simulate values an earlier run of the plugin stored in the same player session, or pass `startPlugin(..., { sessionStorage })`. Like the player, a key is 1–1024 bytes, a `set` that would make the entries larger than 1 MiB (keys, values as compact JSON and `SESSION_STORAGE_LIMITS.entryBytes` per key) fails `limit`, and an object's properties are stored sorted, as the player hands them back.
12825
13323
  */
12826
13324
  readonly sessionStorage: Map<string, Json>;
12827
- /** What `plugin.app.system()` reports. */
12828
- systemInfo: WireSystemInfo;
13325
+ /**
13326
+ * What `plugin.app.system()` reports. Its `rendering` follows videoRender.gpu (`"gpu"` or `"software"`), so one switch covers the render options and the fact; its `renderingReason` is rendering.reason.
13327
+ */
13328
+ systemInfo: Omit<WireSystemInfo, "rendering" | "renderingReason">;
12829
13329
  /** What `plugin.app` reports. Set it before startPlugin: the plugin receives it once at start-up. */
12830
13330
  app: {
12831
13331
  /** The player version. */
@@ -12842,6 +13342,70 @@ class TestHost implements Transport {
12842
13342
  slint: string;
12843
13343
  };
12844
13344
  };
13345
+ /**
13346
+ * The player's rendering choice behind `plugin.rendering` (player
13347
+ * 0.5.13). The mode drawing now is videoRender.gpu, as for `plugin.app.system()`; the rest is preset here
13348
+ * and changed by the plugin's calls, as the player would:
13349
+ *
13350
+ * - `setPreference` saves `preference` and never changes the mode. It
13351
+ * refuses `"gpu"` while `gpu.available` is false: with `unsupported`
13352
+ * when `gpu.reason.code` is `no-gpu-backend` or `headless`, otherwise
13353
+ * with `unavailable`, the reason's message in both cases.
13354
+ * - The next start uses `"software"` for the `"software"` choice, else
13355
+ * software with `gpu.reason` while the GPU is not available, else the
13356
+ * GPU (code `preference` for a `"gpu"` choice, `default` for
13357
+ * `"auto"`). `restartPending` compares that with `startedWith`.
13358
+ * - `restartPlayer` counts in `restarts` and leaves the plugin running
13359
+ * (call `stop()` yourself); a second one rejects with `busy`, as a
13360
+ * restart is then under way.
13361
+ *
13362
+ * Inject other refusals (`failed` for an unwritable settings file or a
13363
+ * new process that could not start) with `host.handle("rendering.setPreference", …)`
13364
+ * or `host.handle("rendering.restart", …)`. The defaults are a macOS
13365
+ * player on the GPU: `"auto"`, available, started on the GPU.
13366
+ * @example Test a GPU switch on a system without a GPU renderer
13367
+ * The plugin's GPU menu item is greyed out with the player's reason when
13368
+ * the GPU cannot be chosen. The host is preset before the plugin starts,
13369
+ * because the plugin reads the state while activating; software
13370
+ * rendering is `videoRender.gpu = false`, and `gpu` says why the GPU
13371
+ * cannot be chosen.
13372
+ * ```ts
13373
+ * import assert from "node:assert/strict";
13374
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
13375
+ * import activate from "../main.ts";
13376
+ *
13377
+ * const host = new TestHost();
13378
+ * host.videoRender.gpu = false; // software draws now (Windows)
13379
+ * host.rendering.startedWith = "software";
13380
+ * host.rendering.reason = { code: "no-gpu-backend", message: "No GPU renderer on Windows" };
13381
+ * host.rendering.gpu = { available: false, reason: host.rendering.reason };
13382
+ * const { stop } = await startPlugin(activate, {
13383
+ * directory: new URL("..", import.meta.url),
13384
+ * host,
13385
+ * });
13386
+ * const item = host.command("gpu-rendering");
13387
+ * assert.equal(item.state.enabled, false);
13388
+ * assert.equal(item.state.reason, "No GPU renderer on Windows");
13389
+ * await stop();
13390
+ * ```
13391
+ */
13392
+ readonly rendering: {
13393
+ /** Why the mode drawing now is what it is (`RenderingState.reason`, `SystemInfo.renderingReason`). */
13394
+ reason: RenderingReason;
13395
+ /** The saved choice: `"auto"`, `"gpu"` or `"software"`. */
13396
+ preference: RenderingPreference;
13397
+ /** The mode this run started with; `restartPending` compares the next start's mode with it. */
13398
+ startedWith: RenderingMode;
13399
+ /** Whether the plugin may choose `"gpu"`, and why not (`null` while available). */
13400
+ gpu: {
13401
+ /** `false`: `setPreference("gpu")` is refused. */
13402
+ available: boolean;
13403
+ /** Why the GPU cannot be chosen; `null` while it can. */
13404
+ reason: RenderingReason | null;
13405
+ };
13406
+ /** Accepted `restartPlayer` calls. */
13407
+ restarts: number;
13408
+ };
12845
13409
  /** What `player.chapters()` lists while media is ready. */
12846
13410
  chapters: {
12847
13411
  /** The chapter title, `null` without one. */
@@ -13530,8 +14094,8 @@ current.
13530
14094
 
13531
14095
  A member not listed under any capability needs none (`plugin.tools`,
13532
14096
  `plugin.storage`, `plugin.system`, `plugin.app`, `plugin.ui`,
13533
- `plugin.i18n`, reading `plugin.window.state`, `plugin.appearance.theme`
13534
- and `plugin.packages.list`).
14097
+ `plugin.i18n`, reading `plugin.window.state`, `plugin.appearance.theme`,
14098
+ `plugin.packages.list` and `plugin.rendering.state`).
13535
14099
 
13536
14100
  ```ts
13537
14101
  const CAPABILITY_INFO: Readonly<Record<string, CapabilityInfo>>;
@@ -14061,6 +14625,61 @@ if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
14061
14625
  }
14062
14626
  ```
14063
14627
 
14628
+ ### SUBTITLE_LIMITS
14629
+
14630
+ The player's limits on subtitle files a plugin loads with
14631
+ Subtitles.load, which `TestHost` enforces with the player's codes.
14632
+ The track counts are per source: opening another file starts from zero.
14633
+ A file over `fileBytes` fails with `invalid`; every other limit fails with
14634
+ `busy`, and nothing is loaded.
14635
+
14636
+ Each `load` uses a new resource on the source, and removing the track
14637
+ frees its place among the live tracks (`tracksPerPlugin`, `tracks`,
14638
+ `totalBytes`) but not among the loads (`loadsPerPlugin`, `loads`). So one
14639
+ plugin loads at most 16 files into one source, with at most 4 of them
14640
+ present at once.
14641
+
14642
+ ```ts
14643
+ const SUBTITLE_LIMITS: Readonly<{
14644
+ /** UTF-8 bytes of one file; a larger one fails with `invalid` (2 MiB). */
14645
+ readonly fileBytes: number;
14646
+ /** Tracks one plugin has loaded into the current source at the same time (4). */
14647
+ readonly tracksPerPlugin: 4;
14648
+ /** Loaded tracks of every plugin in the current source at the same time (16). */
14649
+ readonly tracks: 16;
14650
+ /** Bytes of the loaded tracks of every plugin in the current source together (16 MiB). */
14651
+ readonly totalBytes: number;
14652
+ /** Loads by one plugin into the current source, removed tracks included (16). */
14653
+ readonly loadsPerPlugin: 16;
14654
+ /** Loads by every plugin into the current source, removed tracks included (128). */
14655
+ readonly loads: 128;
14656
+ /** Loads of one plugin in progress at the same time (2). */
14657
+ readonly uploadsPerPlugin: 2;
14658
+ /** Loads of every plugin in progress at the same time (8). */
14659
+ readonly uploads: 8;
14660
+ }>;
14661
+ ```
14662
+
14663
+ Example: Replace the plugin's oldest subtitle when it has four
14664
+
14665
+ The plugin keeps the files it loaded, newest last, and removes the oldest
14666
+ before a fifth would be refused with `busy`. A load the player still
14667
+ refuses (the source holds 16 tracks of every plugin, or this plugin
14668
+ already loaded 16 files into it) is reported on the OSD.
14669
+ ```ts
14670
+ import { SUBTITLE_LIMITS } from "@vestra-player/sdk/testing";
14671
+
14672
+ const loaded: SubtitleTrack[] = []; // this source's, newest last
14673
+ if (loaded.length >= SUBTITLE_LIMITS.tracksPerPlugin)
14674
+ await plugin.subtitles.remove(loaded.shift()!);
14675
+ try {
14676
+ loaded.push(await plugin.subtitles.load(bytes));
14677
+ } catch (error) {
14678
+ if ((error as PluginError).code !== "busy") throw error;
14679
+ await plugin.osd.show("No room for another subtitle on this file.");
14680
+ }
14681
+ ```
14682
+
14064
14683
  ### UI_VALUE_LIMITS
14065
14684
 
14066
14685
  The player's limits on one property value (a `PropValue` as written
@@ -14549,7 +15168,7 @@ interface SubtitleState {
14549
15168
 
14550
15169
  ### WireSystemInfo
14551
15170
 
14552
- `app.system`: the operating system and the displays.
15171
+ `app.system`: the operating system, the displays and the renderer.
14553
15172
 
14554
15173
  ```ts
14555
15174
  interface WireSystemInfo {
@@ -14567,6 +15186,10 @@ interface WireSystemInfo {
14567
15186
  refreshRate: number | null;
14568
15187
  primary: boolean;
14569
15188
  }[];
15189
+ /** Player 0.5.12; older players leave it out. */
15190
+ rendering?: "gpu" | "software";
15191
+ /** Why `rendering` is what it is (player 0.5.13; older players leave it out). */
15192
+ renderingReason?: WireRenderingReason;
14570
15193
  }
14571
15194
  ```
14572
15195