@vestra-player/sdk 1.6.8 → 1.6.9

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.6.9
4
+
5
+ **Who must change: nobody.** Additions only: timeline ranges, OSD messages with action buttons, a limits constant, a wire method and `TestHost` support, and documentation of a bottom bar's `content-full-width`. `apiVersion` stays `vestra/1`, no new capability (`timeline.marks`, `playback.read` and `ui.osd` cover them). A plugin that does not use them sends exactly what it sent before; `onChange` of `plugin.timeline` still runs only when the marks change. One that uses them needs player 0.7.6 (`"minPlayerVersion": "0.7.6"`): the player builds `plugin` from its own SDK, so on older players `setRanges` and `ranges` do not exist (a `TypeError`), and `osd.show` there takes no options.
6
+
7
+ - **Timeline ranges** (player 0.7.6): `TimelineLayer.setRanges(ranges, { ifCurrent? })` puts spans of the media on the seek bar, each `{ start, end, style, title? }` with `style` `"selection"` (a span the user picked to act on, such as a clip to export; drawn in the accent colour) or `"region"` (a part of the media the plugin describes; drawn dimmer). A layer's marks and ranges are replaced on their own (`set` keeps the ranges, `setRanges` keeps the marks); disposing the layer, its scope or stopping the plugin removes both, and a new source clears every layer. At most 100 ranges per layer, `0 ≤ start < end`, `end` within the media, titles ≤ 100 characters (`TIMELINE_LIMITS`, new, with the marks' 1000); failures go to `onError` as for marks. `plugin.timeline.ranges` (new, `playback.read`) is every layer's ranges merged by start, with `TimelineRange` (`start`, `end`, `style`, `title`, `plugin`); read it in a binding to follow it. Wire: `timeline.setRanges` (needs `timeline.marks`), and `ranges` beside `marks` in `timeline.marks` and `timeline.changed`.
8
+ - **OSD messages with actions** (player 0.7.6): `plugin.osd.show(text, args?, { actions })` ends the message in up to two buttons (`OsdAction`: `title`, translated like the text, 1–40 characters; `command`, the local id of one of the plugin's own commands). The message stays about 4 seconds instead of 2 and does not fade while the pointer is on it and moving (it goes 2 seconds after the pointer leaves or rests, and at the latest 30 seconds after it showed); a click runs the command as if picked from a menu and removes the message. It stays one line, and nothing in it is created or removed while the pointer moves. A newer message replaces it with its buttons; the buttons go when the plugin stops, and while they show the plugin is not stopped for being idle. `osd.show` rejects with `invalid` for more than two actions, a bad title or a command the plugin has not declared. `OSD_LIMITS` gains `actions` (2) and `actionChars` (40). Older players ignore `actions`.
9
+ - **`TestHost`**: `timeline.rangeLayers`, `timeline.ranges` and `setTimelineRanges(pluginId, ranges)` (another plugin's ranges); `timeline.setRanges` checked as the player does; `osdActions` (each message's buttons, in step with `osd`) and `pressOsdAction(index)`, which runs the command like a click.
10
+ - **A bottom bar's `content-full-width`** (player 0.7.6, docs only): a bar that drops controls of its own also reports its row with every control shown in `content-full-width`, a public `length` property declared like `content-min-width`. The player then gives plugin buttons only the width beyond it, so every plugin button moves to » before the bar drops a control, and narrowing the window never brings a button back. A bar with only `content-min-width` keeps the earlier rule. [Writing a bottom bar](docs/guidelines/interface.md#writing-a-bottom-bar) and [Reference: theme](docs/reference/theme.md#bar-buttons) say so.
11
+
3
12
  ## 1.6.8
4
13
 
5
14
  **Who must change: nobody; authors of a bottom bar should move to `content-min-width`.** Documentation, a read-only Slint property and a removed CLI warning: no change to the TypeScript API, the wire or `TestHost`, `apiVersion` stays `vestra/1`, no new capability. A plugin's `button` declarations stay as they are. A bottom bar that still places `BarButtons` keeps working on player 0.7.6 (it shows that strip, and the player draws no second one); it is deprecated and goes in a later patch.
@@ -71,8 +71,12 @@ export const API_REQUESTS = Object.freeze({
71
71
  "Subtitles.remove": [{ method: "subtitles.remove" }, readsSubtitles],
72
72
  "Subtitles.setStyle": [{ method: "subtitles.style" }, readsSubtitles],
73
73
  "Timeline.marks": [{ method: "timeline.marks" }],
74
+ "Timeline.ranges": [{ method: "timeline.marks" }],
74
75
  "Timeline.onChange": [listen("timeline.changed")],
75
- "Timeline.layer": [{ method: "timeline.set" }],
76
+ "Timeline.layer": [
77
+ { method: "timeline.set" },
78
+ { method: "timeline.setRanges" },
79
+ ],
76
80
  "MainWindow.onChange": [listen("window.state")],
77
81
  "MainWindow.setVideoArea": [{ method: "window.setVideoArea" }],
78
82
  "MainWindow.setAlwaysOnTop": [{ method: "window.setAlwaysOnTop" }],
Binary file
@@ -300,7 +300,10 @@ export const HOST_CAPABILITIES = {
300
300
  },
301
301
  {
302
302
  label: "Show marks on the progress bar",
303
- methods: [{ name: "timeline.set", needs: "timeline.marks" }],
303
+ methods: [
304
+ { name: "timeline.set", needs: "timeline.marks" },
305
+ { name: "timeline.setRanges", needs: "timeline.marks" },
306
+ ],
304
307
  name: "timeline.marks",
305
308
  requires: [],
306
309
  residency: "live",
@@ -16,6 +16,7 @@ export { HOST_CAPABILITIES } from "./host-capabilities.ts";
16
16
  export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
17
17
  export { BINDING_INPUTS } from "./plugin/bindings.ts";
18
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
19
+ export { TIMELINE_LIMITS } from "./plugin/timeline.ts";
19
20
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.ts";
20
21
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.ts";
21
22
  export { VIDEO_PROCESSING_LIMITS } from "./processing.ts";
@@ -14,6 +14,7 @@ export { HOST_CAPABILITIES } from "./host-capabilities.js";
14
14
  export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
15
15
  export { BINDING_INPUTS } from "./plugin/bindings.js";
16
16
  export { OSD_LIMITS } from "./plugin/osd.js";
17
+ export { TIMELINE_LIMITS } from "./plugin/timeline.js";
17
18
  export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.js";
18
19
  export { SUBTITLE_LIMITS } from "./plugin/subtitles.js";
19
20
  export { VIDEO_PROCESSING_LIMITS } from "./processing.js";
@@ -46,6 +46,10 @@ export declare const OSD_LIMITS: Readonly<{
46
46
  readonly minColumns: 8;
47
47
  /** The largest `OsdPanelOptions.maxColumns` (1000). */
48
48
  readonly maxColumns: 1000;
49
+ /** Action buttons of one `osd.show` message (`OsdShowOptions.actions`; player 0.7.6) (2). */
50
+ readonly actions: 2;
51
+ /** Characters of an action's title, as shown (40; at least 1 that is not white space). */
52
+ readonly actionChars: 40;
49
53
  }>;
50
54
  /**
51
55
  * Copy and check template arguments: an array or object of at most 16
@@ -47,7 +47,32 @@ export const OSD_LIMITS = Object.freeze({
47
47
  minColumns: 8,
48
48
  /** The largest `OsdPanelOptions.maxColumns` (1000). */
49
49
  maxColumns: 1000,
50
+ /** Action buttons of one `osd.show` message (`OsdShowOptions.actions`; player 0.7.6) (2). */
51
+ actions: 2,
52
+ /** Characters of an action's title, as shown (40; at least 1 that is not white space). */
53
+ actionChars: 40,
50
54
  });
55
+ /** Copy and check `OsdShowOptions.actions`: what the SDK can check before the player does. */
56
+ function osdActions(options) {
57
+ if (options === undefined || options === null)
58
+ return undefined;
59
+ if (typeof options !== "object")
60
+ throw failure("invalid", "options is an object");
61
+ const actions = options.actions;
62
+ if (actions === undefined)
63
+ return undefined;
64
+ if (!Array.isArray(actions) || actions.length > OSD_LIMITS.actions)
65
+ throw failure("invalid", `actions is a list of at most ${OSD_LIMITS.actions}`);
66
+ return actions.map((action, i) => {
67
+ const { title, command } = (action ?? {});
68
+ if (typeof command !== "string" || command === "")
69
+ throw failure("invalid", `actions[${i}].command is a command id`);
70
+ return {
71
+ title: shownText(title, undefined, `actions[${i}].title`),
72
+ command,
73
+ };
74
+ });
75
+ }
51
76
  /**
52
77
  * Copy and check template arguments: an array or object of at most 16
53
78
  * strings, finite numbers, texts and lists (`TEXT_LIMITS`).
@@ -134,6 +159,6 @@ class PanelHandle {
134
159
  }
135
160
  }
136
161
  export const osd = (scope) => ({
137
- show: async (text, args) => scope.runtime.host.osd.show(shownText(text, args), templateArgs(args)),
162
+ show: async (text, args, options) => scope.runtime.host.osd.show(shownText(text, args), templateArgs(args), osdActions(options)),
138
163
  panel: () => new PanelHandle(scope),
139
164
  });
@@ -7,6 +7,7 @@ import type { BuiltinCommand, BuiltinSubmenu } from "../commands.ts";
7
7
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.ts";
8
8
  import type { CapabilityInfo } from "../capabilities.ts";
9
9
  import { OSD_LIMITS } from "./osd.ts";
10
+ import { TIMELINE_LIMITS } from "./timeline.ts";
10
11
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.ts";
11
12
  import { SUBTITLE_LIMITS } from "./subtitles.ts";
12
13
  import { BINDING_INPUTS } from "./bindings.ts";
@@ -36,6 +37,8 @@ export type { CapabilityInfo };
36
37
  export { MESSAGE_LIMIT };
37
38
  /** The player's limits on the on-screen display. */
38
39
  export { OSD_LIMITS };
40
+ /** The player's limits on a timeline layer. */
41
+ export { TIMELINE_LIMITS };
39
42
  /** The player's limits on stored secrets. */
40
43
  export { SECRET_LIMITS };
41
44
  /** The player's limits on `plugin.storage.session`. */
@@ -647,6 +650,43 @@ export declare class TestHost implements Transport {
647
650
  * template and args the plugin sent are in `host.sent("osd.show")`.
648
651
  */
649
652
  readonly osd: string[];
653
+ /**
654
+ * The action buttons of each `osd.show` message, in step with `osd`
655
+ * (index for index): each button's title as shown and its command; `[]`
656
+ * for a message without actions (SDK 1.6.9).
657
+ */
658
+ readonly osdActions: {
659
+ /** The button title as shown, in the plugin's locale. */
660
+ title: string;
661
+ /** The local id of the plugin's command the button runs. */
662
+ command: string;
663
+ }[][];
664
+ /**
665
+ * Press button `index` of the newest OSD message, as the user does
666
+ * (player 0.7.6): its command runs as if picked from a menu, and settles.
667
+ * A disabled or busy command does nothing, as in the player.
668
+ *
669
+ * @param index - 0 or 1.
670
+ *
671
+ * @throws `Error` (rejects) The newest message has no button `index`.
672
+ *
673
+ * @example Press "Show in Folder" on the saved message
674
+ * The test checks the button the plugin put on its newest message, then
675
+ * presses it as the user would: the plugin's `reveal` command runs.
676
+ * ```ts
677
+ * import assert from "node:assert/strict";
678
+ * declare const host: TestHost;
679
+ * // ---cut---
680
+ * assert.deepEqual(host.osdActions.at(-1), [
681
+ * { title: "Show in Folder", command: "reveal" },
682
+ * ]);
683
+ * await host.pressOsdAction(0); // runs the plugin's `reveal` command
684
+ * ```
685
+ *
686
+ * @capability None.
687
+ * @since 1.6.9
688
+ */
689
+ pressOsdAction(index: number): Promise<void>;
650
690
  /** The text of this plugin's osd panel as shown, `null` when hidden. */
651
691
  osdPanel: string | null;
652
692
  /**
@@ -948,7 +988,65 @@ export declare class TestHost implements Transport {
948
988
  /** The mark's title, `null` without one. */
949
989
  title: string | null;
950
990
  }[];
991
+ /**
992
+ * Each plugin's ranges by plugin id; layers without ranges are absent
993
+ * (SDK 1.6.9).
994
+ */
995
+ rangeLayers: Map<string, {
996
+ /** Seconds from the start. */
997
+ start: number;
998
+ /** Seconds from the start, after `start`. */
999
+ end: number;
1000
+ /** `"selection"` or `"region"`. */
1001
+ style: string;
1002
+ /** The range's title, `null` without one. */
1003
+ title: string | null;
1004
+ }[]>;
1005
+ /** Every layer's ranges merged, sorted by start, with the plugin's id. */
1006
+ readonly ranges: {
1007
+ /** The manifest id of the plugin that published the range. */
1008
+ plugin: string;
1009
+ /** Seconds from the start. */
1010
+ start: number;
1011
+ /** Seconds from the start, after `start`. */
1012
+ end: number;
1013
+ /** `"selection"` or `"region"`. */
1014
+ style: string;
1015
+ /** The range's title, `null` without one. */
1016
+ title: string | null;
1017
+ }[];
951
1018
  };
1019
+ /**
1020
+ * Simulate another plugin publishing its timeline ranges (or clearing
1021
+ * them with `[]`); pushes `timeline.changed` when the plugin subscribed.
1022
+ *
1023
+ * @param pluginId - The other plugin's manifest id.
1024
+ * @param ranges - All its ranges.
1025
+ *
1026
+ * @example Let another plugin publish a clip range
1027
+ * The plugin under test sees a range another plugin put on the seek bar:
1028
+ * `setTimelineRanges` stores that plugin's ranges and pushes
1029
+ * `timeline.changed`, and `settle()` lets it arrive.
1030
+ * ```ts
1031
+ * import assert from "node:assert/strict";
1032
+ * declare const host: TestHost;
1033
+ * // ---cut---
1034
+ * host.setTimelineRanges("community.clips", [
1035
+ * { start: 40, end: 52.5, style: "selection", title: "Clip" },
1036
+ * ]);
1037
+ * await host.settle();
1038
+ * assert.equal(plugin.timeline.ranges[0]?.plugin, "community.clips");
1039
+ * ```
1040
+ *
1041
+ * @capability None.
1042
+ * @since 1.6.9
1043
+ */
1044
+ setTimelineRanges(pluginId: string, ranges: {
1045
+ start: number;
1046
+ end: number;
1047
+ style: string;
1048
+ title?: string | null;
1049
+ }[]): void;
952
1050
  /**
953
1051
  * Simulate another plugin publishing its timeline layer (or clearing it
954
1052
  * with `[]`); pushes `timeline.changed` when the plugin subscribed.
@@ -76,6 +76,7 @@ import { localSocketPath } from "./local.js";
76
76
  import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
77
77
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
78
78
  import { OSD_LIMITS } from "./osd.js";
79
+ import { TIMELINE_LIMITS } from "./timeline.js";
79
80
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
80
81
  import { SUBTITLE_LIMITS } from "./subtitles.js";
81
82
  import { BINDING_INPUTS, canonical, keyLabel } from "./bindings.js";
@@ -105,6 +106,8 @@ export { CAPABILITY_INFO };
105
106
  export { MESSAGE_LIMIT };
106
107
  /** The player's limits on the on-screen display. */
107
108
  export { OSD_LIMITS };
109
+ /** The player's limits on a timeline layer. */
110
+ export { TIMELINE_LIMITS };
108
111
  /** The player's limits on stored secrets. */
109
112
  export { SECRET_LIMITS };
110
113
  /** The player's limits on `plugin.storage.session`. */
@@ -536,6 +539,46 @@ export class TestHost {
536
539
  * template and args the plugin sent are in `host.sent("osd.show")`.
537
540
  */
538
541
  osd = [];
542
+ /**
543
+ * The action buttons of each `osd.show` message, in step with `osd`
544
+ * (index for index): each button's title as shown and its command; `[]`
545
+ * for a message without actions (SDK 1.6.9).
546
+ */
547
+ osdActions = [];
548
+ /**
549
+ * Press button `index` of the newest OSD message, as the user does
550
+ * (player 0.7.6): its command runs as if picked from a menu, and settles.
551
+ * A disabled or busy command does nothing, as in the player.
552
+ *
553
+ * @param index - 0 or 1.
554
+ *
555
+ * @throws `Error` (rejects) The newest message has no button `index`.
556
+ *
557
+ * @example Press "Show in Folder" on the saved message
558
+ * The test checks the button the plugin put on its newest message, then
559
+ * presses it as the user would: the plugin's `reveal` command runs.
560
+ * ```ts
561
+ * import assert from "node:assert/strict";
562
+ * declare const host: TestHost;
563
+ * // ---cut---
564
+ * assert.deepEqual(host.osdActions.at(-1), [
565
+ * { title: "Show in Folder", command: "reveal" },
566
+ * ]);
567
+ * await host.pressOsdAction(0); // runs the plugin's `reveal` command
568
+ * ```
569
+ *
570
+ * @capability None.
571
+ * @since 1.6.9
572
+ */
573
+ async pressOsdAction(index) {
574
+ const action = this.osdActions.at(-1)?.[index];
575
+ if (!action)
576
+ throw new Error(`the newest OSD message has no button ${index}`);
577
+ const command = this.commands.get(action.command);
578
+ if (!command || command.state.busy || !command.state.enabled)
579
+ return;
580
+ await command.run();
581
+ }
539
582
  /** The text of this plugin's osd panel as shown, `null` when hidden. */
540
583
  osdPanel = null;
541
584
  #locale;
@@ -900,7 +943,59 @@ export class TestHost {
900
943
  })))
901
944
  .sort((a, b) => a.time - b.time);
902
945
  },
946
+ /**
947
+ * Each plugin's ranges by plugin id; layers without ranges are absent
948
+ * (SDK 1.6.9).
949
+ */
950
+ rangeLayers: new Map(),
951
+ /** Every layer's ranges merged, sorted by start, with the plugin's id. */
952
+ get ranges() {
953
+ return [...this.rangeLayers]
954
+ .flatMap(([plugin, ranges]) => ranges.map((range) => ({
955
+ ...range,
956
+ /** The manifest id of the plugin that published the range. */
957
+ plugin,
958
+ })))
959
+ .sort((a, b) => a.start - b.start);
960
+ },
903
961
  };
962
+ /**
963
+ * Simulate another plugin publishing its timeline ranges (or clearing
964
+ * them with `[]`); pushes `timeline.changed` when the plugin subscribed.
965
+ *
966
+ * @param pluginId - The other plugin's manifest id.
967
+ * @param ranges - All its ranges.
968
+ *
969
+ * @example Let another plugin publish a clip range
970
+ * The plugin under test sees a range another plugin put on the seek bar:
971
+ * `setTimelineRanges` stores that plugin's ranges and pushes
972
+ * `timeline.changed`, and `settle()` lets it arrive.
973
+ * ```ts
974
+ * import assert from "node:assert/strict";
975
+ * declare const host: TestHost;
976
+ * // ---cut---
977
+ * host.setTimelineRanges("community.clips", [
978
+ * { start: 40, end: 52.5, style: "selection", title: "Clip" },
979
+ * ]);
980
+ * await host.settle();
981
+ * assert.equal(plugin.timeline.ranges[0]?.plugin, "community.clips");
982
+ * ```
983
+ *
984
+ * @capability None.
985
+ * @since 1.6.9
986
+ */
987
+ setTimelineRanges(pluginId, ranges) {
988
+ if (ranges.length)
989
+ this.timeline.rangeLayers.set(pluginId, ranges.map((r) => ({
990
+ start: r.start,
991
+ end: r.end,
992
+ style: r.style,
993
+ title: r.title ?? null,
994
+ })));
995
+ else
996
+ this.timeline.rangeLayers.delete(pluginId);
997
+ this.#timelineChanged();
998
+ }
904
999
  /**
905
1000
  * Simulate another plugin publishing its timeline layer (or clearing it
906
1001
  * with `[]`); pushes `timeline.changed` when the plugin subscribed.
@@ -941,7 +1036,10 @@ export class TestHost {
941
1036
  this.#timelineChanged();
942
1037
  }
943
1038
  #timelineChanged() {
944
- this.emit("timeline.changed", { marks: this.timeline.marks });
1039
+ this.emit("timeline.changed", {
1040
+ marks: this.timeline.marks,
1041
+ ranges: this.timeline.ranges,
1042
+ });
945
1043
  }
946
1044
  /**
947
1045
  * Runtime images written to views (`plugin.ui.image`,
@@ -3663,8 +3761,9 @@ export class TestHost {
3663
3761
  next.terminalReason = null;
3664
3762
  next.capabilities = { ...readyCapabilities };
3665
3763
  next.loop = null; // a new source clears the A-B loop
3666
- if (this.timeline.layers.size) {
3764
+ if (this.timeline.layers.size || this.timeline.rangeLayers.size) {
3667
3765
  this.timeline.layers.clear(); // and every timeline layer
3766
+ this.timeline.rangeLayers.clear();
3668
3767
  queueMicrotask(() => this.#timelineChanged());
3669
3768
  }
3670
3769
  }
@@ -4452,7 +4551,22 @@ export class TestHost {
4452
4551
  const text = this.#shown(p.text, p.args, "text").replace(/[\n\r]/g, " ");
4453
4552
  if (!text.trim() || [...text].length > OSD_LIMITS.messageChars)
4454
4553
  throw new HostError("invalid", `osd text needs 1..${OSD_LIMITS.messageChars} characters`);
4554
+ // Player 0.7.6: up to 2 buttons, each a command of this plugin.
4555
+ const sent = p.actions;
4556
+ if (sent !== undefined &&
4557
+ (!Array.isArray(sent) || sent.length > OSD_LIMITS.actions))
4558
+ throw new HostError("invalid", `actions is a list of at most ${OSD_LIMITS.actions}`);
4559
+ const actions = (sent ?? []).map((action) => {
4560
+ const title = this.#shown(action.title, undefined, "title").replace(/[\n\r]/g, " ");
4561
+ if (!title.trim() || [...title].length > OSD_LIMITS.actionChars)
4562
+ throw new HostError("invalid", `an action title needs 1..=${OSD_LIMITS.actionChars} characters`);
4563
+ const command = String(action.command);
4564
+ if (!this.commands.has(command))
4565
+ throw new HostError("invalid", `${command} is not a command of this plugin`);
4566
+ return { title, command };
4567
+ });
4455
4568
  this.osd.push(text);
4569
+ this.osdActions.push(actions);
4456
4570
  },
4457
4571
  "osd.panel": (p) => {
4458
4572
  if (p.text === null)
@@ -4701,7 +4815,10 @@ export class TestHost {
4701
4815
  if (next !== undefined)
4702
4816
  panel.props[property] = next;
4703
4817
  },
4704
- "timeline.marks": () => ({ marks: this.timeline.marks }),
4818
+ "timeline.marks": () => ({
4819
+ marks: this.timeline.marks,
4820
+ ranges: this.timeline.ranges,
4821
+ }),
4705
4822
  "drops.provide": (p) => {
4706
4823
  const id = String(p.id);
4707
4824
  const index = this.dropProviders.findIndex((d) => d.id === id);
@@ -4895,6 +5012,44 @@ export class TestHost {
4895
5012
  })));
4896
5013
  return { applied: true };
4897
5014
  },
5015
+ // Player 0.7.6: the layer's ranges, replaced on their own.
5016
+ "timeline.setRanges": (p) => {
5017
+ if (p.sourceId !== undefined &&
5018
+ p.sourceId !== this.source.stamp.generation)
5019
+ return { applied: false };
5020
+ const sent = p.ranges;
5021
+ const duration = this.source.duration;
5022
+ if (!Array.isArray(sent) || sent.length > TIMELINE_LIMITS.ranges)
5023
+ throw new HostError("invalid", `a layer holds at most ${TIMELINE_LIMITS.ranges} ranges`);
5024
+ const ranges = sent.map((range) => ({ ...range }));
5025
+ for (const range of ranges) {
5026
+ const { start, end } = range;
5027
+ const time = (t) => typeof t === "number" && Number.isFinite(t) && t >= 0;
5028
+ if (!time(start) || !time(end))
5029
+ throw new HostError("invalid", "start and end must be numbers ≥ 0");
5030
+ if (end <= start)
5031
+ throw new HostError("invalid", `end ${end} must be after start ${start}`);
5032
+ // As the player (timeline.rs): 1e-6 s of rounding past the end is allowed.
5033
+ if (duration !== null && end > duration + 1e-6)
5034
+ throw new HostError("invalid", `end ${end} is after the end of the media`);
5035
+ if (!["selection", "region"].includes(String(range.style)))
5036
+ throw new HostError("invalid", "style is selection or region");
5037
+ if (isTextValue(range.title))
5038
+ range.title = this.#declared(range.title, "title");
5039
+ if (range.title !== undefined &&
5040
+ range.title !== null &&
5041
+ (typeof range.title !== "string" ||
5042
+ [...range.title].length > TIMELINE_LIMITS.titleChars))
5043
+ throw new HostError("invalid", `title must be ≤ ${TIMELINE_LIMITS.titleChars} characters`);
5044
+ }
5045
+ this.setTimelineRanges(this.manifestId, ranges.map((r) => ({
5046
+ start: r.start,
5047
+ end: r.end,
5048
+ style: String(r.style),
5049
+ title: r.title ?? null,
5050
+ })));
5051
+ return { applied: true };
5052
+ },
4898
5053
  "ui.interaction": (p) => void (this.mounted().locks = p),
4899
5054
  "dialogs.confirm": (p) => {
4900
5055
  this.confirms.push(p);
@@ -1,7 +1,43 @@
1
1
  import type { Runtime } from "./runtime.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
- import type { WireMark } from "../wire.ts";
4
- import type { Timeline, TimelineMark } from "./types.ts";
3
+ import type { WireTimeline } from "../wire.ts";
4
+ import type { Timeline, TimelineMark, TimelineRange } from "./types.ts";
5
+ /**
6
+ * The player's limits on one plugin's timeline layer
7
+ * ({@link TimelineLayer}). A list over them fails with code `invalid`,
8
+ * reported to `Scope.onError` (`set` and `setRanges` never throw). The
9
+ * player's tests check that these agree with the player.
10
+ *
11
+ * @example Keep a long list of ranges inside the limit
12
+ * The plugin publishes the first 100 of its ranges; the player refuses a
13
+ * longer list whole.
14
+ * ```ts
15
+ * import type { Plugin } from "@vestra-player/sdk";
16
+ * declare const plugin: Plugin;
17
+ * declare const segments: { start: number; end: number }[];
18
+ * // ---cut---
19
+ * import { TIMELINE_LIMITS } from "@vestra-player/sdk/testing";
20
+ *
21
+ * // A longer list would be refused whole.
22
+ * plugin.timeline.layer().setRanges(
23
+ * segments
24
+ * .slice(0, TIMELINE_LIMITS.ranges)
25
+ * .map((s) => ({ ...s, style: "region" as const })),
26
+ * );
27
+ * ```
28
+ *
29
+ * @since 1.6.9
30
+ *
31
+ * @category Testing
32
+ */
33
+ export declare const TIMELINE_LIMITS: Readonly<{
34
+ /** Marks in one layer (`TimelineLayer.set`) (1000). */
35
+ readonly marks: 1000;
36
+ /** Ranges in one layer (`TimelineLayer.setRanges`; player 0.7.6) (100). */
37
+ readonly ranges: 100;
38
+ /** Characters of a mark's or a range's title, as shown (100). */
39
+ readonly titleChars: 100;
40
+ }>;
5
41
  /**
6
42
  * One per plugin: read before activation (with `playback.read`), then kept
7
43
  * current from `timeline.changed` (with `events`) once the plugin uses it.
@@ -9,11 +45,12 @@ import type { Timeline, TimelineMark } from "./types.ts";
9
45
  export declare class TimelineService {
10
46
  #private;
11
47
  marks: readonly TimelineMark[];
48
+ ranges: readonly TimelineRange[];
12
49
  layer: boolean;
13
50
  constructor(runtime: Runtime);
14
51
  init(): Promise<void>;
15
52
  start(): void;
16
- accept(marks: WireMark[]): void;
53
+ accept(lists: WireTimeline): void;
17
54
  read(): Promise<void>;
18
55
  watch(listener: (marks: readonly TimelineMark[]) => void): {
19
56
  dispose: () => Promise<void>;
@@ -1,11 +1,54 @@
1
1
  /** Seek-bar marks (`plugin.timeline`): one layer per plugin, merged by the player. */
2
2
  import { failure, Latest, quiet } from "./runtime.js";
3
+ /**
4
+ * The player's limits on one plugin's timeline layer
5
+ * ({@link TimelineLayer}). A list over them fails with code `invalid`,
6
+ * reported to `Scope.onError` (`set` and `setRanges` never throw). The
7
+ * player's tests check that these agree with the player.
8
+ *
9
+ * @example Keep a long list of ranges inside the limit
10
+ * The plugin publishes the first 100 of its ranges; the player refuses a
11
+ * longer list whole.
12
+ * ```ts
13
+ * import type { Plugin } from "@vestra-player/sdk";
14
+ * declare const plugin: Plugin;
15
+ * declare const segments: { start: number; end: number }[];
16
+ * // ---cut---
17
+ * import { TIMELINE_LIMITS } from "@vestra-player/sdk/testing";
18
+ *
19
+ * // A longer list would be refused whole.
20
+ * plugin.timeline.layer().setRanges(
21
+ * segments
22
+ * .slice(0, TIMELINE_LIMITS.ranges)
23
+ * .map((s) => ({ ...s, style: "region" as const })),
24
+ * );
25
+ * ```
26
+ *
27
+ * @since 1.6.9
28
+ *
29
+ * @category Testing
30
+ */
31
+ export const TIMELINE_LIMITS = Object.freeze({
32
+ /** Marks in one layer (`TimelineLayer.set`) (1000). */
33
+ marks: 1000,
34
+ /** Ranges in one layer (`TimelineLayer.setRanges`; player 0.7.6) (100). */
35
+ ranges: 100,
36
+ /** Characters of a mark's or a range's title, as shown (100). */
37
+ titleChars: 100,
38
+ });
3
39
  const view = (marks) => Object.freeze(marks.map((mark) => Object.freeze({
4
40
  time: mark.time,
5
41
  kind: mark.kind,
6
42
  title: mark.title ?? null,
7
43
  plugin: mark.plugin,
8
44
  })));
45
+ const viewRanges = (ranges) => Object.freeze(ranges.map((range) => Object.freeze({
46
+ start: range.start,
47
+ end: range.end,
48
+ style: range.style,
49
+ title: range.title ?? null,
50
+ plugin: range.plugin,
51
+ })));
9
52
  /**
10
53
  * One per plugin: read before activation (with `playback.read`), then kept
11
54
  * current from `timeline.changed` (with `events`) once the plugin uses it.
@@ -13,6 +56,7 @@ const view = (marks) => Object.freeze(marks.map((mark) => Object.freeze({
13
56
  export class TimelineService {
14
57
  #runtime;
15
58
  marks = Object.freeze([]);
59
+ ranges = Object.freeze([]);
16
60
  #listeners = new Set();
17
61
  #started = false;
18
62
  layer = false;
@@ -29,26 +73,35 @@ export class TimelineService {
29
73
  this.#started = true;
30
74
  // A mirror: a plugin listens through `watch` and bindings.
31
75
  this.#runtime.on("timeline.changed", (data) => {
32
- const marks = data?.marks;
33
- if (Array.isArray(marks))
34
- this.accept(marks);
76
+ const lists = data;
77
+ if (lists && Array.isArray(lists.marks))
78
+ this.accept(lists);
35
79
  else
36
80
  void this.read();
37
81
  }, "events", true);
38
82
  }
39
- accept(marks) {
40
- const next = view(marks);
41
- if (JSON.stringify(next) === JSON.stringify(this.marks))
83
+ accept(lists) {
84
+ const marks = view(lists.marks);
85
+ // Players before 0.7.6 send no ranges.
86
+ const ranges = viewRanges(Array.isArray(lists.ranges) ? lists.ranges : []);
87
+ const marksChanged = JSON.stringify(marks) !== JSON.stringify(this.marks);
88
+ const rangesChanged = JSON.stringify(ranges) !== JSON.stringify(this.ranges);
89
+ if (!marksChanged && !rangesChanged)
42
90
  return;
43
- this.marks = next;
44
- for (const listener of [...this.#listeners])
45
- listener(next);
91
+ if (marksChanged)
92
+ this.marks = marks;
93
+ if (rangesChanged)
94
+ this.ranges = ranges;
95
+ // `onChange` follows the marks only, as before ranges existed.
96
+ if (marksChanged)
97
+ for (const listener of [...this.#listeners])
98
+ listener(marks);
46
99
  this.#runtime.invalidate();
47
100
  }
48
101
  async read() {
49
102
  const result = await this.#runtime.host.timeline.marks().catch(() => null);
50
103
  if (result)
51
- this.accept(result.marks);
104
+ this.accept(result);
52
105
  }
53
106
  watch(listener) {
54
107
  this.#listeners.add(listener);
@@ -70,6 +123,11 @@ export function timeline(scope) {
70
123
  scope.runtime.reading("timeline.changed");
71
124
  return service().marks;
72
125
  },
126
+ get ranges() {
127
+ service().start();
128
+ scope.runtime.reading("timeline.changed");
129
+ return service().ranges;
130
+ },
73
131
  onChange(listener) {
74
132
  service().start();
75
133
  const watch = service().watch((marks) => scope.run(listener, marks));
@@ -93,12 +151,18 @@ export function timeline(scope) {
93
151
  class Layer {
94
152
  #scope;
95
153
  #latest;
154
+ #latestRanges;
155
+ /** Ranges were sent: disposing clears them too. */
156
+ #ranged = false;
96
157
  #disposed = false;
97
158
  constructor(scope) {
98
159
  this.#scope = scope;
99
160
  this.#latest = new Latest(async (write) => {
100
161
  await scope.runtime.host.timeline.set(write.marks, write.sourceId);
101
162
  });
163
+ this.#latestRanges = new Latest(async (write) => {
164
+ await scope.runtime.host.timeline.setRanges(write.ranges, write.sourceId);
165
+ });
102
166
  scope.add(this);
103
167
  }
104
168
  set = (marks, options = {}) => {
@@ -121,13 +185,41 @@ class Layer {
121
185
  write.sourceId = options.ifCurrent.sourceId;
122
186
  quiet(this.#latest.push(write).catch((error) => this.#scope.report(error)));
123
187
  };
188
+ setRanges = (ranges, options = {}) => {
189
+ if (this.#disposed)
190
+ return;
191
+ if (!Array.isArray(ranges)) {
192
+ this.#scope.report(failure("invalid", "ranges must be a list"));
193
+ return;
194
+ }
195
+ const write = {
196
+ ranges: ranges.map((range) => ({
197
+ start: range?.start,
198
+ end: range?.end,
199
+ style: range?.style,
200
+ ...(range?.title === undefined
201
+ ? {}
202
+ : { title: range.title }),
203
+ })),
204
+ };
205
+ if (options.ifCurrent)
206
+ write.sourceId = options.ifCurrent.sourceId;
207
+ this.#ranged = true;
208
+ quiet(this.#latestRanges
209
+ .push(write)
210
+ .catch((error) => this.#scope.report(error)));
211
+ };
124
212
  async dispose() {
125
213
  if (this.#disposed)
126
214
  return;
127
215
  this.#disposed = true;
128
216
  this.#scope.forget(this);
129
217
  this.#latest.stop();
218
+ this.#latestRanges.stop();
130
219
  timelineOf(this.#scope.runtime).layer = false;
131
220
  await this.#scope.runtime.host.timeline.set([]).catch(() => { });
221
+ // Only a layer that sent ranges: an older player refuses the method.
222
+ if (this.#ranged)
223
+ await this.#scope.runtime.host.timeline.setRanges([]).catch(() => { });
132
224
  }
133
225
  }
@@ -4216,6 +4216,36 @@ export interface TimelineMark {
4216
4216
  /** The manifest id of the plugin that published it. */
4217
4217
  readonly plugin: string;
4218
4218
  }
4219
+ /**
4220
+ * How a timeline range is drawn; readers choose colours by it.
4221
+ *
4222
+ * - `"selection"`: a span the user picked to act on, such as a clip to
4223
+ * export. The player's seek bar draws it in the accent colour.
4224
+ * - `"region"`: a part of the media the plugin describes, such as an intro
4225
+ * or a chapter's span. Drawn dimmer, under selections.
4226
+ *
4227
+ * @since 1.6.9
4228
+ * @category Playback
4229
+ */
4230
+ export type RangeStyle = "selection" | "region";
4231
+ /**
4232
+ * One range on the seek bar (player 0.7.6).
4233
+ *
4234
+ * @since 1.6.9
4235
+ * @category Playback
4236
+ */
4237
+ export interface TimelineRange {
4238
+ /** Seconds from the start. */
4239
+ readonly start: number;
4240
+ /** Seconds from the start, after `start`. */
4241
+ readonly end: number;
4242
+ /** How it is drawn. */
4243
+ readonly style: RangeStyle;
4244
+ /** Label, `null` without one. */
4245
+ readonly title: string | null;
4246
+ /** The manifest id of the plugin that published it. */
4247
+ readonly plugin: string;
4248
+ }
4219
4249
  /**
4220
4250
  * This plugin's own marks; disposing it removes them.
4221
4251
  *
@@ -4273,6 +4303,54 @@ export interface TimelineLayer extends Disposable {
4273
4303
  }[], options?: {
4274
4304
  ifCurrent?: PlayerState;
4275
4305
  }): void;
4306
+ /**
4307
+ * Replace the layer's ranges (last write wins; `[]` clears them), spans of
4308
+ * the media drawn on the seek bar (player 0.7.6). The marks and the ranges
4309
+ * of a layer are replaced on their own: `set` keeps the ranges and
4310
+ * `setRanges` keeps the marks. Disposing the layer, its scope or stopping
4311
+ * the plugin removes both, and a new source clears every layer.
4312
+ *
4313
+ * At most 100 ranges (`TIMELINE_LIMITS.ranges`), `0 ≤ start < end`, `end`
4314
+ * within the media while its duration is known, titles ≤ 100 characters.
4315
+ * Ranges may overlap. Failures go to `onError`; with `ifCurrent`, a
4316
+ * write for another source is dropped silently. A title is data or a
4317
+ * {@link Text} the player translates, as in `set`.
4318
+ *
4319
+ * Players before 0.7.6 build `plugin` from an SDK without `setRanges`, so
4320
+ * the call throws a `TypeError` there: declare
4321
+ * `"minPlayerVersion": "0.7.6"`.
4322
+ *
4323
+ * @example Show the clip range on the seek bar while a panel is open
4324
+ * The span the user picked shows on the seek bar in the accent colour
4325
+ * while the plugin's tab is showing, and goes when it is not. Two point
4326
+ * marks would show the ends but not the span between them.
4327
+ * ```ts
4328
+ * declare const panel: Panel;
4329
+ * declare const clip: { start: number; end: number };
4330
+ * // ---cut---
4331
+ * const layer = plugin.timeline.layer();
4332
+ * // Only while the tab is on screen; [] takes the range away.
4333
+ * const show = () =>
4334
+ * layer.setRanges(
4335
+ * panel.state?.effective
4336
+ * ? [{ start: clip.start, end: clip.end, style: "selection", title: "Clip" }]
4337
+ * : [],
4338
+ * { ifCurrent: plugin.player.state },
4339
+ * );
4340
+ * panel.onChange(show);
4341
+ * ```
4342
+ *
4343
+ * @capability timeline.marks
4344
+ * @since 1.6.9
4345
+ */
4346
+ setRanges(ranges: {
4347
+ start: number;
4348
+ end: number;
4349
+ style: RangeStyle;
4350
+ title?: string | Text;
4351
+ }[], options?: {
4352
+ ifCurrent?: PlayerState;
4353
+ }): void;
4276
4354
  }
4277
4355
  /**
4278
4356
  * `plugin.timeline`: marks on the seek bar. Every plugin publishes one
@@ -4303,6 +4381,38 @@ export interface TimelineLayer extends Disposable {
4303
4381
  export interface Timeline {
4304
4382
  /** Every layer merged, by time (`playback.read`). Empty on a new source. */
4305
4383
  readonly marks: readonly TimelineMark[];
4384
+ /**
4385
+ * Every layer's ranges merged, by start (`playback.read`; player 0.7.6:
4386
+ * on older players `ranges` does not exist and reads `undefined`). Empty
4387
+ * on a new source. Kept current like `marks` (with `events`): read it in
4388
+ * a {@link View.bind} function and the view follows it. `onChange` runs
4389
+ * only when the marks change.
4390
+ *
4391
+ * @example Draw the published ranges in a seek bar of your own
4392
+ * A bottom bar draws a band for every range on the seek bar, whoever
4393
+ * published it, as fractions of the duration.
4394
+ * ```ts
4395
+ * declare const bar: View;
4396
+ * // ---cut---
4397
+ * bar.bind(() => {
4398
+ * // Reading `ranges` here runs the binding again when they change.
4399
+ * const duration = plugin.player.state.duration ?? 0;
4400
+ * return {
4401
+ * ranges: duration
4402
+ * ? plugin.timeline.ranges.map((range) => ({
4403
+ * from: range.start / duration,
4404
+ * to: range.end / duration,
4405
+ * selection: range.style === "selection",
4406
+ * }))
4407
+ * : [],
4408
+ * };
4409
+ * });
4410
+ * ```
4411
+ *
4412
+ * @capability playback.read
4413
+ * @since 1.6.9
4414
+ */
4415
+ readonly ranges: readonly TimelineRange[];
4306
4416
  /**
4307
4417
  * The merged list changed (needs `events`; at most every 100 ms).
4308
4418
  *
@@ -6700,12 +6810,19 @@ export interface Osd {
6700
6810
  *
6701
6811
  * @param text - The template, 1–200 characters once translated and
6702
6812
  * formatted; line breaks become spaces.
6813
+ * With `options.actions` (player 0.7.6) the message ends in up to two
6814
+ * buttons, each running one of the plugin's own commands
6815
+ * ({@link OsdShowOptions}). Older players show the message without them.
6816
+ *
6703
6817
  * @param args - Values for its placeholders (player 0.4.3; older players
6704
- * show the template as it is).
6818
+ * show the template as it is). Pass `null` with a `Text` and options.
6819
+ * @param options - Action buttons after the text (SDK 1.6.9).
6705
6820
  * @returns Resolves once the message is on screen.
6706
6821
  *
6707
6822
  * @throws `invalid` (rejects) The shown text is empty, only white space,
6708
- * or longer than 200 characters, or `args` is not a {@link TemplateArgs}.
6823
+ * or longer than 200 characters, or `args` is not a {@link TemplateArgs};
6824
+ * or there are more than 2 actions, a title is empty or longer than 40
6825
+ * characters once translated, or a command is not one of the plugin's.
6709
6826
  * @throws `unavailable` (rejects) The player runs without a user interface.
6710
6827
  * @throws `capability` (rejects) The manifest does not declare `ui.osd`.
6711
6828
  *
@@ -6719,9 +6836,28 @@ export interface Osd {
6719
6836
  * await plugin.osd.show("Speed {}×", [plugin.player.state.rate]);
6720
6837
  * ```
6721
6838
  *
6839
+ * @example Offer "Show in Folder" after saving a file
6840
+ * The OSD reads "Saved clip.mp4 [Show in Folder]" for about 4 seconds,
6841
+ * longer while the pointer is on it. A click runs the plugin's own
6842
+ * `reveal` command, which knows the last saved file; the message goes.
6843
+ * ```ts
6844
+ * declare const path: string;
6845
+ * // ---cut---
6846
+ * let saved = path; // the command acts on the last saved file
6847
+ * plugin.commands.add({
6848
+ * id: "reveal",
6849
+ * title: "Show Last Saved File in Folder",
6850
+ * enabled: () => saved !== "",
6851
+ * run: () => plugin.system.reveal(saved),
6852
+ * });
6853
+ * await plugin.osd.show("Saved {}", [saved.split(/[\\/]/).pop() ?? saved], {
6854
+ * actions: [{ title: "Show in Folder", command: "reveal" }], // one button
6855
+ * });
6856
+ * ```
6857
+ *
6722
6858
  * @since 1.0.0
6723
6859
  */
6724
- show(text: TextValue, args?: TemplateArgs): Promise<void>;
6860
+ show(text: TextValue, args?: TemplateArgs | null, options?: OsdShowOptions): Promise<void>;
6725
6861
  /**
6726
6862
  * Create this plugin's information block.
6727
6863
  *
@@ -6748,6 +6884,54 @@ export interface Osd {
6748
6884
  */
6749
6885
  panel(): OsdPanel;
6750
6886
  }
6887
+ /**
6888
+ * One button at the end of an OSD message ({@link Osd.show}, player 0.7.6).
6889
+ *
6890
+ * @since 1.6.9
6891
+ * @category UI
6892
+ */
6893
+ export interface OsdAction {
6894
+ /**
6895
+ * The button's label: English source text the player translates with the
6896
+ * plugin's catalog, or a {@link Text}; 1–40 characters once translated
6897
+ * (`OSD_LIMITS.actionChars`). A verb, as on a menu item: "Show in
6898
+ * Folder", "Copy", "Undo".
6899
+ */
6900
+ title: TextValue;
6901
+ /**
6902
+ * The local id of one of this plugin's own commands
6903
+ * ({@link CommandOptions.id}), declared when the message is shown. A
6904
+ * click runs it as if the user picked it from a menu: a disabled command
6905
+ * does nothing. The command takes no arguments, so it acts on what the
6906
+ * plugin remembers (the file it just saved).
6907
+ */
6908
+ command: string;
6909
+ }
6910
+ /**
6911
+ * Options of {@link Osd.show} (SDK 1.6.9).
6912
+ *
6913
+ * @remarks
6914
+ * A message with actions stays about 4 seconds instead of 2, and does not
6915
+ * fade while the pointer is on it and moving: it goes 2 seconds after the
6916
+ * pointer leaves or rests (the player's idle time), and at the latest 30
6917
+ * seconds after it showed, hovered or not. A click on a button runs its
6918
+ * command and removes the message.
6919
+ * It is still one line: the buttons follow the text. Buttons take no
6920
+ * keyboard focus; screen readers can press them. A newer message, from any
6921
+ * plugin or the player, replaces the message with its buttons, and the
6922
+ * buttons go when the plugin stops. While its buttons show, the plugin is
6923
+ * not stopped for being idle.
6924
+ *
6925
+ * Players before 0.7.6 ignore `actions` and show the text alone for 2
6926
+ * seconds, so do not make an action the only way to reach something.
6927
+ *
6928
+ * @since 1.6.9
6929
+ * @category UI
6930
+ */
6931
+ export interface OsdShowOptions {
6932
+ /** At most 2 buttons (`OSD_LIMITS.actions`), in order after the text. */
6933
+ actions?: readonly OsdAction[];
6934
+ }
6751
6935
  /**
6752
6936
  * The state of a filter, display layer or render options handle.
6753
6937
  * After `failed` nothing retries automatically until the next `set()`,
package/dist/wire.d.ts CHANGED
@@ -262,6 +262,19 @@ export interface WireMark {
262
262
  title: string | null;
263
263
  plugin: string;
264
264
  }
265
+ /** One timeline range as the player merges them (player 0.7.6). */
266
+ export interface WireRange {
267
+ start: number;
268
+ end: number;
269
+ style: "selection" | "region";
270
+ title: string | null;
271
+ plugin: string;
272
+ }
273
+ /** `timeline.marks` and `timeline.changed`; `ranges` from player 0.7.6. */
274
+ export interface WireTimeline {
275
+ marks: WireMark[];
276
+ ranges?: WireRange[];
277
+ }
265
278
  /** The backdrop as `layout.state` reports it; `null` without one. */
266
279
  export interface LayoutBackdrop {
267
280
  owner: number;
@@ -568,9 +581,7 @@ export declare function createWire(transport: Transport): {
568
581
  };
569
582
  /** Mark layers on the seek bar (`plugin.timeline`). */
570
583
  timeline: {
571
- marks: () => Promise<{
572
- marks: WireMark[];
573
- }>;
584
+ marks: () => Promise<WireTimeline>;
574
585
  set: (marks: {
575
586
  time: number;
576
587
  kind: string;
@@ -578,6 +589,15 @@ export declare function createWire(transport: Transport): {
578
589
  }[], sourceId?: number) => Promise<{
579
590
  applied: boolean;
580
591
  }>;
592
+ /** Player 0.7.6. */
593
+ setRanges: (ranges: {
594
+ start: number;
595
+ end: number;
596
+ style: string;
597
+ title?: string | TextJson;
598
+ }[], sourceId?: number) => Promise<{
599
+ applied: boolean;
600
+ }>;
581
601
  };
582
602
  /** Chapters and the A-B loop of the current media. */
583
603
  marks: {
@@ -666,7 +686,10 @@ export declare function createWire(transport: Transport): {
666
686
  };
667
687
  /** `text` is a template in the plugin's catalog, formatted with `args` (player 0.4.3). */
668
688
  osd: {
669
- show: (text: TextValueJson, args?: TemplateArgsJson) => Promise<void>;
689
+ show: (text: TextValueJson, args?: TemplateArgsJson, actions?: {
690
+ title: TextValueJson;
691
+ command: string;
692
+ }[]) => Promise<void>;
670
693
  panel: (text: TextValueJson | null, args?: TemplateArgsJson, maxColumns?: number) => Promise<void>;
671
694
  };
672
695
  layout: {
package/dist/wire.js CHANGED
@@ -156,6 +156,11 @@ export function createWire(transport) {
156
156
  marks,
157
157
  ...(sourceId === undefined ? {} : { sourceId }),
158
158
  }),
159
+ /** Player 0.7.6. */
160
+ setRanges: (ranges, sourceId) => transport.request("timeline.setRanges", {
161
+ ranges,
162
+ ...(sourceId === undefined ? {} : { sourceId }),
163
+ }),
159
164
  },
160
165
  /** Chapters and the A-B loop of the current media. */
161
166
  marks: {
@@ -245,9 +250,11 @@ export function createWire(transport) {
245
250
  },
246
251
  /** `text` is a template in the plugin's catalog, formatted with `args` (player 0.4.3). */
247
252
  osd: {
248
- show: (text, args) => transport.request("osd.show", {
253
+ show: (text, args, actions) => transport.request("osd.show", {
249
254
  text,
250
255
  ...(args === undefined ? {} : { args }),
256
+ // Player 0.7.6; older players ignore it.
257
+ ...(actions === undefined ? {} : { actions }),
251
258
  }),
252
259
  panel: (text, args, maxColumns) => transport.request("osd.panel", {
253
260
  text,
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.6.8: 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. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
3
+ > @vestra-player/sdk 1.6.9: 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. The documentation is a tree of short pages read by path, like `go doc`: the same pages and API as https://vestrasys.github.io/vestra-player-sdk/.
4
4
 
5
5
  ## Read the documentation
6
6
 
@@ -31,12 +31,12 @@
31
31
  - `/API/Commands`: 15 entries: CommandState, Bindable, Condition, When, MenuId, BarButtonIcon, CommandButton, CommandOptions, Command, Commands, HostAction, Host, BindingRef, BindingInfo, Bindings
32
32
  - `/API/Media`: 6 entries: CaptureOptions, Snapshot, ExportOptions, JobState, ExportJob, MediaCapture
33
33
  - `/API/Packages`: 10 entries: PackageManifest, PackageSource, InstallOptions, PackageCandidate, PackageChangeStatus, PackageChange, PluginInfo, PluginDisplay, PluginWarning, Packages
34
- - `/API/Playback`: 18 entries: MarkKind, TimelineMark, TimelineLayer, Timeline, AudioTrack, PlayerState, AudioDevice, Chapter, Conditional, Media, Scrub, StreamFrame, StreamOptions, StreamStartError, Stream,…
34
+ - `/API/Playback`: 20 entries: MarkKind, TimelineMark, RangeStyle, TimelineRange, TimelineLayer, Timeline, AudioTrack, PlayerState, AudioDevice, Chapter, Conditional, Media, Scrub, StreamFrame, StreamOptions,…
35
35
  - `/API/Plugin`: 13 entries: Disposable, PluginError, Scope, Plugin, StopReason, DisposeInfo, WindowState, MainWindow, AppInfo, App, SystemInfo, DisplayInfo, Json
36
36
  - `/API/Processing`: 18 entries: FilterStatus, VideoFilter, AudioFilter, ViewSettings, VideoView, RenderSettings, RenderOptions, VideoShaders, UpscalingReasonCode, UpscalingReason, UpscalingDecodingCause,…
37
37
  - `/API/Services`: 15 entries: Clipboard, Power, ToolResult, Tools, JsonStore, Storage, SessionStorage, System, Secrets, RenderingMode, RenderingPreference, RenderingReasonCode, RenderingReason, RenderingState,…
38
38
  - `/API/Sources`: 7 entries: ResolverOptions, WebResolverOptions, FileResolverOptions, ResolvedStream, Resolver, Resolvers, ResolvedMedia
39
39
  - `/API/Subtitles`: 5 entries: SubtitleTrack, SubtitlesState, SubtitleConditional, Subtitles, SubtitleStyle
40
- - `/API/UI`: 42 entries: ImageRef, ImageOptions, MarkdownRef, MarkdownOptions, PropValue, PropData, Props, Ui, View, Window, Windows, SlotState, SlotPanel, PanelState, PanelSlot, SidebarSlot, Panel,…
41
- - `/API/Testing`: 32 entries: CapabilityInfo, CAPABILITY_INFO, MESSAGE_LIMIT, COMMAND_LIMITS, MENU_IDS, BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BuiltinSubmenu, BUILTIN_SUBMENUS, BuiltinCommand, BUILTIN_COMMANDS, LOCALES,…
40
+ - `/API/UI`: 44 entries: ImageRef, ImageOptions, MarkdownRef, MarkdownOptions, PropValue, PropData, Props, Ui, View, Window, Windows, SlotState, SlotPanel, PanelState, PanelSlot, SidebarSlot, Panel,…
41
+ - `/API/Testing`: 33 entries: CapabilityInfo, CAPABILITY_INFO, MESSAGE_LIMIT, COMMAND_LIMITS, MENU_IDS, BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BuiltinSubmenu, BUILTIN_SUBMENUS, BuiltinCommand, BUILTIN_COMMANDS, LOCALES,…
42
42
  - `/API/Docs`: 4 entries: DOCS_PAGE_BYTES, DocsOptions, docs, searchDocs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.6.8",
3
+ "version": "1.6.9",
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": {