@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 +25 -0
- package/dist/plugin/resolvers.js +16 -4
- package/dist/plugin/stream.d.ts +11 -1
- package/dist/plugin/stream.js +142 -68
- package/dist/plugin/testing.d.ts +48 -2
- package/dist/plugin/testing.js +218 -16
- package/dist/plugin/types.d.ts +176 -27
- package/dist/plugin/ui.js +22 -7
- package/dist/source-resolvers.d.ts +33 -6
- package/dist/source-resolvers.js +42 -14
- package/dist/wire.d.ts +1 -1
- package/package.json +1 -1
- package/slint/vestra/markdown.slint +56 -5
package/dist/plugin/testing.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
2281
|
-
|
|
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
|
|
2710
|
-
"sourceResolvers.unregister": (p) =>
|
|
2711
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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();
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -562,18 +562,31 @@ export interface ImageRef {
|
|
|
562
562
|
readonly __vestraImage: true;
|
|
563
563
|
}
|
|
564
564
|
/**
|
|
565
|
-
* A Markdown document for `MarkdownView`, made by
|
|
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
|
|
569
|
-
* `[MarkdownBlock]
|
|
570
|
-
* `@vestra/markdown.slint`)
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
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
|
|
611
|
-
* as the whole value of
|
|
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
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
3815
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
3850
|
-
* `resolve`; `signal` aborts when the open is cancelled or
|
|
3851
|
-
* scheme + host pair has one resolver
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
35
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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) {
|