@vestra-player/sdk 1.5.4 → 1.5.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.7
4
+
5
+ **Replaced or closed streams stop with `null`.** A fix; no API change, `apiVersion` stays `vestra/1`, any player.
6
+
7
+ - **`Stream.onStop(null)` when another source replaced the stream or it was closed**, as documented. The player retires a stream's producer at once in both cases, and the SDK then passed the player's answer (`unavailable`, "producer absent") to `onStop` as an error. The SDK now reads the player's state to tell the two apart: replaced or closed is `null`; a failure is a `stream` error with the player's text (also when the player retired the producer right after failing it, which used to give "producer absent" too).
8
+ - The same in the start-up paths: `openStream` replaced or closed before it ran rejects with `replaced` (was `unavailable`); a file resolver's stream replaced after the player selected it is a cancellation and no longer reaches `onError`.
9
+ - **A file resolver's stream that cannot start after `resolve` returned** fails the open with Chinese text instead of the SDK's English (`"audio device is not ready: deviceLost"`): `device` "音频输出设备不可用", `timeout` "启动超时", `busy` "播放器正忙,请稍后重试", other start-up failures "无法启动播放"; the English detail goes to the plugin's log. The plugin never saw these errors, so the user read the SDK's words. An error thrown by `frame()` keeps its message; text for a file the plugin can judge (a bad header) belongs in `resolve`.
10
+ - **`TestHost` retires producers like the player**: on `open`, `close`, another `openProvider` or resolved stream, a retired producer's `producer.state` answers `unavailable` and `producer.ack` `stale`. New `FakeProducer.retired`, and **`host.failStream(producerId, message)`** to fail the playing stream the way the player does (`phase: "error"` with `message`, producer retired). Tests that replaced a stream used to pass where the player failed.
11
+
12
+ ## 1.5.6
13
+
14
+ **A reference for AI assistants.** Documentation and packaging only; no API change, `apiVersion` stays `vestra/1`. (1.5.5 was not published to npm; this release includes its changes.)
15
+
16
+ - **`llms.txt` and `llms-full.txt`** ([llmstxt.org](https://llmstxt.org)) in the package, exported as `@vestra-player/sdk/llms.txt` and `@vestra-player/sdk/llms-full.txt` (in `node_modules/@vestra-player/sdk/`), and on the documentation site (`https://vestrasys.github.io/vestra-player-sdk/llms.txt`). For Vestra Player AI and other assistants that write plugins.
17
+ - `llms.txt` (about 10 KB): what a plugin is, the rules, the folder layout, a manifest with every field and the manifest limits, `activate`, every capability, a `TestHost` test, the command line, the common error codes, and the line numbers of each topic in the full file.
18
+ - `llms-full.txt` (about 300 KB): the guides (Getting started, the UI concept page, both how-tos), every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its description and its `@capability`, `@player`, `@param`, `@returns`, `@throws` and `@deprecated` tags, the internal types the API refers to, and each example's README, walkthrough and complete source (TypeScript, Slint, tests).
19
+ - Generated from the sources by `npm run docs:llms` (`scripts/llms.mjs`); CI fails when the committed files are stale.
20
+ - **Getting started**: a "Rules for plugins" section (only the public SDK, `import type`, no other plugins, no system commands, macOS and Windows, your own id) and a manifest with every field.
21
+ - `scripts/check-pack.mjs` allows the two files and checks that they name the package version; the install smoke test resolves both exports.
22
+
23
+ ## 1.5.5
24
+
25
+ **Reserved `vestra.` ids** (player 0.4.1). Documentation only; no API change, `apiVersion` stays `vestra/1`.
26
+
27
+ - Only the plugins built into the player are official plugins; the ids `vestra` and `vestra.*` (any case) are reserved for them. Every other plugin is a third-party plugin, including those hosted in the official plugin repository, and uses `<GitHub user or organization>.<plugin name>`.
28
+ - `PluginError` lists the code `reserved`: `packages.validate`, `packages.validateArchive`, `PackageCandidate.trial` and `PackageCandidate.install` refuse a package with a reserved id. Development loads (`vestra-player --plugin DIR`) still accept one, with a warning, so built-in plugins can be developed; they are never installed.
3
29
  ## 1.5.4
4
30
 
5
31
  **Markdown in list rows** (the player after 0.4.0). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.3 need no change.
package/README.md CHANGED
@@ -12,7 +12,8 @@
12
12
  <a href="https://vestrasys.github.io/vestra-player-sdk/">Documentation</a> ·
13
13
  <a href="https://vestrasys.github.io/vestra-player-sdk/documents/Getting_started.html">Getting started</a> ·
14
14
  <a href="https://github.com/vestrasys/vestra-player-sdk/tree/main/examples">Examples</a> ·
15
- <a href="https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md">Changelog</a>
15
+ <a href="https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md">Changelog</a> ·
16
+ <a href="https://vestrasys.github.io/vestra-player-sdk/llms.txt">llms.txt</a>
16
17
  </p>
17
18
  ## Features
18
19
 
@@ -21,6 +22,7 @@
21
22
  - **Tests without the player**: `@vestra-player/sdk/testing` runs your plugin against an in-memory host with `node --test`.
22
23
  - **One command line tool**, `vestra-plugin`, to check, test, build and pack a `.vxp` package.
23
24
  - **The same API on macOS and Windows**; every plugin runs in its own Node.js 26 process that the player ships.
25
+ - **A reference for AI assistants**: [`llms.txt`](https://vestrasys.github.io/vestra-player-sdk/llms.txt) (short index) and [`llms-full.txt`](https://vestrasys.github.io/vestra-player-sdk/llms-full.txt) (every declaration, the guides and the examples), also in the package as `@vestra-player/sdk/llms.txt` and `@vestra-player/sdk/llms-full.txt`.
24
26
 
25
27
  ## Install
26
28
 
@@ -79,7 +79,8 @@ class StreamHandle {
79
79
  }, 5);
80
80
  }
81
81
  catch (error) {
82
- void this.#end(toError(error));
82
+ const retired = await retirement(this.#service, this.#generation, error);
83
+ void this.#end(retired === undefined ? toError(error) : retired);
83
84
  }
84
85
  }
85
86
  async #render() {
@@ -131,6 +132,28 @@ class StreamHandle {
131
132
  await this.stop().catch(() => { });
132
133
  }
133
134
  }
135
+ /**
136
+ * The player retires a producer the moment another source replaces it or
137
+ * the source closes, and after it failed, so its `producer.state` then
138
+ * answers `unavailable` (and `producer.ack` `stale`). The player's state
139
+ * says which: `null` (replaced or closed), the failure with the player's
140
+ * words, or `undefined` when the error is not a retirement.
141
+ */
142
+ async function retirement(service, generation, error) {
143
+ const code = error?.code;
144
+ if (code !== "unavailable" && code !== "stale")
145
+ return undefined;
146
+ const readable = service.runtime.can("playback.read");
147
+ if (readable)
148
+ await service.refresh().catch(() => { });
149
+ const raw = service.raw;
150
+ if (raw.stamp.generation !== generation || raw.phase === "empty")
151
+ return null;
152
+ if (raw.phase === "error")
153
+ return failure("stream", service.state.error ?? "无法打开此媒体");
154
+ // Without `playback.read` the state may be behind: no failure was seen.
155
+ return readable ? undefined : null;
156
+ }
134
157
  /** Start-up timeouts say which step ran out of time. */
135
158
  function timeout(stage, message) {
136
159
  return Object.assign(failure("timeout", message), { stage });
@@ -303,10 +326,40 @@ export async function openStream(scope, service, options) {
303
326
  // An abort can arrive before the host answered the selecting request: wait
304
327
  // for that answer so a source selected afterwards is still closed.
305
328
  await opened?.catch(() => { });
329
+ // Read before closing: replaced during activation is not a failure.
330
+ const retired = generation === undefined
331
+ ? undefined
332
+ : await retirement(service, generation, error);
306
333
  await abandon(service, p, generation);
307
- throw error;
334
+ if (retired === null)
335
+ throw failure("replaced", "另一个来源已替换此媒体");
336
+ throw retired ?? error;
308
337
  }
309
338
  }
339
+ /**
340
+ * What the user reads when a file resolver's stream cannot start. The
341
+ * player shows the answer as the open's failure, and the plugin never sees
342
+ * these errors: they happen after `resolve` returned. The SDK's and the
343
+ * player's own codes get the player's words; the detail goes to the log.
344
+ * An error the plugin threw from `frame()` keeps its own words.
345
+ */
346
+ const START_TEXT = {
347
+ device: "音频输出设备不可用",
348
+ timeout: "启动超时",
349
+ busy: "播放器正忙,请稍后重试",
350
+ stream: "无法启动播放",
351
+ producer: "无法启动播放",
352
+ unavailable: "无法启动播放",
353
+ invalid: "无法启动播放",
354
+ };
355
+ function startFailure(scope, error) {
356
+ const code = error?.code;
357
+ const text = code === undefined ? undefined : START_TEXT[code];
358
+ if (text === undefined)
359
+ return error;
360
+ console.error(`[${scope.id}] file resolver stream did not start:`, error.message);
361
+ return Object.assign(failure(code, text), { cause: error });
362
+ }
310
363
  /**
311
364
  * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
312
365
  * the open waits, then handed to the host as the answer. Once the host
@@ -320,6 +373,8 @@ export async function resolvedStream(scope, service, stream, signal) {
320
373
  const p = await prepare(host, stream, () => {
321
374
  if (signal.aborted || scope.disposed)
322
375
  throw abortError();
376
+ }).catch((error) => {
377
+ throw startFailure(scope, error);
323
378
  });
324
379
  return {
325
380
  kind: "stream",
@@ -336,9 +391,12 @@ export async function resolvedStream(scope, service, stream, signal) {
336
391
  throw abortError();
337
392
  }
338
393
  catch (error) {
394
+ // Replaced or closed before it ran is a cancellation, not a failure.
395
+ const retired = await retirement(service, stamp.generation, error);
339
396
  await abandon(service, p, stamp.generation);
340
- if (error.code !== "aborted")
341
- scope.report(error);
397
+ const reported = retired === undefined ? error : retired;
398
+ if (reported && reported.code !== "aborted")
399
+ scope.report(reported);
342
400
  return;
343
401
  }
344
402
  const handle = handleOf(scope, service, p, stamp.generation);
@@ -117,6 +117,13 @@ export interface FakeProducer {
117
117
  videoFrames: number;
118
118
  /** `true` once the plugin released the producer (stream stopped or disposed). */
119
119
  released: boolean;
120
+ /**
121
+ * `true` once the player retired the producer, as the real player does at
122
+ * once: another source replaced it (`open`, another stream, a resolved
123
+ * file), the source was closed, or {@link TestHost.failStream} failed it.
124
+ * A retired producer's `producer.state` answers `unavailable`.
125
+ */
126
+ retired: boolean;
120
127
  /**
121
128
  * `true` if the frame connection closed while this stream was still the
122
129
  * player's source: the player reports that as a playback failure
@@ -1025,6 +1032,22 @@ export declare class TestHost implements Transport {
1025
1032
  * ```
1026
1033
  */
1027
1034
  openElsewhere(source: string, phase?: SourceState["phase"]): void;
1035
+ /**
1036
+ * Simulate a generated stream failing in the player (its audio device
1037
+ * lost, its frame connection broken), like the player: the source fails
1038
+ * with `message` (`phase: "error"`) and the producer is retired at once.
1039
+ * The stream's `onStop` listeners get a `stream` error with `message`.
1040
+ *
1041
+ * @param producerId - The producer, from {@link TestHost.producers}.
1042
+ * @param message - The player's failure text.
1043
+ *
1044
+ * @example
1045
+ * ```ts
1046
+ * const [producer] = host.producers.values();
1047
+ * host.failStream(producer.id, "producer transport closed");
1048
+ * ```
1049
+ */
1050
+ failStream(producerId: number, message: string): void;
1028
1051
  /**
1029
1052
  * Press a binding like the host's table would: panel-focus bindings first
1030
1053
  * when `panel` is true, then global ones; the earliest registration wins a
@@ -1385,6 +1385,31 @@ export class TestHost {
1385
1385
  if (this.#resolve(source, false) === null)
1386
1386
  this.setSource({ phase });
1387
1387
  }
1388
+ /**
1389
+ * Simulate a generated stream failing in the player (its audio device
1390
+ * lost, its frame connection broken), like the player: the source fails
1391
+ * with `message` (`phase: "error"`) and the producer is retired at once.
1392
+ * The stream's `onStop` listeners get a `stream` error with `message`.
1393
+ *
1394
+ * @param producerId - The producer, from {@link TestHost.producers}.
1395
+ * @param message - The player's failure text.
1396
+ *
1397
+ * @example
1398
+ * ```ts
1399
+ * const [producer] = host.producers.values();
1400
+ * host.failStream(producer.id, "producer transport closed");
1401
+ * ```
1402
+ */
1403
+ failStream(producerId, message) {
1404
+ const producer = this.producers.get(producerId);
1405
+ if (!producer || !this.#current(producerId))
1406
+ throw new Error(`producer ${producerId} is not the player's source`);
1407
+ this.setSource({
1408
+ phase: "error",
1409
+ terminalReason: { failed: message },
1410
+ });
1411
+ producer.retired = true;
1412
+ }
1388
1413
  /**
1389
1414
  * Press a binding like the host's table would: panel-focus bindings first
1390
1415
  * when `panel` is true, then global ones; the earliest registration wins a
@@ -1721,6 +1746,10 @@ export class TestHost {
1721
1746
  if (control.kind === "rate")
1722
1747
  next.rate = Number(control.rate);
1723
1748
  }
1749
+ // Like the player: the selected producer is retired as soon as another
1750
+ // source replaces it or the source closes.
1751
+ if (["open", "openProvider", "close"].includes(String(intent.kind)))
1752
+ this.#retireSelected(intent.kind === "openProvider" ? Number(intent.producerId) : null);
1724
1753
  if (intent.kind === "openProvider")
1725
1754
  this.#selected = Number(intent.producerId);
1726
1755
  if (intent.kind === "open" || intent.kind === "close")
@@ -1748,6 +1777,14 @@ export class TestHost {
1748
1777
  }
1749
1778
  return null;
1750
1779
  }
1780
+ /** Retire the producer that is the player's source, unless it is `keep`. */
1781
+ #retireSelected(keep) {
1782
+ for (const producer of this.producers.values())
1783
+ if (producer.id !== keep &&
1784
+ !producer.retired &&
1785
+ this.#current(producer.id))
1786
+ producer.retired = true;
1787
+ }
1751
1788
  /** Whether producer `id` is the player's source. */
1752
1789
  #current(id) {
1753
1790
  return (this.source.kind === "producer" &&
@@ -1853,9 +1890,10 @@ export class TestHost {
1853
1890
  const producer = this.producers.get(Number(result.producerId));
1854
1891
  if (result.kind !== "stream")
1855
1892
  fail("invalid resolved stream descriptor");
1856
- if (!producer || producer.released)
1893
+ if (!producer || producer.released || producer.retired)
1857
1894
  fail("unavailable: producer absent");
1858
1895
  // Like openProvider, but the source keeps the path and `startPaused`.
1896
+ this.#retireSelected(producer.id);
1859
1897
  this.#selected = producer.id;
1860
1898
  producer.active = true;
1861
1899
  producer.acknowledged = false;
@@ -2922,6 +2960,7 @@ export class TestHost {
2922
2960
  receivedFrames: 0,
2923
2961
  videoFrames: 0,
2924
2962
  released: false,
2963
+ retired: false,
2925
2964
  droppedWhileSelected: false,
2926
2965
  path,
2927
2966
  admitted: !tcp,
@@ -2988,6 +3027,8 @@ export class TestHost {
2988
3027
  const producer = this.producers.get(Number(p.producerId));
2989
3028
  if (!producer)
2990
3029
  throw new HostError("missing", "producer");
3030
+ if (producer.retired || producer.released)
3031
+ throw new HostError("unavailable", "producer absent");
2991
3032
  const current = this.#current(producer.id);
2992
3033
  return {
2993
3034
  input: null,
@@ -3017,6 +3058,8 @@ export class TestHost {
3017
3058
  const producer = this.producers.get(Number(control.producerId));
3018
3059
  if (!producer)
3019
3060
  throw new HostError("missing", "producer");
3061
+ if (producer.retired || producer.released)
3062
+ throw new HostError("stale", "producer control changed");
3020
3063
  producer.pending = null;
3021
3064
  producer.acknowledged = true;
3022
3065
  producer.epoch = Number(control.epoch);
@@ -75,6 +75,7 @@ export interface Disposable {
75
75
  * | `backend-uncertain` | The player's backend blocked a filter stage; call `retry()`. |
76
76
  * | `cleanup` | {@link PackageTrial.stop} could not confirm cleanup; call it again. |
77
77
  * | `limit` | A player-wide limit was reached (for example the number of commands). |
78
+ * | `reserved` | {@link Packages.validate}, {@link PackageCandidate.trial}, {@link PackageCandidate.install}: the id is `vestra` or starts with `vestra.` (any case), which only the player's built-in plugins use (player 0.4.1). |
78
79
  * | `versionMismatch` | The package needs a newer player (`minPlayerVersion`) or another `apiVersion`. |
79
80
  * | `ui-prepare` | The player could not prepare a mounted panel or a settings page (its Slint file does not compile, a bound property is not declared, or another plugin's panel holds the slot); `message` has the diagnostics. |
80
81
  * | `ui` | The player could not apply a property to a tool window or a settings page, or could not build the Options window (a property the Slint file does not declare, a value of the wrong type); `message` has Slint's reason. |
@@ -3001,9 +3002,15 @@ export interface Stream extends Disposable {
3001
3002
  /**
3002
3003
  * The stream stopped for a reason outside this plugin: `error` is null when
3003
3004
  * another source replaced it or the user closed it, otherwise the failure
3004
- * (`stream`, `invalid` for a bad frame, …). Not called when the plugin
3005
- * itself stops it (`stop()`, `dispose()`, deactivation): the promise of
3006
- * `stop()` is that notification.
3005
+ * (`stream` with the player's text, `invalid` for a bad frame, …). Not
3006
+ * called when the plugin itself stops it (`stop()`, `dispose()`,
3007
+ * deactivation): the promise of `stop()` is that notification.
3008
+ *
3009
+ * The player retires a stream's producer as soon as it is replaced or
3010
+ * closed; the SDK reads the player's state to tell that from a failure
3011
+ * (before 1.5.7 a replaced or closed stream could get `unavailable`
3012
+ * "producer absent"). Without `playback.read` a failure the SDK has not
3013
+ * seen yet is reported as null.
3007
3014
  */
3008
3015
  onStop(listener: (error: PluginError | null) => void): Disposable;
3009
3016
  /**
@@ -3146,7 +3153,8 @@ export interface Player {
3146
3153
  * activation, pausing and acknowledgement. Frames travel over a local
3147
3154
  * connection the SDK sets up: a Unix socket on macOS and Linux, loopback
3148
3155
  * TCP with a token on Windows. Rejects with a {@link StreamStartError}
3149
- * (`timeout`), `stream` or `device`.
3156
+ * (`timeout`), `stream`, `device`, or `replaced` when another source
3157
+ * replaced it (or it was closed) before it ran.
3150
3158
  *
3151
3159
  * @example
3152
3160
  * ```ts
@@ -3936,6 +3944,14 @@ export interface FileResolverOptions {
3936
3944
  /**
3937
3945
  * What a file resolver returns: a stream, with the same frame contract as
3938
3946
  * {@link StreamOptions} (size limits, `frame()` on the audio clock).
3947
+ *
3948
+ * After `resolve` returns, the SDK starts the stream while the open waits.
3949
+ * If that fails, the open fails with the player's words instead of the
3950
+ * SDK's (the detail goes to the log): `device` "音频输出设备不可用",
3951
+ * `timeout` "启动超时", `busy` "播放器正忙,请稍后重试", other start-up
3952
+ * failures "无法启动播放". An error thrown by `frame()` keeps its message.
3953
+ * To give your own text for a file you can tell is unusable (a bad header),
3954
+ * check it in `resolve` and throw there.
3939
3955
  */
3940
3956
  export interface ResolvedStream {
3941
3957
  /** Always `"stream"`. */
@@ -4083,13 +4099,18 @@ export interface PackageCandidate {
4083
4099
  readonly manifest: PackageManifest;
4084
4100
  /** The checks the player ran. */
4085
4101
  readonly checks: string[];
4086
- /** Load it temporarily alongside the installed plugins. */
4102
+ /**
4103
+ * Load it temporarily alongside the installed plugins. A reserved id
4104
+ * (`vestra.*`) fails with `reserved`.
4105
+ */
4087
4106
  trial(): Promise<PackageTrial>;
4088
4107
  /**
4089
4108
  * Install it. The same source upgrades the installed plugin (its data
4090
4109
  * stays); another source fails with `conflict` unless `replace` is set;
4091
4110
  * a built-in id fails with `unsupported` (built-ins update with the
4092
- * player).
4111
+ * player). A reserved id (`vestra` or `vestra.*`, which every built-in
4112
+ * uses) fails with `reserved` (player 0.4.1; already when validated), as
4113
+ * only the player installs built-in plugins.
4093
4114
  */
4094
4115
  install(options?: InstallOptions): Promise<PackageChange>;
4095
4116
  }
@@ -4197,12 +4218,17 @@ export interface PluginWarning {
4197
4218
  * ```
4198
4219
  */
4199
4220
  export interface Packages {
4200
- /** Validate a package directory (`plugins.manage`). */
4221
+ /**
4222
+ * Validate a package directory (`plugins.manage`). A package whose id is
4223
+ * reserved for the built-in plugins (`vestra` or `vestra.*`) fails with
4224
+ * `reserved` (player 0.4.1).
4225
+ */
4201
4226
  validate(directory: string): Promise<PackageCandidate>;
4202
4227
  /**
4203
4228
  * Validate a `.vxp` package file (`plugins.manage`, player 0.2.0): the
4204
4229
  * same checks as {@link Packages.validate} after unpacking. Check the
4205
- * file's sha256 yourself before calling when you know it.
4230
+ * file's sha256 yourself before calling when you know it. A reserved id
4231
+ * fails with `reserved`, as in {@link Packages.validate}.
4206
4232
  */
4207
4233
  validateArchive(file: string): Promise<PackageCandidate>;
4208
4234
  /** Restore a plugin's previous version (`plugins.manage`). */