@vestra-player/sdk 1.6.18 → 1.6.19

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/docs-tree.json.gz +0 -0
  3. package/dist/plugin/appearance.js +1 -1
  4. package/dist/plugin/bindings.js +1 -1
  5. package/dist/plugin/commands.js +3 -2
  6. package/dist/plugin/connection.d.ts +119 -0
  7. package/dist/plugin/connection.js +402 -0
  8. package/dist/plugin/drops.d.ts +3 -3
  9. package/dist/plugin/drops.js +1 -1
  10. package/dist/plugin/errors.d.ts +9 -0
  11. package/dist/plugin/errors.js +35 -0
  12. package/dist/plugin/filters.js +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.js +3 -1
  15. package/dist/plugin/language.js +1 -1
  16. package/dist/plugin/latest.d.ts +12 -0
  17. package/dist/plugin/latest.js +52 -0
  18. package/dist/plugin/local.js +1 -2
  19. package/dist/plugin/markdown.d.ts +2 -2
  20. package/dist/plugin/markdown.js +1 -1
  21. package/dist/plugin/media.js +1 -1
  22. package/dist/plugin/osd.js +2 -2
  23. package/dist/plugin/packages.js +1 -1
  24. package/dist/plugin/passes.d.ts +111 -0
  25. package/dist/plugin/passes.js +341 -0
  26. package/dist/plugin/player.d.ts +2 -3
  27. package/dist/plugin/player.js +2 -7
  28. package/dist/plugin/resolvers.js +1 -1
  29. package/dist/plugin/runtime.d.ts +24 -209
  30. package/dist/plugin/runtime.js +34 -770
  31. package/dist/plugin/scope.d.ts +2 -1
  32. package/dist/plugin/scope.js +5 -2
  33. package/dist/plugin/services/dialogs.d.ts +3 -0
  34. package/dist/plugin/services/dialogs.js +62 -0
  35. package/dist/plugin/services.js +1 -1
  36. package/dist/plugin/settings.js +2 -2
  37. package/dist/plugin/shaders.js +2 -1
  38. package/dist/plugin/stream.js +1 -2
  39. package/dist/plugin/subtitles.js +2 -2
  40. package/dist/plugin/testing/transport.js +1 -1
  41. package/dist/plugin/timeline.js +2 -1
  42. package/dist/plugin/view/base.d.ts +29 -0
  43. package/dist/plugin/view/base.js +88 -0
  44. package/dist/plugin/view/binder.d.ts +22 -0
  45. package/dist/plugin/view/binder.js +57 -0
  46. package/dist/plugin/view/image.d.ts +21 -0
  47. package/dist/plugin/view/image.js +60 -0
  48. package/dist/plugin/view/layout.d.ts +47 -0
  49. package/dist/plugin/view/layout.js +171 -0
  50. package/dist/plugin/view/panel.d.ts +3 -0
  51. package/dist/plugin/view/panel.js +178 -0
  52. package/dist/plugin/view/window.d.ts +3 -0
  53. package/dist/plugin/view/window.js +99 -0
  54. package/dist/plugin/view/writer.d.ts +58 -0
  55. package/dist/plugin/view/writer.js +634 -0
  56. package/dist/plugin/view.js +2 -1
  57. package/dist/plugin/window.js +1 -1
  58. package/llms.txt +1 -1
  59. package/package.json +2 -2
  60. package/dist/plugin/ui.d.ts +0 -74
  61. package/dist/plugin/ui.js +0 -1234
@@ -1,4 +1,5 @@
1
- import type { Change, Runtime } from "./runtime.ts";
1
+ import type { Change } from "./passes.ts";
2
+ import type { Runtime } from "./runtime.ts";
2
3
  import type { Disposable, DisposeInfo, Plugin, PluginError, Scope, StopReason } from "./types.ts";
3
4
  export declare class ScopeImpl implements Plugin {
4
5
  #private;
@@ -1,8 +1,11 @@
1
1
  /** Scopes own handles. Disposal runs newest first and never throws. */
2
- import { failure, toError } from "./runtime.js";
2
+ import { failure, toError } from "./errors.js";
3
3
  import { commands } from "./commands.js";
4
4
  import { WHEN } from "./when.js";
5
- import { dialogs, panels, ui, windows } from "./ui.js";
5
+ import { dialogs } from "./services/dialogs.js";
6
+ import { ui } from "./view/image.js";
7
+ import { panels } from "./view/panel.js";
8
+ import { windows } from "./view/window.js";
6
9
  import { timeline } from "./timeline.js";
7
10
  import { drops } from "./drops.js";
8
11
  import { appearance } from "./appearance.js";
@@ -0,0 +1,3 @@
1
+ import type { ScopeImpl } from "../scope.ts";
2
+ import type { Dialogs } from "../types.ts";
3
+ export declare const dialogs: (scope: ScopeImpl) => Dialogs;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * System dialogs (`plugin.dialogs`): the player shows them; this side
3
+ * checks the texts and fills their templates (L1, a service;
4
+ * doc/specs/sdk-refactor.md §2.2 in the player repository).
5
+ */
6
+ import { isTextValue } from "../../i18n.js";
7
+ import { failure } from "../errors.js";
8
+ import { shownText, templateArgs } from "../osd.js";
9
+ export const dialogs = (scope) => {
10
+ const open = async (kind, options = {}) => {
11
+ const result = await scope.runtime.host.dialogs.open({
12
+ kind,
13
+ ...(options.title === undefined
14
+ ? {}
15
+ : { title: shownText(options.title, undefined, "title") }),
16
+ ...(options.fileName === undefined
17
+ ? {}
18
+ : { fileName: shownText(options.fileName, undefined, "fileName") }),
19
+ });
20
+ return result.cancelled ? [] : result.paths;
21
+ };
22
+ return {
23
+ async confirm(options) {
24
+ const { message, detail, confirm, cancel, destructive, checkbox, args } = options ?? {};
25
+ const values = templateArgs(args);
26
+ // A string's length is checked here; a text value's (shown) length by
27
+ // the player.
28
+ const length = (text, min, max) => isTextValue(text) ||
29
+ (typeof text === "string" &&
30
+ [...text].length >= min &&
31
+ [...text].length <= max);
32
+ if (!length(message, 1, 200) ||
33
+ !length(confirm, 1, 40) ||
34
+ (detail !== undefined && !length(detail, 0, 2000)) ||
35
+ (cancel !== undefined && !length(cancel, 1, 40)) ||
36
+ (checkbox !== undefined && !length(checkbox, 1, 40)) ||
37
+ (destructive !== undefined && typeof destructive !== "boolean"))
38
+ throw failure("invalid", "confirm needs message 1–200, confirm/cancel/checkbox 1–40, detail ≤ 2000 characters");
39
+ // `args` fills the string templates; a text value carries its own.
40
+ const shown = (text, what) => shownText(text, undefined, what);
41
+ const answer = await scope.runtime.host.dialogs.confirm({
42
+ message: shown(message, "message"),
43
+ confirm: shown(confirm, "confirm"),
44
+ ...(detail === undefined ? {} : { detail: shown(detail, "detail") }),
45
+ ...(cancel === undefined ? {} : { cancel: shown(cancel, "cancel") }),
46
+ ...(destructive === undefined ? {} : { destructive }),
47
+ ...(checkbox === undefined
48
+ ? {}
49
+ : { checkbox: shown(checkbox, "checkbox") }),
50
+ ...(values === undefined ? {} : { args: values }),
51
+ });
52
+ return {
53
+ confirmed: answer.confirmed === true,
54
+ checked: answer.checked === true,
55
+ };
56
+ },
57
+ openFile: async (options) => (await open("openFile", options))[0] ?? null,
58
+ openFiles: (options) => open("openFiles", options),
59
+ openDirectory: async (options) => (await open("openDirectory", options))[0] ?? null,
60
+ saveFile: async (options) => (await open("saveFile", options))[0] ?? null,
61
+ };
62
+ };
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readFileSync, renameSync } from "node:fs";
8
8
  import { mkdir, open, rename, rm, stat } from "node:fs/promises";
9
9
  import { join, resolve } from "node:path";
10
- import { failure } from "./runtime.js";
10
+ import { failure } from "./errors.js";
11
11
  import { systemServices } from "./local.js";
12
12
  import { playerOf, validStep } from "./player.js";
13
13
  const MAX_OUTPUT = 16 * 1024 * 1024;
@@ -1,6 +1,6 @@
1
1
  /** Pages of the player's options window (`plugin.settings`, `ui.settings`). */
2
- import { failure, quiet } from "./runtime.js";
3
- import { exported, ViewBase } from "./ui.js";
2
+ import { failure, quiet } from "./errors.js";
3
+ import { exported, ViewBase } from "./view/base.js";
4
4
  const CATEGORIES = [
5
5
  "general",
6
6
  "playback",
@@ -7,7 +7,8 @@
7
7
  * media), or a failure.
8
8
  */
9
9
  import { isAbsolute } from "node:path";
10
- import { failure, Latest, quiet } from "./runtime.js";
10
+ import { Latest } from "./latest.js";
11
+ import { failure, quiet } from "./errors.js";
11
12
  import { playerOf } from "./player.js";
12
13
  import { hasControl, VIDEO_PROCESSING_LIMITS } from "../processing.js";
13
14
  /** The handle id when `shaders()` is called without one. */
@@ -3,8 +3,7 @@
3
3
  * activation, pause/epoch acknowledgements, input acknowledgements and pacing;
4
4
  * the plugin only renders frames.
5
5
  */
6
- import { failure, sleep, toError } from "./runtime.js";
7
- import { abortError } from "./player.js";
6
+ import { abortError, failure, sleep, toError } from "./errors.js";
8
7
  /**
9
8
  * Frames of audio buffered ahead of the device before rendering pauses
10
9
  * (50 ms). Each pump refills the whole lead, so it must outlast one pump
@@ -1,8 +1,8 @@
1
1
  /** Subtitle selection, external SRT resources and this plugin's style layer. */
2
2
  import { createHash } from "node:crypto";
3
3
  import { isTextValue, textProblem } from "../i18n.js";
4
- import { Latest, failure, sleep, uncertain } from "./runtime.js";
5
- import { abortError } from "./player.js";
4
+ import { Latest } from "./latest.js";
5
+ import { abortError, failure, sleep, uncertain } from "./errors.js";
6
6
  /**
7
7
  * The player's limits on subtitle files a plugin loads with
8
8
  * {@link Subtitles.load}, which `TestHost` enforces with the player's codes.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { rmSync } from "node:fs";
10
10
  import { HostError } from "../../wire.js";
11
- import { WAKE_EVENTS } from "../runtime.js";
11
+ import { WAKE_EVENTS } from "../connection.js";
12
12
  export const tick = () => new Promise((resolve) => setImmediate(resolve));
13
13
  /**
14
14
  * Refusals for a position or resolver another plugin holds: not activation
@@ -1,5 +1,6 @@
1
1
  /** Seek-bar marks (`plugin.timeline`): one layer per plugin, merged by the player. */
2
- import { failure, Latest, quiet } from "./runtime.js";
2
+ import { Latest } from "./latest.js";
3
+ import { failure, quiet } from "./errors.js";
3
4
  /**
4
5
  * The player's limits on one plugin's timeline layer
5
6
  * ({@link TimelineLayer}). A list over them fails with code `invalid`,
@@ -0,0 +1,29 @@
1
+ import type { Json, PropertyWrite } from "../../wire.ts";
2
+ import type { ScopeImpl } from "../scope.ts";
3
+ import type { Disposable, PropValue, Props, View } from "../types.ts";
4
+ /** Property and callback plumbing shared by windows, panels and settings pages. */
5
+ export declare abstract class ViewBase implements View {
6
+ #private;
7
+ protected readonly scope: ScopeImpl;
8
+ protected readonly handlers: Map<string, (...args: Json[]) => unknown>;
9
+ protected closed: boolean;
10
+ constructor(scope: ScopeImpl);
11
+ protected abstract live(): boolean;
12
+ protected abstract write(property: string, write: PropertyWrite): Promise<void>;
13
+ /** {@link ViewWriter.wired}: initial properties in wire form. */
14
+ protected wired(values: Record<string, PropValue>, refuse?: boolean): Record<string, Json>;
15
+ on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
16
+ set(props: Props): void;
17
+ bind(props: () => Props): Disposable;
18
+ /** {@link ViewBinder.snapshot}: current desired properties. */
19
+ protected snapshot(): Record<string, PropValue>;
20
+ protected evaluate(): void;
21
+ /** The host already shows these values (e.g. sent as initial properties). */
22
+ protected shown(values: Record<string, Json>): void;
23
+ /** {@link ViewWriter.flush}: send what changed. */
24
+ protected flush(): void;
25
+ protected dispatch(callback: string, args: Json[]): void;
26
+ protected release(): Promise<void>;
27
+ }
28
+ /** Find `export component X` / `export global Y` so most mounts need no names. */
29
+ export declare function exported(directory: string, file: string, kind: string): string;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `ViewBase`: what windows, panels and settings pages share — callbacks, and
3
+ * a {@link ViewWriter} (L1) with a {@link ViewBinder} (L2) over it. Every
4
+ * view joins the update passes when made (doc/specs/sdk-refactor.md §1.2 in
5
+ * the player repository; Step 2 moves that to the first `bind()`).
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { PATCH_FEATURE } from "../patch.js";
10
+ import { ViewBinder } from "./binder.js";
11
+ import { kebab, ViewWriter } from "./writer.js";
12
+ /** Property and callback plumbing shared by windows, panels and settings pages. */
13
+ export class ViewBase {
14
+ scope;
15
+ handlers = new Map();
16
+ #writer;
17
+ #binder;
18
+ #binding;
19
+ closed = false;
20
+ constructor(scope) {
21
+ this.scope = scope;
22
+ this.#writer = new ViewWriter({
23
+ live: () => this.live(),
24
+ write: (property, write) => this.write(property, write),
25
+ report: (error) => this.scope.report(error),
26
+ patches: () => this.scope.runtime.features.has(PATCH_FEATURE),
27
+ });
28
+ this.#binder = new ViewBinder(scope, this.#writer, () => this.closed);
29
+ this.#binding = scope.runtime.bind(() => this.evaluate());
30
+ }
31
+ /** {@link ViewWriter.wired}: initial properties in wire form. */
32
+ wired(values, refuse = false) {
33
+ return this.#writer.wired(values, refuse);
34
+ }
35
+ on(callback, handler) {
36
+ const name = kebab(callback);
37
+ this.handlers.set(name, handler);
38
+ return this.scope.onDispose(() => {
39
+ if (this.handlers.get(name) === handler)
40
+ this.handlers.delete(name);
41
+ });
42
+ }
43
+ set(props) {
44
+ this.#writer.set(props);
45
+ }
46
+ bind(props) {
47
+ return this.#binder.bind(props);
48
+ }
49
+ /** {@link ViewBinder.snapshot}: current desired properties. */
50
+ snapshot() {
51
+ return this.#binder.snapshot();
52
+ }
53
+ evaluate() {
54
+ this.#binder.evaluate();
55
+ }
56
+ /** The host already shows these values (e.g. sent as initial properties). */
57
+ shown(values) {
58
+ this.#writer.shown(values);
59
+ }
60
+ /** {@link ViewWriter.flush}: send what changed. */
61
+ flush() {
62
+ this.#writer.flush();
63
+ }
64
+ dispatch(callback, args) {
65
+ if (this.closed)
66
+ return;
67
+ const handler = this.handlers.get(callback);
68
+ if (handler)
69
+ this.scope.run(handler, ...args);
70
+ else
71
+ this.scope.update();
72
+ }
73
+ async release() {
74
+ this.closed = true;
75
+ this.#writer.stop();
76
+ await this.#binding.dispose();
77
+ }
78
+ }
79
+ /** Find `export component X` / `export global Y` so most mounts need no names. */
80
+ export function exported(directory, file, kind) {
81
+ const text = readFileSync(resolve(directory, file), "utf8");
82
+ const names = [
83
+ ...text.matchAll(new RegExp(`export\\s+${kind}\\s+([\\w-]+)`, "g")),
84
+ ].map((m) => m[1]);
85
+ if (names.length !== 1)
86
+ throw new Error(`${file}: name the ${kind} to mount (found ${names.length})`);
87
+ return names[0];
88
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The view binder (L2, the framework; doc/specs/sdk-refactor.md §2.2 in the
3
+ * player repository): `View.bind` functions, run in the update passes with
4
+ * read tracking (`Runtime.part`), their values handed to the view's writer.
5
+ */
6
+ import type { ScopeImpl } from "../scope.ts";
7
+ import type { PropValue, Props } from "../types.ts";
8
+ import type { ViewWriter } from "./writer.ts";
9
+ /** One view's bindings, evaluated into its {@link ViewWriter}. */
10
+ export declare class ViewBinder {
11
+ #private;
12
+ constructor(scope: ScopeImpl, writer: ViewWriter, closed: () => boolean);
13
+ bind(props: () => Props): import("../types.ts").Disposable;
14
+ /**
15
+ * Current desired properties (bound values win over earlier `set`). A
16
+ * precise pass (`Runtime.part`) skips a bind function that read none of
17
+ * the changed state: its last values stay.
18
+ */
19
+ snapshot(): Record<string, PropValue>;
20
+ /** One update pass: the bindings' values, then whatever changed is sent. */
21
+ evaluate(): void;
22
+ }
@@ -0,0 +1,57 @@
1
+ import { encode } from "./writer.js";
2
+ /** One view's bindings, evaluated into its {@link ViewWriter}. */
3
+ export class ViewBinder {
4
+ #scope;
5
+ #writer;
6
+ /** The view was released: nothing is evaluated any more. */
7
+ #closed;
8
+ /** Each binding, with where the plugin added it (for the player's log). */
9
+ #binders = new Map();
10
+ constructor(scope, writer, closed) {
11
+ this.#scope = scope;
12
+ this.#writer = writer;
13
+ this.#closed = closed;
14
+ }
15
+ bind(props) {
16
+ // `token` names this binding of this view in the runtime's read
17
+ // tracking: the same function bound to two views is two parts.
18
+ this.#binders.set(props, {
19
+ site: this.#scope.runtime.callSite(),
20
+ token: {},
21
+ });
22
+ this.#scope.update();
23
+ return this.#scope.onDispose(() => {
24
+ this.#binders.delete(props);
25
+ });
26
+ }
27
+ /**
28
+ * Current desired properties (bound values win over earlier `set`). A
29
+ * precise pass (`Runtime.part`) skips a bind function that read none of
30
+ * the changed state: its last values stay.
31
+ */
32
+ snapshot() {
33
+ const runtime = this.#scope.runtime;
34
+ for (const [binder, { site, token }] of this.#binders) {
35
+ try {
36
+ // Encoded inside the part: what encoding reads counts as the
37
+ // function's (a state object it returned).
38
+ const result = runtime.part(token, () => encode(runtime.reader("a view's bind", site, binder)));
39
+ if (!result)
40
+ continue;
41
+ for (const [key, value] of Object.entries(result.value))
42
+ this.#writer.assign(key, value);
43
+ }
44
+ catch (error) {
45
+ this.#scope.report(error);
46
+ }
47
+ }
48
+ return this.#writer.values();
49
+ }
50
+ /** One update pass: the bindings' values, then whatever changed is sent. */
51
+ evaluate() {
52
+ if (this.#closed())
53
+ return;
54
+ this.snapshot();
55
+ this.#writer.flush();
56
+ }
57
+ }
@@ -0,0 +1,21 @@
1
+ import type { Json } from "../../wire.ts";
2
+ import type { ScopeImpl } from "../scope.ts";
3
+ import type { ImageOptions, ImageRef, Ui } from "../types.ts";
4
+ /**
5
+ * `plugin.ui.image`. The wire form (`{ $vestraImage: path, ref }`) is an
6
+ * internal detail; `ref` makes every call a new value, so a file rewritten
7
+ * under the same path is sent (and decoded) again. The player checks the
8
+ * path on every use, so a hand-made wire value gains nothing.
9
+ */
10
+ export declare class ImageHandle implements ImageRef {
11
+ #private;
12
+ readonly path: string;
13
+ readonly __vestraImage: true;
14
+ constructor(scope: ScopeImpl, path: string, maxSide?: number);
15
+ /** Released with its scope: writing it is `invalid`. */
16
+ get released(): boolean;
17
+ toJSON(): Json;
18
+ }
19
+ /** `ImageOptions.maxSide`, checked: an integer 1–4096 or absent. */
20
+ export declare function imageSide(options: ImageOptions | undefined): number | undefined;
21
+ export declare const ui: (scope: ScopeImpl) => Ui;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `plugin.ui`: image and Markdown values for view properties (L1, a
3
+ * primitive; doc/specs/sdk-refactor.md §2.2 in the player repository).
4
+ * Commands take the same images as bar button icons.
5
+ */
6
+ import { failure } from "../errors.js";
7
+ import { markdown, remoteImageFile } from "../markdown.js";
8
+ let images = 0;
9
+ /**
10
+ * `plugin.ui.image`. The wire form (`{ $vestraImage: path, ref }`) is an
11
+ * internal detail; `ref` makes every call a new value, so a file rewritten
12
+ * under the same path is sent (and decoded) again. The player checks the
13
+ * path on every use, so a hand-made wire value gains nothing.
14
+ */
15
+ export class ImageHandle {
16
+ path;
17
+ __vestraImage = true;
18
+ #scope;
19
+ #ref = ++images;
20
+ #maxSide;
21
+ constructor(scope, path, maxSide) {
22
+ this.#scope = scope;
23
+ this.path = path;
24
+ this.#maxSide = maxSide;
25
+ }
26
+ /** Released with its scope: writing it is `invalid`. */
27
+ get released() {
28
+ return this.#scope.disposed;
29
+ }
30
+ toJSON() {
31
+ // `maxSide` only when given: players before 0.5.1 refuse the field.
32
+ return this.#maxSide === undefined
33
+ ? { $vestraImage: this.path, ref: this.#ref }
34
+ : { $vestraImage: this.path, ref: this.#ref, maxSide: this.#maxSide };
35
+ }
36
+ }
37
+ /** `ImageOptions.maxSide`, checked: an integer 1–4096 or absent. */
38
+ export function imageSide(options) {
39
+ const side = options?.maxSide;
40
+ if (side === undefined)
41
+ return undefined;
42
+ if (!Number.isInteger(side) || side < 1 || side > 4096)
43
+ throw failure("invalid", "maxSide must be an integer 1–4096");
44
+ return side;
45
+ }
46
+ export const ui = (scope) => ({
47
+ image(path, options) {
48
+ if (typeof path !== "string" || !path)
49
+ throw failure("invalid", "ui.image needs a file path");
50
+ return new ImageHandle(scope, path, imageSide(options));
51
+ },
52
+ async remoteImage(url, options) {
53
+ if (typeof url !== "string" || !url.startsWith("https://"))
54
+ throw failure("invalid", "ui.remoteImage needs an https: address");
55
+ const side = imageSide(options);
56
+ const file = await remoteImageFile(scope.runtime, url);
57
+ return new ImageHandle(scope, file, side);
58
+ },
59
+ markdown: (text, options) => markdown(scope, text, options),
60
+ });
@@ -0,0 +1,47 @@
1
+ import type { Runtime } from "../runtime.ts";
2
+ import type { ScopeImpl } from "../scope.ts";
3
+ import type { BackdropSlot, PanelSlot, SidebarSlot, SlotPanel, SlotState } from "../types.ts";
4
+ /** The positions `layout.state` reports and `layout.set` changes. */
5
+ export type LayoutName = "bottom" | "sidebar" | "adjust";
6
+ export type Layout = {
7
+ bottom: SlotState | null;
8
+ sidebar: SlotState | null;
9
+ /** Player 0.7.6; `null` from older players. */
10
+ adjust: SlotState | null;
11
+ /** `PanelSlot.panels` (null from players without tabs). */
12
+ tabs: {
13
+ bottom: readonly SlotPanel[] | null;
14
+ sidebar: readonly SlotPanel[] | null;
15
+ adjust: readonly SlotPanel[] | null;
16
+ };
17
+ backdrop: {
18
+ readonly effective: boolean;
19
+ } | null;
20
+ };
21
+ /**
22
+ * Both positions, one per plugin: read with `layout.state` after every
23
+ * `window.state` event (one read in flight; events during it cause one more)
24
+ * and after this plugin's own changes. Started on first use.
25
+ */
26
+ export declare class LayoutService {
27
+ #private;
28
+ state: Layout;
29
+ constructor(runtime: Runtime);
30
+ /** Read again, but only once something in this plugin uses the state. */
31
+ refresh(): Promise<void>;
32
+ start(): void;
33
+ watch(listener: (layout: Layout) => void): {
34
+ dispose: () => Promise<void>;
35
+ };
36
+ read(): Promise<void>;
37
+ set(slot: LayoutName, change: {
38
+ visible?: boolean;
39
+ detached?: boolean;
40
+ active?: string;
41
+ reveal?: boolean;
42
+ }): Promise<void>;
43
+ }
44
+ export declare const layoutOf: (runtime: Runtime) => LayoutService;
45
+ export declare function slot(scope: ScopeImpl, name: "bottom" | "adjust"): PanelSlot;
46
+ export declare function slot(scope: ScopeImpl, name: "sidebar"): SidebarSlot;
47
+ export declare function backdrop(scope: ScopeImpl): BackdropSlot;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Both positions, one per plugin: read with `layout.state` after every
3
+ * `window.state` event (one read in flight; events during it cause one more)
4
+ * and after this plugin's own changes. Started on first use.
5
+ */
6
+ export class LayoutService {
7
+ #runtime;
8
+ state = {
9
+ bottom: null,
10
+ sidebar: null,
11
+ adjust: null,
12
+ tabs: { bottom: null, sidebar: null, adjust: null },
13
+ backdrop: null,
14
+ };
15
+ #listeners = new Set();
16
+ #started = false;
17
+ #reading = false;
18
+ #again = false;
19
+ constructor(runtime) {
20
+ this.#runtime = runtime;
21
+ }
22
+ /** Read again, but only once something in this plugin uses the state. */
23
+ async refresh() {
24
+ if (this.#started)
25
+ await this.read();
26
+ }
27
+ start() {
28
+ if (this.#started || !this.#runtime.can("ui.mount"))
29
+ return;
30
+ this.#started = true;
31
+ // A mirror: panels listen through `watch`.
32
+ this.#runtime.on("window.state", () => void this.read(), "events", true);
33
+ void this.read();
34
+ }
35
+ watch(listener) {
36
+ this.start();
37
+ this.#listeners.add(listener);
38
+ const held = this.#runtime.hold("window.state");
39
+ return {
40
+ dispose: async () => {
41
+ this.#listeners.delete(listener);
42
+ await held.dispose();
43
+ },
44
+ };
45
+ }
46
+ async read() {
47
+ if (this.#reading) {
48
+ this.#again = true;
49
+ return;
50
+ }
51
+ this.#reading = true;
52
+ try {
53
+ do {
54
+ this.#again = false;
55
+ // "pending" until the host confirmed the layout: keep the last value.
56
+ const raw = await this.#runtime.host.layout.state().catch(() => null);
57
+ if (!raw)
58
+ continue;
59
+ const view = (slot) => slot && {
60
+ preferred: slot.preferred,
61
+ effective: slot.effective,
62
+ reason: slot.reason,
63
+ detached: slot.detached,
64
+ };
65
+ const tabs = (slot) => slot?.panels
66
+ ? Object.freeze(slot.panels.map((panel) => Object.freeze({
67
+ plugin: panel.plugin,
68
+ title: panel.title,
69
+ active: panel.owner === slot.owner,
70
+ })))
71
+ : null;
72
+ const adjust = raw.adjust ?? null;
73
+ const next = {
74
+ bottom: view(raw.bottom),
75
+ sidebar: view(raw.sidebar),
76
+ adjust: view(adjust),
77
+ tabs: {
78
+ bottom: tabs(raw.bottom),
79
+ sidebar: tabs(raw.sidebar),
80
+ adjust: tabs(adjust),
81
+ },
82
+ backdrop: raw.backdrop
83
+ ? Object.freeze({ effective: raw.backdrop.effective === true })
84
+ : null,
85
+ };
86
+ if (JSON.stringify(next) === JSON.stringify(this.state))
87
+ continue;
88
+ this.state = next;
89
+ for (const listener of [...this.#listeners])
90
+ listener(next);
91
+ this.#runtime.invalidate();
92
+ } while (this.#again);
93
+ }
94
+ finally {
95
+ this.#reading = false;
96
+ }
97
+ }
98
+ async set(slot, change) {
99
+ this.start();
100
+ await this.#runtime.host.layout.set({ slot, ...change });
101
+ await this.read();
102
+ }
103
+ }
104
+ export const layoutOf = (runtime) => runtime.service("layout", () => new LayoutService(runtime));
105
+ export function slot(scope, name) {
106
+ const service = () => layoutOf(scope.runtime);
107
+ return {
108
+ get state() {
109
+ service().start();
110
+ return service().state[name];
111
+ },
112
+ get panels() {
113
+ service().start();
114
+ return service().state.tabs[name];
115
+ },
116
+ onChange(listener) {
117
+ const seen = (layout) => JSON.stringify([layout[name], layout.tabs[name]]);
118
+ let last = seen(service().state);
119
+ const watch = service().watch((layout) => {
120
+ const next = seen(layout);
121
+ if (next === last)
122
+ return;
123
+ last = next;
124
+ scope.run(listener, layout[name]);
125
+ });
126
+ return scope.add({
127
+ dispose: async () => {
128
+ scope.forget(watch);
129
+ await watch.dispose();
130
+ },
131
+ });
132
+ },
133
+ setVisible: (visible) => service().set(name, { visible }),
134
+ async toggle() {
135
+ await service().read();
136
+ const current = service().state[name];
137
+ // A switch (see Panel.toggle): nothing pops out in fullscreen.
138
+ await service().set(name, {
139
+ visible: !(current?.preferred ?? true),
140
+ reveal: false,
141
+ });
142
+ },
143
+ setDetached: (detached) => service().set(name, { detached }),
144
+ activate: (plugin) => service().set(name, { active: plugin }),
145
+ };
146
+ }
147
+ export function backdrop(scope) {
148
+ const service = () => layoutOf(scope.runtime);
149
+ return {
150
+ get state() {
151
+ service().start();
152
+ return service().state.backdrop;
153
+ },
154
+ onChange(listener) {
155
+ let last = JSON.stringify(service().state.backdrop);
156
+ const watch = service().watch((layout) => {
157
+ const next = JSON.stringify(layout.backdrop);
158
+ if (next === last)
159
+ return;
160
+ last = next;
161
+ scope.run(listener, layout.backdrop);
162
+ });
163
+ return scope.add({
164
+ dispose: async () => {
165
+ scope.forget(watch);
166
+ await watch.dispose();
167
+ },
168
+ });
169
+ },
170
+ };
171
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScopeImpl } from "../scope.ts";
2
+ import type { Panels } from "../types.ts";
3
+ export declare const panels: (scope: ScopeImpl) => Panels;