@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
@@ -1,780 +1,60 @@
1
- /** Shared machinery behind every scope: invocation, update passes, events. */
2
- import { createWire, HostError } from "../wire.js";
3
- import { LOCALES } from "../i18n.js";
4
- import { basename, isAbsolute, relative } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- /** The SDK's own files (`src/` or `dist/`): left out of a call site. */
7
- const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
8
1
  /**
9
- * Events the player wakes a stopped plugin for (player 0.5.21): a listener
10
- * of one does not keep the plugin running. They follow deliberate changes
11
- * (an install, a key edit, the palette, a resolver), not playback.
2
+ * Shared machinery behind every scope: the connection to the player
3
+ * (`connection.ts`, L1) with the update passes (`passes.ts`, L2) on top.
4
+ * `Runtime` is the connection plus the pass methods, delegated, so every
5
+ * caller keeps `runtime.host`, `runtime.on`, `runtime.bind` and the rest.
12
6
  */
13
- export const WAKE_EVENTS = [
14
- "packages.changed",
15
- "bindings.changed",
16
- // Player 0.7.6: sent to a plugin whose own keys changed and, from SDK
17
- // 1.6.16, to every listener when a player command (`host/<id>`) is
18
- // rebound, which wakes each parked listener.
19
- "commands.keys",
20
- "appearance.changed",
21
- "resolvers.fileTypes",
22
- ];
23
- /**
24
- * The state each event keeps current, and whether `plugin.when` can say
25
- * the same without the plugin running.
26
- */
27
- const STATE = {
28
- "source.state": { name: "plugin.player.state", when: true },
29
- "window.state": { name: "plugin.window.state", when: true },
30
- "appearance.changed": { name: "plugin.appearance", when: true },
31
- "timeline.changed": { name: "plugin.timeline.marks", when: false },
32
- "commands.keys": { name: "plugin.commands.keys", when: false },
33
- "playback.backend": { name: "plugin.subtitles.state", when: false },
34
- "resolvers.fileTypes": {
35
- name: "plugin.resolvers.fileTypes",
36
- when: false,
37
- },
38
- };
39
- export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
40
- export function toError(error) {
41
- if (error instanceof Error) {
42
- const coded = error;
43
- if (typeof coded.code !== "string")
44
- Object.defineProperty(coded, "code", {
45
- value: error.name === "AbortError" ? "aborted" : "error",
46
- enumerable: false,
47
- writable: true,
48
- });
49
- return coded;
50
- }
51
- return new HostError("error", String(error));
52
- }
53
- export const failure = (code, message = code) => new HostError(code, message);
54
- /** Host replies are definite; anything else (lost reply, client timeout) is not. */
55
- export const uncertain = (error) => !(error instanceof HostError) || error.code === "timeout";
56
- /** Mark a promise as observed so an unused `ready` never crashes the runner. */
57
- export function quiet(promise) {
58
- promise.catch(() => { });
59
- return promise;
60
- }
61
- /** Whether something that read `deps` may see a different result after `change`. */
62
- function affected(deps, change) {
63
- for (const [key, fields] of change) {
64
- const read = deps.get(key);
65
- if (!read)
66
- continue;
67
- if (read.size === 0)
68
- return true;
69
- for (const field of fields)
70
- if (read.has(field))
71
- return true;
72
- }
73
- return false;
74
- }
75
- /** Add `from` to `into` (an empty set, the whole state, wins). */
76
- function merge(into, from) {
77
- for (const [key, fields] of from) {
78
- const set = into.get(key);
79
- if (!set)
80
- into.set(key, new Set(fields));
81
- else if (set.size && fields.size)
82
- for (const field of fields)
83
- set.add(field);
84
- else
85
- set.clear();
86
- }
87
- }
88
- /**
89
- * The longest a binding waits for a full pass while precise passes skip
90
- * it: state a plugin changes outside code the SDK runs (its own timer,
91
- * without `update()`) still shows within about this long.
92
- */
93
- export const SAFETY_PASS_MS = 1000;
94
- /**
95
- * The player's answers to a declaration that refuse that one declaration
96
- * (a command's path, texts or count): during activation they skip it
97
- * instead of failing the plugin (`Runtime.declaring`, SDK 1.5.49).
98
- */
99
- const DECLARATION_REFUSALS = new Set([
100
- "invalid",
101
- "conflict",
102
- "limit",
103
- ]);
104
- /** The reasons `StopReason` names; anything else reads as `undefined`. */
105
- const STOP_REASONS = new Set([
106
- "quit",
107
- "disable",
108
- "uninstall",
109
- "reload",
110
- "upgrade",
111
- "idle",
112
- "failed",
113
- "host-lost",
114
- ]);
115
- /** A stop reason the runner passed on, if this SDK knows it. */
116
- export function stopReason(value) {
117
- return typeof value === "string" && STOP_REASONS.has(value)
118
- ? value
119
- : undefined;
120
- }
121
- export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
122
- /** One value in flight, only the newest waiting. Each push learns its fate. */
123
- export class Latest {
124
- #queued = [];
125
- #running = false;
126
- #stopped = false;
127
- #send;
128
- constructor(send) {
129
- this.#send = send;
130
- }
131
- push(value) {
132
- if (this.#stopped)
133
- return Promise.resolve(false);
134
- return new Promise((resolve, reject) => {
135
- for (const older of this.#queued.splice(0))
136
- older.settle(false);
137
- this.#queued.push({
138
- value,
139
- settle: (ok, error) => error === undefined ? resolve(ok) : reject(error),
140
- });
141
- if (!this.#running)
142
- void this.#drain();
143
- });
144
- }
145
- stop() {
146
- this.#stopped = true;
147
- for (const older of this.#queued.splice(0))
148
- older.settle(false);
149
- }
150
- async #drain() {
151
- this.#running = true;
152
- try {
153
- let next;
154
- while ((next = this.#queued.shift()) && !this.#stopped) {
155
- try {
156
- await this.#send(next.value);
157
- next.settle(true);
158
- }
159
- catch (error) {
160
- next.settle(false, error);
161
- }
162
- }
163
- }
164
- finally {
165
- this.#running = false;
166
- }
167
- }
168
- }
169
- /**
170
- * Per-plugin services. Scopes are thin views over this object; the flat RPC
171
- * clients stay private here so protocol fields never reach plugin code.
172
- */
173
- export class Runtime {
174
- host;
175
- manifest;
176
- directory;
177
- /** Writable per-plugin data directory from the host, if it provided one. */
178
- dataDirectory;
179
- /** Test doubles for local processes; the real system when absent. */
180
- local;
181
- /** Registrations that activation must wait for before reporting ready. */
182
- startup = new Set();
183
- #activating = true;
184
- /** `RuntimeOptions.strictDeclarations`. */
185
- #strict;
186
- /** Declarations skipped during activation: reported once it completes. */
187
- #refusals = [];
188
- #transport;
189
- /** Each binding with the events whose state it read last time (held). */
190
- #bindings = new Map();
191
- /** The events read by the binding being evaluated, with who read them. */
192
- #reads;
193
- /** Who reads now (a command's field and where it was added). */
194
- #reader;
195
- #scheduled = false;
196
- /** The next pass evaluates every binding (not only a state copy changed). */
197
- #full = false;
198
- /** State copy changes since the last pass (a precise pass, unless `#full`). */
199
- #pending = new Map();
200
- /** The change the running precise pass is for; `undefined` in a full pass. */
201
- #pass;
202
- /** The state copy fields read by what is being evaluated now. */
203
- #deps;
204
- /** Each part's reads and fields last time (`part`). */
205
- #parts = new WeakMap();
206
- /** A precise pass skipped something: a full pass follows (`SAFETY_PASS_MS`). */
207
- #safety;
208
- /** The running pass skipped a binding or a part. */
209
- #skipped = false;
210
- /** The plugin was disposed: no safety pass is armed any more. */
211
- #stopped = false;
212
- #events = new Map();
213
- /** The missing `events` warning was given (once per plugin). */
214
- #warnedEvents = false;
215
- views = new Map();
216
- commands = new Map();
217
- services = new Map();
218
- /**
219
- * Set when the player stops the plugin (`PluginInstance.dispose`), before
220
- * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
221
- */
222
- stopping;
223
- /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
224
- app;
225
- /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
226
- locale;
227
- /**
228
- * Protocol additions the player advertises at start-up (`features` in the
229
- * handshake's app data, player 0.6.0): `ui.patch` (row patches of
230
- * property writes). Not part of the plugin API; empty for older players.
231
- */
232
- features;
7
+ import { Connection } from "./connection.js";
8
+ import { Passes } from "./passes.js";
9
+ /** Per-plugin services: the connection, and the update passes over it. */
10
+ export class Runtime extends Connection {
11
+ /** The update pass scheduler. */
12
+ passes;
13
+ /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
14
+ counts;
233
15
  constructor(transport, manifest, directory, dataDirectory, app, options = {}) {
234
- this.#transport = transport;
235
- this.#strict = options.strictDeclarations ?? false;
236
- this.dataDirectory = dataDirectory;
237
- this.local = transport.local;
238
- this.host = createWire(transport);
239
- const host = this.host;
240
- this.app = appInfo(app, () => host.app.system());
241
- const features = app
242
- ?.features;
243
- this.features = new Set(Array.isArray(features)
244
- ? features.filter((name) => typeof name === "string")
245
- : []);
246
- this.locale = LOCALES.includes(transport.locale ?? "")
247
- ? transport.locale
248
- : "en";
249
- this.manifest = manifest;
250
- this.directory = directory;
251
- transport.on("ui.callback", (data) => {
252
- const event = data;
253
- // A panel's callback names its slot (player 0.7.6: one panel per
254
- // slot); older players name only `"$mount"`, and the plugin has one.
255
- const view = event.view === "$mount"
256
- ? event.slot
257
- ? `panel:${event.slot}`
258
- : [...this.views.keys()].find((key) => key.startsWith("panel:"))
259
- : event.view;
260
- if (view)
261
- this.views.get(view)?.(event.callback, event.args);
262
- });
263
- transport.on("commands.execute", (data) => {
264
- this.commands.get(data.id)?.();
265
- });
266
- }
267
- can(capability) {
268
- return this.manifest.capabilities.includes(capability);
269
- }
270
- /** Lazily created singletons shared by every scope of this plugin. */
271
- service(name, create) {
272
- if (!this.services.has(name))
273
- this.services.set(name, create());
274
- return this.services.get(name);
275
- }
276
- /**
277
- * Registrations started while `activate` runs must succeed before the
278
- * plugin reports ready; later ones report failures to their scope.
279
- */
280
- starting(work, report) {
281
- if (this.#activating)
282
- this.startup.add(work);
283
- else
284
- work.catch(report);
285
- work.catch(() => { });
286
- return work;
287
- }
288
- /**
289
- * A declaration the player checks on its own (`what`: "command x"),
290
- * started like `starting`. In the player's runner a refusal of it during
291
- * activation (`invalid`, `conflict`, `limit`) does not fail the plugin:
292
- * it is logged, skipped and reported to the scope once activation
293
- * completes, so an `onError` added anywhere in `activate` hears it (SDK
294
- * 1.5.49; VS Code logs a bad contribution and activates the extension).
295
- * Under `strictDeclarations` (TestHost) it fails activation as before.
296
- */
297
- declaring(work, what, report) {
298
- if (!this.#activating || this.#strict)
299
- return this.starting(work, report);
300
- const settled = work.then(() => { }, (error) => {
301
- if (!(error instanceof HostError) ||
302
- !DECLARATION_REFUSALS.has(error.code))
303
- throw error;
304
- console.error(`[${this.manifest.id}] ${what} skipped, the player refused it (${error.code}): ${error.message}`);
305
- this.#refusals.push(() => report(error));
306
- });
307
- // Observed now: `activated()` may come after `activate` awaits, and
308
- // its `allSettled` still sees the rejection.
309
- settled.catch(() => { });
310
- this.startup.add(settled);
311
- work.catch(() => { });
312
- return work;
313
- }
314
- async activated() {
315
- while (this.startup.size) {
316
- const pending = [...this.startup];
317
- this.startup.clear();
318
- for (const result of await Promise.allSettled(pending))
319
- if (result.status === "rejected")
320
- throw result.reason;
321
- }
322
- this.#activating = false;
323
- for (const report of this.#refusals.splice(0))
324
- report();
16
+ super(transport, manifest, directory, dataDirectory, app, options);
17
+ this.passes = new Passes(this);
18
+ this.counts = this.passes.counts;
325
19
  }
20
+ /** {@link Passes.bind} */
326
21
  bind(evaluate) {
327
- const held = new Map();
328
- const binding = { held, deps: new Map() };
329
- this.#bindings.set(evaluate, binding);
330
- this.invalidate();
331
- return {
332
- dispose: async () => {
333
- if (this.#bindings.get(evaluate) !== binding)
334
- return;
335
- this.#bindings.delete(evaluate);
336
- for (const hold of held.values())
337
- await hold.dispose();
338
- held.clear();
339
- },
340
- };
22
+ return this.passes.bind(evaluate);
341
23
  }
342
- /**
343
- * A service's state for `event` is read: a binding evaluated now depends
344
- * on it, so the event counts as listened to while the binding reads it.
345
- */
24
+ /** {@link Passes.reading} */
346
25
  reading(event) {
347
- const reads = this.#reads;
348
- if (!reads || reads.has(event))
349
- return;
350
- const reader = this.#reader;
351
- const state = STATE[event];
352
- reads.set(event, () => {
353
- const read = state?.name ?? event;
354
- const site = reader?.where?.();
355
- const at = site ? ` (${site})` : "";
356
- if (!reader)
357
- return `a binding reads ${read}`;
358
- if (!reader.field)
359
- return `${reader.label} reads ${read}${at}`;
360
- const hint = state?.when ? " — use plugin.when" : "";
361
- return `${reader.label} is a function that reads ${read}${at}${hint}`;
362
- });
26
+ this.passes.reading(event);
363
27
  }
364
- /**
365
- * A field of the SDK's copy of a state (`key`) is read; without `field`,
366
- * the state is taken (`plugin.player.state`). What is being evaluated
367
- * then depends on it: a precise pass after a change of that field runs
368
- * it again.
369
- */
28
+ /** {@link Passes.depends} */
370
29
  depends(key, field) {
371
- const deps = this.#deps;
372
- if (!deps)
373
- return;
374
- let fields = deps.get(key);
375
- if (!fields)
376
- deps.set(key, (fields = new Set()));
377
- if (field !== undefined)
378
- fields.add(field);
30
+ this.passes.depends(key, field);
379
31
  }
380
- /**
381
- * Evaluate one part of the binding being evaluated (one `view.bind`
382
- * function), identified by `token`. A precise pass skips it when nothing
383
- * it read last time changed, and returns `undefined`; what it read then
384
- * still counts for the binding.
385
- */
32
+ /** {@link Passes.part} */
386
33
  part(token, read) {
387
- const last = this.#parts.get(token);
388
- const outerReads = this.#reads;
389
- const outerDeps = this.#deps;
390
- if (last && this.#pass && !affected(last.deps, this.#pass)) {
391
- this.#skipped = true;
392
- if (outerReads)
393
- for (const [event, why] of last.reads)
394
- if (!outerReads.has(event))
395
- outerReads.set(event, why);
396
- if (outerDeps)
397
- merge(outerDeps, last.deps);
398
- return undefined;
399
- }
400
- const reads = new Map();
401
- const deps = new Map();
402
- this.#reads = outerReads && reads;
403
- this.#deps = deps;
404
- try {
405
- return { value: read() };
406
- }
407
- finally {
408
- this.#reads = outerReads;
409
- this.#deps = outerDeps;
410
- this.#parts.set(token, { reads, deps });
411
- if (outerReads)
412
- for (const [event, why] of reads)
413
- if (!outerReads.has(event))
414
- outerReads.set(event, why);
415
- if (outerDeps)
416
- merge(outerDeps, deps);
417
- }
34
+ return this.passes.part(token, read);
418
35
  }
419
- /**
420
- * Run plugin code (a state listener) and return the state copy fields it
421
- * read; they are not counted as a binding's.
422
- */
36
+ /** {@link Passes.observe} */
423
37
  observe(run) {
424
- const outerReads = this.#reads;
425
- const outerDeps = this.#deps;
426
- const deps = new Map();
427
- this.#reads = undefined;
428
- this.#deps = deps;
429
- try {
430
- return { value: run(), deps };
431
- }
432
- finally {
433
- this.#reads = outerReads;
434
- this.#deps = outerDeps;
435
- }
38
+ return this.passes.observe(run);
436
39
  }
437
- /**
438
- * Whether code that read `deps` while `change` was delivered needs a full
439
- * pass after it: it read a field that changed, or no field of that state
440
- * at all (so the SDK cannot tell what it depends on).
441
- */
40
+ /** {@link Passes.needsFull} */
442
41
  needsFull(deps, change) {
443
- for (const key of change.keys()) {
444
- const read = deps.get(key);
445
- if (!read || read.size === 0)
446
- return true;
447
- }
448
- return affected(deps, change);
42
+ return this.passes.needsFull(deps, change);
449
43
  }
450
- /**
451
- * Evaluate `read` as `label`, added at `where`: the state it reads is
452
- * reported as read by it. `field`: a command's `enabled` or `checked`,
453
- * which a `plugin.when` condition can replace.
454
- */
44
+ /** {@link Passes.reader} */
455
45
  reader(label, where, read, field = false) {
456
- const outer = this.#reader;
457
- this.#reader = { label, where, field };
458
- try {
459
- return read();
460
- }
461
- finally {
462
- this.#reader = outer;
463
- }
464
- }
465
- /**
466
- * Where the plugin's code called into the SDK: the first stack frame
467
- * outside the SDK, as a path relative to the plugin's directory and a
468
- * line (`main.ts:353`). Captured now, formatted when asked.
469
- */
470
- callSite() {
471
- const limit = Error.stackTraceLimit;
472
- Error.stackTraceLimit = 40;
473
- const error = new Error();
474
- Error.stackTraceLimit = limit;
475
- let site = null;
476
- return () => {
477
- if (site === null)
478
- site = this.#site(error.stack ?? "");
479
- return site;
480
- };
481
- }
482
- #site(stack) {
483
- for (const line of stack.split("\n").slice(1)) {
484
- const match = /\(?((?:file:\/\/)?[^()\s]+?):(\d+):\d+\)?\s*$/.exec(line);
485
- if (!match)
486
- continue;
487
- let file = match[1];
488
- if (file.startsWith("file:"))
489
- try {
490
- file = fileURLToPath(file);
491
- }
492
- catch {
493
- continue;
494
- }
495
- const sdk = relative(SDK_DIRECTORY, file);
496
- if (!isAbsolute(file) || !(sdk.startsWith("..") || isAbsolute(sdk)))
497
- continue;
498
- const inside = relative(this.directory, file);
499
- const shown = inside.startsWith("..") || isAbsolute(inside) ? basename(file) : inside;
500
- return `${shown.replaceAll("\\", "/")}:${match[2]}`;
501
- }
502
- return undefined;
503
- }
504
- /** Evaluate one binding, holding the events whose state it read. */
505
- #evaluate(evaluate, binding) {
506
- const held = binding.held;
507
- const reads = new Map();
508
- const deps = new Map();
509
- const outer = this.#reads;
510
- const outerDeps = this.#deps;
511
- const reader = this.#reader;
512
- this.#reads = reads;
513
- this.#deps = deps;
514
- this.#reader = undefined;
515
- try {
516
- evaluate();
517
- }
518
- finally {
519
- this.#reads = outer;
520
- this.#deps = outerDeps;
521
- this.#reader = reader;
522
- binding.deps = deps;
523
- for (const [event, hold] of held)
524
- if (!reads.has(event)) {
525
- held.delete(event);
526
- void hold.dispose();
527
- }
528
- for (const [event, why] of reads)
529
- if (!held.has(event))
530
- held.set(event, this.hold(event, why));
531
- }
46
+ return this.passes.reader(label, where, read, field);
532
47
  }
533
- /**
534
- * A full pass: every binding is evaluated. Requested after plugin code
535
- * the SDK ran, by `plugin.update()`, and on any change other than a state
536
- * copy's fields. Coalesced: one pass per microtask turn, however many
537
- * requests.
538
- */
48
+ /** {@link Passes.invalidate} */
539
49
  invalidate() {
540
- this.#full = true;
541
- this.#schedule();
50
+ this.passes.invalidate();
542
51
  }
543
- /**
544
- * Fields of the SDK's copy of a state (`key`) changed: a precise pass
545
- * evaluates only the bindings (and view bind functions) that read one of
546
- * them, unless a full pass is due anyway.
547
- */
52
+ /** {@link Passes.changed} */
548
53
  changed(key, fields) {
549
- let set = this.#pending.get(key);
550
- if (!set)
551
- this.#pending.set(key, (set = new Set()));
552
- for (const field of fields)
553
- set.add(field);
554
- this.#schedule();
54
+ this.passes.changed(key, fields);
555
55
  }
556
- #schedule() {
557
- if (this.#scheduled)
558
- return;
559
- this.#scheduled = true;
560
- queueMicrotask(() => this.#run());
561
- }
562
- /**
563
- * One pass. A precise pass that skips anything arms a full pass
564
- * `SAFETY_PASS_MS` later (unless a full pass comes first), for plugin
565
- * state changed outside code the SDK ran.
566
- */
567
- #run() {
568
- this.#scheduled = false;
569
- const full = this.#full;
570
- const change = this.#pending;
571
- this.#full = false;
572
- this.#pending = new Map();
573
- if (full) {
574
- clearTimeout(this.#safety);
575
- this.#safety = undefined;
576
- }
577
- this.#skipped = false;
578
- for (const [evaluate, binding] of [...this.#bindings]) {
579
- if (this.#bindings.get(evaluate) !== binding)
580
- continue;
581
- if (!full && !affected(binding.deps, change)) {
582
- this.#skipped = true;
583
- continue;
584
- }
585
- this.#pass = full ? undefined : change;
586
- try {
587
- this.#evaluate(evaluate, binding);
588
- }
589
- catch (error) {
590
- console.error(`[${this.manifest.id}] binding failed:`, error);
591
- }
592
- finally {
593
- this.#pass = undefined;
594
- }
595
- }
596
- if (this.#skipped && !this.#safety && !this.#stopped) {
597
- this.#safety = setTimeout(() => {
598
- this.#safety = undefined;
599
- this.invalidate();
600
- }, SAFETY_PASS_MS);
601
- this.#safety.unref?.();
602
- }
603
- }
604
- /** The plugin was disposed: cancel the pending safety pass, arm no more. */
56
+ /** {@link Passes.stop} */
605
57
  stop() {
606
- this.#stopped = true;
607
- clearTimeout(this.#safety);
608
- this.#safety = undefined;
609
- }
610
- /**
611
- * Host events with one `events.subscribe` per name, however many listeners.
612
- * Without the `events` capability the listener simply never fires.
613
- * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
614
- * the like), not a listener of the plugin's (see {@link EventGroup}).
615
- */
616
- on(event, listener,
617
- /** The capability the event needs; `null`: none. */
618
- needs = "events", mirror = false) {
619
- let entry = this.#events.get(event);
620
- const created = !entry;
621
- if (!entry) {
622
- entry = { listeners: new Set(), active: 0, whys: new Map() };
623
- this.#events.set(event, entry);
624
- }
625
- const group = entry;
626
- group.listeners.add(listener);
627
- const token = {};
628
- if (!mirror) {
629
- group.active++;
630
- group.whys.set(token, listenerWhy(this.callSite()));
631
- }
632
- if (created && needs === "events" && !this.can(needs))
633
- group.unheard = true;
634
- if (!mirror && group.unheard)
635
- this.#warnUnheard(event);
636
- if (created && (needs === null || this.can(needs))) {
637
- group.told = group.active > 0;
638
- const whys = group.told ? allWhys(group) : undefined;
639
- group.toldWhy = whys?.join("\n");
640
- group.off = this.host.events.subscribe(event, (data) => {
641
- for (const each of [...group.listeners])
642
- each(data);
643
- }, !group.told, whys);
644
- this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
645
- }
646
- else
647
- this.#tell(event, group);
648
- let listening = true;
649
- return {
650
- dispose: async () => {
651
- if (!listening)
652
- return;
653
- listening = false;
654
- group.listeners.delete(listener);
655
- if (!mirror)
656
- group.active--;
657
- group.whys.delete(token);
658
- if (this.#events.get(event) !== group)
659
- return;
660
- if (group.listeners.size)
661
- return this.#tell(event, group);
662
- this.#events.delete(event);
663
- const off = await group.off?.catch(() => undefined);
664
- await off?.().catch(() => { });
665
- },
666
- };
667
- }
668
- /**
669
- * Count `event` as listened to while held: a watcher of a service's
670
- * state, a binding that reads it. Nothing when nothing subscribed to it
671
- * (no capability, or the service did not start).
672
- */
673
- hold(event, why) {
674
- const group = this.#events.get(event);
675
- if (!group)
676
- return { dispose: async () => { } };
677
- if (group.unheard)
678
- this.#warnUnheard(event);
679
- group.active++;
680
- const token = {};
681
- group.whys.set(token, why ?? listenerWhy(this.callSite()));
682
- this.#tell(event, group);
683
- let held = true;
684
- return {
685
- dispose: async () => {
686
- if (!held)
687
- return;
688
- held = false;
689
- group.active--;
690
- group.whys.delete(token);
691
- if (this.#events.get(event) === group)
692
- this.#tell(event, group);
693
- },
694
- };
695
- }
696
- /**
697
- * A development warning, once per plugin, no behaviour change (SDK
698
- * 1.6.12): the plugin follows `event` (an `onChange`, a `bind` that reads
699
- * the state) without the `events` capability, so changes made elsewhere
700
- * (the user, the player, another plugin) never reach it; the state moves
701
- * only after its own calls.
702
- */
703
- #warnUnheard(event) {
704
- if (this.#warnedEvents)
705
- return;
706
- this.#warnedEvents = true;
707
- console.warn(`[${this.manifest.id}] follows ${event} (onChange, or a bind() that reads the state) without the "events" capability: changes made elsewhere will not reach it, only its own calls refresh the state. Declare "events" in manifest.json.`);
58
+ this.passes.stop();
708
59
  }
709
- /**
710
- * Tell the player when `group` changes between mirror and listener, and
711
- * why it listens when that changed.
712
- */
713
- #tell(event, group) {
714
- const off = group.off;
715
- if (!off)
716
- return;
717
- const active = group.active > 0;
718
- const whys = active ? allWhys(group) : undefined;
719
- const why = whys?.join("\n");
720
- if (group.told === active && group.toldWhy === why)
721
- return;
722
- group.told = active;
723
- group.toldWhy = why;
724
- // Part of activation while it runs: the player classifies the plugin
725
- // at its ready, which must see this mark. A failure is not one of
726
- // activation (an older player never refuses it).
727
- const told = off
728
- .then(() => this.host.events.listen(event, active, whys))
729
- .catch(() => { });
730
- this.starting(told, () => { });
731
- }
732
- /**
733
- * Why the plugin listens to each event it really listens to (mirrors
734
- * left out), as the player's log says it: for {@link TestHost} and
735
- * `vestra-plugin test`.
736
- */
737
- listening() {
738
- const result = new Map();
739
- for (const [event, group] of this.#events)
740
- if (group.active > 0)
741
- result.set(event, allWhys(group));
742
- return result;
743
- }
744
- /** Raw transport events the host pushes without a subscription. */
745
- raw(event, listener) {
746
- const off = this.#transport.on(event, listener);
747
- return { dispose: async () => off() };
748
- }
749
- }
750
- /** A plugin listener's reason: where it was added. */
751
- const listenerWhy = (site) => () => {
752
- const at = site();
753
- return at ? `a listener added at ${at}` : "a listener";
754
- };
755
- /** Every active holder's reason in `group`, in the order they came, once each. */
756
- function allWhys(group) {
757
- return [...new Set([...group.whys.values()].map((why) => why()))];
758
- }
759
- /** `plugin.app` from the start-up handshake (the runner's environment). */
760
- export function appInfo(raw, system = () => Promise.reject(failure("unavailable", "no player to ask"))) {
761
- const info = (raw ?? {});
762
- const parts = (info.components ?? {});
763
- const text = (value) => (typeof value === "string" ? value : null);
764
- const libav = {};
765
- for (const [name, version] of Object.entries(parts.libav ?? {}))
766
- if (typeof version === "string")
767
- libav[name] = version;
768
- const app = {
769
- version: text(info.version) ?? "0.0.0",
770
- build: text(info.build),
771
- components: Object.freeze({
772
- mpv: text(parts.mpv),
773
- libav: Object.freeze(libav),
774
- slint: text(parts.slint) ?? "",
775
- }),
776
- };
777
- // A method, not data: kept out of comparisons and JSON of the facts.
778
- Object.defineProperty(app, "system", { value: system, enumerable: false });
779
- return Object.freeze(app);
780
60
  }