@vestra-player/sdk 1.5.25 → 1.5.27

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The capability rule, as data: which manifest capability a request needs
3
+ * (the player's table, `HOST_CAPABILITIES`, synced from its
4
+ * `src/host/capabilities.json`) and which requests each public member of the
5
+ * plugin API sends (`API_REQUESTS`). `CAPABILITY_INFO`, `vestra-plugin check`
6
+ * and the TypeScript language-service plugin are all built from these two
7
+ * tables, so they and the player cannot disagree.
8
+ *
9
+ * @internal
10
+ */
11
+ import { API_REQUESTS } from "./api-requests.js";
12
+ import { HOST_CAPABILITIES } from "./host-capabilities.js";
13
+ /** @internal The SDK's rules: the synced player table and `API_REQUESTS`. */
14
+ export const RULES = {
15
+ host: HOST_CAPABILITIES,
16
+ apis: API_REQUESTS,
17
+ };
18
+ /**
19
+ * The player's entry for a method, as the player routes it: the exact name
20
+ * first, then the first `prefix.*` entry whose prefix it starts with.
21
+ * `undefined`: the player does not know the method.
22
+ *
23
+ * @internal
24
+ */
25
+ export function hostMethod(method, host = RULES.host) {
26
+ const all = [
27
+ ...host.capabilities.flatMap((capability) => capability.methods),
28
+ ...host.uncapable,
29
+ ];
30
+ return (all.find((entry) => entry.name === method) ??
31
+ all.find((entry) => entry.name.endsWith(".*") && method.startsWith(entry.name.slice(0, -1))));
32
+ }
33
+ /**
34
+ * The capability one request needs, as the player decides it
35
+ * (`capabilities::Route::needs`): `null` for none, `undefined` for a method
36
+ * the player does not know.
37
+ *
38
+ * @param method - The wire method.
39
+ * @param params - Its string parameters that a `Needs::Param` rule reads.
40
+ * @param declared - Whether the plugin declared a capability.
41
+ *
42
+ * @internal
43
+ */
44
+ export function requestNeeds(method, params, declared, host = RULES.host) {
45
+ const entry = hostMethod(method, host);
46
+ if (!entry)
47
+ return undefined;
48
+ const needs = entry.needs;
49
+ if (needs === null || typeof needs === "string")
50
+ return needs;
51
+ if ("param" in needs) {
52
+ const value = params?.[needs.param];
53
+ return value !== undefined && Object.hasOwn(needs.cases, value)
54
+ ? needs.cases[value]
55
+ : needs.otherwise;
56
+ }
57
+ return declared(needs.ifDeclared) ? needs.then : needs.otherwise;
58
+ }
59
+ /**
60
+ * Every capability a member of the plugin API needs with the given manifest:
61
+ * those its requests need and, through `requires`, those they need in turn.
62
+ * `undefined`: the member is not in the table (it needs none).
63
+ *
64
+ * @param api - The member as `Interface.member` (`Player.seek`).
65
+ * @param capabilities - The manifest's capabilities.
66
+ *
67
+ * @internal
68
+ */
69
+ export function memberNeeds(api, capabilities, rules = RULES) {
70
+ const requests = rules.apis[api];
71
+ if (!requests)
72
+ return undefined;
73
+ // An `ifDeclared` rule is decided last, as if the plugin had declared what
74
+ // the member's other requests need: `Player.seek` needs `playback.control`
75
+ // for `source.submit`, and with it `source.operation` needs nothing more.
76
+ const declared = (name) => capabilities.includes(name) ||
77
+ needs.some((need) => need.capability === name);
78
+ const conditional = (request) => {
79
+ const rule = hostMethod(request.method, rules.host)?.needs;
80
+ return typeof rule === "object" && rule !== null && "ifDeclared" in rule;
81
+ };
82
+ const needs = [];
83
+ const add = (need) => {
84
+ if (needs.some((each) => each.capability === need.capability))
85
+ return;
86
+ needs.push(need);
87
+ const entry = rules.host.capabilities.find((capability) => capability.name === need.capability);
88
+ for (const required of entry?.requires ?? [])
89
+ add({
90
+ capability: required,
91
+ method: need.method,
92
+ requiredBy: need.capability,
93
+ });
94
+ };
95
+ for (const request of [
96
+ ...requests.filter((request) => !conditional(request)),
97
+ ...requests.filter(conditional),
98
+ ]) {
99
+ const capability = requestNeeds(request.method, request.params, declared, rules.host);
100
+ if (capability)
101
+ add({ capability, method: request.method });
102
+ }
103
+ return needs;
104
+ }
105
+ /**
106
+ * `CAPABILITY_INFO` from the tables: the label, highlight and requirements
107
+ * from the player's table; the members from `API_REQUESTS` (every member a
108
+ * request of which needs the capability, in the table's order; requests
109
+ * marked `also` are left out, as they are documented at the member).
110
+ *
111
+ * @internal
112
+ */
113
+ export function capabilityInfo(rules = RULES) {
114
+ const info = {};
115
+ for (const capability of rules.host.capabilities)
116
+ info[capability.name] = {
117
+ apis: [],
118
+ requires: [...capability.requires],
119
+ label: capability.label,
120
+ risky: capability.risky,
121
+ };
122
+ for (const [api, requests] of Object.entries(rules.apis))
123
+ for (const request of requests) {
124
+ if (request.also)
125
+ continue;
126
+ const capability = requestNeeds(request.method, request.params, () => true, rules.host);
127
+ const entry = capability ? info[capability] : undefined;
128
+ if (entry && !entry.apis.includes(api))
129
+ entry.apis.push(api);
130
+ }
131
+ return info;
132
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The player's capability table, copied from its `src/host/capabilities.json`
3
+ * by `npm run capabilities:sync -- <player repository>`. Do not edit: change
4
+ * the player's capability modules, regenerate its JSON and sync again.
5
+ *
6
+ * @internal
7
+ */
8
+ import type { HostCapabilities } from "./capability-rules.ts";
9
+ /** @internal */
10
+ export declare const HOST_CAPABILITIES: HostCapabilities;
@@ -0,0 +1,360 @@
1
+ // "then" is a field of the player's IfDeclared rule, not a thenable.
2
+ /* oxlint-disable unicorn/no-thenable */
3
+ /** @internal */
4
+ export const HOST_CAPABILITIES = {
5
+ about: "Generated from src/host/src/capabilities/ (VESTRA_UPDATE_CAPABILITIES=1 cargo test capabilities); do not edit. A method's needs: null (none), a capability name, {param, cases, otherwise} (by a string parameter) or {ifDeclared, then, otherwise}. A method name ending in .* covers the prefix's other methods.",
6
+ apiVersion: "vestra/1",
7
+ capabilities: [
8
+ {
9
+ label: "Provide audio and video it generates itself",
10
+ methods: [
11
+ { name: "producer.prepare", needs: "producer.av" },
12
+ { name: "producer.state", needs: "producer.av" },
13
+ { name: "producer.release", needs: "producer.av" },
14
+ { name: "producer.ack", needs: "producer.av" },
15
+ { name: "producer.declareInput", needs: "producer.av" },
16
+ { name: "producer.ackInput", needs: "producer.av" },
17
+ ],
18
+ name: "producer.av",
19
+ requires: ["playback.control"],
20
+ residency: "live",
21
+ risky: false,
22
+ },
23
+ {
24
+ label: "Read what is playing and the position",
25
+ methods: [
26
+ { name: "source.state", needs: "playback.read" },
27
+ { name: "processing.state", needs: "playback.read" },
28
+ { name: "playback.stats", needs: "playback.read" },
29
+ { name: "player.chapters", needs: "playback.read" },
30
+ { name: "player.audioDevices", needs: "playback.read" },
31
+ { name: "timeline.marks", needs: "playback.read" },
32
+ ],
33
+ name: "playback.read",
34
+ requires: [],
35
+ residency: "none",
36
+ risky: false,
37
+ },
38
+ {
39
+ label: "Open media, control playback, keep the computer awake",
40
+ methods: [
41
+ { name: "playback.set", needs: "playback.control" },
42
+ { name: "source.submit", needs: "playback.control" },
43
+ {
44
+ name: "source.operation",
45
+ needs: {
46
+ ifDeclared: "playback.control",
47
+ otherwise: "playback.read",
48
+ then: "playback.control",
49
+ },
50
+ },
51
+ { name: "audio.addTrack", needs: "playback.control" },
52
+ { name: "audio.selectTrack", needs: "playback.control" },
53
+ { name: "player.setLoop", needs: "playback.control" },
54
+ { name: "player.setAudioDevice", needs: "playback.control" },
55
+ { name: "player.step", needs: "playback.control" },
56
+ { name: "player.setStepSeek", needs: "playback.control" },
57
+ { name: "power.preventSleep", needs: "playback.control" },
58
+ { name: "sequence.set", needs: "playback.control" },
59
+ { name: "sequence.step", needs: "playback.control" },
60
+ ],
61
+ name: "playback.control",
62
+ requires: [],
63
+ residency: "live",
64
+ risky: false,
65
+ },
66
+ {
67
+ label: "Resolve online video addresses",
68
+ methods: [
69
+ { name: "sourceResolvers.register", needs: "source.resolve" },
70
+ { name: "sourceResolvers.unregister", needs: "source.resolve" },
71
+ { name: "sourceResolvers.respond", needs: "source.resolve" },
72
+ { name: "sourceResolvers.*", needs: "source.resolve" },
73
+ ],
74
+ name: "source.resolve",
75
+ requires: [],
76
+ residency: "recordable",
77
+ risky: false,
78
+ },
79
+ {
80
+ label: "Receive playback changes as they happen",
81
+ methods: [
82
+ {
83
+ name: "events.subscribe",
84
+ needs: {
85
+ cases: {
86
+ "bindings.changed": "bindings.manage",
87
+ "packages.changed": null,
88
+ },
89
+ otherwise: "events",
90
+ param: "event",
91
+ },
92
+ },
93
+ {
94
+ name: "events.unsubscribe",
95
+ needs: {
96
+ cases: {
97
+ "bindings.changed": "bindings.manage",
98
+ "packages.changed": null,
99
+ },
100
+ otherwise: "events",
101
+ param: "event",
102
+ },
103
+ },
104
+ ],
105
+ name: "events",
106
+ requires: [],
107
+ residency: "live",
108
+ risky: false,
109
+ },
110
+ {
111
+ label: "Add menu commands and shortcuts",
112
+ methods: [
113
+ { name: "commands.register", needs: "commands" },
114
+ { name: "commands.update", needs: "commands" },
115
+ { name: "commands.unregister", needs: "commands" },
116
+ { name: "host.run", needs: "commands" },
117
+ ],
118
+ name: "commands",
119
+ requires: [],
120
+ residency: "recordable",
121
+ risky: false,
122
+ },
123
+ {
124
+ label: "Open tool windows",
125
+ methods: [
126
+ { name: "ui.open", needs: "ui.window" },
127
+ { name: "ui.set", needs: "ui.window" },
128
+ { name: "ui.show", needs: "ui.window" },
129
+ { name: "ui.close", needs: "ui.window" },
130
+ ],
131
+ name: "ui.window",
132
+ requires: [],
133
+ residency: "live",
134
+ risky: false,
135
+ },
136
+ {
137
+ label: "Show panels in the bottom bar, the sidebar or behind the video",
138
+ methods: [
139
+ { name: "ui.mount", needs: "ui.mount" },
140
+ { name: "ui.setMount", needs: "ui.mount" },
141
+ { name: "ui.unmount", needs: "ui.mount" },
142
+ { name: "ui.interaction", needs: "ui.mount" },
143
+ { name: "ui.menu", needs: "ui.mount" },
144
+ { name: "layout.state", needs: "ui.mount" },
145
+ { name: "layout.set", needs: "ui.mount" },
146
+ ],
147
+ name: "ui.mount",
148
+ requires: [],
149
+ residency: "live",
150
+ risky: false,
151
+ },
152
+ {
153
+ label: "Show file pickers and confirmations",
154
+ methods: [
155
+ { name: "dialogs.open", needs: "ui.dialog" },
156
+ { name: "dialogs.confirm", needs: "ui.dialog" },
157
+ ],
158
+ name: "ui.dialog",
159
+ requires: [],
160
+ residency: "none",
161
+ risky: false,
162
+ },
163
+ {
164
+ label: "Install, uninstall and disable other plugins",
165
+ methods: [{ name: "packages.*", needs: "plugins.manage" }],
166
+ name: "plugins.manage",
167
+ requires: [],
168
+ residency: "none",
169
+ risky: true,
170
+ },
171
+ {
172
+ label: "Process the picture (filters, shaders)",
173
+ methods: [
174
+ {
175
+ name: "processing.replace",
176
+ needs: {
177
+ cases: { video: "processing.video" },
178
+ otherwise: "processing.audio",
179
+ param: "stage",
180
+ },
181
+ },
182
+ {
183
+ name: "processing.operation",
184
+ needs: {
185
+ cases: { video: "processing.video" },
186
+ otherwise: "processing.audio",
187
+ param: "stage",
188
+ },
189
+ },
190
+ { name: "video.view.set", needs: "processing.video" },
191
+ { name: "video.view.release", needs: "processing.video" },
192
+ { name: "video.render.set", needs: "processing.video" },
193
+ { name: "video.render.release", needs: "processing.video" },
194
+ ],
195
+ name: "processing.video",
196
+ requires: ["playback.read"],
197
+ residency: "live",
198
+ risky: false,
199
+ },
200
+ {
201
+ label: "Process the sound",
202
+ methods: [],
203
+ name: "processing.audio",
204
+ requires: ["playback.read", "events"],
205
+ residency: "live",
206
+ risky: false,
207
+ },
208
+ {
209
+ label: "Read subtitles",
210
+ methods: [{ name: "subtitles.state", needs: "subtitles.read" }],
211
+ name: "subtitles.read",
212
+ requires: [],
213
+ residency: "none",
214
+ risky: false,
215
+ },
216
+ {
217
+ label: "Load and change subtitles",
218
+ methods: [{ name: "subtitles.*", needs: "subtitles.control" }],
219
+ name: "subtitles.control",
220
+ requires: [],
221
+ residency: "live",
222
+ risky: false,
223
+ },
224
+ {
225
+ label: "Take snapshots and export clips",
226
+ methods: [
227
+ { name: "snapshot.state", needs: "media.read" },
228
+ { name: "jobs.state", needs: "media.read" },
229
+ { name: "jobs.operation", needs: "media.read" },
230
+ ],
231
+ name: "media.read",
232
+ requires: ["media.control"],
233
+ residency: "none",
234
+ risky: false,
235
+ },
236
+ {
237
+ label: "Take snapshots and export clips",
238
+ methods: [
239
+ { name: "snapshot.*", needs: "media.control" },
240
+ { name: "jobs.*", needs: "media.control" },
241
+ ],
242
+ name: "media.control",
243
+ requires: ["media.read"],
244
+ residency: "live",
245
+ risky: false,
246
+ },
247
+ {
248
+ label: "Show messages on the video",
249
+ methods: [
250
+ { name: "osd.show", needs: "ui.osd" },
251
+ { name: "osd.panel", needs: "ui.osd" },
252
+ ],
253
+ name: "ui.osd",
254
+ requires: [],
255
+ residency: "live",
256
+ risky: false,
257
+ },
258
+ {
259
+ label: "Read and write the clipboard",
260
+ methods: [
261
+ { name: "clipboard.readText", needs: "clipboard" },
262
+ { name: "clipboard.writeText", needs: "clipboard" },
263
+ { name: "clipboard.writeImage", needs: "clipboard" },
264
+ ],
265
+ name: "clipboard",
266
+ requires: [],
267
+ residency: "none",
268
+ risky: true,
269
+ },
270
+ {
271
+ label: "Adjust the main window (size, on top, picture-in-picture)",
272
+ methods: [
273
+ { name: "window.setVideoArea", needs: "window.control" },
274
+ { name: "window.setAlwaysOnTop", needs: "window.control" },
275
+ { name: "window.setCursorAutoHide", needs: "window.control" },
276
+ { name: "window.setPictureInPicture", needs: "window.control" },
277
+ ],
278
+ name: "window.control",
279
+ requires: [],
280
+ residency: "live",
281
+ risky: false,
282
+ },
283
+ {
284
+ label: "Change all shortcuts",
285
+ methods: [
286
+ { name: "bindings.list", needs: "bindings.manage" },
287
+ { name: "bindings.set", needs: "bindings.manage" },
288
+ { name: "bindings.reset", needs: "bindings.manage" },
289
+ ],
290
+ name: "bindings.manage",
291
+ requires: [],
292
+ residency: "none",
293
+ risky: true,
294
+ },
295
+ {
296
+ label: "Show marks on the progress bar",
297
+ methods: [{ name: "timeline.set", needs: "timeline.marks" }],
298
+ name: "timeline.marks",
299
+ requires: [],
300
+ residency: "live",
301
+ risky: false,
302
+ },
303
+ {
304
+ label: "Receive files dropped on the player",
305
+ methods: [
306
+ { name: "drops.provide", needs: "ui.drop" },
307
+ { name: "drops.withdraw", needs: "ui.drop" },
308
+ { name: "drops.listen", needs: "ui.drop" },
309
+ ],
310
+ name: "ui.drop",
311
+ requires: [],
312
+ residency: "recordable",
313
+ risky: false,
314
+ },
315
+ {
316
+ label: "Add pages to the Options window",
317
+ methods: [{ name: "settings.*", needs: "ui.settings" }],
318
+ name: "ui.settings",
319
+ requires: [],
320
+ residency: "recordable",
321
+ risky: false,
322
+ },
323
+ {
324
+ label: "Change the player's palette",
325
+ methods: [
326
+ { name: "appearance.set", needs: "ui.theme" },
327
+ { name: "appearance.setLanguage", needs: "ui.theme" },
328
+ ],
329
+ name: "ui.theme",
330
+ requires: [],
331
+ residency: "none",
332
+ risky: false,
333
+ },
334
+ {
335
+ label: "Store sign-in details in the system keychain",
336
+ methods: [
337
+ { name: "secrets.get", needs: "secrets" },
338
+ { name: "secrets.set", needs: "secrets" },
339
+ { name: "secrets.delete", needs: "secrets" },
340
+ ],
341
+ name: "secrets",
342
+ requires: [],
343
+ residency: "none",
344
+ risky: true,
345
+ },
346
+ ],
347
+ uncapable: [
348
+ { name: "packages.list", needs: null },
349
+ { name: "window.state", needs: null },
350
+ { name: "system.reveal", needs: null },
351
+ { name: "system.openPath", needs: null },
352
+ { name: "system.openUrl", needs: null },
353
+ { name: "app.system", needs: null },
354
+ { name: "ui.markdownScan", needs: null },
355
+ { name: "storage.session.get", needs: null },
356
+ { name: "storage.session.set", needs: null },
357
+ { name: "storage.session.delete", needs: null },
358
+ { name: "storage.session.keys", needs: null },
359
+ ],
360
+ };
@@ -12,6 +12,7 @@ export type { PluginInstance, PluginManifest } from "./plugin/index.ts";
12
12
  export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.ts";
15
+ export { HOST_CAPABILITIES } from "./host-capabilities.ts";
15
16
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
16
17
  export { BINDING_INPUTS } from "./plugin/bindings.ts";
17
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
@@ -10,6 +10,7 @@
10
10
  export { createPlugin } from "./plugin/index.js";
11
11
  export { HostError } from "./wire.js";
12
12
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.js";
13
+ export { HOST_CAPABILITIES } from "./host-capabilities.js";
13
14
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
14
15
  export { BINDING_INPUTS } from "./plugin/bindings.js";
15
16
  export { OSD_LIMITS } from "./plugin/osd.js";
@@ -5,8 +5,33 @@
5
5
  */
6
6
  import { failure, sleep, toError } from "./runtime.js";
7
7
  import { abortError } from "./player.js";
8
- /** Frames of audio buffered ahead of the device before rendering pauses. */
8
+ /**
9
+ * Frames of audio buffered ahead of the device before rendering pauses
10
+ * (50 ms). Each pump refills the whole lead, so it must outlast one pump
11
+ * period: the pump itself (a `producer.state` round trip and the frames),
12
+ * plus a Windows timer tick (15.6 ms) unless the pump rendered and already
13
+ * took {@link PERIOD}. Measured with a
14
+ * real-time clock, round trips up to about 30 ms never let the device run
15
+ * dry; near 50 ms they do now and then. A larger lead would add latency
16
+ * between input and picture.
17
+ */
9
18
  const AHEAD = 2400;
19
+ /**
20
+ * At most this many frames per pump. A lead is a few frames (three of
21
+ * 1/60 s); the cap only stops a plugin whose frames carry next to no audio
22
+ * from rendering without pause.
23
+ */
24
+ const BATCH = 16;
25
+ /**
26
+ * Milliseconds from the start of one pump to the start of the next. A pump
27
+ * that rendered frames and already took longer is followed at once: a timer
28
+ * on top would add a whole Windows tick (15.6 ms; a timer there never fires
29
+ * sooner) to every pump while playing, and the period must stay well inside
30
+ * the lead. A pump that rendered nothing (paused, lead full, a control not
31
+ * yet acknowledged) waits this long after it ends, so an idle stream polls
32
+ * the player no more often than it always did.
33
+ */
34
+ const PERIOD = 5;
10
35
  /** Frames written before the host is asked to select the stream. */
11
36
  const PREROLL = 2048;
12
37
  class StreamHandle {
@@ -23,6 +48,7 @@ class StreamHandle {
23
48
  #sample;
24
49
  #sequence;
25
50
  #timer;
51
+ #immediate;
26
52
  #pumping = Promise.resolve();
27
53
  constructor(scope, service, setup) {
28
54
  this.#scope = scope;
@@ -48,6 +74,7 @@ class StreamHandle {
48
74
  }
49
75
  async #pump() {
50
76
  const producer = this.#service.runtime.host.producer;
77
+ const begun = performance.now();
51
78
  try {
52
79
  const state = await producer.state(this.#producer);
53
80
  if (!this.#running)
@@ -67,16 +94,36 @@ class StreamHandle {
67
94
  if (state.input?.pending)
68
95
  await producer.ackInput(state.input.pending);
69
96
  const paused = control ? control.paused : state.paused;
97
+ let rendered = 0;
98
+ // Refill the whole lead now, not one frame per pump: a pump period (a
99
+ // round trip, and a timer tick after a short pump: 16–40 ms on Windows)
100
+ // is longer than a 1/60 s frame. No control lands while this loop runs: the next one is
101
+ // read by the next pump, at a frame boundary.
70
102
  if (!control &&
71
103
  state.acknowledged &&
72
104
  state.epoch === this.#epoch &&
73
- !paused &&
74
- this.#sample - state.consumedFrames < AHEAD)
75
- await this.#render();
76
- if (this.#running)
77
- this.#timer = setTimeout(() => {
78
- this.#pumping = this.#pump();
79
- }, 5);
105
+ !paused)
106
+ while (this.#running &&
107
+ rendered < BATCH &&
108
+ this.#sample - state.consumedFrames < AHEAD) {
109
+ const before = this.#sample;
110
+ await this.#render();
111
+ // A frame without audio does not move the clock: wait for the next
112
+ // pump, after a full PERIOD (it does not count as rendered).
113
+ if (this.#sample === before)
114
+ break;
115
+ rendered++;
116
+ }
117
+ if (!this.#running)
118
+ return;
119
+ const next = () => {
120
+ this.#pumping = this.#pump();
121
+ };
122
+ const wait = rendered ? PERIOD - (performance.now() - begun) : PERIOD;
123
+ if (wait > 0)
124
+ this.#timer = setTimeout(next, wait);
125
+ else
126
+ this.#immediate = setImmediate(next);
80
127
  }
81
128
  catch (error) {
82
129
  const retired = await retirement(this.#service, this.#generation, error);
@@ -114,6 +161,7 @@ class StreamHandle {
114
161
  this.#stopping ??= (async () => {
115
162
  this.#running = false;
116
163
  clearTimeout(this.#timer);
164
+ clearImmediate(this.#immediate);
117
165
  await this.#pumping.catch(() => { });
118
166
  try {
119
167
  await this.#service.closeIfCurrent(this.#generation);
@@ -4390,7 +4390,8 @@ export interface SubtitleConditional {
4390
4390
  }
4391
4391
  /**
4392
4392
  * `plugin.subtitles`: tracks, uploads and style. Reading needs
4393
- * `subtitles.read`, changes need `subtitles.control`.
4393
+ * `subtitles.read`; changes need `subtitles.control` and also
4394
+ * `subtitles.read`, since they read the current selection first.
4394
4395
  *
4395
4396
  * @example Load an SRT file as the secondary subtitle
4396
4397
  * ```ts
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The TypeScript language-service plugin `@vestra-player/sdk/ts-plugin`: the
3
+ * capability checker of `vestra-plugin check` in the editor. A use of an API
4
+ * member whose capability `manifest.json` does not declare is an error at
5
+ * the member, with the quick fix "Add '<capability>' to manifest.json".
6
+ *
7
+ * A project enables it in its tsconfig.json:
8
+ *
9
+ * { "compilerOptions": { "plugins": [{ "name": "@vestra-player/sdk/ts-plugin" }] } }
10
+ *
11
+ * The editor must run the project's own TypeScript (VS Code: "TypeScript:
12
+ * Select TypeScript Version" → "Use Workspace Version"); `tsc` ignores
13
+ * language-service plugins, which is why `vestra-plugin check` runs the same
14
+ * checker. The manifest is the nearest `manifest.json` above the file; files
15
+ * in its `tests/`, `node_modules/`, `dist/` and `vendor/` are not checked.
16
+ *
17
+ * @internal
18
+ */
19
+ import type TS from "typescript";
20
+ /** @internal The plugin factory tsserver calls (`ts-plugin/index.js`). */
21
+ export default function init(modules: {
22
+ typescript: typeof TS;
23
+ }): TS.server.PluginModule;