@vestra-player/sdk 1.5.2 → 1.5.4

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.4
4
+
5
+ **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.
6
+
7
+ - **A `MarkdownRef` at any depth.** A document from `plugin.ui.markdown` may now be a field at any depth of a property value, wherever the Slint type is `[MarkdownBlock]`: for example the `body` of each row of a chat's `[Message]` list. Before, it could only be the whole value of a `[MarkdownBlock]` property. `PropValue` is now `PropData | ImageRef`, where the new **`PropData`** is JSON whose values may also be `MarkdownRef`s, at any depth. A released document is refused with `invalid` wherever it is in the value. An `ImageRef` stays a whole value only: nested in an array or object it is `invalid` (a type error in TypeScript, and refused by the SDK and the player). Older players refuse a nested document like any value they cannot convert.
8
+ - **Limits:** a document still holds at most 256 KB of text, 4000 blocks and 64 images; all of a value's text, its documents' text included, counts toward the value's 1 MiB; each document is one of its 4096 values.
9
+ - **`MarkdownBlocks`** (new in `@vestra/markdown.slint`): the blocks of a document without scrolling, as tall as their content, for your own layout or a `ListView` row. `in [MarkdownBlock] blocks`, `in bool code-copy` (default `false`), callbacks `link-clicked(string)` and `copy-code(string)`. `MarkdownView` is now a `ScrollView` around it and gains the same `code-copy` and `copy-code`; it looks as before.
10
+ - **Code copy, opt in.** With `code-copy: true`, code blocks get a 复制 button that reports `copy-code(code)` with the block's text; the plugin copies it (`plugin.clipboard.writeText`). Off by default: views that do not set it look and behave as before.
11
+ - **Cheaper updates** (player): writing a value again with unchanged documents does not parse them again (a new reference for the same text included), and a value with documents is updated in place where its shape allows: changed rows, rows added or removed at the end, and only the changed blocks of a changed document. Streaming a reply into the last row of a 200-message list therefore keeps the list, its scroll position and the other rows. Nothing to change in plugins.
12
+ - `TestHost` keeps nested documents in the view's properties as they were sent; `host.markdown(value)` reads one back from a field (`host.markdown(rows[0].body)`).
13
+ ## 1.5.3
14
+
15
+ **File resolvers: local files as generated streams** (player 0.4.1). Additive; `apiVersion` stays `vestra/1`. Plugins that register one set `"minPlayerVersion": "0.4.1"`: an older player refuses the declaration (`invalid`).
16
+
17
+ - **`resolvers.add({ id, extensions, resolve })`** (`FileResolverOptions`). When anyone opens a local file of those extensions with `player.open` (the Open dialog, the playlist, next / previous, the recent list), the player asks this plugin for a stream instead of playing the file. `resolve(path, signal)` returns a `ResolvedStream`: `{ kind: "stream", width, height, frame, started? }`, with the frame contract of `openStream`. Needs `source.resolve` and `producer.av`. `ResolverOptions` is now the union of `WebResolverOptions` (the existing form) and `FileResolverOptions`.
18
+ - The open keeps its arbitration: its `ifCurrent` and operation (the opener's `open` / `media.ready()` settle as usual), and `paused` reaches the stream's start. `state.source` stays the path, `state.kind` is `"stream"`.
19
+ - Cancellation: `signal` aborts when the open is replaced or closed (`player.close()` included), times out (30 s) or the resolver is disposed. Cancelled after `resolve` returned but before the stream ran, the SDK releases the stream and never calls `started`.
20
+ - `started(stream)` is called synchronously right after activation, before any `onStop`; the `Stream` is the same as `openStream`'s and belongs to the resolver's plugin.
21
+ - Extensions: 1–16 entries of 1–16 lower-case letters or digits, not `"folder"`; only absolute local paths match (any case), URLs never do.
22
+ - **One extension, and one scheme + host pair, has one resolver.** Another plugin's makes `Resolver.ready` reject with `conflict` (reason `resolver-taken` on the wire), and that no longer fails activation, for web resolvers too (as for panels and the sequence since 1.5.2); the player shows a warning (`PluginInfo.warnings`: `code` `"resolver-taken"`, `owner`, the new `target`). The plugin's own second claim still fails activation.
23
+ - **Resolver failures are sent as the error's message** (`error.message`, was `String(error)`, e.g. `"Error: …"`), and the player shows them word for word (it prefixed `resolver failed: `). This changes the failure text of web resolvers too.
24
+ - **Docs**: `PlayerState.source` documents the naming of generated streams: `producer://<n>` (opaque) for `openStream`, the path for a file resolver's; tell streams apart by `kind`.
25
+ - **`TestHost`** simulates file resolutions: opening a matching path (`player.open`, `openElsewhere`) sends `source.resolveRequest` and keeps the open pending; the answer selects the stream (or fails the open); any later source change cancels it (`source.resolveCancel`). `resolutions` lists the pending ones, `otherResolvers` simulates other plugins' registrations (conflicts). `startPlugin` does not fail activation for such a conflict. Web resolutions are still driven by the test (emit `source.resolveRequest`).
26
+ - Internals: `openStream` is split into prepare / activate / abandon steps shared with file resolvers; the wire client (`sourceResolvers.register`) takes a `{ id, extensions }` declaration and a handler answer with the prepared producer.
27
+
3
28
  ## 1.5.2
4
29
 
5
30
  **Sidebar tabs** (the player after 0.4.0). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.1 need no change. The new members work against older players too (see each one).
@@ -1,10 +1,22 @@
1
- /** Source resolvers turn page URLs into stream URLs for `player.open`. */
2
- import { quiet } from "./runtime.js";
1
+ /**
2
+ * Source resolvers: page URLs to stream URLs, or local files to a stream
3
+ * this plugin generates, for `player.open`.
4
+ */
5
+ import { failure, quiet } from "./runtime.js";
6
+ import { playerOf } from "./player.js";
7
+ import { resolvedStream } from "./stream.js";
8
+ const isFiles = (options) => "extensions" in options;
3
9
  export const resolvers = (scope) => ({
4
10
  add(options) {
5
- const { resolve, ...declaration } = options;
6
11
  const runtime = scope.runtime;
7
- const registration = runtime.host.sourceResolvers.register(declaration, ({ url, signal }) => resolve(url, signal));
12
+ const registration = isFiles(options)
13
+ ? runtime.host.sourceResolvers.register({ id: options.id, extensions: options.extensions }, async ({ url, signal }) => {
14
+ const stream = await options.resolve(url, signal);
15
+ if (stream?.kind !== "stream")
16
+ throw failure("invalid", 'a file resolver returns { kind: "stream", width, height, frame }');
17
+ return resolvedStream(scope, playerOf(runtime), stream, signal);
18
+ })
19
+ : runtime.host.sourceResolvers.register({ id: options.id, schemes: options.schemes, hosts: options.hosts }, ({ url, signal }) => options.resolve(url, signal));
8
20
  let disposed = false;
9
21
  const handle = {
10
22
  ready: quiet(runtime
@@ -1,4 +1,14 @@
1
1
  import type { PlayerService } from "./player.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
- import type { Stream, StreamOptions } from "./types.ts";
3
+ import type { ResolvedProducer } from "../source-resolvers.ts";
4
+ import type { ResolvedStream, Stream, StreamOptions } from "./types.ts";
4
5
  export declare function openStream(scope: ScopeImpl, service: PlayerService, options: StreamOptions): Promise<Stream>;
6
+ /**
7
+ * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
8
+ * the open waits, then handed to the host as the answer. Once the host
9
+ * selected it, the start control is acknowledged and `started` gets the
10
+ * running stream, synchronously, before any `onStop`. Cancelled before or
11
+ * during activation (the open was replaced or closed, the plugin stopped),
12
+ * the producer is released and `started` is never called.
13
+ */
14
+ export declare function resolvedStream(scope: ScopeImpl, service: PlayerService, stream: ResolvedStream, signal: AbortSignal): Promise<ResolvedProducer>;
@@ -154,17 +154,11 @@ async function sendFrame(socket, frame, clock, epoch) {
154
154
  clock.advance(samples.length / 2);
155
155
  }
156
156
  }
157
- export async function openStream(scope, service, options) {
158
- const host = service.runtime.host;
159
- const check = () => {
160
- if (options.signal?.aborted || scope.disposed)
161
- throw abortError();
162
- };
163
- let prepared;
164
- let socket;
165
- let generation;
166
- /** The request selecting the stream, once sent. */
167
- let opened;
157
+ /**
158
+ * Prepare a producer, connect and preroll it until the host could select it.
159
+ * `check` throws to give up (an abort); the producer is then released.
160
+ */
161
+ async function prepare(host, options, check) {
168
162
  const bytes = options.width * options.height * 4;
169
163
  /** The plugin's frame, checked against the declared size. */
170
164
  const frame = () => {
@@ -173,103 +167,183 @@ export async function openStream(scope, service, options) {
173
167
  throw failure("invalid", `frame().video must be ${options.width}×${options.height}×4 = ${bytes} bytes of RGBA`);
174
168
  return next;
175
169
  };
170
+ let producer;
171
+ let socket;
176
172
  try {
177
173
  check();
178
174
  const spec = await host.producer.prepare({
179
175
  width: options.width,
180
176
  height: options.height,
181
177
  });
182
- prepared = spec.producerId;
178
+ producer = spec.producerId;
183
179
  check();
184
180
  socket = await host.producer.connect(spec);
185
181
  check();
186
- let epoch = spec.epoch;
187
- let sample = 0;
188
- let sequence = 0;
189
- const clock = {
190
- get sample() {
191
- return sample;
192
- },
193
- advance: (n) => (sample += n),
194
- nextSequence: () => sequence++,
182
+ const p = {
183
+ producer,
184
+ socket,
185
+ frame,
186
+ epoch: spec.epoch,
187
+ sample: 0,
188
+ sequence: 0,
195
189
  };
190
+ const clock = clockOf(p);
196
191
  // A prepared producer is paused at sample zero. Supply a frame at zero and
197
192
  // enough contiguous PCM before asking the host to select it.
198
- while (sample < PREROLL) {
193
+ while (p.sample < PREROLL) {
199
194
  check();
200
- await writeFrame(socket, frame(), clock, epoch);
195
+ await writeFrame(socket, frame(), clock, p.epoch);
201
196
  }
202
197
  for (const deadline = Date.now() + 10_000;; await sleep(10)) {
203
198
  check();
204
- const state = await host.producer.state(prepared);
199
+ const state = await host.producer.state(producer);
205
200
  if (state.error)
206
201
  throw failure("stream", state.error);
207
- if (state.epoch !== epoch)
202
+ if (state.epoch !== p.epoch)
208
203
  throw failure("stream", "producer epoch changed before activation");
209
204
  if (!["ok", "recoverableXrun", "deviceChanged"].includes(state.health))
210
205
  throw failure("device", `audio device is not ready: ${state.health}`);
211
206
  if (state.acknowledged && state.receivedFrames >= 1024 && state.hasVideo)
212
- break;
207
+ return p;
213
208
  if (Date.now() >= deadline)
214
209
  throw timeout("prefill", "timed out preparing the first buffers");
215
210
  }
211
+ }
212
+ catch (error) {
213
+ socket?.close();
214
+ if (producer !== undefined)
215
+ await host.producer.release(producer).catch(() => { });
216
+ throw error;
217
+ }
218
+ }
219
+ const clockOf = (p) => ({
220
+ get sample() {
221
+ return p.sample;
222
+ },
223
+ advance: (n) => (p.sample += n),
224
+ nextSequence: () => p.sequence++,
225
+ });
226
+ /**
227
+ * Wait until the host runs the selected producer: acknowledge its start
228
+ * control. `selected` says whether the host has accepted the selection yet
229
+ * (`"selected"`: it has, and a producer no longer active was replaced);
230
+ * `check` throws to give up.
231
+ */
232
+ async function activate(host, p, check, selected) {
233
+ for (const deadline = Date.now() + 10_000;; await sleep(10)) {
234
+ check();
235
+ const state = await host.producer.state(p.producer);
236
+ if (state.error)
237
+ throw failure("stream", state.error);
238
+ if (selected === "selected" && !state.active)
239
+ throw abortError();
240
+ if (state.pendingControl) {
241
+ p.epoch = state.pendingControl.epoch;
242
+ p.sample = state.pendingControl.baseSample;
243
+ p.sequence = 0;
244
+ await host.producer.ack(state.pendingControl);
245
+ }
246
+ if ((selected === "selected" || selected()) &&
247
+ state.active &&
248
+ !state.pendingControl &&
249
+ state.acknowledged &&
250
+ state.epoch === p.epoch)
251
+ return;
252
+ if (Date.now() >= deadline)
253
+ throw timeout("activation", "timed out waiting for stream activation");
254
+ }
255
+ }
256
+ /** Give a prepared producer up: its source (if still ours), connection, producer. */
257
+ async function abandon(service, p, generation) {
258
+ // A cancelled open may already have selected this producer: close only
259
+ // that source, before its frame connection (as in `stop`).
260
+ if (generation !== undefined)
261
+ await service.closeIfCurrent(generation).catch(() => { });
262
+ p.socket.close();
263
+ await service.runtime.host.producer.release(p.producer).catch(() => { });
264
+ }
265
+ const handleOf = (scope, service, p, generation) => new StreamHandle(scope, service, {
266
+ producer: p.producer,
267
+ socket: p.socket,
268
+ generation,
269
+ frame: p.frame,
270
+ epoch: p.epoch,
271
+ sample: p.sample,
272
+ sequence: p.sequence,
273
+ });
274
+ export async function openStream(scope, service, options) {
275
+ const host = service.runtime.host;
276
+ const check = () => {
277
+ if (options.signal?.aborted || scope.disposed)
278
+ throw abortError();
279
+ };
280
+ const p = await prepare(host, options, check);
281
+ let generation;
282
+ /** The request selecting the stream. */
283
+ let opened;
284
+ try {
216
285
  let accepted = false;
286
+ let failed;
217
287
  opened = service
218
- .submit({ kind: "openProvider", producerId: prepared, startPaused: false }, { accepted: (ticket) => (generation = ticket.stamp.generation) })
288
+ .submit({ kind: "openProvider", producerId: p.producer, startPaused: false }, { accepted: (ticket) => (generation = ticket.stamp.generation) })
219
289
  .then((ticket) => {
220
290
  generation = ticket.stamp.generation;
221
291
  accepted = true;
222
292
  });
223
- let failed;
224
293
  opened.catch((error) => (failed = error));
225
294
  // Activation needs this side to acknowledge the host's start control.
226
- for (const deadline = Date.now() + 10_000;; await sleep(10)) {
295
+ await activate(host, p, () => {
227
296
  check();
228
297
  if (failed)
229
298
  throw failed;
230
- const state = await host.producer.state(prepared);
231
- if (state.error)
232
- throw failure("stream", state.error);
233
- if (state.pendingControl) {
234
- epoch = state.pendingControl.epoch;
235
- sample = state.pendingControl.baseSample;
236
- sequence = 0;
237
- await host.producer.ack(state.pendingControl);
238
- }
239
- if (accepted &&
240
- state.active &&
241
- !state.pendingControl &&
242
- state.acknowledged &&
243
- state.epoch === epoch)
244
- break;
245
- if (Date.now() >= deadline)
246
- throw timeout("activation", "timed out waiting for stream activation");
247
- }
248
- const handle = new StreamHandle(scope, service, {
249
- producer: prepared,
250
- socket,
251
- generation: generation,
252
- frame,
253
- epoch,
254
- sample,
255
- sequence,
256
- });
257
- prepared = undefined;
258
- socket = undefined;
259
- return handle;
299
+ }, () => accepted);
300
+ return handleOf(scope, service, p, generation);
260
301
  }
261
302
  catch (error) {
262
303
  // An abort can arrive before the host answered the selecting request: wait
263
304
  // for that answer so a source selected afterwards is still closed.
264
- if (opened && prepared !== undefined)
265
- await opened.catch(() => { });
266
- // A cancelled open may already have selected this producer: close only
267
- // that source, before its frame connection (as in `stop`).
268
- if (generation !== undefined && prepared !== undefined)
269
- await service.closeIfCurrent(generation).catch(() => { });
270
- socket?.close();
271
- if (prepared !== undefined)
272
- await host.producer.release(prepared).catch(() => { });
305
+ await opened?.catch(() => { });
306
+ await abandon(service, p, generation);
273
307
  throw error;
274
308
  }
275
309
  }
310
+ /**
311
+ * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
312
+ * the open waits, then handed to the host as the answer. Once the host
313
+ * selected it, the start control is acknowledged and `started` gets the
314
+ * running stream, synchronously, before any `onStop`. Cancelled before or
315
+ * during activation (the open was replaced or closed, the plugin stopped),
316
+ * the producer is released and `started` is never called.
317
+ */
318
+ export async function resolvedStream(scope, service, stream, signal) {
319
+ const host = service.runtime.host;
320
+ const p = await prepare(host, stream, () => {
321
+ if (signal.aborted || scope.disposed)
322
+ throw abortError();
323
+ });
324
+ return {
325
+ kind: "stream",
326
+ producerId: p.producer,
327
+ abandoned: () => abandon(service, p, undefined),
328
+ accepted: async ({ stamp }) => {
329
+ try {
330
+ await activate(host, p, () => {
331
+ if (scope.disposed)
332
+ throw abortError();
333
+ }, "selected");
334
+ // Replaced before it ran: the source is no longer this stream.
335
+ if (service.raw.stamp.generation > stamp.generation)
336
+ throw abortError();
337
+ }
338
+ catch (error) {
339
+ await abandon(service, p, stamp.generation);
340
+ if (error.code !== "aborted")
341
+ scope.report(error);
342
+ return;
343
+ }
344
+ const handle = handleOf(scope, service, p, stamp.generation);
345
+ if (stream.started)
346
+ scope.run(stream.started, handle);
347
+ },
348
+ };
349
+ }
@@ -744,6 +744,45 @@ export declare class TestHost implements Transport {
744
744
  }>;
745
745
  /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
746
746
  readonly resolvers: Map<string, Json>;
747
+ /**
748
+ * Resolvers other plugins hold, as `resolvers.add` declares them plus the
749
+ * holder's id: a registration of this plugin that claims one of their
750
+ * extensions (or scheme + host pairs) is refused with `conflict`, which
751
+ * does not fail activation, like the player.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * const host = new TestHost();
756
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
757
+ * ```
758
+ */
759
+ readonly otherResolvers: {
760
+ /** The manifest id of the plugin holding them (shown in the refusal). */
761
+ owner: string;
762
+ /** File extensions it resolves (a file resolver), lower case, no dot. */
763
+ extensions?: string[];
764
+ /** URL schemes it resolves (a web resolver), with `hosts`. */
765
+ schemes?: string[];
766
+ /** Exact host names it resolves (a web resolver), with `schemes`. */
767
+ hosts?: string[];
768
+ }[];
769
+ /**
770
+ * File opens waiting for this plugin's file resolver (`extensions`), by
771
+ * request id: the path and how it was opened. Opening a matching path
772
+ * (`player.open`, {@link TestHost.openElsewhere}) sends
773
+ * `source.resolveRequest`; the answer selects the stream, fails the open
774
+ * or, once the source changed, is `stale`. Any later source change
775
+ * cancels it (`source.resolveCancel`). Web resolvers are not simulated:
776
+ * emit `source.resolveRequest` yourself.
777
+ */
778
+ readonly resolutions: Map<number, {
779
+ /** The id of this plugin's resolver asked (`resolvers.add` `id`). */
780
+ resolverId: string;
781
+ /** The path being opened, as sent to `resolve`. */
782
+ source: string;
783
+ /** The open asked for a paused start (`player.open(path, { paused })`). */
784
+ paused: boolean;
785
+ }>;
747
786
  /**
748
787
  * Answers for `dialogs.open` (paths, `null` = cancelled) and
749
788
  * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
@@ -1089,9 +1128,16 @@ export declare class TestHost implements Transport {
1089
1128
  */
1090
1129
  readonly remoteImages: Map<string, string | null>;
1091
1130
  /**
1092
- * The Markdown document a property holds (its wire form read back), or
1131
+ * The Markdown document a value holds (its wire form read back), or
1093
1132
  * `null` when the value is not one. `images` maps each remote address to
1094
- * the downloaded file.
1133
+ * the downloaded file. Pass a whole property value or a field of one: a
1134
+ * document nested in rows is read the same way.
1135
+ *
1136
+ * @example
1137
+ * ```ts
1138
+ * const rows = host.window("chat").props["messages"] as { body: Json }[];
1139
+ * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
1140
+ * ```
1095
1141
  */
1096
1142
  markdown(value: Json | undefined): MarkdownWire | null;
1097
1143
  /**