@vestra-player/sdk 1.5.36 → 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,23 @@
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
+
12
+ ## 1.5.37
13
+
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.
15
+
16
+ - **`plugin.video.upscaling`** (player 0.5.16): the GPU's video upscaler, NVIDIA RTX Video Super Resolution on Windows. `state()` reports whether it works now (`available`) and why not (`reason`, `{ code, message }` with the stable codes of `UpscalingReasonCode`: `platform`, `no-gpu`, `driver`, `gpu-unsupported`, `rendering`, `adapter`, `decoding`, `unchecked`; a newer player may add codes), whether this plugin turned it on (`enabled`), and whether the player asked the GPU to upscale now (`requested`, with the scale `factor`). `set(enabled)` turns this plugin's request on or off and resolves with the new state; `true` rejects with `unsupported` where upscaling cannot work in this run: every reason except `decoding` (it can pass with the next file) and except `rendering` while GPU rendering is still starting, which are accepted and kept; `false` is always accepted and answered at once without the GPU check (before any check: `available: false` with `unchecked`); `invalid` for a non-boolean. The player checks the GPU once, at the first `state()` or `set(true)` (it wakes a sleeping discrete GPU, so the examples ask on the first pick of the item, not while the plugin starts), puts the upscaler last in the video filter chain scaled to the area the video is shown in (only above 1×), follows size changes, and takes it out when the last plugin turns it off or stops. There is no "upscaling active" signal: on Windows the user must also turn on Super Resolution in the NVIDIA app, which no program can read. New types `VideoUpscaling`, `UpscalingState`, `UpscalingReason`, `UpscalingReasonCode`.
17
+ - **New capability `video.upscaling`** ("Upscale the video with the GPU's video upscaler", not highlighted in the store: it changes only how the picture is scaled while the plugin runs and touches no data), last in `CAPABILITIES`; both requests need it and reject with `capability` without it. The player's capability table copy, `CAPABILITY_INFO`, `vestra-plugin check` and the editor plugin know it.
18
+ - **`TestHost.upscaling`** models the player: preset `reason` (`null`: it works) and `factor`, read `enabled` and `checked` (whether the plugin made the player check the GPU), set `renderingStarting`; `set(true)` refuses with `unsupported` unless the reason is `decoding`, or `rendering` with `renderingStarting`; `set(false)` checks nothing (`unchecked` before any check).
19
+ - _Video and audio processing_ and _Platforms_ describe the upscaler.
20
+
3
21
  ## 1.5.36
4
22
 
5
23
  **Who must change: nobody.** No API changes, `apiVersion` stays `vestra/1`, no player version needed.
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.
@@ -143,4 +143,6 @@ export const API_REQUESTS = Object.freeze({
143
143
  "Rendering.state": [{ method: "rendering.state" }],
144
144
  "Rendering.setPreference": [{ method: "rendering.setPreference" }],
145
145
  "Rendering.restartPlayer": [{ method: "rendering.restart" }],
146
+ "VideoUpscaling.state": [{ method: "video.upscaling.state" }],
147
+ "VideoUpscaling.set": [{ method: "video.upscaling.set" }],
146
148
  });
@@ -354,6 +354,17 @@ export const HOST_CAPABILITIES = {
354
354
  residency: "none",
355
355
  risky: true,
356
356
  },
357
+ {
358
+ label: "Upscale the video with the GPU's video upscaler",
359
+ methods: [
360
+ { name: "video.upscaling.state", needs: "video.upscaling" },
361
+ { name: "video.upscaling.set", needs: "video.upscaling" },
362
+ ],
363
+ name: "video.upscaling",
364
+ requires: [],
365
+ residency: "live",
366
+ risky: false,
367
+ },
357
368
  ],
358
369
  uncapable: [
359
370
  { name: "packages.list", needs: null },
@@ -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);
@@ -15,6 +15,7 @@ export declare class ScopeImpl implements Plugin {
15
15
  readonly video: {
16
16
  view: () => import("./types.ts").VideoView;
17
17
  render: () => import("./types.ts").RenderOptions;
18
+ upscaling: import("./types.ts").VideoUpscaling;
18
19
  filter(id: string): import("./types.ts").VideoFilter;
19
20
  };
20
21
  readonly audio: {
@@ -17,7 +17,7 @@ import { packages } from "./packages.js";
17
17
  import { osd } from "./osd.js";
18
18
  import { mainWindow } from "./window.js";
19
19
  import { bindingsApi } from "./bindings.js";
20
- import { clipboard, host, power, rendering, secrets, storage, system, tools, } from "./services.js";
20
+ import { clipboard, host, power, rendering, secrets, storage, system, tools, videoUpscaling, } from "./services.js";
21
21
  export class ScopeImpl {
22
22
  runtime;
23
23
  #parent;
@@ -49,6 +49,7 @@ export class ScopeImpl {
49
49
  ...videoFilters(this),
50
50
  view: () => videoView(this),
51
51
  render: () => videoRender(this),
52
+ upscaling: videoUpscaling(this),
52
53
  };
53
54
  audio = audioFilters(this);
54
55
  subtitles = subtitles(this);
@@ -1,5 +1,5 @@
1
1
  import type { ScopeImpl } from "./scope.ts";
2
- import type { Clipboard, Host, Power, Rendering, Secrets, Storage, System, Tools } from "./types.ts";
2
+ import type { Clipboard, Host, Power, Rendering, Secrets, Storage, System, Tools, VideoUpscaling } from "./types.ts";
3
3
  export declare const clipboard: (scope: ScopeImpl) => Clipboard;
4
4
  export declare const host: (scope: ScopeImpl) => Host;
5
5
  export declare const power: (scope: ScopeImpl) => Power;
@@ -113,3 +113,9 @@ export declare const SECRET_LIMITS: Readonly<{
113
113
  }>;
114
114
  export declare const secrets: (scope: ScopeImpl) => Secrets;
115
115
  export declare const rendering: (scope: ScopeImpl) => Rendering;
116
+ /**
117
+ * `plugin.video.upscaling` (player 0.5.16): both requests need
118
+ * `video.upscaling`; the player answers once its GPU check ran (the first
119
+ * call starts it).
120
+ */
121
+ export declare const videoUpscaling: (scope: ScopeImpl) => VideoUpscaling;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Shared services: the clipboard, sleep prevention, showing and opening files
3
- * and links, secrets, session storage and the rendering choice go to the
4
- * host; external tools and settings files run in this plugin's own process.
3
+ * and links, secrets, session storage, the rendering choice and the video
4
+ * upscaler go to the host; external tools and settings files run in this
5
+ * plugin's own process.
5
6
  */
6
7
  import { existsSync, readFileSync, renameSync } from "node:fs";
7
8
  import { mkdir, open, rename, rm, stat } from "node:fs/promises";
@@ -497,3 +498,26 @@ export const rendering = (scope) => {
497
498
  },
498
499
  };
499
500
  };
501
+ /**
502
+ * `plugin.video.upscaling` (player 0.5.16): both requests need
503
+ * `video.upscaling`; the player answers once its GPU check ran (the first
504
+ * call starts it).
505
+ */
506
+ export const videoUpscaling = (scope) => {
507
+ const declared = () => {
508
+ if (!scope.runtime.can("video.upscaling"))
509
+ throw failure("capability", "video.upscaling must be declared in manifest");
510
+ };
511
+ return {
512
+ async state() {
513
+ declared();
514
+ return (await scope.runtime.host.video.upscalingState());
515
+ },
516
+ async set(enabled) {
517
+ declared();
518
+ if (typeof enabled !== "boolean")
519
+ throw failure("invalid", "enabled must be true or false");
520
+ return (await scope.runtime.host.video.upscalingSet(enabled));
521
+ },
522
+ };
523
+ };
@@ -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) {
@@ -15,7 +15,7 @@ import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
15
15
  import type { CommandDeclaration } from "../commands.ts";
16
16
  import type { SourceState } from "../source.ts";
17
17
  import type { SubtitleState } from "../subtitles.ts";
18
- import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, ViewSettings } from "./types.ts";
18
+ import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingReason, ViewSettings } from "./types.ts";
19
19
  import type { MediaJob } from "../media.ts";
20
20
  import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
21
21
  import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
@@ -469,7 +469,8 @@ export interface MarkdownWire {
469
469
  * {@link TestHost.app}, {@link TestHost.systemInfo},
470
470
  * {@link TestHost.chapters}, {@link TestHost.audioDevices},
471
471
  * {@link TestHost.secrets}, {@link TestHost.clipboard},
472
- * {@link TestHost.tools}, {@link TestHost.rendering}.
472
+ * {@link TestHost.tools}, {@link TestHost.rendering},
473
+ * {@link TestHost.upscaling}.
473
474
  * - **Assert** on records: {@link TestHost.osd}, {@link TestHost.calls},
474
475
  * {@link TestHost.sent}, {@link TestHost.conflicts},
475
476
  * {@link TestHost.layout}, {@link TestHost.menus}, and many more.
@@ -534,6 +535,16 @@ export declare class TestHost implements Transport {
534
535
  readonly calls: Call[];
535
536
  /** The host events the plugin subscribed to (needs `events` etc.). */
536
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>;
537
548
  /**
538
549
  * The player's source facts (phase, position, duration, paused, rate,
539
550
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -1703,6 +1714,65 @@ export declare class TestHost implements Transport {
1703
1714
  /** Accepted `restartPlayer` calls. */
1704
1715
  restarts: number;
1705
1716
  };
1717
+ /**
1718
+ * The GPU's video upscaler behind `plugin.video.upscaling` (player
1719
+ * 0.5.16), answered as the player would:
1720
+ *
1721
+ * - `state()` and `set(true)` set `checked` (the player's one GPU check; a
1722
+ * plugin that should not wake the GPU while it starts can assert it is
1723
+ * still `false`) and report `available` from `reason` (`null`: it
1724
+ * works), this plugin's `enabled`, and `requested` with `factor` while
1725
+ * enabled, available and `factor` is above 1.
1726
+ * - `set(true)` refuses with `unsupported` and the reason's message while
1727
+ * `reason` is set, unless its code is `decoding`, or `rendering` with
1728
+ * `renderingStarting` (GPU rendering still starting): those can pass
1729
+ * during the run, so the player keeps the request. Nothing changes when
1730
+ * it refuses.
1731
+ * - `set(false)` always succeeds and does not check the GPU: before any
1732
+ * check it reports `available: false` with the reason `unchecked`.
1733
+ *
1734
+ * Inject other answers with `host.handle("video.upscaling.set", …)`. The
1735
+ * defaults are a player where upscaling works and a 1080p video shown
1736
+ * full screen on a 4K display (`factor` 2), whatever `systemInfo` says.
1737
+ *
1738
+ * @example Test the menu item on a computer without an RTX GPU
1739
+ * The plugin's upscaling item is greyed out with the player's reason after
1740
+ * the user first picks it. Starting the plugin checks nothing (`checked`
1741
+ * stays false, so no GPU wakes up); the first pick reads the state, and
1742
+ * `set(true)` would reject with `unsupported`, as in the player.
1743
+ * ```ts
1744
+ * import assert from "node:assert/strict";
1745
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1746
+ * import activate from "../main.ts";
1747
+ *
1748
+ * const host = new TestHost();
1749
+ * host.upscaling.reason = { code: "no-gpu", message: "no NVIDIA GPU" };
1750
+ * const { stop } = await startPlugin(activate, {
1751
+ * directory: new URL("..", import.meta.url),
1752
+ * host,
1753
+ * });
1754
+ * assert.equal(host.upscaling.checked, false); // starting woke no GPU
1755
+ * const item = host.command("upscaling");
1756
+ * await item.run(); // the first pick checks the GPU
1757
+ * assert.equal(item.state.enabled, false); // greyed out
1758
+ * assert.equal(item.state.reason, "no NVIDIA GPU"); // the player's message
1759
+ * await stop();
1760
+ * ```
1761
+ *
1762
+ * @since 1.5.37
1763
+ */
1764
+ readonly upscaling: {
1765
+ /** Why upscaling does not work; `null` while it does. */
1766
+ reason: UpscalingReason | null;
1767
+ /** Whether this plugin has upscaling on (`set`); a test may preset it. */
1768
+ enabled: boolean;
1769
+ /** The factor the player would request; `null` or at most 1: the video needs no upscaling. */
1770
+ factor: number | null;
1771
+ /** Whether the plugin made the player check the GPU (`state`, `set(true)`). */
1772
+ checked: boolean;
1773
+ /** GPU rendering is still starting: a `rendering` reason then lets `set(true)` keep the request. */
1774
+ renderingStarting: boolean;
1775
+ };
1706
1776
  /** What `player.chapters()` lists while media is ready. */
1707
1777
  chapters: {
1708
1778
  /** The chapter title, `null` without one. */