@vestra-player/sdk 1.5.1 → 1.5.3

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,6 +110,23 @@ const readyCapabilities = {
110
110
  close: true,
111
111
  snapshotSource: true,
112
112
  };
113
+ /**
114
+ * Refusals for a position or resolver another plugin holds: not activation
115
+ * failures (`startPlugin`).
116
+ */
117
+ const takenErrors = new WeakSet();
118
+ const taken = (message) => {
119
+ const error = new HostError("conflict", message);
120
+ takenErrors.add(error);
121
+ return error;
122
+ };
123
+ const SLOT_LABELS = {
124
+ bottom: "底栏",
125
+ sidebar: "侧栏",
126
+ backdrop: "视频背景",
127
+ };
128
+ /** The player's limit of sidebar tabs. */
129
+ const MAX_SIDEBAR_TABS = 8;
113
130
  /**
114
131
  * An in-memory fake Vestra Player for plugin tests.
115
132
  *
@@ -531,6 +548,109 @@ export class TestHost {
531
548
  this.backdrop.effective = effective;
532
549
  this.emit("window.state", this.#windowFacts());
533
550
  }
551
+ #nextOwner = 2;
552
+ /** Owners of other plugins' panels, by plugin id ({@link TestHost.mountOther}). */
553
+ #others = new Map();
554
+ /**
555
+ * Simulate another plugin mounting a panel, before or after the plugin
556
+ * under test, as the player handles it: in the sidebar it is another tab
557
+ * (after the existing ones; the first panel's tab is current); the bottom
558
+ * bar and the backdrop take one panel, so the plugin's own mount there
559
+ * then rejects with `conflict` (not an activation failure, as in the
560
+ * player). Pushed as a `window.state` change.
561
+ *
562
+ * @param slot - The position.
563
+ * @param plugin - The other plugin's manifest id.
564
+ * @param options - `title`: its tab label (default: `plugin`).
565
+ *
566
+ * @example The playlist next to this plugin's sidebar panel
567
+ * ```ts
568
+ * const host = new TestHost();
569
+ * host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
570
+ * const { plugin } = await startPlugin(activate, { host, directory });
571
+ * assert.equal(host.layout.sidebar?.panels?.length, 2);
572
+ * ```
573
+ *
574
+ * @since 1.5.2
575
+ */
576
+ mountOther(slot, plugin, options = {}) {
577
+ if (this.#others.has(plugin))
578
+ throw new Error(`${plugin} already has a panel`);
579
+ const owner = this.#nextOwner++;
580
+ if (slot === "backdrop") {
581
+ if (this.backdrop)
582
+ throw new Error("the backdrop is taken");
583
+ this.backdrop = { owner: plugin, effective: true };
584
+ }
585
+ else {
586
+ if (slot === "bottom" && this.layout.bottom)
587
+ throw new Error("the bottom bar is taken");
588
+ this.#addPanel(slot, { owner, plugin, title: options.title ?? plugin });
589
+ }
590
+ this.#others.set(plugin, { slot, owner });
591
+ this.emit("window.state", this.#windowFacts());
592
+ }
593
+ /**
594
+ * Simulate another plugin's panel going away ({@link TestHost.mountOther}):
595
+ * when its tab was current, the next tab (else the previous one) is.
596
+ *
597
+ * @since 1.5.2
598
+ */
599
+ unmountOther(plugin) {
600
+ const other = this.#others.get(plugin);
601
+ if (!other)
602
+ throw new Error(`${plugin} has no panel`);
603
+ this.#others.delete(plugin);
604
+ if (other.slot === "backdrop")
605
+ this.backdrop = null;
606
+ else
607
+ this.#removePanel(other.slot, other.owner);
608
+ this.emit("window.state", this.#windowFacts());
609
+ }
610
+ /**
611
+ * Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin
612
+ * under test's `manifestId`, or one of {@link TestHost.mountOther})
613
+ * becomes the current tab. Pushed as a `window.state` change.
614
+ *
615
+ * @since 1.5.2
616
+ */
617
+ selectTab(plugin) {
618
+ const slot = this.layout.sidebar;
619
+ const panel = slot?.panels?.find((entry) => entry.plugin === plugin);
620
+ if (!slot || !panel)
621
+ throw new Error(`${plugin} has no sidebar tab`);
622
+ slot.owner = panel.owner;
623
+ this.emit("window.state", this.#windowFacts());
624
+ }
625
+ #addPanel(slot, panel) {
626
+ const state = this.layout[slot];
627
+ if (state)
628
+ state.panels = [...(state.panels ?? []), panel];
629
+ else
630
+ this.layout[slot] = {
631
+ owner: panel.owner,
632
+ preferred: true,
633
+ effective: true,
634
+ reason: null,
635
+ detached: false,
636
+ panels: [panel],
637
+ };
638
+ }
639
+ #removePanel(slot, owner) {
640
+ const state = this.layout[slot];
641
+ if (!state)
642
+ return;
643
+ const panels = state.panels ?? [];
644
+ const index = panels.findIndex((panel) => panel.owner === owner);
645
+ const rest = panels.filter((panel) => panel.owner !== owner);
646
+ if (rest.length === 0) {
647
+ this.layout[slot] = null;
648
+ return;
649
+ }
650
+ state.panels = rest;
651
+ if (state.owner === owner)
652
+ state.owner = (rest[index] ?? rest[rest.length - 1]).owner;
653
+ }
534
654
  /**
535
655
  * The display layer (`plugin.video.view()`): who owns it (this plugin is
536
656
  * `manifestId`) and the values the player holds.
@@ -640,6 +760,29 @@ export class TestHost {
640
760
  producers = new Map();
641
761
  /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
642
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();
643
786
  /**
644
787
  * Answers for `dialogs.open` (paths, `null` = cancelled) and
645
788
  * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
@@ -733,8 +876,12 @@ export class TestHost {
733
876
  #overrides = new Map();
734
877
  #listeners = new Map();
735
878
  #inFlight = 0;
736
- #next = { snapshot: 0, job: 0, producer: 0, source: 0 };
879
+ #next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
737
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;
738
885
  #scratch = mkdtempSync(join(tmpdir(), "vestra-test-host-"));
739
886
  /**
740
887
  * `plugin.dataDirectory`: a fresh empty temporary directory, removed with
@@ -1234,7 +1381,9 @@ export class TestHost {
1234
1381
  */
1235
1382
  openElsewhere(source, phase = "ready") {
1236
1383
  this.#intent({ kind: "open", source, startPaused: false });
1237
- 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 });
1238
1387
  }
1239
1388
  /**
1240
1389
  * Press a binding like the host's table would: panel-focus bindings first
@@ -1565,14 +1714,166 @@ export class TestHost {
1565
1714
  if (control.kind === "rate")
1566
1715
  next.rate = Number(control.rate);
1567
1716
  }
1717
+ if (intent.kind === "openProvider")
1718
+ this.#selected = Number(intent.producerId);
1719
+ if (intent.kind === "open" || intent.kind === "close")
1720
+ this.#selected = null;
1568
1721
  this.source = next;
1569
1722
  this.subtitles.source = {
1570
1723
  generation: next.stamp.generation,
1571
1724
  phase: next.phase,
1572
1725
  };
1573
1726
  this.emit("source.state", this.source);
1727
+ this.#cancelResolutions();
1574
1728
  return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
1575
1729
  }
1730
+ /** The file resolver of this plugin that claims `source`, if any. */
1731
+ #fileResolver(source) {
1732
+ if (source.includes("://") || !isAbsolute(source))
1733
+ return null;
1734
+ const extension = extname(source).slice(1).toLowerCase();
1735
+ if (!extension)
1736
+ return null;
1737
+ for (const [id, declaration] of this.resolvers) {
1738
+ const extensions = declaration.extensions;
1739
+ if (extensions?.includes(extension))
1740
+ return id;
1741
+ }
1742
+ return null;
1743
+ }
1744
+ /** Whether producer `id` is the player's source. */
1745
+ #current(id) {
1746
+ return (this.source.kind === "producer" &&
1747
+ (this.#selected === id || this.source.source === `producer://${id}`));
1748
+ }
1749
+ /** Start resolving `source` for the open just made; its request id, or null. */
1750
+ #resolve(source, paused, operation) {
1751
+ const resolverId = this.#fileResolver(source);
1752
+ if (resolverId === null)
1753
+ return null;
1754
+ const requestId = ++this.#next.resolution;
1755
+ this.resolutions.set(requestId, { resolverId, source, paused });
1756
+ this.#resolving.set(requestId, {
1757
+ stamp: { ...this.source.stamp },
1758
+ ...(operation === undefined ? {} : { operation }),
1759
+ });
1760
+ this.emit("source.resolveRequest", { requestId, resolverId, url: source });
1761
+ return requestId;
1762
+ }
1763
+ /** Settle a resolution's open; `error` fails it. */
1764
+ #settle(requestId, error) {
1765
+ const operation = this.#resolving.get(requestId)?.operation;
1766
+ this.resolutions.delete(requestId);
1767
+ this.#resolving.delete(requestId);
1768
+ if (operation === undefined)
1769
+ return;
1770
+ const entry = this.#sourceBook.get(operation);
1771
+ this.#sourceBook.set(operation, error === undefined
1772
+ ? { status: "completed", ticket: entry?.ticket ?? null }
1773
+ : { status: "failed", error: { backend: error } });
1774
+ }
1775
+ /** Resolutions whose open is no longer the source (or whose resolver left). */
1776
+ #cancelResolutions() {
1777
+ const stamp = this.source.stamp;
1778
+ for (const [requestId, resolution] of [...this.resolutions]) {
1779
+ const pending = this.#resolving.get(requestId);
1780
+ if (pending.stamp.generation === stamp.generation &&
1781
+ pending.stamp.intentVersion === stamp.intentVersion &&
1782
+ this.resolvers.has(resolution.resolverId))
1783
+ continue;
1784
+ this.#settle(requestId, "source resolution cancelled, expired or superseded");
1785
+ this.emit("source.resolveCancel", {
1786
+ requestId,
1787
+ resolverId: resolution.resolverId,
1788
+ });
1789
+ }
1790
+ }
1791
+ /** `sourceResolvers.register`: the player's rules for both forms. */
1792
+ #register(p) {
1793
+ const id = String(p.id);
1794
+ const extensions = p.extensions;
1795
+ if (this.resolvers.has(id))
1796
+ throw new HostError("conflict", "resolver id already registered");
1797
+ if (extensions !== undefined) {
1798
+ if (p.schemes !== undefined ||
1799
+ p.hosts !== undefined ||
1800
+ !Array.isArray(extensions) ||
1801
+ extensions.length < 1 ||
1802
+ extensions.length > 16 ||
1803
+ extensions.some((e) => typeof e !== "string" ||
1804
+ !/^[a-z0-9]{1,16}$/.test(e) ||
1805
+ e === "folder"))
1806
+ throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
1807
+ }
1808
+ const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
1809
+ (other.hosts?.some((h) => p.hosts?.includes(h)) &&
1810
+ other.schemes?.some((x) => p.schemes?.includes(x))
1811
+ ? "host"
1812
+ : undefined);
1813
+ for (const own of this.resolvers.values())
1814
+ if (claims(own))
1815
+ throw new HostError("conflict", "already registered by this plugin");
1816
+ for (const other of this.otherResolvers) {
1817
+ const what = claims(other);
1818
+ if (what) {
1819
+ const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1820
+ throw taken(`${target} 已由 ${other.owner} 解析`);
1821
+ }
1822
+ }
1823
+ this.resolvers.set(id, p);
1824
+ }
1825
+ /** `sourceResolvers.respond` for a simulated file resolution. */
1826
+ #respond(p) {
1827
+ const requestId = Number(p.requestId);
1828
+ const resolution = this.resolutions.get(requestId);
1829
+ if (!resolution) {
1830
+ // Web resolutions are not simulated: their answers are only recorded.
1831
+ if (requestId > this.#next.resolution)
1832
+ return null;
1833
+ throw new HostError("stale", "resolution no longer pending");
1834
+ }
1835
+ const fail = (message) => {
1836
+ this.#settle(requestId, message);
1837
+ this.setSource({
1838
+ phase: "error",
1839
+ terminalReason: { failed: message },
1840
+ });
1841
+ throw new HostError("resolution", message);
1842
+ };
1843
+ if (typeof p.error === "string")
1844
+ fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
1845
+ const result = (p.result ?? {});
1846
+ const producer = this.producers.get(Number(result.producerId));
1847
+ if (result.kind !== "stream")
1848
+ fail("invalid resolved stream descriptor");
1849
+ if (!producer || producer.released)
1850
+ fail("unavailable: producer absent");
1851
+ // Like openProvider, but the source keeps the path and `startPaused`.
1852
+ this.#selected = producer.id;
1853
+ producer.active = true;
1854
+ producer.acknowledged = false;
1855
+ producer.pending = {
1856
+ producerId: producer.id,
1857
+ epoch: producer.epoch + 1,
1858
+ baseSample: producer.receivedFrames,
1859
+ paused: resolution.paused,
1860
+ };
1861
+ this.#settle(requestId);
1862
+ this.setSource({
1863
+ kind: "producer",
1864
+ phase: "ready",
1865
+ paused: resolution.paused,
1866
+ capabilities: {
1867
+ pause: true,
1868
+ seek: false,
1869
+ rate: false,
1870
+ restart: false,
1871
+ close: true,
1872
+ snapshotSource: false,
1873
+ },
1874
+ });
1875
+ return { stamp: { ...this.source.stamp } };
1876
+ }
1576
1877
  #processingState() {
1577
1878
  const list = (stage) => [...this.processing.resources]
1578
1879
  .filter(([, value]) => value.stage === stage)
@@ -1710,8 +2011,18 @@ export class TestHost {
1710
2011
  "ui.mount": (p) => {
1711
2012
  if (this.panel)
1712
2013
  throw new HostError("conflict", "owner already mounted");
1713
- if (p.slot === "backdrop" && this.backdrop)
1714
- throw new HostError("conflict", "another plugin shows the backdrop");
2014
+ // Like the player: one panel in the bottom bar and the backdrop, tabs
2015
+ // in the sidebar (up to 8).
2016
+ const holder = p.slot === "backdrop"
2017
+ ? this.backdrop?.owner
2018
+ : p.slot === "bottom"
2019
+ ? this.layout.bottom?.panels?.[0]?.plugin
2020
+ : undefined;
2021
+ if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
2022
+ throw taken(`${SLOT_LABELS[String(p.slot)]}已被 ${holder} 使用`);
2023
+ if (p.slot === "sidebar" &&
2024
+ (this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
2025
+ throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
1715
2026
  this.#seeImages(p.properties);
1716
2027
  const host = this;
1717
2028
  this.panel = {
@@ -1734,25 +2045,25 @@ export class TestHost {
1734
2045
  return { status: "ready", prepareMs: 0, shellGeneration: 1 };
1735
2046
  }
1736
2047
  const slot = String(p.slot);
1737
- this.layout[slot] = {
2048
+ this.#addPanel(slot, {
1738
2049
  owner: 1,
1739
- preferred: true,
1740
- effective: true,
1741
- reason: null,
1742
- detached: false,
1743
- };
2050
+ plugin: this.manifestId,
2051
+ title: typeof p.title === "string" && p.title ? p.title : this.manifestId,
2052
+ });
1744
2053
  return { status: "ready", prepareMs: 0, shellGeneration: 1 };
1745
2054
  },
1746
2055
  "ui.unmount": () => {
1747
2056
  if (this.panel?.slot === "backdrop")
1748
2057
  this.backdrop = null;
1749
2058
  else if (this.panel)
1750
- this.layout[this.panel.slot] = null;
2059
+ this.#removePanel(this.panel.slot, 1);
1751
2060
  this.panel = null;
1752
2061
  },
1753
2062
  "ui.menu": (p) => {
1754
2063
  if (!this.panel)
1755
2064
  throw new HostError("unavailable", "panel not shown");
2065
+ if (this.panel.slot === "sidebar" && this.layout.sidebar?.owner !== 1)
2066
+ throw new HostError("unavailable", "the panel's tab is not the current one");
1756
2067
  const items = p.items;
1757
2068
  for (const id of items)
1758
2069
  if (id !== null && !this.commands.has(id))
@@ -1773,6 +2084,12 @@ export class TestHost {
1773
2084
  throw new HostError("missing", "no panel at this position");
1774
2085
  if (p.detached !== undefined && slot === "bottom")
1775
2086
  throw new HostError("invalid", "the bottom bar cannot be detached");
2087
+ if (p.active !== undefined && p.active !== null) {
2088
+ const panel = state.panels?.find((entry) => entry.plugin === p.active);
2089
+ if (!panel)
2090
+ throw new HostError("missing", `${String(p.active)} has no panel at this position`);
2091
+ state.owner = panel.owner;
2092
+ }
1776
2093
  if (typeof p.visible === "boolean") {
1777
2094
  state.preferred = p.visible;
1778
2095
  state.effective = p.visible;
@@ -2144,8 +2461,17 @@ export class TestHost {
2144
2461
  "source.state": () => this.source,
2145
2462
  "source.submit": (p) => {
2146
2463
  const intent = p.intent;
2147
- const result = { status: "completed", ...this.#intent(intent) };
2148
- this.#sourceBook.set(Number(p.operationId), result);
2464
+ const ticket = this.#intent(intent);
2465
+ const operation = Number(p.operationId);
2466
+ // A file this plugin's file resolver claims: pending until it answers.
2467
+ if (intent.kind === "open" &&
2468
+ this.#resolve(String(intent.source), intent.startPaused === true, operation) !== null) {
2469
+ const pending = { status: "pending", ...ticket };
2470
+ this.#sourceBook.set(operation, pending);
2471
+ return pending;
2472
+ }
2473
+ const result = { status: "completed", ...ticket };
2474
+ this.#sourceBook.set(operation, result);
2149
2475
  // Selecting a provider asks it to acknowledge a start control.
2150
2476
  const producer = this.producers.get(Number(intent.producerId));
2151
2477
  if (intent.kind === "openProvider" && producer) {
@@ -2573,9 +2899,12 @@ export class TestHost {
2573
2899
  return { jobId: job.jobId, cancel: "requested" };
2574
2900
  },
2575
2901
  "jobs.operation": () => ({ status: "unknown" }),
2576
- "sourceResolvers.register": (p) => void this.resolvers.set(String(p.id), p),
2577
- "sourceResolvers.unregister": (p) => void this.resolvers.delete(String(p.id)),
2578
- "sourceResolvers.respond": () => null,
2902
+ "sourceResolvers.register": (p) => void this.#register(p),
2903
+ "sourceResolvers.unregister": (p) => {
2904
+ this.resolvers.delete(String(p.id));
2905
+ this.#cancelResolutions();
2906
+ },
2907
+ "sourceResolvers.respond": (p) => this.#respond(p),
2579
2908
  "producer.prepare": async () => {
2580
2909
  const id = ++this.#next.producer;
2581
2910
  const tcp = this.producerTransport === "tcp";
@@ -2621,8 +2950,7 @@ export class TestHost {
2621
2950
  });
2622
2951
  socket.on("error", () => { });
2623
2952
  socket.on("close", () => {
2624
- if (this.source.kind === "producer" &&
2625
- this.source.source === `producer://${producer.id}`)
2953
+ if (this.#current(producer.id))
2626
2954
  producer.droppedWhileSelected = true;
2627
2955
  });
2628
2956
  });
@@ -2653,8 +2981,7 @@ export class TestHost {
2653
2981
  const producer = this.producers.get(Number(p.producerId));
2654
2982
  if (!producer)
2655
2983
  throw new HostError("missing", "producer");
2656
- const current = this.source.kind === "producer" &&
2657
- this.source.source === `producer://${producer.id}`;
2984
+ const current = this.#current(producer.id);
2658
2985
  return {
2659
2986
  input: null,
2660
2987
  pendingControl: producer.pending,
@@ -2756,7 +3083,18 @@ export async function startPlugin(activate, options = {}) {
2756
3083
  mkdirSync(data, { recursive: true });
2757
3084
  const instance = await createPlugin(host, manifest, directory, data, host.app);
2758
3085
  await activate(instance.plugin);
2759
- await instance.activated();
3086
+ // Like the player: a position or resolver another plugin holds is not an
3087
+ // activation failure.
3088
+ for (;;) {
3089
+ try {
3090
+ await instance.activated();
3091
+ break;
3092
+ }
3093
+ catch (error) {
3094
+ if (!(error instanceof Error && takenErrors.has(error)))
3095
+ throw error;
3096
+ }
3097
+ }
2760
3098
  await host.settle();
2761
3099
  return {
2762
3100
  /** The test host the plugin talks to. */