@vestra-player/sdk 1.5.18 → 1.5.21

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.21
4
+
5
+ **Who must change: nobody.** Plugins built on earlier SDKs keep working unchanged; the additions need player 0.5.2. Additive and bug fixes; `apiVersion` stays `vestra/1`.
6
+
7
+ - **`Host.run("fullscreen")`** (player 0.5.2; requested by the playback plugin for a bottom-bar button): toggles the main window's fullscreen exactly like the Toggle Fullscreen command (Enter, F11): in when windowed, out when fullscreen. Read the result from `window.state.fullscreen`. `unavailable` when the player has no window; players before 0.5.2 reject the id with `invalid`, so set `"minPlayerVersion": "0.5.2"`. `HostAction` gains `"fullscreen"`.
8
+ - **Default mouse bindings** (player 0.5.2, user ruling): a left click on the video is Play/Pause (`Mouse:LeftClick`, fired on release) and nothing is bound to a double click; Toggle Fullscreen keeps `Enter`, `Alt+Enter`, `F11`. `BUILTIN_COMMANDS` says so. A plugin that declared `Mouse:LeftClick` itself is shadowed by the player's default, as for any key the player binds; users' own bindings are unchanged.
9
+ - **`ToolTipArea` no longer swallows a press** (`slint/vestra/widgets.slint`, the player's widget library; bug fix): the tip was a `PopupWindow`, so while it showed, the first press anywhere only closed it, and it took the keyboard focus. It is now Slint's input-transparent tooltip: every press reaches the control under the pointer. It shows after about half a second (was 600 ms), and `showing` follows that delay closely. No API change.
10
+ - **Icons `fullscreen` and `exit-fullscreen`** in `slint/vestra/icons.slint` (14 × 12, the V-Line style).
11
+ - **`TestHost`**: `host.run("fullscreen")` flips `host.mainWindow.fullscreen` and emits `window.state` (recorded in `coreRuns`); `host/play-pause` declares `Space` and `Mouse:LeftClick`.
12
+ - Docs: *Commands* (the player's mouse defaults), *Playback* (a fullscreen button), the Theme and widgets example (the tooltip); generated tables and `llms.txt` / `llms-full.txt` regenerated.
13
+
14
+ ## 1.5.20
15
+
16
+ Republishes 1.5.19, which was tagged but never published: its API documentation build failed (an example title and four undocumented `IMAGE_LIMITS` fields). Documentation only; the code is the same as 1.5.19 below.
17
+
18
+ ## 1.5.19
19
+
20
+ Images in list rows, restarting a plugin, and keyframe step seeks (player 0.5.1). Additive; `apiVersion` stays `vestra/1`, and plugins that do not use the new members need no change. Requested by the plugin store (STORE-ROW-LOGO, STORE-RELOAD-BUILTIN) and the playback plugin (keyframe seeks); the online video plugins' search results need the same row images.
21
+
22
+ - **`ImageRef` at any depth** (`PropData` includes `ImageRef`): an image from `ui.image` goes wherever the Slint type is `image`, also a field of a struct or a row of a model, for example `rows: [{ key, name, icon: plugin.ui.image(path) }]` with `struct Row { key: string, name: string, icon: image }`. Players before 0.5.1 refuse a nested image with `invalid`, as the SDK did until now. A nested image whose file fails the player's checks is drawn empty and the rest of the value is shown; a released image is refused at any depth. Rows with images update in place in the player (a `ListView` keeps its scroll position).
23
+ - **`ui.image(path, { maxSide })`** and the new **`ImageOptions`**: decode the image scaled down, aspect kept, so that neither side is over `maxSide` (1–4096). Use about twice the drawn size for logos and thumbnails. Players before 0.5.1 refuse a reference with `maxSide`; without it the wire form is unchanged.
24
+ - **Per-view image budget** (player 0.5.1): the images nested in one view's values may show at most 32 MB of decoded pixels together; a write past it is refused with `limit`. **`IMAGE_LIMITS`** (`@vestra-player/sdk/runtime` and `/testing`) lists the player's image limits.
25
+ - **`ui.remoteImage(url, options?)`**: downloads an `https:` PNG or JPEG into `<dataDirectory>/.vestra-cache/images/` with the Markdown image rules (4 MB, 10 s, 4 at a time per plugin, a 32 MB cache refreshed after a day) and resolves with an `ImageRef`; rejects `unavailable` when the download fails. Works with any player; nested, with 0.5.1.
26
+ - **`Packages.reload(id)`** (`plugins.manage`, player 0.5.1): stop a plugin and start it again from the same package with the same data, built-ins included; a stopped or failed one is just started. The disabled flag is never written. `invalid` for oneself or a disabled plugin, `missing` unknown, `busy` while it is being installed or reloaded; `packages.onChange` shows `starting`, then `running` or `failed`. Listed in `CAPABILITY_INFO["plugins.manage"].apis`.
27
+ - **`Player.setStepSeek({ seconds, keyframes })`** (player 0.5.1): with `keyframes: true` the step seeks land on a keyframe (forward at or after position + step, backward at or before position − step, always moving), presses during a seek add up from the landed position, and the on-screen text shows the landed time. Sources without moving video seek exactly; the seek bar, `player.seek`, scrubs, chapters, loops and `player.step` are unaffected. A non-boolean `keyframes` is `invalid`.
28
+ - **`Host.run("seek-backward" | "seek-forward", { seconds })`** (player 0.5.1): one step seek of another step, with the core's checks, mode, accumulation and text. In keyframe mode it resolves once the seek has landed. `seconds` with another action, or outside 0.1–3600 in tenths, is `invalid`. Older players ignore `seconds`: set `"minPlayerVersion": "0.5.1"`.
29
+ - **`TestHost`**: nested images are recorded in `host.images` (with `maxSide`); `ui.remoteImage` is served from `host.remoteImages`; `packages.reload` is modelled (`starting`, then `running`; `packageCalls` method `"reload"`); `stepSeekKeyframes`, the test data `keyframes` and the log `coreSeeks` model keyframe step seeks, and `host.run` takes `seconds`. The test host lands each press at once (the player completes presses that add up together, with one text).
30
+ - Docs: *Windows, panels, settings pages* (images in rows), *Playback* (Step seeks: exact or keyframe); `llms.txt` and `llms-full.txt` regenerated.
31
+
3
32
  ## 1.5.18
4
33
 
5
34
  Fixes for Windows and for a vendored SDK, and built-ins that can no longer be disabled. `apiVersion` stays `vestra/1`; plugins that do not manage other plugins need no change.
@@ -131,6 +131,7 @@ export const CAPABILITY_INFO = Object.freeze({
131
131
  "Packages.validate",
132
132
  "Packages.validateArchive",
133
133
  "Packages.setEnabled",
134
+ "Packages.reload",
134
135
  "Packages.uninstall",
135
136
  ],
136
137
  requires: [],
package/dist/commands.js CHANGED
@@ -142,7 +142,7 @@ vestra.playback | reopen | Reopen Current/Last File | Ctrl+Y
142
142
  vestra.playback | copy-name | Copy to Clipboard ▸ File Name
143
143
  vestra.playback | copy-folder | Copy to Clipboard ▸ Folder Path
144
144
  vestra.playback | copy-path | Copy to Clipboard ▸ Full Path
145
- host | play-pause | Playback ▸ Play/Pause | Space
145
+ host | play-pause | Playback ▸ Play/Pause | Space, Mouse:LeftClick
146
146
  host | previous | Playback ▸ Previous | PageUp, Mouse:X1
147
147
  host | next | Playback ▸ Next | PageDown, Mouse:X2
148
148
  host | frame-back | Playback ▸ Seek ▸ Previous Frame | D
@@ -372,7 +372,7 @@ vestra.visualizer | audio-cover | Audio ▸ Show Album Cover for Audio Only
372
372
  vestra.nes | nes.open | Emulator ▸ Open NES ROM…
373
373
  vestra.nes | nes.stop | Emulator ▸ Stop NES
374
374
  vestra.nes | nes.window | Emulator ▸ NES Tools…
375
- host | fullscreen | Toggle Fullscreen | Enter, Alt+Enter, F11, Mouse:LeftDoubleClick
375
+ host | fullscreen | Toggle Fullscreen | Enter, Alt+Enter, F11
376
376
  vestra.window | pip | Screen ▸ Picture-in-Picture
377
377
  vestra.window | mini | Screen ▸ Mini Size | \`
378
378
  vestra.window | size.0.5 | Screen ▸ 0.5× | 1
@@ -401,7 +401,6 @@ vestra.preferences | options | Settings ▸ Options… | F5
401
401
  vestra.hotkeys | keys | Settings ▸ Keyboard Shortcuts…
402
402
  vestra.hotkeys | mouse | Settings ▸ Mouse…
403
403
  vestra.hotkeys | ignore-defaults | Settings ▸ Ignore Default Shortcuts
404
- vestra.plugins | manage | Settings ▸ Plugin Manager…
405
404
  vestra.preferences | theme.vestra | Settings ▸ Appearance ▸ Vestra Dark
406
405
  vestra.preferences | theme.vestra-light | Settings ▸ Appearance ▸ Vestra Light
407
406
  vestra.visualizer | idle-logo | Settings ▸ Appearance ▸ Show Logo When Idle
@@ -20,4 +20,4 @@ export { THEME_PALETTES } from "./palettes.ts";
20
20
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.ts";
21
21
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
22
22
  export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
23
- export { UI_VALUE_LIMITS } from "./plugin/values.ts";
23
+ export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.ts";
@@ -18,4 +18,4 @@ export { THEME_PALETTES } from "./palettes.js";
18
18
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.js";
19
19
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
20
20
  export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
21
- export { UI_VALUE_LIMITS } from "./plugin/values.js";
21
+ export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.js";
@@ -1,4 +1,5 @@
1
1
  import type { Json } from "../wire.ts";
2
+ import type { Runtime } from "./runtime.ts";
2
3
  import type { ScopeImpl } from "./scope.ts";
3
4
  import type { MarkdownOptions, MarkdownRef } from "./types.ts";
4
5
  export declare const MARKDOWN_LIMITS: Readonly<{
@@ -37,4 +38,10 @@ export declare function httpsDownload(url: string, options: {
37
38
  maxBytes: number;
38
39
  signal: AbortSignal;
39
40
  }, fetchImpl?: typeof fetch): Promise<Uint8Array>;
41
+ /**
42
+ * `ui.remoteImage`: the local copy of an `https:` image in
43
+ * `<dataDirectory>/.vestra-cache/images/` (the Markdown limits: 4 MB, 10 s,
44
+ * 4 at a time per plugin, 32 MB of cache, a day). Rejects `unavailable`.
45
+ */
46
+ export declare function remoteImageFile(runtime: Runtime, url: string): Promise<string>;
40
47
  export declare function markdown(scope: ScopeImpl, text: string, options?: MarkdownOptions): Promise<MarkdownRef>;
@@ -113,33 +113,12 @@ async function fetchImages(runtime, urls) {
113
113
  return {};
114
114
  const cache = join(data, ".vestra-cache", "markdown");
115
115
  await mkdir(cache, { recursive: true });
116
- const download = runtime.local?.download ?? httpsDownload;
117
116
  const found = {};
118
117
  const queue = [...new Set(urls)].filter((u) => u.startsWith("https://"));
119
118
  const worker = async () => {
120
119
  for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
121
- const file = join(cache, createHash("sha256").update(url).digest("hex"));
122
120
  try {
123
- const info = await stat(file).catch(() => null);
124
- const now = Date.now();
125
- if (info && now - info.mtimeMs < MARKDOWN_LIMITS.cacheMaxAgeMs) {
126
- const time = new Date(info.mtimeMs);
127
- await utimes(file, new Date(now), time).catch(() => { });
128
- found[url] = file;
129
- continue;
130
- }
131
- const bytes = await download(url, {
132
- maxBytes: MARKDOWN_LIMITS.imageBytes,
133
- signal: AbortSignal.timeout(MARKDOWN_LIMITS.imageTimeoutMs),
134
- });
135
- if (bytes.length > MARKDOWN_LIMITS.imageBytes)
136
- throw new Error("image too large");
137
- if (!isPng(bytes) && !isJpeg(bytes))
138
- throw new Error("not a PNG or JPEG image");
139
- const temporary = `${file}.${process.pid}.tmp`;
140
- await writeFile(temporary, bytes);
141
- await rename(temporary, file);
142
- found[url] = file;
121
+ found[url] = (await cached(runtime, cache, url)).file;
143
122
  }
144
123
  catch {
145
124
  // Shown as its alt text.
@@ -150,6 +129,67 @@ async function fetchImages(runtime, urls) {
150
129
  await trim(cache, new Set(Object.values(found)));
151
130
  return found;
152
131
  }
132
+ /**
133
+ * The cached copy of `url` in `cache`, downloaded when missing or older than
134
+ * a day; throws why it could not be had. `fresh`: it was downloaded now.
135
+ */
136
+ async function cached(runtime, cache, url) {
137
+ const file = join(cache, createHash("sha256").update(url).digest("hex"));
138
+ const info = await stat(file).catch(() => null);
139
+ const now = Date.now();
140
+ if (info && now - info.mtimeMs < MARKDOWN_LIMITS.cacheMaxAgeMs) {
141
+ const time = new Date(info.mtimeMs);
142
+ await utimes(file, new Date(now), time).catch(() => { });
143
+ return { file, fresh: false };
144
+ }
145
+ const download = runtime.local?.download ?? httpsDownload;
146
+ const bytes = await download(url, {
147
+ maxBytes: MARKDOWN_LIMITS.imageBytes,
148
+ signal: AbortSignal.timeout(MARKDOWN_LIMITS.imageTimeoutMs),
149
+ });
150
+ if (bytes.length > MARKDOWN_LIMITS.imageBytes)
151
+ throw new Error("image too large");
152
+ if (!isPng(bytes) && !isJpeg(bytes))
153
+ throw new Error("not a PNG or JPEG image");
154
+ const temporary = `${file}.${process.pid}.tmp`;
155
+ await writeFile(temporary, bytes);
156
+ await rename(temporary, file);
157
+ return { file, fresh: true };
158
+ }
159
+ /** `ui.remoteImage` downloads in flight and waiting, per plugin. */
160
+ const slots = new WeakMap();
161
+ /**
162
+ * `ui.remoteImage`: the local copy of an `https:` image in
163
+ * `<dataDirectory>/.vestra-cache/images/` (the Markdown limits: 4 MB, 10 s,
164
+ * 4 at a time per plugin, 32 MB of cache, a day). Rejects `unavailable`.
165
+ */
166
+ export async function remoteImageFile(runtime, url) {
167
+ const data = runtime.dataDirectory;
168
+ if (!data)
169
+ throw failure("unavailable", "the plugin has no data directory");
170
+ const cache = join(data, ".vestra-cache", "images");
171
+ let slot = slots.get(runtime);
172
+ if (!slot)
173
+ slots.set(runtime, (slot = { active: 0, waiting: [] }));
174
+ const queue = slot;
175
+ if (queue.active >= MARKDOWN_LIMITS.concurrency)
176
+ await new Promise((go) => queue.waiting.push(go));
177
+ queue.active += 1;
178
+ try {
179
+ await mkdir(cache, { recursive: true });
180
+ const { file, fresh } = await cached(runtime, cache, url);
181
+ if (fresh)
182
+ await trim(cache, new Set([file]));
183
+ return file;
184
+ }
185
+ catch (error) {
186
+ throw failure("unavailable", `image ${url}: ${error instanceof Error ? error.message : String(error)}`);
187
+ }
188
+ finally {
189
+ queue.active -= 1;
190
+ queue.waiting.shift()?.();
191
+ }
192
+ }
153
193
  /** Keep the cache under its size: drop the least recently used files not in use. */
154
194
  async function trim(cache, keep) {
155
195
  const files = [];
@@ -141,6 +141,11 @@ export const packages = (scope) => ({
141
141
  throw failure("invalid", "setEnabled takes an id and a boolean");
142
142
  await scope.runtime.host.packages.setEnabled(id, enabled);
143
143
  },
144
+ async reload(id) {
145
+ if (typeof id !== "string" || !id)
146
+ throw failure("invalid", "reload takes a plugin id");
147
+ await scope.runtime.host.packages.reload(id);
148
+ },
144
149
  uninstall: (id, options = {}) => change(scope, (requestId, revision) => scope.runtime.host.packages.uninstall(id, options.removeData === true, revision, requestId), id),
145
150
  });
146
151
  const info = (plugin) => Object.freeze({
@@ -4,6 +4,8 @@ import type { Runtime } from "./runtime.ts";
4
4
  import type { ScopeImpl } from "./scope.ts";
5
5
  import type { Conditional, Disposable, Player, PlayerState, PluginError } from "./types.ts";
6
6
  export declare const rawOf: (state: PlayerState) => SourceState | undefined;
7
+ /** A step-seek length the player accepts: 0.1–3600 seconds in tenths. */
8
+ export declare const validStep: (seconds: unknown) => seconds is number;
7
9
  export declare const sameStamp: (a: SourceStamp, b: SourceStamp) => boolean;
8
10
  export declare function abortError(): PluginError;
9
11
  /** One per plugin: the state store and the operation book client. */
@@ -88,6 +88,12 @@ function decoding(hwdec) {
88
88
  return hwdec === "no" ? "software" : "hardware";
89
89
  }
90
90
  export const rawOf = (state) => raws.get(state);
91
+ /** A step-seek length the player accepts: 0.1–3600 seconds in tenths. */
92
+ export const validStep = (seconds) => typeof seconds === "number" &&
93
+ Number.isFinite(seconds) &&
94
+ seconds >= 0.1 &&
95
+ seconds <= 3600 &&
96
+ Math.abs(Math.round(seconds * 10) - seconds * 10) <= 1e-9;
91
97
  export const sameStamp = (a, b) => a.generation === b.generation && a.intentVersion === b.intentVersion;
92
98
  function sourceFailure(error) {
93
99
  return typeof error === "string"
@@ -529,13 +535,12 @@ export function player(scope) {
529
535
  .refresh()
530
536
  .catch(() => { });
531
537
  },
532
- async setStepSeek({ seconds }) {
533
- if (!Number.isFinite(seconds) ||
534
- seconds < 0.1 ||
535
- seconds > 3600 ||
536
- Math.abs(Math.round(seconds * 10) - seconds * 10) > 1e-9)
538
+ async setStepSeek({ seconds, keyframes }) {
539
+ if (!validStep(seconds))
537
540
  throw failure("invalid", "seconds must be 0.1–3600 in tenths");
538
- await scope.runtime.host.step.setStepSeek(seconds);
541
+ if (keyframes !== undefined && typeof keyframes !== "boolean")
542
+ throw failure("invalid", "keyframes must be a boolean");
543
+ await scope.runtime.host.step.setStepSeek(seconds, keyframes);
539
544
  },
540
545
  async audioDevices() {
541
546
  const devices = await scope.runtime.host.audioOutput.devices();
@@ -8,7 +8,7 @@ import { mkdir, open, rename, rm, stat } from "node:fs/promises";
8
8
  import { join, resolve } from "node:path";
9
9
  import { failure } from "./runtime.js";
10
10
  import { systemServices } from "./local.js";
11
- import { playerOf } from "./player.js";
11
+ import { playerOf, validStep } from "./player.js";
12
12
  const MAX_OUTPUT = 16 * 1024 * 1024;
13
13
  export const clipboard = (scope) => ({
14
14
  readText: () => scope.runtime.host.clipboard.readText(),
@@ -30,12 +30,20 @@ const ACTIONS = new Set([
30
30
  "volume-up",
31
31
  "volume-down",
32
32
  "mute",
33
+ "fullscreen",
33
34
  ]);
34
35
  export const host = (scope) => ({
35
- async run(action) {
36
+ async run(action, options) {
36
37
  if (!ACTIONS.has(action))
37
38
  throw failure("invalid", `unknown host action ${String(action)}`);
38
- await scope.runtime.host.host.run(action);
39
+ const seconds = options?.seconds;
40
+ if (options !== undefined) {
41
+ if (action !== "seek-backward" && action !== "seek-forward")
42
+ throw failure("invalid", `seconds applies only to seek-backward and seek-forward, not ${action}`);
43
+ if (!validStep(seconds))
44
+ throw failure("invalid", "seconds must be 0.1–3600 in tenths");
45
+ }
46
+ await scope.runtime.host.host.run(action, seconds);
39
47
  // Like the player's own controls: `player.state` shows the result.
40
48
  if (scope.runtime.can("playback.read"))
41
49
  await playerOf(scope.runtime)
@@ -10,7 +10,7 @@ import { OSD_LIMITS } from "./osd.ts";
10
10
  import { SECRET_LIMITS } from "./services.ts";
11
11
  import { BINDING_INPUTS } from "./bindings.ts";
12
12
  import { THEME_PALETTES } from "../palettes.ts";
13
- import { UI_VALUE_LIMITS } from "./values.ts";
13
+ import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
14
14
  import type { CommandDeclaration } from "../commands.ts";
15
15
  import type { SourceState } from "../source.ts";
16
16
  import type { SubtitleState } from "../subtitles.ts";
@@ -39,7 +39,7 @@ export { BINDING_INPUTS };
39
39
  /** The colours of the player's palettes, by `Theme` token. */
40
40
  export { THEME_PALETTES };
41
41
  /** The player's property value limits, which the test host enforces as well. */
42
- export { UI_VALUE_LIMITS };
42
+ export { IMAGE_LIMITS, UI_VALUE_LIMITS };
43
43
  /**
44
44
  * The player's limits on one text value ({@link Text}, {@link TextList},
45
45
  * with everything nested in it), which the test host enforces as well:
@@ -618,14 +618,19 @@ export declare class TestHost implements Transport {
618
618
  title?: string | null;
619
619
  }[]): void;
620
620
  /**
621
- * Runtime images written to views (`plugin.ui.image`), newest last. The
622
- * SDK refuses images of released scopes before anything is sent.
621
+ * Runtime images written to views (`plugin.ui.image`,
622
+ * `plugin.ui.remoteImage`), whole values and (SDK 1.5.19) images nested
623
+ * in rows, newest last. The SDK refuses images of released scopes before
624
+ * anything is sent. The test host does not read the files, so it does not
625
+ * check the player's per-view budget (`IMAGE_LIMITS.viewBytes`).
623
626
  */
624
627
  readonly images: {
625
628
  /** The image file's path, as given to `ui.image`. */
626
629
  path: string;
627
630
  /** The SDK's reference number for that `ImageRef`. */
628
631
  ref: number;
632
+ /** Its `maxSide` option, when given. */
633
+ maxSide?: number;
629
634
  }[];
630
635
  /**
631
636
  * The file an image property shows, or `null` when the value is not a
@@ -1109,6 +1114,39 @@ export declare class TestHost implements Transport {
1109
1114
  constructor();
1110
1115
  /** The `player.setStepSeek` value in effect (default 5). */
1111
1116
  stepSeek: number;
1117
+ /** The `player.setStepSeek` `keyframes` in effect (default `false`, SDK 1.5.19). */
1118
+ stepSeekKeyframes: boolean;
1119
+ /**
1120
+ * Test data: the keyframe times (seconds, ascending) of the current
1121
+ * source, for keyframe step seeks. `null` (the default): none modelled,
1122
+ * and step seeks are exact even with `stepSeekKeyframes`.
1123
+ *
1124
+ * @example
1125
+ * ```ts
1126
+ * host.setSource({ phase: "ready", duration: 45, position: 12 });
1127
+ * host.keyframes = [0, 10, 20, 30, 40];
1128
+ * await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
1129
+ * await plugin.host.run("seek-forward");
1130
+ * assert.equal(host.source.position, 20);
1131
+ * assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
1132
+ * ```
1133
+ */
1134
+ keyframes: number[] | null;
1135
+ /**
1136
+ * Every step seek the core ran (`seek-backward` / `seek-forward`, from a
1137
+ * key, the menu or `host.run`), newest last: the step, whether it went to
1138
+ * a keyframe, and where it landed.
1139
+ */
1140
+ readonly coreSeeks: {
1141
+ /** `seek-backward` or `seek-forward`. */
1142
+ action: string;
1143
+ /** The step: `host.run`'s `seconds`, else `setStepSeek`'s. */
1144
+ seconds: number;
1145
+ /** It landed on a keyframe (`stepSeekKeyframes` and keyframes modelled). */
1146
+ keyframes: boolean;
1147
+ /** The new position. */
1148
+ landed: number;
1149
+ }[];
1112
1150
  /** Every `player.step`, newest last. */
1113
1151
  readonly steps: {
1114
1152
  /** `"frame"` or `"keyframe"`. */
@@ -1339,7 +1377,7 @@ export declare class TestHost implements Transport {
1339
1377
  /** Every `packages.setEnabled` / `uninstall` / `install`, newest last. */
1340
1378
  readonly packageCalls: {
1341
1379
  /** Which call. */
1342
- method: "setEnabled" | "uninstall" | "install";
1380
+ method: "setEnabled" | "reload" | "uninstall" | "install";
1343
1381
  /** The target plugin id. */
1344
1382
  id: string;
1345
1383
  /** `setEnabled`'s value. */
@@ -1368,9 +1406,10 @@ export declare class TestHost implements Transport {
1368
1406
  */
1369
1407
  readonly candidates: Map<string, PackageManifest>;
1370
1408
  /**
1371
- * Remote Markdown images (`plugin.ui.markdown`): address → a local PNG or
1372
- * JPEG file served for it, or `null` for a failing download. Nothing is
1373
- * fetched from the network in tests; unknown addresses fail.
1409
+ * Remote images (`plugin.ui.markdown`, `plugin.ui.remoteImage`): address
1410
+ * → a local PNG or JPEG file served for it, or `null` for a failing
1411
+ * download. Nothing is fetched from the network in tests; unknown
1412
+ * addresses fail.
1374
1413
  */
1375
1414
  readonly remoteImages: Map<string, string | null>;
1376
1415
  /**
@@ -72,7 +72,8 @@ import { OSD_LIMITS } from "./osd.js";
72
72
  import { SECRET_LIMITS } from "./services.js";
73
73
  import { BINDING_INPUTS } from "./bindings.js";
74
74
  import { THEME_PALETTES } from "../palettes.js";
75
- import { UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
75
+ import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
76
+ import { validStep } from "./player.js";
76
77
  import { nodeProblem } from "../processing.js";
77
78
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
78
79
  import { createPlugin } from "./index.js";
@@ -97,7 +98,7 @@ export { BINDING_INPUTS };
97
98
  /** The colours of the player's palettes, by `Theme` token. */
98
99
  export { THEME_PALETTES };
99
100
  /** The player's property value limits, which the test host enforces as well. */
100
- export { UI_VALUE_LIMITS };
101
+ export { IMAGE_LIMITS, UI_VALUE_LIMITS };
101
102
  /**
102
103
  * The player's limits on one text value ({@link Text}, {@link TextList},
103
104
  * with everything nested in it), which the test host enforces as well:
@@ -625,8 +626,11 @@ export class TestHost {
625
626
  this.emit("timeline.changed", { marks: this.timeline.marks });
626
627
  }
627
628
  /**
628
- * Runtime images written to views (`plugin.ui.image`), newest last. The
629
- * SDK refuses images of released scopes before anything is sent.
629
+ * Runtime images written to views (`plugin.ui.image`,
630
+ * `plugin.ui.remoteImage`), whole values and (SDK 1.5.19) images nested
631
+ * in rows, newest last. The SDK refuses images of released scopes before
632
+ * anything is sent. The test host does not read the files, so it does not
633
+ * check the player's per-view budget (`IMAGE_LIMITS.viewBytes`).
630
634
  */
631
635
  images = [];
632
636
  /**
@@ -700,14 +704,22 @@ export class TestHost {
700
704
  return kept;
701
705
  }
702
706
  #seeImages(values) {
703
- for (const value of Object.values(values)) {
707
+ const see = (value) => {
704
708
  const path = this.imagePath(value);
705
- if (path !== null)
709
+ if (path !== null) {
710
+ const { ref, maxSide } = value;
706
711
  this.images.push({
707
712
  path,
708
- ref: Number(value.ref),
713
+ ref: Number(ref),
714
+ ...(maxSide === undefined ? {} : { maxSide }),
709
715
  });
710
- }
716
+ }
717
+ else if (value !== null && typeof value === "object")
718
+ for (const item of Object.values(value))
719
+ see(item);
720
+ };
721
+ for (const value of Object.values(values))
722
+ see(value);
711
723
  }
712
724
  // ------------------------------------------------------------ round 3b batch 2
713
725
  /** `drops.provide` registrations of this plugin, in order (`id` is the SDK's). */
@@ -1241,6 +1253,30 @@ export class TestHost {
1241
1253
  // ------------------------------------------------------------ core actions
1242
1254
  /** The `player.setStepSeek` value in effect (default 5). */
1243
1255
  stepSeek = 5;
1256
+ /** The `player.setStepSeek` `keyframes` in effect (default `false`, SDK 1.5.19). */
1257
+ stepSeekKeyframes = false;
1258
+ /**
1259
+ * Test data: the keyframe times (seconds, ascending) of the current
1260
+ * source, for keyframe step seeks. `null` (the default): none modelled,
1261
+ * and step seeks are exact even with `stepSeekKeyframes`.
1262
+ *
1263
+ * @example
1264
+ * ```ts
1265
+ * host.setSource({ phase: "ready", duration: 45, position: 12 });
1266
+ * host.keyframes = [0, 10, 20, 30, 40];
1267
+ * await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
1268
+ * await plugin.host.run("seek-forward");
1269
+ * assert.equal(host.source.position, 20);
1270
+ * assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
1271
+ * ```
1272
+ */
1273
+ keyframes = null;
1274
+ /**
1275
+ * Every step seek the core ran (`seek-backward` / `seek-forward`, from a
1276
+ * key, the menu or `host.run`), newest last: the step, whether it went to
1277
+ * a keyframe, and where it landed.
1278
+ */
1279
+ coreSeeks = [];
1244
1280
  /** Every `player.step`, newest last. */
1245
1281
  steps = [];
1246
1282
  /** Every core action run (`host.run`, a core command, a pressed core binding), newest last. */
@@ -1339,7 +1375,7 @@ export class TestHost {
1339
1375
  ["Playback", "Play/Pause"],
1340
1376
  201,
1341
1377
  "domain",
1342
- ["Space"],
1378
+ ["Space", "Mouse:LeftClick"],
1343
1379
  false,
1344
1380
  ],
1345
1381
  [
@@ -1470,7 +1506,7 @@ export class TestHost {
1470
1506
  this.coreCommands.set(`host/${id}`, command);
1471
1507
  }
1472
1508
  }
1473
- #runCore(action) {
1509
+ #runCore(action, seconds) {
1474
1510
  const state = this.#coreState(action);
1475
1511
  if (!state.enabled)
1476
1512
  throw new HostError("unavailable", state.reason ?? "unavailable");
@@ -1512,13 +1548,36 @@ export class TestHost {
1512
1548
  return;
1513
1549
  case "seek-backward":
1514
1550
  case "seek-forward": {
1515
- const delta = action === "seek-forward" ? this.stepSeek : -this.stepSeek;
1551
+ const step = seconds ?? this.stepSeek;
1552
+ const delta = action === "seek-forward" ? step : -step;
1516
1553
  const base = s.position ?? 0;
1517
- const target = Math.max(0, Math.min(s.duration ?? Infinity, base + delta));
1554
+ let target = Math.max(0, Math.min(s.duration ?? Infinity, base + delta));
1555
+ // Keyframe mode, as the player: forward on the first keyframe at or
1556
+ // after the target (none: the exact target), backward on the last at
1557
+ // or before it; only sources with moving video that can seek.
1558
+ const keyframes = this.stepSeekKeyframes &&
1559
+ this.keyframes !== null &&
1560
+ s.hasVideo !== false &&
1561
+ s.capabilities.seek;
1562
+ if (keyframes && target !== base) {
1563
+ const keys = this.keyframes;
1564
+ target =
1565
+ delta > 0
1566
+ ? (keys.find((k) => k >= target) ?? target)
1567
+ : keys
1568
+ .filter((k) => k <= target)
1569
+ .reduce((a, k) => Math.max(a, k), 0);
1570
+ }
1518
1571
  this.#intent({
1519
1572
  kind: "control",
1520
1573
  control: { kind: "seek", seconds: target },
1521
1574
  });
1575
+ this.coreSeeks.push({
1576
+ action,
1577
+ seconds: step,
1578
+ keyframes,
1579
+ landed: target,
1580
+ });
1522
1581
  this.osd.push(this.#hostText(delta < 0 ? "Back {} s: {} / {}" : "Forward {} s: {} / {}", [Math.abs(delta), time(target), time(s.duration)]));
1523
1582
  return;
1524
1583
  }
@@ -1886,9 +1945,10 @@ export class TestHost {
1886
1945
  */
1887
1946
  candidates = new Map();
1888
1947
  /**
1889
- * Remote Markdown images (`plugin.ui.markdown`): address → a local PNG or
1890
- * JPEG file served for it, or `null` for a failing download. Nothing is
1891
- * fetched from the network in tests; unknown addresses fail.
1948
+ * Remote images (`plugin.ui.markdown`, `plugin.ui.remoteImage`): address
1949
+ * → a local PNG or JPEG file served for it, or `null` for a failing
1950
+ * download. Nothing is fetched from the network in tests; unknown
1951
+ * addresses fail.
1892
1952
  */
1893
1953
  remoteImages = new Map();
1894
1954
  /**
@@ -2675,17 +2735,38 @@ export class TestHost {
2675
2735
  "volume-up",
2676
2736
  "volume-down",
2677
2737
  "mute",
2738
+ "fullscreen",
2678
2739
  ];
2679
2740
  if (!actions.includes(String(p.action)))
2680
2741
  throw new HostError("invalid", `unknown host action ${p.action}`);
2681
- this.#runCore(String(p.action));
2742
+ // SDK 1.5.19: one step seek of another step.
2743
+ let seconds;
2744
+ if (p.seconds !== undefined && p.seconds !== null) {
2745
+ if (p.action !== "seek-backward" && p.action !== "seek-forward")
2746
+ throw new HostError("invalid", `seconds applies only to seek-backward and seek-forward, not ${p.action}`);
2747
+ if (!validStep(p.seconds))
2748
+ throw new HostError("invalid", "seconds must be 0.1..3600 in tenths");
2749
+ seconds = p.seconds;
2750
+ }
2751
+ // SDK 1.5.21: the main window's Toggle Fullscreen, like Enter: in when
2752
+ // windowed, out when fullscreen, then `window.state`.
2753
+ if (p.action === "fullscreen") {
2754
+ this.coreRuns.push("fullscreen");
2755
+ this.setMainWindow({ fullscreen: !this.mainWindow.fullscreen });
2756
+ return;
2757
+ }
2758
+ this.#runCore(String(p.action), seconds);
2682
2759
  },
2683
2760
  "player.setStepSeek": (p) => {
2684
- const seconds = Number(p.seconds);
2685
- if (!(seconds >= 0.1 && seconds <= 3600) ||
2686
- Math.abs(Math.round(seconds * 10) - seconds * 10) > 1e-9)
2761
+ if (!validStep(p.seconds))
2687
2762
  throw new HostError("invalid", "seconds must be 0.1..3600 in tenths");
2688
- this.stepSeek = seconds;
2763
+ if (p.keyframes !== undefined &&
2764
+ p.keyframes !== null &&
2765
+ typeof p.keyframes !== "boolean")
2766
+ throw new HostError("invalid", "keyframes must be a boolean");
2767
+ // Written as a pair, as the player does.
2768
+ this.stepSeek = p.seconds;
2769
+ this.stepSeekKeyframes = p.keyframes === true;
2689
2770
  },
2690
2771
  "player.step": (p) => {
2691
2772
  this.#audioCondition(p.expected);
@@ -3174,6 +3255,28 @@ export class TestHost {
3174
3255
  entry.state = p.enabled === true ? "running" : "disabled";
3175
3256
  this.setPackages(this.packages);
3176
3257
  },
3258
+ // As the player (SDK 1.5.19): stop and start again, built-ins included;
3259
+ // `packages.changed` shows `starting`, then `running`.
3260
+ "packages.reload": (p) => {
3261
+ const id = String(p.id);
3262
+ if (id === this.manifestId)
3263
+ throw new HostError("invalid", "a plugin cannot reload itself");
3264
+ const entry = this.packages.find((each) => each.id === id);
3265
+ if (!entry)
3266
+ throw new HostError("missing", `no plugin ${id}`);
3267
+ if (entry.state === "starting")
3268
+ throw new HostError("busy", "the package is being installed or reloaded");
3269
+ if (entry.state === "disabled")
3270
+ throw new HostError("invalid", `${id} is disabled: enable it instead of reloading it`);
3271
+ this.packageCalls.push({ method: "reload", id });
3272
+ entry.state = "starting";
3273
+ entry.error = null;
3274
+ this.setPackages(this.packages);
3275
+ setTimeout(() => {
3276
+ entry.state = "running";
3277
+ this.setPackages(this.packages);
3278
+ }, 0);
3279
+ },
3177
3280
  "packages.validate": (p) => this.#validate(String(p.directory)),
3178
3281
  "packages.validateArchive": (p) => this.#validate(String(p.file)),
3179
3282
  "packages.install": (p) => {