@vestra-player/sdk 1.5.37 → 1.5.38

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.38
4
+
5
+ **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. The marks below are read by player 0.5.18 and later; older players ignore them, and a plugin built with an older SDK keeps working (the player builds the `plugin` object with its own SDK). What may change for a plugin is the player's lazy activation, off by default in player 0.5.18 and turned on in a later release: a plugin that holds nothing only its process can serve is stopped while idle and started on its next use, so state kept only in variables is lost between uses (keep it in `plugin.storage.session`).
6
+
7
+ - **Mirror subscriptions** (player 0.5.18): the SDK's own subscriptions that keep `plugin.player.state`, `plugin.window.state`, `plugin.timeline.marks`, `plugin.subtitles.state`, `plugin.appearance`, `plugin.resolvers.fileTypes` and panel positions current are sent as `events.subscribe { event, mirror: true }`. When the plugin adds a listener (an `onChange`, `onEnd`, `onFileTypesChange`) or a binding that reads that state (a command's `enabled`, `checked`, `busy` or `reason` function, a view's `bind`), the SDK sends `events.subscribe { event }` again; when the last one goes, `events.subscribe { event, mirror: true }`. A change made while the plugin activates is sent before it reports ready, so the player sees it when it classifies the plugin. The player keeps a plugin whose subscriptions are all mirrors parkable; the plugin sees no difference.
8
+ - **`PluginInfo.process`** (player 0.5.18, optional): for a running plugin, `"running"` while its process runs, `"idle"` while the player stopped it because nothing used it (its entries stay and its next use starts it). `state` stays `"running"`, and a change of `process` alone does not fire `packages.onChange`.
9
+ - **`TestHost.listening`**: the subscribed events the plugin really listens to (`subscriptions` without the mirrors).
10
+ - _Plugins_ (concepts) has a new section, _Idle plugins are stopped_: what stays while a plugin is stopped, what keeps it running (listeners, bindings that read state, windows, panels, chains, streams…) and what does not (network requests, timers and promises the player does not see: as in Chrome Manifest V3, keep what they need in session storage).
11
+
3
12
  ## 1.5.37
4
13
 
5
14
  **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. The new API needs player 0.5.16 (`"minPlayerVersion": "0.5.16"`): older players do not load a plugin that declares the new capability `video.upscaling` (an unknown capability). A plugin that does not use it sees no difference.
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  <a href="https://github.com/vestrasys/vestra-player-sdk/blob/main/CHANGELOG.md">Changelog</a> ·
16
16
  <a href="https://vestrasys.github.io/vestra-player-sdk/llms.txt">llms.txt</a>
17
17
  </p>
18
+
18
19
  ## Features
19
20
 
20
21
  - **One typed API** (`vestra/1`) for commands and menus, windows, panels, settings pages, on-screen messages, playback state and control, video and audio filters, and generated streams.
@@ -29,6 +29,7 @@ class AppearanceService {
29
29
  if (this.#started)
30
30
  return;
31
31
  this.#started = true;
32
+ // A mirror: a plugin listens through `watch` and bindings.
32
33
  this.#runtime.on("appearance.changed", (data) => {
33
34
  const event = data;
34
35
  const language = preference(event?.language);
@@ -39,7 +40,7 @@ class AppearanceService {
39
40
  const theme = event?.theme;
40
41
  if (theme && THEMES.includes(theme))
41
42
  this.accept(theme);
42
- });
43
+ }, "events", true);
43
44
  }
44
45
  accept(theme) {
45
46
  if (theme === this.theme)
@@ -51,7 +52,13 @@ class AppearanceService {
51
52
  }
52
53
  watch(listener) {
53
54
  this.#listeners.add(listener);
54
- return { dispose: async () => void this.#listeners.delete(listener) };
55
+ const held = this.#runtime.hold("appearance.changed");
56
+ return {
57
+ dispose: async () => {
58
+ this.#listeners.delete(listener);
59
+ await held.dispose();
60
+ },
61
+ };
55
62
  }
56
63
  }
57
64
  const appearanceOf = (runtime) => runtime.service("appearance", () => new AppearanceService(runtime));
@@ -60,6 +67,7 @@ export function appearance(scope) {
60
67
  return {
61
68
  get theme() {
62
69
  service().start();
70
+ scope.runtime.reading("appearance.changed");
63
71
  return service().theme;
64
72
  },
65
73
  onChange(listener) {
@@ -80,6 +88,7 @@ export function appearance(scope) {
80
88
  },
81
89
  get language() {
82
90
  service().start();
91
+ scope.runtime.reading("appearance.changed");
83
92
  return service().language;
84
93
  },
85
94
  async setLanguage(language) {
@@ -164,6 +164,9 @@ const info = (plugin) => Object.freeze({
164
164
  ...(warning.slot === undefined ? {} : { slot: warning.slot }),
165
165
  ...(warning.owner === undefined ? {} : { owner: warning.owner }),
166
166
  }))),
167
+ ...(plugin.process === "running" || plugin.process === "idle"
168
+ ? { process: plugin.process }
169
+ : {}),
167
170
  ...(plugin.display
168
171
  ? {
169
172
  display: Object.freeze({
@@ -116,7 +116,9 @@ export class PlayerService {
116
116
  output;
117
117
  constructor(runtime) {
118
118
  this.runtime = runtime;
119
- runtime.on("source.state", (data) => this.accept(data));
119
+ // The SDK's copy of the state (a mirror): a plugin listens through
120
+ // `watch` and the bindings that read `state`.
121
+ runtime.on("source.state", (data) => this.accept(data), "events", true);
120
122
  this.output = new Latest(async ({ volume, muted }) => {
121
123
  if (volume !== undefined)
122
124
  await runtime.host.playback.set("volume", volume);
@@ -144,9 +146,11 @@ export class PlayerService {
144
146
  }
145
147
  watch(watcher) {
146
148
  this.#watchers.add(watcher);
149
+ const held = this.runtime.hold("source.state");
147
150
  return {
148
151
  dispose: async () => {
149
152
  this.#watchers.delete(watcher);
153
+ await held.dispose();
150
154
  },
151
155
  };
152
156
  }
@@ -429,6 +433,7 @@ export function player(scope) {
429
433
  const service = () => playerOf(scope.runtime);
430
434
  return {
431
435
  get state() {
436
+ scope.runtime.reading("source.state");
432
437
  return service().state;
433
438
  },
434
439
  onChange(listener) {
@@ -26,11 +26,12 @@ class FileTypesService {
26
26
  if (this.#started)
27
27
  return;
28
28
  this.#started = true;
29
+ // A mirror: a plugin listens through `watch` and bindings.
29
30
  this.#runtime.on("resolvers.fileTypes", (data) => {
30
31
  const types = data?.fileTypes;
31
32
  if (Array.isArray(types) && types.every((t) => typeof t === "string"))
32
33
  this.accept(types);
33
- });
34
+ }, "events", true);
34
35
  // What changed between the start-up read and the subscription.
35
36
  const window = windowOf(this.#runtime);
36
37
  void window.read().then(() => {
@@ -50,7 +51,13 @@ class FileTypesService {
50
51
  }
51
52
  watch(listener) {
52
53
  this.#listeners.add(listener);
53
- return { dispose: async () => void this.#listeners.delete(listener) };
54
+ const held = this.#runtime.hold("resolvers.fileTypes");
55
+ return {
56
+ dispose: async () => {
57
+ this.#listeners.delete(listener);
58
+ await held.dispose();
59
+ },
60
+ };
54
61
  }
55
62
  }
56
63
  const fileTypesOf = (runtime) => runtime.service("fileTypes", () => new FileTypesService(runtime));
@@ -58,6 +65,7 @@ export const resolvers = (scope) => ({
58
65
  get fileTypes() {
59
66
  const service = fileTypesOf(scope.runtime);
60
67
  service.start();
68
+ scope.runtime.reading("resolvers.fileTypes");
61
69
  return service.types;
62
70
  },
63
71
  onFileTypesChange(listener) {
@@ -53,15 +53,28 @@ export declare class Runtime {
53
53
  starting<T>(work: Promise<T>, report: (error: unknown) => void): Promise<T>;
54
54
  activated(): Promise<void>;
55
55
  bind(evaluate: () => void): Disposable;
56
+ /**
57
+ * A service's state for `event` is read: a binding evaluated now depends
58
+ * on it, so the event counts as listened to while the binding reads it.
59
+ */
60
+ reading(event: string): void;
56
61
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
57
62
  invalidate(): void;
58
63
  /**
59
64
  * Host events with one `events.subscribe` per name, however many listeners.
60
65
  * Without the `events` capability the listener simply never fires.
66
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
67
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
61
68
  */
62
69
  on(event: string, listener: Listener,
63
70
  /** The capability the event needs; `null`: none. */
64
- needs?: string | null): Disposable;
71
+ needs?: string | null, mirror?: boolean): Disposable;
72
+ /**
73
+ * Count `event` as listened to while held: a watcher of a service's
74
+ * state, a binding that reads it. Nothing when nothing subscribed to it
75
+ * (no capability, or the service did not start).
76
+ */
77
+ hold(event: string): Disposable;
65
78
  /** Raw transport events the host pushes without a subscription. */
66
79
  raw(event: string, listener: Listener): Disposable;
67
80
  }
@@ -87,7 +87,10 @@ export class Runtime {
87
87
  startup = new Set();
88
88
  #activating = true;
89
89
  #transport;
90
- #bindings = new Set();
90
+ /** Each binding with the events whose state it read last time (held). */
91
+ #bindings = new Map();
92
+ /** The events read by the binding being evaluated. */
93
+ #reads;
91
94
  #scheduled = false;
92
95
  #events = new Map();
93
96
  views = new Map();
@@ -149,14 +152,47 @@ export class Runtime {
149
152
  this.#activating = false;
150
153
  }
151
154
  bind(evaluate) {
152
- this.#bindings.add(evaluate);
155
+ const held = new Map();
156
+ this.#bindings.set(evaluate, held);
153
157
  this.invalidate();
154
158
  return {
155
159
  dispose: async () => {
160
+ if (this.#bindings.get(evaluate) !== held)
161
+ return;
156
162
  this.#bindings.delete(evaluate);
163
+ for (const hold of held.values())
164
+ await hold.dispose();
165
+ held.clear();
157
166
  },
158
167
  };
159
168
  }
169
+ /**
170
+ * A service's state for `event` is read: a binding evaluated now depends
171
+ * on it, so the event counts as listened to while the binding reads it.
172
+ */
173
+ reading(event) {
174
+ this.#reads?.add(event);
175
+ }
176
+ /** Evaluate one binding, holding the events whose state it read. */
177
+ #evaluate(evaluate, held) {
178
+ const reads = new Set();
179
+ const outer = this.#reads;
180
+ this.#reads = reads;
181
+ try {
182
+ evaluate();
183
+ }
184
+ finally {
185
+ this.#reads = outer;
186
+ for (const [event, hold] of held)
187
+ if (!reads.has(event)) {
188
+ held.delete(event);
189
+ void hold.dispose();
190
+ }
191
+ for (const event of reads)
192
+ if (!held.has(event))
193
+ held.set(event, this.hold(event));
194
+ }
195
+ }
160
196
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
161
197
  invalidate() {
162
198
  if (this.#scheduled)
@@ -164,9 +200,11 @@ export class Runtime {
164
200
  this.#scheduled = true;
165
201
  queueMicrotask(() => {
166
202
  this.#scheduled = false;
167
- for (const evaluate of this.#bindings) {
203
+ for (const [evaluate, held] of [...this.#bindings]) {
204
+ if (this.#bindings.get(evaluate) !== held)
205
+ continue;
168
206
  try {
169
- evaluate();
207
+ this.#evaluate(evaluate, held);
170
208
  }
171
209
  catch (error) {
172
210
  console.error(`[${this.manifest.id}] binding failed:`, error);
@@ -177,36 +215,89 @@ export class Runtime {
177
215
  /**
178
216
  * Host events with one `events.subscribe` per name, however many listeners.
179
217
  * Without the `events` capability the listener simply never fires.
218
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
219
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
180
220
  */
181
221
  on(event, listener,
182
222
  /** The capability the event needs; `null`: none. */
183
- needs = "events") {
223
+ needs = "events", mirror = false) {
184
224
  let entry = this.#events.get(event);
225
+ const created = !entry;
185
226
  if (!entry) {
186
- entry = { listeners: new Set() };
227
+ entry = { listeners: new Set(), active: 0 };
187
228
  this.#events.set(event, entry);
188
- if (needs === null || this.can(needs)) {
189
- const group = entry;
190
- entry.off = this.host.events.subscribe(event, (data) => {
191
- for (const each of [...group.listeners])
192
- each(data);
193
- });
194
- this.starting(entry.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
195
- }
196
229
  }
197
- entry.listeners.add(listener);
198
230
  const group = entry;
231
+ group.listeners.add(listener);
232
+ if (!mirror)
233
+ group.active++;
234
+ if (created && (needs === null || this.can(needs))) {
235
+ group.told = group.active > 0;
236
+ group.off = this.host.events.subscribe(event, (data) => {
237
+ for (const each of [...group.listeners])
238
+ each(data);
239
+ }, !group.told);
240
+ this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
241
+ }
242
+ else
243
+ this.#tell(event, group);
244
+ let listening = true;
199
245
  return {
200
246
  dispose: async () => {
247
+ if (!listening)
248
+ return;
249
+ listening = false;
201
250
  group.listeners.delete(listener);
202
- if (group.listeners.size || this.#events.get(event) !== group)
251
+ if (!mirror)
252
+ group.active--;
253
+ if (this.#events.get(event) !== group)
203
254
  return;
255
+ if (group.listeners.size)
256
+ return this.#tell(event, group);
204
257
  this.#events.delete(event);
205
258
  const off = await group.off?.catch(() => undefined);
206
259
  await off?.().catch(() => { });
207
260
  },
208
261
  };
209
262
  }
263
+ /**
264
+ * Count `event` as listened to while held: a watcher of a service's
265
+ * state, a binding that reads it. Nothing when nothing subscribed to it
266
+ * (no capability, or the service did not start).
267
+ */
268
+ hold(event) {
269
+ const group = this.#events.get(event);
270
+ if (!group)
271
+ return { dispose: async () => { } };
272
+ group.active++;
273
+ this.#tell(event, group);
274
+ let held = true;
275
+ return {
276
+ dispose: async () => {
277
+ if (!held)
278
+ return;
279
+ held = false;
280
+ group.active--;
281
+ if (this.#events.get(event) === group)
282
+ this.#tell(event, group);
283
+ },
284
+ };
285
+ }
286
+ /** Tell the player when `group` changes between mirror and listener. */
287
+ #tell(event, group) {
288
+ const off = group.off;
289
+ const active = group.active > 0;
290
+ if (!off || group.told === active)
291
+ return;
292
+ group.told = active;
293
+ // Part of activation while it runs: the player classifies the plugin
294
+ // at its ready, which must see this mark. A failure is not one of
295
+ // activation (an older player never refuses it).
296
+ const told = off
297
+ .then(() => this.host.events.listen(event, active))
298
+ .catch(() => { });
299
+ this.starting(told, () => { });
300
+ }
210
301
  /** Raw transport events the host pushes without a subscription. */
211
302
  raw(event, listener) {
212
303
  const off = this.#transport.on(event, listener);
@@ -62,6 +62,8 @@ export const SUBTITLE_LIMITS = Object.freeze({
62
62
  /** Loads of every plugin in progress at the same time (8). */
63
63
  uploads: 8,
64
64
  });
65
+ /** The events the subtitle state follows (it has none of its own). */
66
+ const EVENTS = ["source.state", "playback.backend"];
65
67
  const raws = new WeakMap();
66
68
  const targets = new WeakMap();
67
69
  const CHUNK = 32 * 1024;
@@ -85,6 +87,7 @@ class SubtitleService {
85
87
  constructor(runtime) {
86
88
  this.#runtime = runtime;
87
89
  // No subtitle event exists: re-read when the source or backend changes.
90
+ // Mirrors: a plugin listens through `watch` and bindings.
88
91
  runtime.on("source.state", (data) => {
89
92
  const raw = data;
90
93
  const source = `${raw.stamp.generation}:${raw.phase}`;
@@ -92,8 +95,8 @@ class SubtitleService {
92
95
  return;
93
96
  this.#source = source;
94
97
  void this.refresh().catch(() => { });
95
- });
96
- runtime.on("playback.backend", () => void this.refresh().catch(() => { }));
98
+ }, "events", true);
99
+ runtime.on("playback.backend", () => void this.refresh().catch(() => { }), "events", true);
97
100
  this.style = new Latest(async ({ style, options }) => {
98
101
  const expected = await this.expected(options);
99
102
  await this.#mutate(++this.#operation, (operationId) => runtime.host.subtitles.style({
@@ -106,12 +109,20 @@ class SubtitleService {
106
109
  }
107
110
  watch(watcher) {
108
111
  this.#watchers.add(watcher);
112
+ const held = EVENTS.map((event) => this.#runtime.hold(event));
109
113
  return {
110
114
  dispose: async () => {
111
115
  this.#watchers.delete(watcher);
116
+ for (const hold of held.splice(0))
117
+ await hold.dispose();
112
118
  },
113
119
  };
114
120
  }
121
+ /** A binding that reads the state follows both events. */
122
+ reading() {
123
+ for (const event of EVENTS)
124
+ this.#runtime.reading(event);
125
+ }
115
126
  /** Keep the newest view: older generations and older revisions are dropped. */
116
127
  accept(raw) {
117
128
  const old = this.raw;
@@ -258,6 +269,7 @@ export function subtitles(scope) {
258
269
  const host = () => scope.runtime.host.subtitles;
259
270
  return {
260
271
  get state() {
272
+ service().reading();
261
273
  return service().state;
262
274
  },
263
275
  onChange(listener) {
@@ -535,6 +535,16 @@ export declare class TestHost implements Transport {
535
535
  readonly calls: Call[];
536
536
  /** The host events the plugin subscribed to (needs `events` etc.). */
537
537
  readonly subscriptions: Set<string>;
538
+ /**
539
+ * The subscribed events the plugin really listens to: an `onChange` and
540
+ * the like, or a binding that reads the state. The SDK's own copies of
541
+ * the state (what keeps `plugin.player.state` current) are left out.
542
+ * Empty means the player may stop the plugin while nothing uses it
543
+ * (unless it holds something else: a window, a panel, a filter…).
544
+ *
545
+ * @since 1.5.38
546
+ */
547
+ readonly listening: Set<string>;
538
548
  /**
539
549
  * The player's source facts (phase, position, duration, paused, rate,
540
550
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -333,6 +333,16 @@ export class TestHost {
333
333
  calls = [];
334
334
  /** The host events the plugin subscribed to (needs `events` etc.). */
335
335
  subscriptions = new Set();
336
+ /**
337
+ * The subscribed events the plugin really listens to: an `onChange` and
338
+ * the like, or a binding that reads the state. The SDK's own copies of
339
+ * the state (what keeps `plugin.player.state` current) are left out.
340
+ * Empty means the player may stop the plugin while nothing uses it
341
+ * (unless it holds something else: a window, a panel, a filter…).
342
+ *
343
+ * @since 1.5.38
344
+ */
345
+ listening = new Set();
336
346
  /**
337
347
  * The player's source facts (phase, position, duration, paused, rate,
338
348
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -3377,8 +3387,18 @@ export class TestHost {
3377
3387
  };
3378
3388
  }
3379
3389
  #defaults = {
3380
- "events.subscribe": (p) => void this.subscriptions.add(String(p.event)),
3381
- "events.unsubscribe": (p) => void this.subscriptions.delete(String(p.event)),
3390
+ "events.subscribe": (p) => {
3391
+ const event = String(p.event);
3392
+ this.subscriptions.add(event);
3393
+ if (p.mirror === true)
3394
+ this.listening.delete(event);
3395
+ else
3396
+ this.listening.add(event);
3397
+ },
3398
+ "events.unsubscribe": (p) => {
3399
+ this.subscriptions.delete(String(p.event));
3400
+ this.listening.delete(String(p.event));
3401
+ },
3382
3402
  "commands.register": (p) => {
3383
3403
  const id = String(p.id);
3384
3404
  // The player's built-in menu ids (MENU_IDS) in the first path segment.
@@ -16,7 +16,7 @@ export declare class TimelineService {
16
16
  accept(marks: WireMark[]): void;
17
17
  read(): Promise<void>;
18
18
  watch(listener: (marks: readonly TimelineMark[]) => void): {
19
- dispose: () => Promise<undefined>;
19
+ dispose: () => Promise<void>;
20
20
  };
21
21
  }
22
22
  export declare const timelineOf: (runtime: Runtime) => TimelineService;
@@ -27,13 +27,14 @@ export class TimelineService {
27
27
  if (this.#started)
28
28
  return;
29
29
  this.#started = true;
30
+ // A mirror: a plugin listens through `watch` and bindings.
30
31
  this.#runtime.on("timeline.changed", (data) => {
31
32
  const marks = data?.marks;
32
33
  if (Array.isArray(marks))
33
34
  this.accept(marks);
34
35
  else
35
36
  void this.read();
36
- });
37
+ }, "events", true);
37
38
  }
38
39
  accept(marks) {
39
40
  const next = view(marks);
@@ -51,7 +52,13 @@ export class TimelineService {
51
52
  }
52
53
  watch(listener) {
53
54
  this.#listeners.add(listener);
54
- return { dispose: async () => void this.#listeners.delete(listener) };
55
+ const held = this.#runtime.hold("timeline.changed");
56
+ return {
57
+ dispose: async () => {
58
+ this.#listeners.delete(listener);
59
+ await held.dispose();
60
+ },
61
+ };
55
62
  }
56
63
  }
57
64
  export const timelineOf = (runtime) => runtime.service("timeline", () => new TimelineService(runtime));
@@ -60,6 +67,7 @@ export function timeline(scope) {
60
67
  return {
61
68
  get marks() {
62
69
  service().start();
70
+ scope.runtime.reading("timeline.changed");
63
71
  return service().marks;
64
72
  },
65
73
  onChange(listener) {
@@ -7971,6 +7971,17 @@ export interface PluginInfo {
7971
7971
  * @since 1.5.9
7972
7972
  */
7973
7973
  display?: PluginDisplay;
7974
+ /**
7975
+ * For `state: "running"`: `"running"` while its process runs, `"idle"`
7976
+ * while the player stopped it because nothing used it (lazy activation:
7977
+ * it starts again on its next use, its menu items, keys, settings pages,
7978
+ * resolvers and drop targets stay). Absent in other states and from
7979
+ * players before 0.5.18. A change between the two alone does not fire
7980
+ * {@link Packages.onChange}.
7981
+ *
7982
+ * @since 1.5.38
7983
+ */
7984
+ process?: "running" | "idle";
7974
7985
  }
7975
7986
  /**
7976
7987
  * {@link PluginInfo.display}: a package's `name`, `punchline`,
package/dist/plugin/ui.js CHANGED
@@ -534,13 +534,20 @@ class LayoutService {
534
534
  if (this.#started || !this.#runtime.can("ui.mount"))
535
535
  return;
536
536
  this.#started = true;
537
- this.#runtime.on("window.state", () => void this.read());
537
+ // A mirror: panels listen through `watch`.
538
+ this.#runtime.on("window.state", () => void this.read(), "events", true);
538
539
  void this.read();
539
540
  }
540
541
  watch(listener) {
541
542
  this.start();
542
543
  this.#listeners.add(listener);
543
- return { dispose: async () => void this.#listeners.delete(listener) };
544
+ const held = this.#runtime.hold("window.state");
545
+ return {
546
+ dispose: async () => {
547
+ this.#listeners.delete(listener);
548
+ await held.dispose();
549
+ },
550
+ };
544
551
  }
545
552
  async read() {
546
553
  if (this.#reading) {
@@ -26,7 +26,7 @@ export declare class WindowService {
26
26
  accept(facts: WindowFacts): void;
27
27
  read(): Promise<void>;
28
28
  watch(listener: (state: WindowState) => void): {
29
- dispose: () => Promise<undefined>;
29
+ dispose: () => Promise<void>;
30
30
  };
31
31
  }
32
32
  export declare const windowOf: (runtime: Runtime) => WindowService;
@@ -40,6 +40,7 @@ export class WindowService {
40
40
  if (this.#started)
41
41
  return;
42
42
  this.#started = true;
43
+ // A mirror: a plugin listens through `watch` and bindings.
43
44
  this.#runtime.on("window.state", (data) => {
44
45
  const facts = data;
45
46
  // The event carries the facts; anything else is read again.
@@ -47,7 +48,7 @@ export class WindowService {
47
48
  this.accept(facts);
48
49
  else
49
50
  void this.read();
50
- });
51
+ }, "events", true);
51
52
  void this.read();
52
53
  }
53
54
  /** The palette, as the `window.state` reply carried it (`plugin.appearance`). */
@@ -91,7 +92,13 @@ export class WindowService {
91
92
  }
92
93
  watch(listener) {
93
94
  this.#listeners.add(listener);
94
- return { dispose: async () => void this.#listeners.delete(listener) };
95
+ const held = this.#runtime.hold("window.state");
96
+ return {
97
+ dispose: async () => {
98
+ this.#listeners.delete(listener);
99
+ await held.dispose();
100
+ },
101
+ };
95
102
  }
96
103
  }
97
104
  export const windowOf = (runtime) => runtime.service("window", () => new WindowService(runtime));
@@ -101,6 +108,7 @@ export function mainWindow(scope) {
101
108
  return {
102
109
  get state() {
103
110
  service().start();
111
+ scope.runtime.reading("window.state");
104
112
  return service().state;
105
113
  },
106
114
  onChange(listener) {
package/dist/wire.d.ts CHANGED
@@ -141,6 +141,8 @@ export interface PackageInfo {
141
141
  description: string | null;
142
142
  tags: string[];
143
143
  };
144
+ /** Whether it has a process now (player 0.5.18, `state: "running"`). */
145
+ process?: "running" | "idle";
144
146
  }
145
147
  /** Where an installed package came from (`packages.install` `source`). */
146
148
  export type WireSource = {
@@ -365,6 +367,8 @@ export interface Transport {
365
367
  */
366
368
  locale?: string;
367
369
  }
370
+ /** The events a plugin subscribes to (`events.subscribe`). */
371
+ type HostEvent = "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed" | "resolvers.fileTypes";
368
372
  export declare function createWire(transport: Transport): {
369
373
  processing: {
370
374
  state: () => Promise<import("./processing.ts").ProcessingState>;
@@ -599,8 +603,19 @@ export declare function createWire(transport: Transport): {
599
603
  }) => Promise<void>;
600
604
  };
601
605
  events: {
602
- /** One `events.subscribe` per name however many listeners. */
603
- subscribe(event: "source.state" | "playback.backend" | "window.state" | "packages.changed" | "bindings.changed" | "timeline.changed" | "appearance.changed", listener: (data: unknown) => void): Promise<() => Promise<void>>;
606
+ /**
607
+ * One `events.subscribe` per name however many listeners. `mirror`:
608
+ * only the SDK keeps a copy of the state (player 0.5.18; older
609
+ * players ignore it); `listen` changes it.
610
+ */
611
+ subscribe(event: HostEvent, listener: (data: unknown) => void, mirror?: boolean): Promise<() => Promise<void>>;
612
+ /**
613
+ * Tell the player whether a subscribed event has a real listener
614
+ * (`active`) or only the SDK's copy of the state: `events.subscribe`
615
+ * again, which replaces the mark (player 0.5.18; older players keep
616
+ * one subscription per name and ignore the mark).
617
+ */
618
+ listen: (event: HostEvent, active: boolean) => Promise<void>;
604
619
  };
605
620
  ui: {
606
621
  /** Player 0.2.0: the remote images a Markdown document shows. */
package/dist/wire.js CHANGED
@@ -255,14 +255,18 @@ export function createWire(transport) {
255
255
  set: (options) => transport.request("layout.set", options),
256
256
  },
257
257
  events: {
258
- /** One `events.subscribe` per name however many listeners. */
259
- async subscribe(event, listener) {
258
+ /**
259
+ * One `events.subscribe` per name however many listeners. `mirror`:
260
+ * only the SDK keeps a copy of the state (player 0.5.18; older
261
+ * players ignore it); `listen` changes it.
262
+ */
263
+ async subscribe(event, listener, mirror = false) {
260
264
  const off = transport.on(event, listener);
261
265
  let subscription = subscriptions.get(event);
262
266
  if (!subscription) {
263
267
  subscription = {
264
268
  count: 0,
265
- ready: transport.request("events.subscribe", { event }),
269
+ ready: transport.request("events.subscribe", mirror ? { event, mirror: true } : { event }),
266
270
  };
267
271
  subscriptions.set(event, subscription);
268
272
  }
@@ -287,6 +291,13 @@ export function createWire(transport) {
287
291
  }
288
292
  };
289
293
  },
294
+ /**
295
+ * Tell the player whether a subscribed event has a real listener
296
+ * (`active`) or only the SDK's copy of the state: `events.subscribe`
297
+ * again, which replaces the mark (player 0.5.18; older players keep
298
+ * one subscription per name and ignore the mark).
299
+ */
300
+ listen: (event, active) => transport.request("events.subscribe", active ? { event } : { event, mirror: true }),
290
301
  },
291
302
  ui: {
292
303
  /** Player 0.2.0: the remote images a Markdown document shows. */
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.37: full reference
1
+ # @vestra-player/sdk 1.5.38: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
@@ -329,11 +329,11 @@ The rule that follows: **state you change outside a handler, or after an `await`
329
329
 
330
330
  #### Stopping
331
331
 
332
- The player stops a plugin when the user disables or uninstalls it, when it is reloaded or upgraded, and when the player quits.
332
+ The player stops a plugin when the user disables or uninstalls it, when it is reloaded or upgraded, when the player quits, and, with lazy activation, when nothing has used it for a while (Idle plugins).
333
333
 
334
334
  1. The player first removes everything the plugin owns on its side: menu items, windows, panels, settings pages, filter chains, subtitle layers, snapshots, export jobs, resolvers, generated streams.
335
335
  2. The SDK then refuses new requests, fails pending ones with `code: "disabled"` and disposes the root scope. That disposal only cleans up locally (timers, abort signals, sockets): the player-side resources are already gone.
336
- 3. A process that has not exited 300 ms later is killed.
336
+ 3. A process that has not exited 300 ms later is killed (2 s later when it stops because it is idle).
337
337
 
338
338
  So `onDispose` cleanup must be quick and must not rely on the player. Media the plugin opened with player.open **keeps playing** after it stops.
339
339
 
@@ -344,7 +344,7 @@ A plugin's process does not live as long as the player. Within one player sessio
344
344
  - the plugin is **reloaded** (from the plugin manager, or a development load with `--plugin`) or **upgraded**;
345
345
  - it **crashed**, or failed to start, and is started again.
346
346
 
347
- A later player may restart a plugin in more cases: restarting plugins that share one process together when one of them faults or hangs, or stopping a plugin nobody uses and starting it on its next use. Write the plugin for those too.
347
+ A later player may restart a plugin in more cases: restarting plugins that share one process together when one of them faults or hangs, or stopping a plugin nobody uses and starting it on its next use (Idle plugins). Write the plugin for those too.
348
348
 
349
349
  Everything in the process is gone after such a restart: variables, timers, caches, open sockets. The menus, windows and panels come back because `activate` creates them again. **Keep state that must survive a restart in session storage (or persistent storage):**
350
350
 
@@ -358,6 +358,16 @@ Session storage is for state that belongs to this run of the player: a queue the
358
358
 
359
359
  Write `activate` so that running it twice in one session is normal: read what you kept, then build the interface from it.
360
360
 
361
+ #### Idle plugins are stopped
362
+
363
+ With lazy activation (player 0.5.18 and later, while the player turns it on), the player works like a browser's extension service workers (Chrome Manifest V3): a plugin that holds nothing only its process can serve is stopped after about a minute without use, and started again by its next use.
364
+
365
+ - **What stays while it is stopped**: its menu items and keys (with the last `enabled`, `checked` and `reason` it set), its settings pages, its file and link resolvers and its drop targets. Using one of them, or showing one of its settings pages, starts the plugin; it runs `activate`, registers them again, and then receives the click, the open or the drop. The player also remembers these between runs, so a plugin nobody uses is not started at launch at all.
366
+ - **What keeps it running**: anything the player must deliver to it or that is its running work: a listener (`player.onChange`, `window.onChange`, `bindings.onChange`, `packages.onChange` and the other `onChange`, `onEnd`), a command state, panel or window binding that reads `plugin.player.state` or another state, an open window, a mounted panel, an OSD panel, a timeline layer, a filter chain, a stream or subtitle track it added, a next/previous provider, a sleep lease, a window setting, a job or snapshot in progress. Reading `plugin.player.state` inside a handler does not: the SDK reads the state again when the plugin starts.
367
+ - **What does not keep it running**: work the player does not see. A `fetch`, a socket, a `setTimeout` or `setInterval`, or a promise the plugin awaits is lost when an idle plugin is stopped, as in Chrome. Set the command's `busy` while its work runs (a busy command keeps the plugin running), or keep what it needs in session storage and pick it up in `activate`.
368
+
369
+ `TestHost.listening` lists the events a plugin really listens to; empty means listeners do not keep it running.
370
+
361
371
  #### The data directory
362
372
 
363
373
  plugin.dataDirectory is a writable directory for the plugin's own files (settings, caches, remembered folders). It needs no capability; the player creates it before it starts the plugin.
@@ -10735,6 +10745,10 @@ interface PluginInfo {
10735
10745
  * The manifest's display texts in the interface language, translated with that package's catalog; absent from players before 0.4.3 (read `manifest.json` there).
10736
10746
  */
10737
10747
  display?: PluginDisplay;
10748
+ /**
10749
+ * For `state: "running"`: `"running"` while its process runs, `"idle"` while the player stopped it because nothing used it (lazy activation: it starts again on its next use, its menu items, keys, settings pages, resolvers and drop targets stay). Absent in other states and from players before 0.5.18. A change between the two alone does not fire Packages.onChange.
10750
+ */
10751
+ process?: "running" | "idle";
10738
10752
  }
10739
10753
  ```
10740
10754
 
@@ -12796,6 +12810,10 @@ class TestHost implements Transport {
12796
12810
  readonly calls: Call[];
12797
12811
  /** The host events the plugin subscribed to (needs `events` etc.). */
12798
12812
  readonly subscriptions: Set<string>;
12813
+ /**
12814
+ * The subscribed events the plugin really listens to: an `onChange` and the like, or a binding that reads the state. The SDK's own copies of the state (what keeps `plugin.player.state` current) are left out. Empty means the player may stop the plugin while nothing uses it (unless it holds something else: a window, a panel, a filter…).
12815
+ */
12816
+ readonly listening: Set<string>;
12799
12817
  /**
12800
12818
  * The player's source facts (phase, position, duration, paused, rate, audio tracks, loop, ...). Wire-level shape; change it with TestHost.setSource, which also broadcasts it.
12801
12819
  */
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.5.37: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process. Everything a plugin creates (commands, windows, panels, filters, listeners…) is a handle that is released automatically when the plugin stops. L<n>: a line of llms-full.txt (next to this file), which has every page, declaration and example.
3
+ > @vestra-player/sdk 1.5.38: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process. Everything a plugin creates (commands, windows, panels, filters, listeners…) is a handle that is released automatically when the plugin stops. L<n>: a line of llms-full.txt (next to this file), which has every page, declaration and example.
4
4
 
5
5
  **Rules**
6
6
 
@@ -33,7 +33,7 @@ my-plugin/
33
33
  }
34
34
  ```
35
35
 
36
- Fields: `id`, `version`, `apiVersion`, `entry`, `capabilities`, `name`, `author`, `punchline`, `description`, `tags`, `icon`, `readme`, `changelog`, `sourceUrl`, `license`, `minPlayerVersion`; unknown fields and capabilities are rejected. Rules and limits: L2636–2706.
36
+ Fields: `id`, `version`, `apiVersion`, `entry`, `capabilities`, `name`, `author`, `punchline`, `description`, `tags`, `icon`, `readme`, `changelog`, `sourceUrl`, `license`, `minPlayerVersion`; unknown fields and capabilities are rejected. Rules and limits: L2646–2716.
37
37
 
38
38
  **main.ts**
39
39
 
@@ -78,9 +78,9 @@ export default async function activate(plugin: Plugin) {
78
78
  - `secrets`: Secrets.get/set/delete
79
79
  - `rendering.manage`: Rendering.setPreference/restartPlayer
80
80
  - `video.upscaling`: VideoUpscaling.state/set
81
- - No capability: `tools`, `storage`, `system`, `app`, `ui` (images and Markdown), `i18n`, reading window.state, appearance.theme, packages.list and rendering.state. Details: L2707–2767.
81
+ - No capability: `tools`, `storage`, `system`, `app`, `ui` (images and Markdown), `i18n`, reading window.state, appearance.theme, packages.list and rendering.state. Details: L2717–2777.
82
82
 
83
- **Test**: `tests/*.test.ts` with `node:test`, run by `vestra-plugin test`. `startPlugin(activate, { directory: new URL("..", import.meta.url) })` from `@vestra-player/sdk/testing` returns `{ host, stop }`, `host` an in-memory player (TestHost): act (`host.command(id).run()`, `host.press(key)`), `await host.settle()`, assert on `host.osd`, `host.window(id).props`… Guide: L2077–2265; TestHost: L12744.
83
+ **Test**: `tests/*.test.ts` with `node:test`, run by `vestra-plugin test`. `startPlugin(activate, { directory: new URL("..", import.meta.url) })` from `@vestra-player/sdk/testing` returns `{ host, stop }`, `host` an in-memory player (TestHost): act (`host.command(id).run()`, `host.press(key)`), `await host.settle()`, assert on `host.osd`, `host.window(id).props`… Guide: L2087–2275; TestHost: L12758.
84
84
 
85
85
  **Command line**
86
86
 
@@ -108,39 +108,39 @@ export default async function activate(plugin: Plugin) {
108
108
  - `limit`: A player-wide limit was reached, a property value is over the limits of PropValue
109
109
  - `reserved`: The id is `vestra` or starts with `vestra.`
110
110
  - `versionMismatch`: The package needs a newer player
111
- - Others: PluginError, llms-full.txt L3770.
111
+ - Others: PluginError, llms-full.txt L3780.
112
112
 
113
113
  ## Reference
114
114
 
115
115
  - [llms-full.txt](https://vestrasys.github.io/vestra-player-sdk/llms-full.txt), also node_modules/@vestra-player/sdk/llms-full.txt; pages: [site](https://vestrasys.github.io/vestra-player-sdk/).
116
116
  - Tutorials: Getting started L9–237
117
- - Concepts: How a plugin runs L238–408; Commands, menus and key bindings L409–520; Playback state, controls and sources L521–666; Video and audio processing L667–753; Windows, panels, settings pages, dialogs and the OSD L754–1042
118
- - How-to: Control playback L1043–1171; Store a plugin's data L1172–1239; Load subtitles from a plugin L1240–1318; Keep a slider responsive while dragging L1319–1493; Remember a window's state between runs L1494–1623; Offer a GPU rendering switch L1624–1744; Translate a plugin L1745–1928; Migrate a plugin to English source text L1929–2076; Test a plugin L2077–2265; Publish a plugin L2266–2354; Migrating off package rollback (1.5.16, removed in 1.5.17) L2355–2430
119
- - Guidelines: Interface guidelines L2431–2538; Menu guidelines L2539–2635
120
- - Reference: Manifest L2636–2706; Capabilities L2707–2767; Limits L2768–2897; Commands, menus and bindings L2898–3381; Platforms L3382–3412; Theme L3413–3634; Command line L3635–3692
121
- - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L3693–12428
122
- - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html): L12429–15144
117
+ - Concepts: How a plugin runs L238–418; Commands, menus and key bindings L419–530; Playback state, controls and sources L531–676; Video and audio processing L677–763; Windows, panels, settings pages, dialogs and the OSD L764–1052
118
+ - How-to: Control playback L1053–1181; Store a plugin's data L1182–1249; Load subtitles from a plugin L1250–1328; Keep a slider responsive while dragging L1329–1503; Remember a window's state between runs L1504–1633; Offer a GPU rendering switch L1634–1754; Translate a plugin L1755–1938; Migrate a plugin to English source text L1939–2086; Test a plugin L2087–2275; Publish a plugin L2276–2364; Migrating off package rollback (1.5.16, removed in 1.5.17) L2365–2440
119
+ - Guidelines: Interface guidelines L2441–2548; Menu guidelines L2549–2645
120
+ - Reference: Manifest L2646–2716; Capabilities L2717–2777; Limits L2778–2907; Commands, menus and bindings L2908–3391; Platforms L3392–3422; Theme L3423–3644; Command line L3645–3702
121
+ - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L3703–12442
122
+ - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html): L12443–15162
123
123
 
124
124
  ## API
125
125
 
126
126
  Where each `plugin.<module>` is declared:
127
127
 
128
- - Plugin L4096, Scope L3845, PluginError L3770, Disposable L3708
129
- - Commands L4493; Windows L5430; Panels L6876; Dialogs L7544; Player L8285; video: VideoFilter L9165, VideoView L9265, RenderOptions L9352, VideoUpscaling L9548; audio: AudioFilter L9210; Subtitles L9744; media: MediaCapture L10148; Resolvers L10378; Packages L10776; Osd L9030; Host L8872; Clipboard L11104; Power L11191; Tools L11250; Storage L11402; System L11601; Secrets L11681; window: MainWindow L7193; App L7373; Rendering L11944; Bindings L11004; Ui L4782; Timeline L7105; Drops L6123; Appearance L6204; I18n L6437; Settings L6720.
128
+ - Plugin L4106, Scope L3855, PluginError L3780, Disposable L3718
129
+ - Commands L4503; Windows L5440; Panels L6886; Dialogs L7554; Player L8295; video: VideoFilter L9175, VideoView L9275, RenderOptions L9362, VideoUpscaling L9558; audio: AudioFilter L9220; Subtitles L9754; media: MediaCapture L10158; Resolvers L10388; Packages L10790; Osd L9040; Host L8882; Clipboard L11118; Power L11205; Tools L11264; Storage L11416; System L11615; Secrets L11695; window: MainWindow L7203; App L7383; Rendering L11958; Bindings L11018; Ui L4792; Timeline L7115; Drops L6133; Appearance L6214; I18n L6447; Settings L6730.
130
130
 
131
131
  ## Examples
132
132
 
133
133
  Tested plugins in [examples/](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples):
134
134
 
135
- - hello: The README Quick start: one menu command and an on-screen message. L15579–15668
136
- - blur: A tool window with one slider that blurs the playing video. L15669–15930
137
- - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L15931–16263
138
- - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L16264–16450
139
- - panel-menu: A sidebar panel with its own right-click menu. L16451–16659
140
- - menus: Radio menu items, separator groups and a menu item whose title changes. L16660–16819
141
- - theme-widgets: A tool window drawn with the player's theme and widgets. L16820–16983
142
- - markdown: A window that shows a Markdown document. L16984–17180
143
- - i18n: A plugin in the player's language: English source text, a Chinese translation. L17181–17348
135
+ - hello: The README Quick start: one menu command and an on-screen message. L15597–15686
136
+ - blur: A tool window with one slider that blurs the playing video. L15687–15948
137
+ - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L15949–16281
138
+ - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L16282–16468
139
+ - panel-menu: A sidebar panel with its own right-click menu. L16469–16677
140
+ - menus: Radio menu items, separator groups and a menu item whose title changes. L16678–16837
141
+ - theme-widgets: A tool window drawn with the player's theme and widgets. L16838–17001
142
+ - markdown: A window that shows a Markdown document. L17002–17198
143
+ - i18n: A plugin in the player's language: English source text, a Chinese translation. L17199–17366
144
144
 
145
145
  ## Optional
146
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.37",
3
+ "version": "1.5.38",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {