@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.
@@ -61,7 +61,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
61
61
  import { createServer } from "node:net";
62
62
  import { randomBytes } from "node:crypto";
63
63
  import { tmpdir } from "node:os";
64
- import { join } from "node:path";
64
+ import { extname, isAbsolute, join } from "node:path";
65
65
  import { fileURLToPath } from "node:url";
66
66
  import { HostError } from "../wire.js";
67
67
  import { COMMAND_LIMITS, MENU_IDS } from "../commands.js";
@@ -110,7 +110,10 @@ const readyCapabilities = {
110
110
  close: true,
111
111
  snapshotSource: true,
112
112
  };
113
- /** Refusals for a position another plugin holds: not activation failures (`startPlugin`). */
113
+ /**
114
+ * Refusals for a position or resolver another plugin holds: not activation
115
+ * failures (`startPlugin`).
116
+ */
114
117
  const takenErrors = new WeakSet();
115
118
  const taken = (message) => {
116
119
  const error = new HostError("conflict", message);
@@ -757,6 +760,29 @@ export class TestHost {
757
760
  producers = new Map();
758
761
  /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
759
762
  resolvers = new Map();
763
+ /**
764
+ * Resolvers other plugins hold, as `resolvers.add` declares them plus the
765
+ * holder's id: a registration of this plugin that claims one of their
766
+ * extensions (or scheme + host pairs) is refused with `conflict`, which
767
+ * does not fail activation, like the player.
768
+ *
769
+ * @example
770
+ * ```ts
771
+ * const host = new TestHost();
772
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
773
+ * ```
774
+ */
775
+ otherResolvers = [];
776
+ /**
777
+ * File opens waiting for this plugin's file resolver (`extensions`), by
778
+ * request id: the path and how it was opened. Opening a matching path
779
+ * (`player.open`, {@link TestHost.openElsewhere}) sends
780
+ * `source.resolveRequest`; the answer selects the stream, fails the open
781
+ * or, once the source changed, is `stale`. Any later source change
782
+ * cancels it (`source.resolveCancel`). Web resolvers are not simulated:
783
+ * emit `source.resolveRequest` yourself.
784
+ */
785
+ resolutions = new Map();
760
786
  /**
761
787
  * Answers for `dialogs.open` (paths, `null` = cancelled) and
762
788
  * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
@@ -850,8 +876,12 @@ export class TestHost {
850
876
  #overrides = new Map();
851
877
  #listeners = new Map();
852
878
  #inFlight = 0;
853
- #next = { snapshot: 0, job: 0, producer: 0, source: 0 };
879
+ #next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
854
880
  #sourceBook = new Map();
881
+ /** The pending resolutions' stamps and operations (beside `resolutions`). */
882
+ #resolving = new Map();
883
+ /** The producer the source is (`openProvider`, a resolved stream). */
884
+ #selected = null;
855
885
  #scratch = mkdtempSync(join(tmpdir(), "vestra-test-host-"));
856
886
  /**
857
887
  * `plugin.dataDirectory`: a fresh empty temporary directory, removed with
@@ -1351,7 +1381,9 @@ export class TestHost {
1351
1381
  */
1352
1382
  openElsewhere(source, phase = "ready") {
1353
1383
  this.#intent({ kind: "open", source, startPaused: false });
1354
- this.setSource({ phase });
1384
+ // A file this plugin's file resolver claims waits for its answer.
1385
+ if (this.#resolve(source, false) === null)
1386
+ this.setSource({ phase });
1355
1387
  }
1356
1388
  /**
1357
1389
  * Press a binding like the host's table would: panel-focus bindings first
@@ -1450,9 +1482,16 @@ export class TestHost {
1450
1482
  */
1451
1483
  remoteImages = new Map();
1452
1484
  /**
1453
- * The Markdown document a property holds (its wire form read back), or
1485
+ * The Markdown document a value holds (its wire form read back), or
1454
1486
  * `null` when the value is not one. `images` maps each remote address to
1455
- * the downloaded file.
1487
+ * the downloaded file. Pass a whole property value or a field of one: a
1488
+ * document nested in rows is read the same way.
1489
+ *
1490
+ * @example
1491
+ * ```ts
1492
+ * const rows = host.window("chat").props["messages"] as { body: Json }[];
1493
+ * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
1494
+ * ```
1456
1495
  */
1457
1496
  markdown(value) {
1458
1497
  const doc = value
@@ -1682,14 +1721,166 @@ export class TestHost {
1682
1721
  if (control.kind === "rate")
1683
1722
  next.rate = Number(control.rate);
1684
1723
  }
1724
+ if (intent.kind === "openProvider")
1725
+ this.#selected = Number(intent.producerId);
1726
+ if (intent.kind === "open" || intent.kind === "close")
1727
+ this.#selected = null;
1685
1728
  this.source = next;
1686
1729
  this.subtitles.source = {
1687
1730
  generation: next.stamp.generation,
1688
1731
  phase: next.phase,
1689
1732
  };
1690
1733
  this.emit("source.state", this.source);
1734
+ this.#cancelResolutions();
1691
1735
  return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
1692
1736
  }
1737
+ /** The file resolver of this plugin that claims `source`, if any. */
1738
+ #fileResolver(source) {
1739
+ if (source.includes("://") || !isAbsolute(source))
1740
+ return null;
1741
+ const extension = extname(source).slice(1).toLowerCase();
1742
+ if (!extension)
1743
+ return null;
1744
+ for (const [id, declaration] of this.resolvers) {
1745
+ const extensions = declaration.extensions;
1746
+ if (extensions?.includes(extension))
1747
+ return id;
1748
+ }
1749
+ return null;
1750
+ }
1751
+ /** Whether producer `id` is the player's source. */
1752
+ #current(id) {
1753
+ return (this.source.kind === "producer" &&
1754
+ (this.#selected === id || this.source.source === `producer://${id}`));
1755
+ }
1756
+ /** Start resolving `source` for the open just made; its request id, or null. */
1757
+ #resolve(source, paused, operation) {
1758
+ const resolverId = this.#fileResolver(source);
1759
+ if (resolverId === null)
1760
+ return null;
1761
+ const requestId = ++this.#next.resolution;
1762
+ this.resolutions.set(requestId, { resolverId, source, paused });
1763
+ this.#resolving.set(requestId, {
1764
+ stamp: { ...this.source.stamp },
1765
+ ...(operation === undefined ? {} : { operation }),
1766
+ });
1767
+ this.emit("source.resolveRequest", { requestId, resolverId, url: source });
1768
+ return requestId;
1769
+ }
1770
+ /** Settle a resolution's open; `error` fails it. */
1771
+ #settle(requestId, error) {
1772
+ const operation = this.#resolving.get(requestId)?.operation;
1773
+ this.resolutions.delete(requestId);
1774
+ this.#resolving.delete(requestId);
1775
+ if (operation === undefined)
1776
+ return;
1777
+ const entry = this.#sourceBook.get(operation);
1778
+ this.#sourceBook.set(operation, error === undefined
1779
+ ? { status: "completed", ticket: entry?.ticket ?? null }
1780
+ : { status: "failed", error: { backend: error } });
1781
+ }
1782
+ /** Resolutions whose open is no longer the source (or whose resolver left). */
1783
+ #cancelResolutions() {
1784
+ const stamp = this.source.stamp;
1785
+ for (const [requestId, resolution] of [...this.resolutions]) {
1786
+ const pending = this.#resolving.get(requestId);
1787
+ if (pending.stamp.generation === stamp.generation &&
1788
+ pending.stamp.intentVersion === stamp.intentVersion &&
1789
+ this.resolvers.has(resolution.resolverId))
1790
+ continue;
1791
+ this.#settle(requestId, "source resolution cancelled, expired or superseded");
1792
+ this.emit("source.resolveCancel", {
1793
+ requestId,
1794
+ resolverId: resolution.resolverId,
1795
+ });
1796
+ }
1797
+ }
1798
+ /** `sourceResolvers.register`: the player's rules for both forms. */
1799
+ #register(p) {
1800
+ const id = String(p.id);
1801
+ const extensions = p.extensions;
1802
+ if (this.resolvers.has(id))
1803
+ throw new HostError("conflict", "resolver id already registered");
1804
+ if (extensions !== undefined) {
1805
+ if (p.schemes !== undefined ||
1806
+ p.hosts !== undefined ||
1807
+ !Array.isArray(extensions) ||
1808
+ extensions.length < 1 ||
1809
+ extensions.length > 16 ||
1810
+ extensions.some((e) => typeof e !== "string" ||
1811
+ !/^[a-z0-9]{1,16}$/.test(e) ||
1812
+ e === "folder"))
1813
+ throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
1814
+ }
1815
+ const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
1816
+ (other.hosts?.some((h) => p.hosts?.includes(h)) &&
1817
+ other.schemes?.some((x) => p.schemes?.includes(x))
1818
+ ? "host"
1819
+ : undefined);
1820
+ for (const own of this.resolvers.values())
1821
+ if (claims(own))
1822
+ throw new HostError("conflict", "already registered by this plugin");
1823
+ for (const other of this.otherResolvers) {
1824
+ const what = claims(other);
1825
+ if (what) {
1826
+ const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1827
+ throw taken(`${target} 已由 ${other.owner} 解析`);
1828
+ }
1829
+ }
1830
+ this.resolvers.set(id, p);
1831
+ }
1832
+ /** `sourceResolvers.respond` for a simulated file resolution. */
1833
+ #respond(p) {
1834
+ const requestId = Number(p.requestId);
1835
+ const resolution = this.resolutions.get(requestId);
1836
+ if (!resolution) {
1837
+ // Web resolutions are not simulated: their answers are only recorded.
1838
+ if (requestId > this.#next.resolution)
1839
+ return null;
1840
+ throw new HostError("stale", "resolution no longer pending");
1841
+ }
1842
+ const fail = (message) => {
1843
+ this.#settle(requestId, message);
1844
+ this.setSource({
1845
+ phase: "error",
1846
+ terminalReason: { failed: message },
1847
+ });
1848
+ throw new HostError("resolution", message);
1849
+ };
1850
+ if (typeof p.error === "string")
1851
+ fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
1852
+ const result = (p.result ?? {});
1853
+ const producer = this.producers.get(Number(result.producerId));
1854
+ if (result.kind !== "stream")
1855
+ fail("invalid resolved stream descriptor");
1856
+ if (!producer || producer.released)
1857
+ fail("unavailable: producer absent");
1858
+ // Like openProvider, but the source keeps the path and `startPaused`.
1859
+ this.#selected = producer.id;
1860
+ producer.active = true;
1861
+ producer.acknowledged = false;
1862
+ producer.pending = {
1863
+ producerId: producer.id,
1864
+ epoch: producer.epoch + 1,
1865
+ baseSample: producer.receivedFrames,
1866
+ paused: resolution.paused,
1867
+ };
1868
+ this.#settle(requestId);
1869
+ this.setSource({
1870
+ kind: "producer",
1871
+ phase: "ready",
1872
+ paused: resolution.paused,
1873
+ capabilities: {
1874
+ pause: true,
1875
+ seek: false,
1876
+ rate: false,
1877
+ restart: false,
1878
+ close: true,
1879
+ snapshotSource: false,
1880
+ },
1881
+ });
1882
+ return { stamp: { ...this.source.stamp } };
1883
+ }
1693
1884
  #processingState() {
1694
1885
  const list = (stage) => [...this.processing.resources]
1695
1886
  .filter(([, value]) => value.stage === stage)
@@ -2277,8 +2468,17 @@ export class TestHost {
2277
2468
  "source.state": () => this.source,
2278
2469
  "source.submit": (p) => {
2279
2470
  const intent = p.intent;
2280
- const result = { status: "completed", ...this.#intent(intent) };
2281
- this.#sourceBook.set(Number(p.operationId), result);
2471
+ const ticket = this.#intent(intent);
2472
+ const operation = Number(p.operationId);
2473
+ // A file this plugin's file resolver claims: pending until it answers.
2474
+ if (intent.kind === "open" &&
2475
+ this.#resolve(String(intent.source), intent.startPaused === true, operation) !== null) {
2476
+ const pending = { status: "pending", ...ticket };
2477
+ this.#sourceBook.set(operation, pending);
2478
+ return pending;
2479
+ }
2480
+ const result = { status: "completed", ...ticket };
2481
+ this.#sourceBook.set(operation, result);
2282
2482
  // Selecting a provider asks it to acknowledge a start control.
2283
2483
  const producer = this.producers.get(Number(intent.producerId));
2284
2484
  if (intent.kind === "openProvider" && producer) {
@@ -2706,9 +2906,12 @@ export class TestHost {
2706
2906
  return { jobId: job.jobId, cancel: "requested" };
2707
2907
  },
2708
2908
  "jobs.operation": () => ({ status: "unknown" }),
2709
- "sourceResolvers.register": (p) => void this.resolvers.set(String(p.id), p),
2710
- "sourceResolvers.unregister": (p) => void this.resolvers.delete(String(p.id)),
2711
- "sourceResolvers.respond": () => null,
2909
+ "sourceResolvers.register": (p) => void this.#register(p),
2910
+ "sourceResolvers.unregister": (p) => {
2911
+ this.resolvers.delete(String(p.id));
2912
+ this.#cancelResolutions();
2913
+ },
2914
+ "sourceResolvers.respond": (p) => this.#respond(p),
2712
2915
  "producer.prepare": async () => {
2713
2916
  const id = ++this.#next.producer;
2714
2917
  const tcp = this.producerTransport === "tcp";
@@ -2754,8 +2957,7 @@ export class TestHost {
2754
2957
  });
2755
2958
  socket.on("error", () => { });
2756
2959
  socket.on("close", () => {
2757
- if (this.source.kind === "producer" &&
2758
- this.source.source === `producer://${producer.id}`)
2960
+ if (this.#current(producer.id))
2759
2961
  producer.droppedWhileSelected = true;
2760
2962
  });
2761
2963
  });
@@ -2786,8 +2988,7 @@ export class TestHost {
2786
2988
  const producer = this.producers.get(Number(p.producerId));
2787
2989
  if (!producer)
2788
2990
  throw new HostError("missing", "producer");
2789
- const current = this.source.kind === "producer" &&
2790
- this.source.source === `producer://${producer.id}`;
2991
+ const current = this.#current(producer.id);
2791
2992
  return {
2792
2993
  input: null,
2793
2994
  pendingControl: producer.pending,
@@ -2889,7 +3090,8 @@ export async function startPlugin(activate, options = {}) {
2889
3090
  mkdirSync(data, { recursive: true });
2890
3091
  const instance = await createPlugin(host, manifest, directory, data, host.app);
2891
3092
  await activate(instance.plugin);
2892
- // Like the player: a position another plugin holds is not an activation failure.
3093
+ // Like the player: a position or resolver another plugin holds is not an
3094
+ // activation failure.
2893
3095
  for (;;) {
2894
3096
  try {
2895
3097
  await instance.activated();
@@ -562,18 +562,31 @@ export interface ImageRef {
562
562
  readonly __vestraImage: true;
563
563
  }
564
564
  /**
565
- * A Markdown document for `MarkdownView`, made by {@link Ui.markdown}.
565
+ * A Markdown document for `MarkdownView` or `MarkdownBlocks`, made by
566
+ * {@link Ui.markdown}.
566
567
  *
567
568
  * @remarks
568
- * Opaque, like {@link ImageRef}: pass it as the whole value of a
569
- * `[MarkdownBlock]` property (the `blocks` of `MarkdownView` from
570
- * `@vestra/markdown.slint`). It carries the text, the base address and the
571
- * local copies of its remote images; the player parses and draws it. It
572
- * belongs to the scope that created it: once that scope is disposed, writing
573
- * it fails with `invalid`.
574
- *
575
- * @player 0.2.0
576
- * @see {@link Ui.markdown}, {@link MarkdownOptions}
569
+ * Opaque, like {@link ImageRef}: pass it where the Slint type is
570
+ * `[MarkdownBlock]`: the whole value of such a property (the `blocks` of
571
+ * `MarkdownView` from `@vestra/markdown.slint`) or, from SDK 1.5.4, a
572
+ * `[MarkdownBlock]` field at any depth of a property value, such as the
573
+ * `body` of each row of a chat's message list. It carries the text, the
574
+ * base address and the local copies of its remote images; the player parses
575
+ * and draws it. Writing a document whose text did not change (a new
576
+ * reference for the same text included) costs the player no parsing, and a
577
+ * changed document updates only the blocks that changed. It belongs to the
578
+ * scope that created it: once that scope is disposed, writing it fails with
579
+ * `invalid`, wherever it is in the value.
580
+ *
581
+ * @example Chat messages, one document per row
582
+ * ```ts
583
+ * const chat = plugin.windows.create("ui/chat.slint");
584
+ * const body = await plugin.ui.markdown("**Hello**", { remoteImages: false });
585
+ * chat.set({ messages: [{ id: "m1", role: "assistant", body }] });
586
+ * ```
587
+ *
588
+ * @player 0.2.0; nested in a value: the player after 0.4.0
589
+ * @see {@link Ui.markdown}, {@link MarkdownOptions}, {@link PropValue}
577
590
  * @since 1.1.0
578
591
  * @category UI
579
592
  */
@@ -607,8 +620,9 @@ export interface MarkdownOptions {
607
620
  remoteImages?: boolean;
608
621
  }
609
622
  /**
610
- * The value of one Slint property: JSON, or an image or a Markdown document
611
- * as the whole value of a property.
623
+ * The value of one Slint property: JSON with Markdown documents at any
624
+ * depth ({@link PropData}), or an image as the whole value of an `image`
625
+ * property.
612
626
  *
613
627
  * @remarks
614
628
  * JSON becomes Slint values as you would expect: booleans for `bool`,
@@ -618,14 +632,37 @@ export interface MarkdownOptions {
618
632
  * is refused (Slint treats `_` and `-` as the same, so use camelCase or
619
633
  * kebab-case).
620
634
  *
621
- * Limits per value: nesting depth 8, 4096 values in total, 1024 rows per
622
- * array, 64 KiB per string and 1 MiB of text in total. A value over a limit
623
- * is a failed write (see {@link View}).
635
+ * A {@link MarkdownRef} goes where the Slint type is `[MarkdownBlock]`: the
636
+ * whole value, or a field at any depth (SDK 1.5.4; older players refuse a
637
+ * nested one). An {@link ImageRef} only as the whole value: nested in an
638
+ * array or object it is `invalid`.
639
+ *
640
+ * Limits per value: nesting depth 8, 4096 values in total (a document is
641
+ * one), 1024 rows per array, 64 KiB per string and 1 MiB of text in total,
642
+ * the text of its Markdown documents included (256 KB per document). A
643
+ * value over a limit is a failed write (see {@link View}).
624
644
  *
625
645
  * @since 1.0.0
626
646
  * @category UI
627
647
  */
628
- export type PropValue = Json | ImageRef | MarkdownRef;
648
+ export type PropValue = PropData | ImageRef;
649
+ /**
650
+ * JSON whose values may also be Markdown documents, at any depth: the part
651
+ * of {@link PropValue} that nests.
652
+ *
653
+ * @example A message list whose rows carry a document
654
+ * ```ts
655
+ * const messages: PropData = [
656
+ * { id: "m1", role: "user", body: await plugin.ui.markdown("Hi") },
657
+ * ];
658
+ * ```
659
+ *
660
+ * @since 1.5.4
661
+ * @category UI
662
+ */
663
+ export type PropData = null | boolean | number | string | MarkdownRef | PropData[] | {
664
+ [key: string]: PropData;
665
+ };
629
666
  /**
630
667
  * Properties to write to a view, by name.
631
668
  *
@@ -2771,7 +2808,12 @@ export interface PlayerState {
2771
2808
  readonly kind: "media" | "stream";
2772
2809
  /** The source's life cycle. */
2773
2810
  readonly phase: "empty" | "loading" | "ready" | "ended" | "error";
2774
- /** Path or URL. */
2811
+ /**
2812
+ * Path or URL; `null` when empty. A generated stream started with
2813
+ * {@link Player.openStream} has the opaque `producer://<n>` (do not parse
2814
+ * it); one a file resolver generates ({@link FileResolverOptions}) keeps
2815
+ * the path that was opened. Tell streams apart by `kind`, not by `source`.
2816
+ */
2775
2817
  readonly source: string | null;
2776
2818
  /** Playback is paused. */
2777
2819
  readonly paused: boolean;
@@ -3087,7 +3129,9 @@ export interface Player {
3087
3129
  *
3088
3130
  * It does not wait for playback: use {@link Media.ready}. `paused` opens
3089
3131
  * it paused; aborting `signal` closes it. URLs matching a registered
3090
- * {@link Resolver} are resolved first.
3132
+ * {@link Resolver} are resolved first, and so are local files of an
3133
+ * extension a file resolver claims (the source is then a stream that
3134
+ * plugin generates; `paused` reaches it).
3091
3135
  */
3092
3136
  open(source: string, options?: {
3093
3137
  paused?: boolean;
@@ -3811,8 +3855,13 @@ export interface MediaCapture {
3811
3855
  */
3812
3856
  capture(options?: CaptureOptions): Promise<Snapshot>;
3813
3857
  }
3814
- /** Declaration of {@link Resolvers.add}. */
3815
- export interface ResolverOptions {
3858
+ /**
3859
+ * Declaration of {@link Resolvers.add}: a web resolver
3860
+ * ({@link WebResolverOptions}) or a file resolver ({@link FileResolverOptions}).
3861
+ */
3862
+ export type ResolverOptions = WebResolverOptions | FileResolverOptions;
3863
+ /** A resolver for web pages: `https://host/…` to playable stream URLs. */
3864
+ export interface WebResolverOptions {
3816
3865
  /** Local id, unique within the plugin. */
3817
3866
  id: string;
3818
3867
  /** URL schemes handled. */
@@ -3822,14 +3871,104 @@ export interface ResolverOptions {
3822
3871
  /** Turn a page URL into playable stream URLs. */
3823
3872
  resolve(url: string, signal: AbortSignal): Promise<ResolvedMedia>;
3824
3873
  }
3874
+ /**
3875
+ * A resolver for local files by extension (SDK 1.5.3, player 0.4.1): when
3876
+ * anyone opens such a file with {@link Player.open} (the Open dialog, the
3877
+ * playlist, next / previous, the recent list, a drop that calls `open`),
3878
+ * the player asks this plugin for a stream it generates instead of
3879
+ * playing the file. Needs `source.resolve` and `producer.av`.
3880
+ *
3881
+ * @remarks
3882
+ * The open keeps everything of the original call: its `ifCurrent`, its
3883
+ * operation (the opener's `open` and `media.ready()` settle as for any
3884
+ * file), `paused` (the stream starts paused), and its path as
3885
+ * `state.source`, with `state.kind` `"stream"`. A thrown error fails that
3886
+ * open: `phase` `"error"` with the error's message as `state.error`,
3887
+ * word for word. The stream belongs to this plugin, as with
3888
+ * {@link Player.openStream}.
3889
+ *
3890
+ * Cancellation: `signal` aborts when the open is replaced, closed
3891
+ * (`player.close()`, F4) or times out (30 seconds), or this resolver is
3892
+ * disposed. Cancelled after `resolve` returned but before the stream ran,
3893
+ * the SDK releases the stream and never calls `started`.
3894
+ *
3895
+ * One extension has one resolver: another plugin's resolver for it makes
3896
+ * {@link Resolver.ready} reject with `conflict` without failing activation
3897
+ * (the player shows a warning). When this plugin stops, its extensions are
3898
+ * free again and such files go to the player as before.
3899
+ *
3900
+ * @example
3901
+ * ```ts
3902
+ * const { readFile } = await import("node:fs/promises");
3903
+ * // The plugin's own emulator: one frame per call.
3904
+ * const emulator = (rom: Uint8Array): (() => StreamFrame) => {
3905
+ * const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
3906
+ * return () => ({ video, audio: new Float32Array(1600) });
3907
+ * };
3908
+ * plugin.resolvers.add({
3909
+ * id: "nes",
3910
+ * extensions: ["nes"],
3911
+ * async resolve(path, signal) {
3912
+ * const frame = emulator(await readFile(path, { signal }));
3913
+ * return {
3914
+ * kind: "stream",
3915
+ * width: 256,
3916
+ * height: 240,
3917
+ * frame,
3918
+ * started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
3919
+ * };
3920
+ * },
3921
+ * });
3922
+ * ```
3923
+ */
3924
+ export interface FileResolverOptions {
3925
+ /** Local id, unique within the plugin. */
3926
+ id: string;
3927
+ /**
3928
+ * Lower case, without the dot: 1–16 entries of 1–16 lower-case letters or
3929
+ * digits (not `"folder"`). Only absolute local paths match, by their
3930
+ * extension in any case (`/roms/Game.NES`); URLs never do.
3931
+ */
3932
+ extensions: string[];
3933
+ /** Read the file and describe the stream to generate for it. */
3934
+ resolve(path: string, signal: AbortSignal): Promise<ResolvedStream>;
3935
+ }
3936
+ /**
3937
+ * What a file resolver returns: a stream, with the same frame contract as
3938
+ * {@link StreamOptions} (size limits, `frame()` on the audio clock).
3939
+ */
3940
+ export interface ResolvedStream {
3941
+ /** Always `"stream"`. */
3942
+ kind: "stream";
3943
+ /** Frame width in pixels. */
3944
+ width: number;
3945
+ /** Frame height in pixels. */
3946
+ height: number;
3947
+ /** Produce the next frame. Never called concurrently or while paused. */
3948
+ frame(): StreamFrame;
3949
+ /**
3950
+ * Called with the running stream once the player plays it, synchronously
3951
+ * after activation and before any {@link Stream.onStop}. Not called when
3952
+ * the open was cancelled first. `stream.stop()` closes the source (if
3953
+ * still current); the plugin stopping stops it too.
3954
+ */
3955
+ started?(stream: Stream): void;
3956
+ }
3825
3957
  /** A registered resolver. Disposing it unregisters it and aborts resolutions in flight. */
3826
3958
  export interface Resolver extends Disposable {
3827
- /** Resolves when the player registered it; rejects (e.g. `conflict`) otherwise. */
3959
+ /**
3960
+ * Resolves when the player registered it; rejects otherwise: `conflict`
3961
+ * when another resolver has the extension or the scheme + host (another
3962
+ * plugin's: activation goes on; this plugin's own: activation fails),
3963
+ * `invalid` for a malformed declaration (a player before 0.4.1 also for
3964
+ * `extensions`).
3965
+ */
3828
3966
  readonly ready: Promise<void>;
3829
3967
  }
3830
3968
  /**
3831
- * `plugin.resolvers`: turn web page URLs into playable streams. Needs
3832
- * `source.resolve`.
3969
+ * `plugin.resolvers`: turn web page URLs into playable streams, or local
3970
+ * files into streams this plugin generates. Needs `source.resolve` (file
3971
+ * resolvers also `producer.av`).
3833
3972
  *
3834
3973
  * @example
3835
3974
  * ```ts
@@ -3846,9 +3985,11 @@ export interface Resolver extends Disposable {
3846
3985
  */
3847
3986
  export interface Resolvers {
3848
3987
  /**
3849
- * Register a resolver. When anyone opens a matching URL the player calls
3850
- * `resolve`; `signal` aborts when the open is cancelled or replaced. One
3851
- * scheme + host pair has one resolver (`conflict` otherwise).
3988
+ * Register a resolver. When anyone opens a matching URL or file the
3989
+ * player calls `resolve`; `signal` aborts when the open is cancelled or
3990
+ * replaced. One scheme + host pair, and one extension, has one resolver
3991
+ * (`conflict` otherwise). A rejected `resolve` fails the open with the
3992
+ * error's message.
3852
3993
  */
3853
3994
  add(options: ResolverOptions): Resolver;
3854
3995
  }
@@ -4029,10 +4170,18 @@ export interface PluginInfo {
4029
4170
  * @since 1.5.2
4030
4171
  */
4031
4172
  export interface PluginWarning {
4032
- /** `"slot-occupied"` or `"sequence-taken"`; a newer player may add codes. */
4173
+ /**
4174
+ * `"slot-occupied"`, `"sequence-taken"` or `"resolver-taken"` (player
4175
+ * 0.4.1); a newer player may add codes.
4176
+ */
4033
4177
  code: string;
4034
4178
  /** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
4035
4179
  slot?: string;
4180
+ /**
4181
+ * What was taken, for `resolver-taken`: 「文件类型 .nes」 or the
4182
+ * `scheme://host` of a web resolver.
4183
+ */
4184
+ target?: string;
4036
4185
  /** The manifest id of the plugin holding it (absent for a full sidebar). */
4037
4186
  owner?: string;
4038
4187
  }
package/dist/plugin/ui.js CHANGED
@@ -31,18 +31,33 @@ export class ImageHandle {
31
31
  return { $vestraImage: this.path, ref: this.#ref };
32
32
  }
33
33
  }
34
- /** A property value as sent: images and documents in wire form; released ones refused. */
35
- function wireValue(value) {
34
+ /**
35
+ * A property value as sent: images and documents in wire form, documents at
36
+ * any depth (released ones refused wherever they are); an image only as the
37
+ * whole value, like the player.
38
+ */
39
+ function wireValue(value, nested = false) {
36
40
  if (value instanceof MarkdownHandle) {
37
41
  if (value.released)
38
42
  throw failure("invalid", "Markdown document was released with the scope that created it");
39
43
  return value.toJSON();
40
44
  }
41
- if (!(value instanceof ImageHandle))
42
- return value;
43
- if (value.released)
44
- throw failure("invalid", `image ${value.path} was released with the scope that created it`);
45
- return value.toJSON();
45
+ if (value instanceof ImageHandle) {
46
+ if (nested)
47
+ throw failure("invalid", `image ${value.path}: an image reference must be the whole value of an image property`);
48
+ if (value.released)
49
+ throw failure("invalid", `image ${value.path} was released with the scope that created it`);
50
+ return value.toJSON();
51
+ }
52
+ if (Array.isArray(value))
53
+ return value.map((item) => wireValue(item, true));
54
+ if (value !== null && typeof value === "object") {
55
+ const out = {};
56
+ for (const [key, item] of Object.entries(value))
57
+ out[key] = wireValue(item, true);
58
+ return out;
59
+ }
60
+ return value;
46
61
  }
47
62
  export const ui = (scope) => ({
48
63
  image(path) {