@vestra-player/sdk 1.6.7 → 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.
@@ -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`,
@@ -3405,8 +3503,8 @@ export class TestHost {
3405
3503
  * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
3406
3504
  * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
3407
3505
  * later one stays a menu item only). Empty once the command is disposed.
3408
- * Whether the bar places them is the bar's business: the list is what
3409
- * the player hands to `BarButtons`.
3506
+ * Whether one is in the strip or its » menu is the player's business
3507
+ * (the room and the other plugins' buttons).
3410
3508
  *
3411
3509
  * @example Click the plugin's bottom-bar button in a test
3412
3510
  * The test finds the plugin's Store button, clicks it and checks that the
@@ -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
  }