@vestra-player/sdk 1.6.17 → 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 (88) hide show
  1. package/CHANGELOG.md +24 -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 +26 -185
  30. package/dist/plugin/runtime.js +35 -755
  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/commands.d.ts +65 -0
  41. package/dist/plugin/testing/commands.js +412 -0
  42. package/dist/plugin/testing/core-actions.d.ts +39 -0
  43. package/dist/plugin/testing/core-actions.js +371 -0
  44. package/dist/plugin/testing/declarations.d.ts +37 -0
  45. package/dist/plugin/testing/declarations.js +80 -0
  46. package/dist/plugin/testing/i18n.d.ts +28 -0
  47. package/dist/plugin/testing/i18n.js +107 -0
  48. package/dist/plugin/testing/index.d.ts +3140 -0
  49. package/dist/plugin/testing/index.js +2796 -0
  50. package/dist/plugin/testing/internals.d.ts +76 -0
  51. package/dist/plugin/testing/internals.js +60 -0
  52. package/dist/plugin/testing/jobs.d.ts +9 -0
  53. package/dist/plugin/testing/jobs.js +304 -0
  54. package/dist/plugin/testing/layout.d.ts +85 -0
  55. package/dist/plugin/testing/layout.js +622 -0
  56. package/dist/plugin/testing/media.d.ts +55 -0
  57. package/dist/plugin/testing/media.js +615 -0
  58. package/dist/plugin/testing/osd.d.ts +4 -0
  59. package/dist/plugin/testing/osd.js +57 -0
  60. package/dist/plugin/testing/processing.d.ts +56 -0
  61. package/dist/plugin/testing/processing.js +491 -0
  62. package/dist/plugin/testing/services.d.ts +50 -0
  63. package/dist/plugin/testing/services.js +466 -0
  64. package/dist/plugin/testing/transport.d.ts +29 -0
  65. package/dist/plugin/testing/transport.js +140 -0
  66. package/dist/plugin/testing.d.ts +1 -3140
  67. package/dist/plugin/testing.js +1 -6203
  68. package/dist/plugin/timeline.js +2 -1
  69. package/dist/plugin/view/base.d.ts +29 -0
  70. package/dist/plugin/view/base.js +88 -0
  71. package/dist/plugin/view/binder.d.ts +22 -0
  72. package/dist/plugin/view/binder.js +57 -0
  73. package/dist/plugin/view/image.d.ts +21 -0
  74. package/dist/plugin/view/image.js +60 -0
  75. package/dist/plugin/view/layout.d.ts +47 -0
  76. package/dist/plugin/view/layout.js +171 -0
  77. package/dist/plugin/view/panel.d.ts +3 -0
  78. package/dist/plugin/view/panel.js +178 -0
  79. package/dist/plugin/view/window.d.ts +3 -0
  80. package/dist/plugin/view/window.js +99 -0
  81. package/dist/plugin/view/writer.d.ts +58 -0
  82. package/dist/plugin/view/writer.js +634 -0
  83. package/dist/plugin/view.js +2 -1
  84. package/dist/plugin/window.js +1 -1
  85. package/llms.txt +1 -1
  86. package/package.json +6 -4
  87. package/dist/plugin/ui.d.ts +0 -74
  88. package/dist/plugin/ui.js +0 -1234
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Error helpers shared by every part of the SDK (L1, refactor plan §2.2):
3
+ * no state, no update passes. They lived in `runtime.ts` (`abortError` in
4
+ * `player.ts`), so a module that needed one imported the pass scheduler
5
+ * with it, and `stream.ts` and `player.ts` imported each other.
6
+ */
7
+ import { HostError } from "../wire.js";
8
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ export function toError(error) {
10
+ if (error instanceof Error) {
11
+ const coded = error;
12
+ if (typeof coded.code !== "string")
13
+ Object.defineProperty(coded, "code", {
14
+ value: error.name === "AbortError" ? "aborted" : "error",
15
+ enumerable: false,
16
+ writable: true,
17
+ });
18
+ return coded;
19
+ }
20
+ return new HostError("error", String(error));
21
+ }
22
+ export const failure = (code, message = code) => new HostError(code, message);
23
+ /** Host replies are definite; anything else (lost reply, client timeout) is not. */
24
+ export const uncertain = (error) => !(error instanceof HostError) || error.code === "timeout";
25
+ /** Mark a promise as observed so an unused `ready` never crashes the runner. */
26
+ export function quiet(promise) {
27
+ promise.catch(() => { });
28
+ return promise;
29
+ }
30
+ export function abortError() {
31
+ const error = new Error("aborted");
32
+ error.name = "AbortError";
33
+ error.code = "aborted";
34
+ return error;
35
+ }
@@ -1,4 +1,4 @@
1
- import { failure, sleep, uncertain } from "./runtime.js";
1
+ import { failure, sleep, uncertain } from "./errors.js";
2
2
  import { playerOf } from "./player.js";
3
3
  const nothing = JSON.stringify({ nodes: [], offsetMs: 0 });
4
4
  class FilterHandle {
@@ -6,7 +6,7 @@
6
6
  * @internal
7
7
  */
8
8
  import type { Transport } from "../wire.ts";
9
- import type { PluginManifest, RuntimeOptions } from "./runtime.ts";
9
+ import type { PluginManifest, RuntimeOptions } from "./connection.ts";
10
10
  import type { Plugin } from "./types.ts";
11
11
  export type * from "./types.ts";
12
12
  export type { PluginManifest, RuntimeOptions };
@@ -1,4 +1,6 @@
1
- import { failure, Runtime, stopReason } from "./runtime.js";
1
+ import { Runtime } from "./runtime.js";
2
+ import { stopReason } from "./connection.js";
3
+ import { failure } from "./errors.js";
2
4
  import { ScopeImpl } from "./scope.js";
3
5
  import { playerOf } from "./player.js";
4
6
  import { windowOf } from "./window.js";
@@ -1,6 +1,6 @@
1
1
  /** `plugin.i18n`: the interface language of this run and text values (read-only). */
2
2
  import { Catalog, isTextValue, LIST_STYLES, loadPackageCatalog, renderText, textKey, textProblem, } from "../i18n.js";
3
- import { failure } from "./runtime.js";
3
+ import { failure } from "./errors.js";
4
4
  /** Copy arguments (arrays and objects only; texts are already frozen). */
5
5
  const copyArgs = (args) => Array.isArray(args) ? Object.freeze([...args]) : Object.freeze({ ...args });
6
6
  /** `plugin.i18n.text`: a plain, frozen text value (no translation here). */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The single-flight write queue (L1, refactor plan §2.2): a view's
3
+ * property writes, a command's state, an OSD panel. It knows nothing of
4
+ * update passes (it lived in `runtime.ts`, beside the pass scheduler).
5
+ */
6
+ /** One value in flight, only the newest waiting. Each push learns its fate. */
7
+ export declare class Latest<T> {
8
+ #private;
9
+ constructor(send: (value: T) => Promise<void>);
10
+ push(value: T): Promise<boolean>;
11
+ stop(): void;
12
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The single-flight write queue (L1, refactor plan §2.2): a view's
3
+ * property writes, a command's state, an OSD panel. It knows nothing of
4
+ * update passes (it lived in `runtime.ts`, beside the pass scheduler).
5
+ */
6
+ /** One value in flight, only the newest waiting. Each push learns its fate. */
7
+ export class Latest {
8
+ #queued = [];
9
+ #running = false;
10
+ #stopped = false;
11
+ #send;
12
+ constructor(send) {
13
+ this.#send = send;
14
+ }
15
+ push(value) {
16
+ if (this.#stopped)
17
+ return Promise.resolve(false);
18
+ return new Promise((resolve, reject) => {
19
+ for (const older of this.#queued.splice(0))
20
+ older.settle(false);
21
+ this.#queued.push({
22
+ value,
23
+ settle: (ok, error) => error === undefined ? resolve(ok) : reject(error),
24
+ });
25
+ if (!this.#running)
26
+ void this.#drain();
27
+ });
28
+ }
29
+ stop() {
30
+ this.#stopped = true;
31
+ for (const older of this.#queued.splice(0))
32
+ older.settle(false);
33
+ }
34
+ async #drain() {
35
+ this.#running = true;
36
+ try {
37
+ let next;
38
+ while ((next = this.#queued.shift()) && !this.#stopped) {
39
+ try {
40
+ await this.#send(next.value);
41
+ next.settle(true);
42
+ }
43
+ catch (error) {
44
+ next.settle(false, error);
45
+ }
46
+ }
47
+ }
48
+ finally {
49
+ this.#running = false;
50
+ }
51
+ }
52
+ }
@@ -9,8 +9,7 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { access, constants, stat } from "node:fs/promises";
11
11
  import { delimiter, isAbsolute, join } from "node:path";
12
- import { failure } from "./runtime.js";
13
- import { abortError } from "./player.js";
12
+ import { abortError, failure } from "./errors.js";
14
13
  /** SIGTERM first, SIGKILL if the process is still there after this long. */
15
14
  const KILL_AFTER_MS = 300;
16
15
  const lastLine = (text) => text
@@ -1,5 +1,5 @@
1
1
  import type { Json } from "../wire.ts";
2
- import type { Runtime } from "./runtime.ts";
2
+ import type { Connection } from "./connection.ts";
3
3
  import type { ScopeImpl } from "./scope.ts";
4
4
  import type { MarkdownOptions, MarkdownRef } from "./types.ts";
5
5
  export declare const MARKDOWN_LIMITS: Readonly<{
@@ -43,5 +43,5 @@ export declare function httpsDownload(url: string, options: {
43
43
  * `<dataDirectory>/.vestra-cache/images/` (the Markdown limits: 4 MB, 10 s,
44
44
  * 4 at a time per plugin, 32 MB of cache, a day). Rejects `unavailable`.
45
45
  */
46
- export declare function remoteImageFile(runtime: Runtime, url: string): Promise<string>;
46
+ export declare function remoteImageFile(runtime: Connection, url: string): Promise<string>;
47
47
  export declare function markdown(scope: ScopeImpl, text: string, options?: MarkdownOptions): Promise<MarkdownRef>;
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { mkdir, readdir, rename, stat, unlink, utimes, writeFile, } from "node:fs/promises";
9
9
  import { isAbsolute, join } from "node:path";
10
- import { failure } from "./runtime.js";
10
+ import { failure } from "./errors.js";
11
11
  export const MARKDOWN_LIMITS = Object.freeze({
12
12
  /** Text bytes (UTF-8). */
13
13
  textBytes: 256 * 1024,
@@ -1,4 +1,4 @@
1
- import { failure, sleep, uncertain } from "./runtime.js";
1
+ import { failure, sleep, uncertain } from "./errors.js";
2
2
  import { playerOf } from "./player.js";
3
3
  const TERMINAL = ["completed", "failed", "cancelled"];
4
4
  /** The player's sets for each {@link ExportEncoding} field. */
@@ -1,6 +1,6 @@
1
1
  /** On-screen display: a transient line and a persistent information block. */
2
- import { Latest } from "./runtime.js";
3
- import { failure } from "./runtime.js";
2
+ import { Latest } from "./latest.js";
3
+ import { failure } from "./errors.js";
4
4
  import { isTextValue, textProblem } from "../i18n.js";
5
5
  /**
6
6
  * The player's limits on the on-screen display ({@link Osd}), counted on
@@ -1,7 +1,7 @@
1
1
  /** Package validation, install and uninstall (`plugins.manage`). */
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { resolve } from "node:path";
4
- import { failure, sleep, uncertain } from "./runtime.js";
4
+ import { failure, sleep, uncertain } from "./errors.js";
5
5
  const TERMINAL = [
6
6
  "committed",
7
7
  "cancelled",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The update passes (L2, refactor plan §2.2): bindings, the state copy
3
+ * fields they read (`reading`, `depends`, `part`, `observe`, `reader`),
4
+ * full and precise passes (`invalidate`, `changed`) and the safety pass.
5
+ * It uses the connection (`hold`) and nothing it builds on imports it
6
+ * (`scripts/check-layers.mjs`).
7
+ */
8
+ import type { Connection } from "./connection.ts";
9
+ import type { Disposable } from "./types.ts";
10
+ /**
11
+ * The fields of the SDK's state copies that a binding or listener read, by
12
+ * state (`player`: `plugin.player.state`). An empty set: the state was
13
+ * taken whole (no field read was seen), so any change of it counts.
14
+ */
15
+ export type Deps = Map<string, Set<string>>;
16
+ /** A change of the SDK's state copies: the fields that changed, by state. */
17
+ export type Change = ReadonlyMap<string, ReadonlySet<string>>;
18
+ /**
19
+ * The longest a binding waits for a full pass while precise passes skip
20
+ * it: state a plugin changes outside code the SDK runs (its own timer,
21
+ * without `update()`) still shows within about this long.
22
+ */
23
+ export declare const SAFETY_PASS_MS = 1000;
24
+ /**
25
+ * Read-only counters of the update passes, for the SDK's own
26
+ * characterisation tests and benchmark (`test/golden-traces.test.ts`,
27
+ * `scripts/bench.mjs`): how many passes ran, how many were full, how many
28
+ * bindings and view bind functions (parts) were evaluated or skipped. Not
29
+ * part of the plugin API; counting changes no behaviour.
30
+ */
31
+ export interface PassCounts {
32
+ /** Passes run (`Passes.#run`), precise and full. */
33
+ passes: number;
34
+ /** Of them, full passes (every binding evaluated). */
35
+ fullPasses: number;
36
+ /** Bindings evaluated (`Passes.#evaluate`). */
37
+ evaluations: number;
38
+ /** View bind functions evaluated inside a binding (`Passes.part`). */
39
+ parts: number;
40
+ /** View bind functions a precise pass skipped (`Passes.part`). */
41
+ skippedParts: number;
42
+ /**
43
+ * Full passes the safety timer requested (`SAFETY_PASS_MS` after a
44
+ * precise pass skipped something), counted when the timer fires.
45
+ */
46
+ safetyPasses: number;
47
+ }
48
+ /** The update pass scheduler of one plugin (`Runtime` delegates to it). */
49
+ export declare class Passes {
50
+ #private;
51
+ /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
52
+ readonly counts: PassCounts;
53
+ constructor(connection: Connection);
54
+ bind(evaluate: () => void): Disposable;
55
+ /**
56
+ * A service's state for `event` is read: a binding evaluated now depends
57
+ * on it, so the event counts as listened to while the binding reads it.
58
+ */
59
+ reading(event: string): void;
60
+ /**
61
+ * A field of the SDK's copy of a state (`key`) is read; without `field`,
62
+ * the state is taken (`plugin.player.state`). What is being evaluated
63
+ * then depends on it: a precise pass after a change of that field runs
64
+ * it again.
65
+ */
66
+ depends(key: string, field?: string): void;
67
+ /**
68
+ * Evaluate one part of the binding being evaluated (one `view.bind`
69
+ * function), identified by `token`. A precise pass skips it when nothing
70
+ * it read last time changed, and returns `undefined`; what it read then
71
+ * still counts for the binding.
72
+ */
73
+ part<T>(token: object, read: () => T): {
74
+ value: T;
75
+ } | undefined;
76
+ /**
77
+ * Run plugin code (a state listener) and return the state copy fields it
78
+ * read; they are not counted as a binding's.
79
+ */
80
+ observe<T>(run: () => T): {
81
+ value: T;
82
+ deps: Deps;
83
+ };
84
+ /**
85
+ * Whether code that read `deps` while `change` was delivered needs a full
86
+ * pass after it: it read a field that changed, or no field of that state
87
+ * at all (so the SDK cannot tell what it depends on).
88
+ */
89
+ needsFull(deps: Deps, change: Change): boolean;
90
+ /**
91
+ * Evaluate `read` as `label`, added at `where`: the state it reads is
92
+ * reported as read by it. `field`: a command's `enabled` or `checked`,
93
+ * which a `plugin.when` condition can replace.
94
+ */
95
+ reader<T>(label: string, where: (() => string | undefined) | undefined, read: () => T, field?: boolean): T;
96
+ /**
97
+ * A full pass: every binding is evaluated. Requested after plugin code
98
+ * the SDK ran, by `plugin.update()`, and on any change other than a state
99
+ * copy's fields. Coalesced: one pass per microtask turn, however many
100
+ * requests.
101
+ */
102
+ invalidate(): void;
103
+ /**
104
+ * Fields of the SDK's copy of a state (`key`) changed: a precise pass
105
+ * evaluates only the bindings (and view bind functions) that read one of
106
+ * them, unless a full pass is due anyway.
107
+ */
108
+ changed(key: string, fields: Iterable<string>): void;
109
+ /** The plugin was disposed: cancel the pending safety pass, arm no more. */
110
+ stop(): void;
111
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * The state each event keeps current, and whether `plugin.when` can say
3
+ * the same without the plugin running.
4
+ */
5
+ const STATE = {
6
+ "source.state": { name: "plugin.player.state", when: true },
7
+ "window.state": { name: "plugin.window.state", when: true },
8
+ "appearance.changed": { name: "plugin.appearance", when: true },
9
+ "timeline.changed": { name: "plugin.timeline.marks", when: false },
10
+ "commands.keys": { name: "plugin.commands.keys", when: false },
11
+ "playback.backend": { name: "plugin.subtitles.state", when: false },
12
+ "resolvers.fileTypes": {
13
+ name: "plugin.resolvers.fileTypes",
14
+ when: false,
15
+ },
16
+ };
17
+ /** Whether something that read `deps` may see a different result after `change`. */
18
+ function affected(deps, change) {
19
+ for (const [key, fields] of change) {
20
+ const read = deps.get(key);
21
+ if (!read)
22
+ continue;
23
+ if (read.size === 0)
24
+ return true;
25
+ for (const field of fields)
26
+ if (read.has(field))
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ /** Add `from` to `into` (an empty set, the whole state, wins). */
32
+ function merge(into, from) {
33
+ for (const [key, fields] of from) {
34
+ const set = into.get(key);
35
+ if (!set)
36
+ into.set(key, new Set(fields));
37
+ else if (set.size && fields.size)
38
+ for (const field of fields)
39
+ set.add(field);
40
+ else
41
+ set.clear();
42
+ }
43
+ }
44
+ /**
45
+ * The longest a binding waits for a full pass while precise passes skip
46
+ * it: state a plugin changes outside code the SDK runs (its own timer,
47
+ * without `update()`) still shows within about this long.
48
+ */
49
+ export const SAFETY_PASS_MS = 1000;
50
+ /** The update pass scheduler of one plugin (`Runtime` delegates to it). */
51
+ export class Passes {
52
+ /** The plugin's connection: holds the events a binding reads. */
53
+ #connection;
54
+ /** Each binding with the events whose state it read last time (held). */
55
+ #bindings = new Map();
56
+ /** The events read by the binding being evaluated, with who read them. */
57
+ #reads;
58
+ /** Who reads now (a command's field and where it was added). */
59
+ #reader;
60
+ #scheduled = false;
61
+ /** The next pass evaluates every binding (not only a state copy changed). */
62
+ #full = false;
63
+ /** State copy changes since the last pass (a precise pass, unless `#full`). */
64
+ #pending = new Map();
65
+ /** The change the running precise pass is for; `undefined` in a full pass. */
66
+ #pass;
67
+ /** The state copy fields read by what is being evaluated now. */
68
+ #deps;
69
+ /** Each part's reads and fields last time (`part`). */
70
+ #parts = new WeakMap();
71
+ /** A precise pass skipped something: a full pass follows (`SAFETY_PASS_MS`). */
72
+ #safety;
73
+ /** The running pass skipped a binding or a part. */
74
+ #skipped = false;
75
+ /** The plugin was disposed: no safety pass is armed any more. */
76
+ #stopped = false;
77
+ /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
78
+ counts = {
79
+ passes: 0,
80
+ fullPasses: 0,
81
+ evaluations: 0,
82
+ parts: 0,
83
+ skippedParts: 0,
84
+ safetyPasses: 0,
85
+ };
86
+ constructor(connection) {
87
+ this.#connection = connection;
88
+ }
89
+ bind(evaluate) {
90
+ const held = new Map();
91
+ const binding = { held, deps: new Map() };
92
+ this.#bindings.set(evaluate, binding);
93
+ this.invalidate();
94
+ return {
95
+ dispose: async () => {
96
+ if (this.#bindings.get(evaluate) !== binding)
97
+ return;
98
+ this.#bindings.delete(evaluate);
99
+ for (const hold of held.values())
100
+ await hold.dispose();
101
+ held.clear();
102
+ },
103
+ };
104
+ }
105
+ /**
106
+ * A service's state for `event` is read: a binding evaluated now depends
107
+ * on it, so the event counts as listened to while the binding reads it.
108
+ */
109
+ reading(event) {
110
+ const reads = this.#reads;
111
+ if (!reads || reads.has(event))
112
+ return;
113
+ const reader = this.#reader;
114
+ const state = STATE[event];
115
+ reads.set(event, () => {
116
+ const read = state?.name ?? event;
117
+ const site = reader?.where?.();
118
+ const at = site ? ` (${site})` : "";
119
+ if (!reader)
120
+ return `a binding reads ${read}`;
121
+ if (!reader.field)
122
+ return `${reader.label} reads ${read}${at}`;
123
+ const hint = state?.when ? " — use plugin.when" : "";
124
+ return `${reader.label} is a function that reads ${read}${at}${hint}`;
125
+ });
126
+ }
127
+ /**
128
+ * A field of the SDK's copy of a state (`key`) is read; without `field`,
129
+ * the state is taken (`plugin.player.state`). What is being evaluated
130
+ * then depends on it: a precise pass after a change of that field runs
131
+ * it again.
132
+ */
133
+ depends(key, field) {
134
+ const deps = this.#deps;
135
+ if (!deps)
136
+ return;
137
+ let fields = deps.get(key);
138
+ if (!fields)
139
+ deps.set(key, (fields = new Set()));
140
+ if (field !== undefined)
141
+ fields.add(field);
142
+ }
143
+ /**
144
+ * Evaluate one part of the binding being evaluated (one `view.bind`
145
+ * function), identified by `token`. A precise pass skips it when nothing
146
+ * it read last time changed, and returns `undefined`; what it read then
147
+ * still counts for the binding.
148
+ */
149
+ part(token, read) {
150
+ const last = this.#parts.get(token);
151
+ const outerReads = this.#reads;
152
+ const outerDeps = this.#deps;
153
+ if (last && this.#pass && !affected(last.deps, this.#pass)) {
154
+ this.#skipped = true;
155
+ this.counts.skippedParts++;
156
+ if (outerReads)
157
+ for (const [event, why] of last.reads)
158
+ if (!outerReads.has(event))
159
+ outerReads.set(event, why);
160
+ if (outerDeps)
161
+ merge(outerDeps, last.deps);
162
+ return undefined;
163
+ }
164
+ this.counts.parts++;
165
+ const reads = new Map();
166
+ const deps = new Map();
167
+ this.#reads = outerReads && reads;
168
+ this.#deps = deps;
169
+ try {
170
+ return { value: read() };
171
+ }
172
+ finally {
173
+ this.#reads = outerReads;
174
+ this.#deps = outerDeps;
175
+ this.#parts.set(token, { reads, deps });
176
+ if (outerReads)
177
+ for (const [event, why] of reads)
178
+ if (!outerReads.has(event))
179
+ outerReads.set(event, why);
180
+ if (outerDeps)
181
+ merge(outerDeps, deps);
182
+ }
183
+ }
184
+ /**
185
+ * Run plugin code (a state listener) and return the state copy fields it
186
+ * read; they are not counted as a binding's.
187
+ */
188
+ observe(run) {
189
+ const outerReads = this.#reads;
190
+ const outerDeps = this.#deps;
191
+ const deps = new Map();
192
+ this.#reads = undefined;
193
+ this.#deps = deps;
194
+ try {
195
+ return { value: run(), deps };
196
+ }
197
+ finally {
198
+ this.#reads = outerReads;
199
+ this.#deps = outerDeps;
200
+ }
201
+ }
202
+ /**
203
+ * Whether code that read `deps` while `change` was delivered needs a full
204
+ * pass after it: it read a field that changed, or no field of that state
205
+ * at all (so the SDK cannot tell what it depends on).
206
+ */
207
+ needsFull(deps, change) {
208
+ for (const key of change.keys()) {
209
+ const read = deps.get(key);
210
+ if (!read || read.size === 0)
211
+ return true;
212
+ }
213
+ return affected(deps, change);
214
+ }
215
+ /**
216
+ * Evaluate `read` as `label`, added at `where`: the state it reads is
217
+ * reported as read by it. `field`: a command's `enabled` or `checked`,
218
+ * which a `plugin.when` condition can replace.
219
+ */
220
+ reader(label, where, read, field = false) {
221
+ const outer = this.#reader;
222
+ this.#reader = { label, where, field };
223
+ try {
224
+ return read();
225
+ }
226
+ finally {
227
+ this.#reader = outer;
228
+ }
229
+ }
230
+ /** Evaluate one binding, holding the events whose state it read. */
231
+ #evaluate(evaluate, binding) {
232
+ const held = binding.held;
233
+ const reads = new Map();
234
+ const deps = new Map();
235
+ const outer = this.#reads;
236
+ const outerDeps = this.#deps;
237
+ const reader = this.#reader;
238
+ this.#reads = reads;
239
+ this.#deps = deps;
240
+ this.#reader = undefined;
241
+ this.counts.evaluations++;
242
+ try {
243
+ evaluate();
244
+ }
245
+ finally {
246
+ this.#reads = outer;
247
+ this.#deps = outerDeps;
248
+ this.#reader = reader;
249
+ binding.deps = deps;
250
+ for (const [event, hold] of held)
251
+ if (!reads.has(event)) {
252
+ held.delete(event);
253
+ void hold.dispose();
254
+ }
255
+ for (const [event, why] of reads)
256
+ if (!held.has(event))
257
+ held.set(event, this.#connection.hold(event, why));
258
+ }
259
+ }
260
+ /**
261
+ * A full pass: every binding is evaluated. Requested after plugin code
262
+ * the SDK ran, by `plugin.update()`, and on any change other than a state
263
+ * copy's fields. Coalesced: one pass per microtask turn, however many
264
+ * requests.
265
+ */
266
+ invalidate() {
267
+ this.#full = true;
268
+ this.#schedule();
269
+ }
270
+ /**
271
+ * Fields of the SDK's copy of a state (`key`) changed: a precise pass
272
+ * evaluates only the bindings (and view bind functions) that read one of
273
+ * them, unless a full pass is due anyway.
274
+ */
275
+ changed(key, fields) {
276
+ let set = this.#pending.get(key);
277
+ if (!set)
278
+ this.#pending.set(key, (set = new Set()));
279
+ for (const field of fields)
280
+ set.add(field);
281
+ this.#schedule();
282
+ }
283
+ #schedule() {
284
+ if (this.#scheduled)
285
+ return;
286
+ this.#scheduled = true;
287
+ queueMicrotask(() => this.#run());
288
+ }
289
+ /**
290
+ * One pass. A precise pass that skips anything arms a full pass
291
+ * `SAFETY_PASS_MS` later (unless a full pass comes first), for plugin
292
+ * state changed outside code the SDK ran.
293
+ */
294
+ #run() {
295
+ this.#scheduled = false;
296
+ const full = this.#full;
297
+ const change = this.#pending;
298
+ this.#full = false;
299
+ this.#pending = new Map();
300
+ this.counts.passes++;
301
+ if (full)
302
+ this.counts.fullPasses++;
303
+ if (full) {
304
+ clearTimeout(this.#safety);
305
+ this.#safety = undefined;
306
+ }
307
+ this.#skipped = false;
308
+ for (const [evaluate, binding] of [...this.#bindings]) {
309
+ if (this.#bindings.get(evaluate) !== binding)
310
+ continue;
311
+ if (!full && !affected(binding.deps, change)) {
312
+ this.#skipped = true;
313
+ continue;
314
+ }
315
+ this.#pass = full ? undefined : change;
316
+ try {
317
+ this.#evaluate(evaluate, binding);
318
+ }
319
+ catch (error) {
320
+ console.error(`[${this.#connection.manifest.id}] binding failed:`, error);
321
+ }
322
+ finally {
323
+ this.#pass = undefined;
324
+ }
325
+ }
326
+ if (this.#skipped && !this.#safety && !this.#stopped) {
327
+ this.#safety = setTimeout(() => {
328
+ this.#safety = undefined;
329
+ this.counts.safetyPasses++;
330
+ this.invalidate();
331
+ }, SAFETY_PASS_MS);
332
+ this.#safety.unref?.();
333
+ }
334
+ }
335
+ /** The plugin was disposed: cancel the pending safety pass, arm no more. */
336
+ stop() {
337
+ this.#stopped = true;
338
+ clearTimeout(this.#safety);
339
+ this.#safety = undefined;
340
+ }
341
+ }
@@ -1,8 +1,8 @@
1
1
  import type { SourceIntent, SourceStamp, SourceState, SourceTicket } from "../source.ts";
2
- import { Latest } from "./runtime.ts";
2
+ import { Latest } from "./latest.ts";
3
3
  import type { Runtime } from "./runtime.ts";
4
4
  import type { ScopeImpl } from "./scope.ts";
5
- import type { Conditional, Disposable, Player, PlayerState, PluginError } from "./types.ts";
5
+ import type { Conditional, Disposable, Player, PlayerState } from "./types.ts";
6
6
  /**
7
7
  * The key of `plugin.player.state` in the runtime's field tracking
8
8
  * (`Runtime.depends`). Its fields are the state's own, plus `stamp` for
@@ -13,7 +13,6 @@ export declare const rawOf: (state: PlayerState) => SourceState | undefined;
13
13
  /** A step-seek length the player accepts: 0.1–3600 seconds in tenths. */
14
14
  export declare const validStep: (seconds: unknown) => seconds is number;
15
15
  export declare const sameStamp: (a: SourceStamp, b: SourceStamp) => boolean;
16
- export declare function abortError(): PluginError;
17
16
  /** One per plugin: the state store and the operation book client. */
18
17
  export declare class PlayerService {
19
18
  #private;