@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,796 +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;
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;
212
13
  /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
213
- counts = {
214
- passes: 0,
215
- fullPasses: 0,
216
- evaluations: 0,
217
- parts: 0,
218
- skippedParts: 0,
219
- safetyPasses: 0,
220
- };
221
- #events = new Map();
222
- /** The missing `events` warning was given (once per plugin). */
223
- #warnedEvents = false;
224
- views = new Map();
225
- commands = new Map();
226
- services = new Map();
227
- /**
228
- * Set when the player stops the plugin (`PluginInstance.dispose`), before
229
- * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
230
- */
231
- stopping;
232
- /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
233
- app;
234
- /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
235
- locale;
236
- /**
237
- * Protocol additions the player advertises at start-up (`features` in the
238
- * handshake's app data, player 0.6.0): `ui.patch` (row patches of
239
- * property writes). Not part of the plugin API; empty for older players.
240
- */
241
- features;
14
+ counts;
242
15
  constructor(transport, manifest, directory, dataDirectory, app, options = {}) {
243
- this.#transport = transport;
244
- this.#strict = options.strictDeclarations ?? false;
245
- this.dataDirectory = dataDirectory;
246
- this.local = transport.local;
247
- this.host = createWire(transport);
248
- const host = this.host;
249
- this.app = appInfo(app, () => host.app.system());
250
- const features = app
251
- ?.features;
252
- this.features = new Set(Array.isArray(features)
253
- ? features.filter((name) => typeof name === "string")
254
- : []);
255
- this.locale = LOCALES.includes(transport.locale ?? "")
256
- ? transport.locale
257
- : "en";
258
- this.manifest = manifest;
259
- this.directory = directory;
260
- transport.on("ui.callback", (data) => {
261
- const event = data;
262
- // A panel's callback names its slot (player 0.7.6: one panel per
263
- // slot); older players name only `"$mount"`, and the plugin has one.
264
- const view = event.view === "$mount"
265
- ? event.slot
266
- ? `panel:${event.slot}`
267
- : [...this.views.keys()].find((key) => key.startsWith("panel:"))
268
- : event.view;
269
- if (view)
270
- this.views.get(view)?.(event.callback, event.args);
271
- });
272
- transport.on("commands.execute", (data) => {
273
- this.commands.get(data.id)?.();
274
- });
275
- }
276
- can(capability) {
277
- return this.manifest.capabilities.includes(capability);
278
- }
279
- /** Lazily created singletons shared by every scope of this plugin. */
280
- service(name, create) {
281
- if (!this.services.has(name))
282
- this.services.set(name, create());
283
- return this.services.get(name);
284
- }
285
- /**
286
- * Registrations started while `activate` runs must succeed before the
287
- * plugin reports ready; later ones report failures to their scope.
288
- */
289
- starting(work, report) {
290
- if (this.#activating)
291
- this.startup.add(work);
292
- else
293
- work.catch(report);
294
- work.catch(() => { });
295
- return work;
296
- }
297
- /**
298
- * A declaration the player checks on its own (`what`: "command x"),
299
- * started like `starting`. In the player's runner a refusal of it during
300
- * activation (`invalid`, `conflict`, `limit`) does not fail the plugin:
301
- * it is logged, skipped and reported to the scope once activation
302
- * completes, so an `onError` added anywhere in `activate` hears it (SDK
303
- * 1.5.49; VS Code logs a bad contribution and activates the extension).
304
- * Under `strictDeclarations` (TestHost) it fails activation as before.
305
- */
306
- declaring(work, what, report) {
307
- if (!this.#activating || this.#strict)
308
- return this.starting(work, report);
309
- const settled = work.then(() => { }, (error) => {
310
- if (!(error instanceof HostError) ||
311
- !DECLARATION_REFUSALS.has(error.code))
312
- throw error;
313
- console.error(`[${this.manifest.id}] ${what} skipped, the player refused it (${error.code}): ${error.message}`);
314
- this.#refusals.push(() => report(error));
315
- });
316
- // Observed now: `activated()` may come after `activate` awaits, and
317
- // its `allSettled` still sees the rejection.
318
- settled.catch(() => { });
319
- this.startup.add(settled);
320
- work.catch(() => { });
321
- return work;
322
- }
323
- async activated() {
324
- while (this.startup.size) {
325
- const pending = [...this.startup];
326
- this.startup.clear();
327
- for (const result of await Promise.allSettled(pending))
328
- if (result.status === "rejected")
329
- throw result.reason;
330
- }
331
- this.#activating = false;
332
- for (const report of this.#refusals.splice(0))
333
- report();
16
+ super(transport, manifest, directory, dataDirectory, app, options);
17
+ this.passes = new Passes(this);
18
+ this.counts = this.passes.counts;
334
19
  }
20
+ /** {@link Passes.bind} */
335
21
  bind(evaluate) {
336
- const held = new Map();
337
- const binding = { held, deps: new Map() };
338
- this.#bindings.set(evaluate, binding);
339
- this.invalidate();
340
- return {
341
- dispose: async () => {
342
- if (this.#bindings.get(evaluate) !== binding)
343
- return;
344
- this.#bindings.delete(evaluate);
345
- for (const hold of held.values())
346
- await hold.dispose();
347
- held.clear();
348
- },
349
- };
22
+ return this.passes.bind(evaluate);
350
23
  }
351
- /**
352
- * A service's state for `event` is read: a binding evaluated now depends
353
- * on it, so the event counts as listened to while the binding reads it.
354
- */
24
+ /** {@link Passes.reading} */
355
25
  reading(event) {
356
- const reads = this.#reads;
357
- if (!reads || reads.has(event))
358
- return;
359
- const reader = this.#reader;
360
- const state = STATE[event];
361
- reads.set(event, () => {
362
- const read = state?.name ?? event;
363
- const site = reader?.where?.();
364
- const at = site ? ` (${site})` : "";
365
- if (!reader)
366
- return `a binding reads ${read}`;
367
- if (!reader.field)
368
- return `${reader.label} reads ${read}${at}`;
369
- const hint = state?.when ? " — use plugin.when" : "";
370
- return `${reader.label} is a function that reads ${read}${at}${hint}`;
371
- });
26
+ this.passes.reading(event);
372
27
  }
373
- /**
374
- * A field of the SDK's copy of a state (`key`) is read; without `field`,
375
- * the state is taken (`plugin.player.state`). What is being evaluated
376
- * then depends on it: a precise pass after a change of that field runs
377
- * it again.
378
- */
28
+ /** {@link Passes.depends} */
379
29
  depends(key, field) {
380
- const deps = this.#deps;
381
- if (!deps)
382
- return;
383
- let fields = deps.get(key);
384
- if (!fields)
385
- deps.set(key, (fields = new Set()));
386
- if (field !== undefined)
387
- fields.add(field);
30
+ this.passes.depends(key, field);
388
31
  }
389
- /**
390
- * Evaluate one part of the binding being evaluated (one `view.bind`
391
- * function), identified by `token`. A precise pass skips it when nothing
392
- * it read last time changed, and returns `undefined`; what it read then
393
- * still counts for the binding.
394
- */
32
+ /** {@link Passes.part} */
395
33
  part(token, read) {
396
- const last = this.#parts.get(token);
397
- const outerReads = this.#reads;
398
- const outerDeps = this.#deps;
399
- if (last && this.#pass && !affected(last.deps, this.#pass)) {
400
- this.#skipped = true;
401
- this.counts.skippedParts++;
402
- if (outerReads)
403
- for (const [event, why] of last.reads)
404
- if (!outerReads.has(event))
405
- outerReads.set(event, why);
406
- if (outerDeps)
407
- merge(outerDeps, last.deps);
408
- return undefined;
409
- }
410
- this.counts.parts++;
411
- const reads = new Map();
412
- const deps = new Map();
413
- this.#reads = outerReads && reads;
414
- this.#deps = deps;
415
- try {
416
- return { value: read() };
417
- }
418
- finally {
419
- this.#reads = outerReads;
420
- this.#deps = outerDeps;
421
- this.#parts.set(token, { reads, deps });
422
- if (outerReads)
423
- for (const [event, why] of reads)
424
- if (!outerReads.has(event))
425
- outerReads.set(event, why);
426
- if (outerDeps)
427
- merge(outerDeps, deps);
428
- }
34
+ return this.passes.part(token, read);
429
35
  }
430
- /**
431
- * Run plugin code (a state listener) and return the state copy fields it
432
- * read; they are not counted as a binding's.
433
- */
36
+ /** {@link Passes.observe} */
434
37
  observe(run) {
435
- const outerReads = this.#reads;
436
- const outerDeps = this.#deps;
437
- const deps = new Map();
438
- this.#reads = undefined;
439
- this.#deps = deps;
440
- try {
441
- return { value: run(), deps };
442
- }
443
- finally {
444
- this.#reads = outerReads;
445
- this.#deps = outerDeps;
446
- }
38
+ return this.passes.observe(run);
447
39
  }
448
- /**
449
- * Whether code that read `deps` while `change` was delivered needs a full
450
- * pass after it: it read a field that changed, or no field of that state
451
- * at all (so the SDK cannot tell what it depends on).
452
- */
40
+ /** {@link Passes.needsFull} */
453
41
  needsFull(deps, change) {
454
- for (const key of change.keys()) {
455
- const read = deps.get(key);
456
- if (!read || read.size === 0)
457
- return true;
458
- }
459
- return affected(deps, change);
42
+ return this.passes.needsFull(deps, change);
460
43
  }
461
- /**
462
- * Evaluate `read` as `label`, added at `where`: the state it reads is
463
- * reported as read by it. `field`: a command's `enabled` or `checked`,
464
- * which a `plugin.when` condition can replace.
465
- */
44
+ /** {@link Passes.reader} */
466
45
  reader(label, where, read, field = false) {
467
- const outer = this.#reader;
468
- this.#reader = { label, where, field };
469
- try {
470
- return read();
471
- }
472
- finally {
473
- this.#reader = outer;
474
- }
475
- }
476
- /**
477
- * Where the plugin's code called into the SDK: the first stack frame
478
- * outside the SDK, as a path relative to the plugin's directory and a
479
- * line (`main.ts:353`). Captured now, formatted when asked.
480
- */
481
- callSite() {
482
- const limit = Error.stackTraceLimit;
483
- Error.stackTraceLimit = 40;
484
- const error = new Error();
485
- Error.stackTraceLimit = limit;
486
- let site = null;
487
- return () => {
488
- if (site === null)
489
- site = this.#site(error.stack ?? "");
490
- return site;
491
- };
492
- }
493
- #site(stack) {
494
- for (const line of stack.split("\n").slice(1)) {
495
- const match = /\(?((?:file:\/\/)?[^()\s]+?):(\d+):\d+\)?\s*$/.exec(line);
496
- if (!match)
497
- continue;
498
- let file = match[1];
499
- if (file.startsWith("file:"))
500
- try {
501
- file = fileURLToPath(file);
502
- }
503
- catch {
504
- continue;
505
- }
506
- const sdk = relative(SDK_DIRECTORY, file);
507
- if (!isAbsolute(file) || !(sdk.startsWith("..") || isAbsolute(sdk)))
508
- continue;
509
- const inside = relative(this.directory, file);
510
- const shown = inside.startsWith("..") || isAbsolute(inside) ? basename(file) : inside;
511
- return `${shown.replaceAll("\\", "/")}:${match[2]}`;
512
- }
513
- return undefined;
514
- }
515
- /** Evaluate one binding, holding the events whose state it read. */
516
- #evaluate(evaluate, binding) {
517
- const held = binding.held;
518
- const reads = new Map();
519
- const deps = new Map();
520
- const outer = this.#reads;
521
- const outerDeps = this.#deps;
522
- const reader = this.#reader;
523
- this.#reads = reads;
524
- this.#deps = deps;
525
- this.#reader = undefined;
526
- this.counts.evaluations++;
527
- try {
528
- evaluate();
529
- }
530
- finally {
531
- this.#reads = outer;
532
- this.#deps = outerDeps;
533
- this.#reader = reader;
534
- binding.deps = deps;
535
- for (const [event, hold] of held)
536
- if (!reads.has(event)) {
537
- held.delete(event);
538
- void hold.dispose();
539
- }
540
- for (const [event, why] of reads)
541
- if (!held.has(event))
542
- held.set(event, this.hold(event, why));
543
- }
46
+ return this.passes.reader(label, where, read, field);
544
47
  }
545
- /**
546
- * A full pass: every binding is evaluated. Requested after plugin code
547
- * the SDK ran, by `plugin.update()`, and on any change other than a state
548
- * copy's fields. Coalesced: one pass per microtask turn, however many
549
- * requests.
550
- */
48
+ /** {@link Passes.invalidate} */
551
49
  invalidate() {
552
- this.#full = true;
553
- this.#schedule();
50
+ this.passes.invalidate();
554
51
  }
555
- /**
556
- * Fields of the SDK's copy of a state (`key`) changed: a precise pass
557
- * evaluates only the bindings (and view bind functions) that read one of
558
- * them, unless a full pass is due anyway.
559
- */
52
+ /** {@link Passes.changed} */
560
53
  changed(key, fields) {
561
- let set = this.#pending.get(key);
562
- if (!set)
563
- this.#pending.set(key, (set = new Set()));
564
- for (const field of fields)
565
- set.add(field);
566
- this.#schedule();
54
+ this.passes.changed(key, fields);
567
55
  }
568
- #schedule() {
569
- if (this.#scheduled)
570
- return;
571
- this.#scheduled = true;
572
- queueMicrotask(() => this.#run());
573
- }
574
- /**
575
- * One pass. A precise pass that skips anything arms a full pass
576
- * `SAFETY_PASS_MS` later (unless a full pass comes first), for plugin
577
- * state changed outside code the SDK ran.
578
- */
579
- #run() {
580
- this.#scheduled = false;
581
- const full = this.#full;
582
- const change = this.#pending;
583
- this.#full = false;
584
- this.#pending = new Map();
585
- this.counts.passes++;
586
- if (full)
587
- this.counts.fullPasses++;
588
- if (full) {
589
- clearTimeout(this.#safety);
590
- this.#safety = undefined;
591
- }
592
- this.#skipped = false;
593
- for (const [evaluate, binding] of [...this.#bindings]) {
594
- if (this.#bindings.get(evaluate) !== binding)
595
- continue;
596
- if (!full && !affected(binding.deps, change)) {
597
- this.#skipped = true;
598
- continue;
599
- }
600
- this.#pass = full ? undefined : change;
601
- try {
602
- this.#evaluate(evaluate, binding);
603
- }
604
- catch (error) {
605
- console.error(`[${this.manifest.id}] binding failed:`, error);
606
- }
607
- finally {
608
- this.#pass = undefined;
609
- }
610
- }
611
- if (this.#skipped && !this.#safety && !this.#stopped) {
612
- this.#safety = setTimeout(() => {
613
- this.#safety = undefined;
614
- this.counts.safetyPasses++;
615
- this.invalidate();
616
- }, SAFETY_PASS_MS);
617
- this.#safety.unref?.();
618
- }
619
- }
620
- /** The plugin was disposed: cancel the pending safety pass, arm no more. */
56
+ /** {@link Passes.stop} */
621
57
  stop() {
622
- this.#stopped = true;
623
- clearTimeout(this.#safety);
624
- this.#safety = undefined;
625
- }
626
- /**
627
- * Host events with one `events.subscribe` per name, however many listeners.
628
- * Without the `events` capability the listener simply never fires.
629
- * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
630
- * the like), not a listener of the plugin's (see {@link EventGroup}).
631
- */
632
- on(event, listener,
633
- /** The capability the event needs; `null`: none. */
634
- needs = "events", mirror = false) {
635
- let entry = this.#events.get(event);
636
- const created = !entry;
637
- if (!entry) {
638
- entry = { listeners: new Set(), active: 0, whys: new Map() };
639
- this.#events.set(event, entry);
640
- }
641
- const group = entry;
642
- group.listeners.add(listener);
643
- const token = {};
644
- if (!mirror) {
645
- group.active++;
646
- group.whys.set(token, listenerWhy(this.callSite()));
647
- }
648
- if (created && needs === "events" && !this.can(needs))
649
- group.unheard = true;
650
- if (!mirror && group.unheard)
651
- this.#warnUnheard(event);
652
- if (created && (needs === null || this.can(needs))) {
653
- group.told = group.active > 0;
654
- const whys = group.told ? allWhys(group) : undefined;
655
- group.toldWhy = whys?.join("\n");
656
- group.off = this.host.events.subscribe(event, (data) => {
657
- for (const each of [...group.listeners])
658
- each(data);
659
- }, !group.told, whys);
660
- this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
661
- }
662
- else
663
- this.#tell(event, group);
664
- let listening = true;
665
- return {
666
- dispose: async () => {
667
- if (!listening)
668
- return;
669
- listening = false;
670
- group.listeners.delete(listener);
671
- if (!mirror)
672
- group.active--;
673
- group.whys.delete(token);
674
- if (this.#events.get(event) !== group)
675
- return;
676
- if (group.listeners.size)
677
- return this.#tell(event, group);
678
- this.#events.delete(event);
679
- const off = await group.off?.catch(() => undefined);
680
- await off?.().catch(() => { });
681
- },
682
- };
683
- }
684
- /**
685
- * Count `event` as listened to while held: a watcher of a service's
686
- * state, a binding that reads it. Nothing when nothing subscribed to it
687
- * (no capability, or the service did not start).
688
- */
689
- hold(event, why) {
690
- const group = this.#events.get(event);
691
- if (!group)
692
- return { dispose: async () => { } };
693
- if (group.unheard)
694
- this.#warnUnheard(event);
695
- group.active++;
696
- const token = {};
697
- group.whys.set(token, why ?? listenerWhy(this.callSite()));
698
- this.#tell(event, group);
699
- let held = true;
700
- return {
701
- dispose: async () => {
702
- if (!held)
703
- return;
704
- held = false;
705
- group.active--;
706
- group.whys.delete(token);
707
- if (this.#events.get(event) === group)
708
- this.#tell(event, group);
709
- },
710
- };
711
- }
712
- /**
713
- * A development warning, once per plugin, no behaviour change (SDK
714
- * 1.6.12): the plugin follows `event` (an `onChange`, a `bind` that reads
715
- * the state) without the `events` capability, so changes made elsewhere
716
- * (the user, the player, another plugin) never reach it; the state moves
717
- * only after its own calls.
718
- */
719
- #warnUnheard(event) {
720
- if (this.#warnedEvents)
721
- return;
722
- this.#warnedEvents = true;
723
- 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();
724
59
  }
725
- /**
726
- * Tell the player when `group` changes between mirror and listener, and
727
- * why it listens when that changed.
728
- */
729
- #tell(event, group) {
730
- const off = group.off;
731
- if (!off)
732
- return;
733
- const active = group.active > 0;
734
- const whys = active ? allWhys(group) : undefined;
735
- const why = whys?.join("\n");
736
- if (group.told === active && group.toldWhy === why)
737
- return;
738
- group.told = active;
739
- group.toldWhy = why;
740
- // Part of activation while it runs: the player classifies the plugin
741
- // at its ready, which must see this mark. A failure is not one of
742
- // activation (an older player never refuses it).
743
- const told = off
744
- .then(() => this.host.events.listen(event, active, whys))
745
- .catch(() => { });
746
- this.starting(told, () => { });
747
- }
748
- /**
749
- * Why the plugin listens to each event it really listens to (mirrors
750
- * left out), as the player's log says it: for {@link TestHost} and
751
- * `vestra-plugin test`.
752
- */
753
- listening() {
754
- const result = new Map();
755
- for (const [event, group] of this.#events)
756
- if (group.active > 0)
757
- result.set(event, allWhys(group));
758
- return result;
759
- }
760
- /** Raw transport events the host pushes without a subscription. */
761
- raw(event, listener) {
762
- const off = this.#transport.on(event, listener);
763
- return { dispose: async () => off() };
764
- }
765
- }
766
- /** A plugin listener's reason: where it was added. */
767
- const listenerWhy = (site) => () => {
768
- const at = site();
769
- return at ? `a listener added at ${at}` : "a listener";
770
- };
771
- /** Every active holder's reason in `group`, in the order they came, once each. */
772
- function allWhys(group) {
773
- return [...new Set([...group.whys.values()].map((why) => why()))];
774
- }
775
- /** `plugin.app` from the start-up handshake (the runner's environment). */
776
- export function appInfo(raw, system = () => Promise.reject(failure("unavailable", "no player to ask"))) {
777
- const info = (raw ?? {});
778
- const parts = (info.components ?? {});
779
- const text = (value) => (typeof value === "string" ? value : null);
780
- const libav = {};
781
- for (const [name, version] of Object.entries(parts.libav ?? {}))
782
- if (typeof version === "string")
783
- libav[name] = version;
784
- const app = {
785
- version: text(info.version) ?? "0.0.0",
786
- build: text(info.build),
787
- components: Object.freeze({
788
- mpv: text(parts.mpv),
789
- libav: Object.freeze(libav),
790
- slint: text(parts.slint) ?? "",
791
- }),
792
- };
793
- // A method, not data: kept out of comparisons and JSON of the facts.
794
- Object.defineProperty(app, "system", { value: system, enumerable: false });
795
- return Object.freeze(app);
796
60
  }