@vestra-player/sdk 1.6.8 → 1.6.10

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`. */
@@ -164,6 +167,24 @@ export { LIST_SEPARATORS };
164
167
  * ```
165
168
  */
166
169
  export { LOCALES };
170
+ /** `jobs.start`'s `encoding`, checked against the player's sets. */
171
+ function validEncoding(value) {
172
+ if (typeof value !== "object" || value === null || Array.isArray(value))
173
+ return false;
174
+ const sets = {
175
+ crf: Array.from({ length: 17 }, (_, i) => i + 14),
176
+ speed: ["faster", "balanced", "smaller"],
177
+ maxHeight: [1080, 720, 480],
178
+ fps: [60, 30],
179
+ audioBitrate: [128, 192, 256],
180
+ threads: ["single", "auto"],
181
+ videoBitrate: ["source"],
182
+ };
183
+ const fields = value;
184
+ if (fields.crf !== undefined && fields.videoBitrate !== undefined)
185
+ return false;
186
+ return Object.entries(value).every(([key, v]) => sets[key]?.includes(v) ?? false);
187
+ }
167
188
  /** A `plugin.storage.session` key, checked as the player does. */
168
189
  function sessionKey(key) {
169
190
  if (typeof key !== "string" ||
@@ -536,6 +557,46 @@ export class TestHost {
536
557
  * template and args the plugin sent are in `host.sent("osd.show")`.
537
558
  */
538
559
  osd = [];
560
+ /**
561
+ * The action buttons of each `osd.show` message, in step with `osd`
562
+ * (index for index): each button's title as shown and its command; `[]`
563
+ * for a message without actions (SDK 1.6.9).
564
+ */
565
+ osdActions = [];
566
+ /**
567
+ * Press button `index` of the newest OSD message, as the user does
568
+ * (player 0.7.6): its command runs as if picked from a menu, and settles.
569
+ * A disabled or busy command does nothing, as in the player.
570
+ *
571
+ * @param index - 0 or 1.
572
+ *
573
+ * @throws `Error` (rejects) The newest message has no button `index`.
574
+ *
575
+ * @example Press "Show in Folder" on the saved message
576
+ * The test checks the button the plugin put on its newest message, then
577
+ * presses it as the user would: the plugin's `reveal` command runs.
578
+ * ```ts
579
+ * import assert from "node:assert/strict";
580
+ * declare const host: TestHost;
581
+ * // ---cut---
582
+ * assert.deepEqual(host.osdActions.at(-1), [
583
+ * { title: "Show in Folder", command: "reveal" },
584
+ * ]);
585
+ * await host.pressOsdAction(0); // runs the plugin's `reveal` command
586
+ * ```
587
+ *
588
+ * @capability None.
589
+ * @since 1.6.9
590
+ */
591
+ async pressOsdAction(index) {
592
+ const action = this.osdActions.at(-1)?.[index];
593
+ if (!action)
594
+ throw new Error(`the newest OSD message has no button ${index}`);
595
+ const command = this.commands.get(action.command);
596
+ if (!command || command.state.busy || !command.state.enabled)
597
+ return;
598
+ await command.run();
599
+ }
539
600
  /** The text of this plugin's osd panel as shown, `null` when hidden. */
540
601
  osdPanel = null;
541
602
  #locale;
@@ -900,7 +961,59 @@ export class TestHost {
900
961
  })))
901
962
  .sort((a, b) => a.time - b.time);
902
963
  },
964
+ /**
965
+ * Each plugin's ranges by plugin id; layers without ranges are absent
966
+ * (SDK 1.6.9).
967
+ */
968
+ rangeLayers: new Map(),
969
+ /** Every layer's ranges merged, sorted by start, with the plugin's id. */
970
+ get ranges() {
971
+ return [...this.rangeLayers]
972
+ .flatMap(([plugin, ranges]) => ranges.map((range) => ({
973
+ ...range,
974
+ /** The manifest id of the plugin that published the range. */
975
+ plugin,
976
+ })))
977
+ .sort((a, b) => a.start - b.start);
978
+ },
903
979
  };
980
+ /**
981
+ * Simulate another plugin publishing its timeline ranges (or clearing
982
+ * them with `[]`); pushes `timeline.changed` when the plugin subscribed.
983
+ *
984
+ * @param pluginId - The other plugin's manifest id.
985
+ * @param ranges - All its ranges.
986
+ *
987
+ * @example Let another plugin publish a clip range
988
+ * The plugin under test sees a range another plugin put on the seek bar:
989
+ * `setTimelineRanges` stores that plugin's ranges and pushes
990
+ * `timeline.changed`, and `settle()` lets it arrive.
991
+ * ```ts
992
+ * import assert from "node:assert/strict";
993
+ * declare const host: TestHost;
994
+ * // ---cut---
995
+ * host.setTimelineRanges("community.clips", [
996
+ * { start: 40, end: 52.5, style: "selection", title: "Clip" },
997
+ * ]);
998
+ * await host.settle();
999
+ * assert.equal(plugin.timeline.ranges[0]?.plugin, "community.clips");
1000
+ * ```
1001
+ *
1002
+ * @capability None.
1003
+ * @since 1.6.9
1004
+ */
1005
+ setTimelineRanges(pluginId, ranges) {
1006
+ if (ranges.length)
1007
+ this.timeline.rangeLayers.set(pluginId, ranges.map((r) => ({
1008
+ start: r.start,
1009
+ end: r.end,
1010
+ style: r.style,
1011
+ title: r.title ?? null,
1012
+ })));
1013
+ else
1014
+ this.timeline.rangeLayers.delete(pluginId);
1015
+ this.#timelineChanged();
1016
+ }
904
1017
  /**
905
1018
  * Simulate another plugin publishing its timeline layer (or clearing it
906
1019
  * with `[]`); pushes `timeline.changed` when the plugin subscribed.
@@ -941,7 +1054,10 @@ export class TestHost {
941
1054
  this.#timelineChanged();
942
1055
  }
943
1056
  #timelineChanged() {
944
- this.emit("timeline.changed", { marks: this.timeline.marks });
1057
+ this.emit("timeline.changed", {
1058
+ marks: this.timeline.marks,
1059
+ ranges: this.timeline.ranges,
1060
+ });
945
1061
  }
946
1062
  /**
947
1063
  * Runtime images written to views (`plugin.ui.image`,
@@ -1711,9 +1827,24 @@ export class TestHost {
1711
1827
  /**
1712
1828
  * Export jobs by job id. Each state query advances a job one step
1713
1829
  * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1714
- * Wire-level.
1830
+ * A running clip reports half its `progress`; a finished clip's
1831
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1832
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1833
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1834
+ * `.mp4`). Wire-level.
1715
1835
  */
1716
1836
  jobs = new Map();
1837
+ /**
1838
+ * Set it to make every running export end `failed` with this error on
1839
+ * its next state query, as the player reports one (`unsupported` for a
1840
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1841
+ * default) completes them.
1842
+ *
1843
+ * @since 1.6.10
1844
+ */
1845
+ exportFailure = null;
1846
+ /** What each job was asked to write, for its outcome. */
1847
+ #exportJobs = new Map();
1717
1848
  /**
1718
1849
  * `openStream`'s transport: a local stream (a Unix socket on macOS and
1719
1850
  * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
@@ -3663,8 +3794,9 @@ export class TestHost {
3663
3794
  next.terminalReason = null;
3664
3795
  next.capabilities = { ...readyCapabilities };
3665
3796
  next.loop = null; // a new source clears the A-B loop
3666
- if (this.timeline.layers.size) {
3797
+ if (this.timeline.layers.size || this.timeline.rangeLayers.size) {
3667
3798
  this.timeline.layers.clear(); // and every timeline layer
3799
+ this.timeline.rangeLayers.clear();
3668
3800
  queueMicrotask(() => this.#timelineChanged());
3669
3801
  }
3670
3802
  }
@@ -4452,7 +4584,22 @@ export class TestHost {
4452
4584
  const text = this.#shown(p.text, p.args, "text").replace(/[\n\r]/g, " ");
4453
4585
  if (!text.trim() || [...text].length > OSD_LIMITS.messageChars)
4454
4586
  throw new HostError("invalid", `osd text needs 1..${OSD_LIMITS.messageChars} characters`);
4587
+ // Player 0.7.6: up to 2 buttons, each a command of this plugin.
4588
+ const sent = p.actions;
4589
+ if (sent !== undefined &&
4590
+ (!Array.isArray(sent) || sent.length > OSD_LIMITS.actions))
4591
+ throw new HostError("invalid", `actions is a list of at most ${OSD_LIMITS.actions}`);
4592
+ const actions = (sent ?? []).map((action) => {
4593
+ const title = this.#shown(action.title, undefined, "title").replace(/[\n\r]/g, " ");
4594
+ if (!title.trim() || [...title].length > OSD_LIMITS.actionChars)
4595
+ throw new HostError("invalid", `an action title needs 1..=${OSD_LIMITS.actionChars} characters`);
4596
+ const command = String(action.command);
4597
+ if (!this.commands.has(command))
4598
+ throw new HostError("invalid", `${command} is not a command of this plugin`);
4599
+ return { title, command };
4600
+ });
4455
4601
  this.osd.push(text);
4602
+ this.osdActions.push(actions);
4456
4603
  },
4457
4604
  "osd.panel": (p) => {
4458
4605
  if (p.text === null)
@@ -4701,7 +4848,10 @@ export class TestHost {
4701
4848
  if (next !== undefined)
4702
4849
  panel.props[property] = next;
4703
4850
  },
4704
- "timeline.marks": () => ({ marks: this.timeline.marks }),
4851
+ "timeline.marks": () => ({
4852
+ marks: this.timeline.marks,
4853
+ ranges: this.timeline.ranges,
4854
+ }),
4705
4855
  "drops.provide": (p) => {
4706
4856
  const id = String(p.id);
4707
4857
  const index = this.dropProviders.findIndex((d) => d.id === id);
@@ -4895,6 +5045,44 @@ export class TestHost {
4895
5045
  })));
4896
5046
  return { applied: true };
4897
5047
  },
5048
+ // Player 0.7.6: the layer's ranges, replaced on their own.
5049
+ "timeline.setRanges": (p) => {
5050
+ if (p.sourceId !== undefined &&
5051
+ p.sourceId !== this.source.stamp.generation)
5052
+ return { applied: false };
5053
+ const sent = p.ranges;
5054
+ const duration = this.source.duration;
5055
+ if (!Array.isArray(sent) || sent.length > TIMELINE_LIMITS.ranges)
5056
+ throw new HostError("invalid", `a layer holds at most ${TIMELINE_LIMITS.ranges} ranges`);
5057
+ const ranges = sent.map((range) => ({ ...range }));
5058
+ for (const range of ranges) {
5059
+ const { start, end } = range;
5060
+ const time = (t) => typeof t === "number" && Number.isFinite(t) && t >= 0;
5061
+ if (!time(start) || !time(end))
5062
+ throw new HostError("invalid", "start and end must be numbers ≥ 0");
5063
+ if (end <= start)
5064
+ throw new HostError("invalid", `end ${end} must be after start ${start}`);
5065
+ // As the player (timeline.rs): 1e-6 s of rounding past the end is allowed.
5066
+ if (duration !== null && end > duration + 1e-6)
5067
+ throw new HostError("invalid", `end ${end} is after the end of the media`);
5068
+ if (!["selection", "region"].includes(String(range.style)))
5069
+ throw new HostError("invalid", "style is selection or region");
5070
+ if (isTextValue(range.title))
5071
+ range.title = this.#declared(range.title, "title");
5072
+ if (range.title !== undefined &&
5073
+ range.title !== null &&
5074
+ (typeof range.title !== "string" ||
5075
+ [...range.title].length > TIMELINE_LIMITS.titleChars))
5076
+ throw new HostError("invalid", `title must be ≤ ${TIMELINE_LIMITS.titleChars} characters`);
5077
+ }
5078
+ this.setTimelineRanges(this.manifestId, ranges.map((r) => ({
5079
+ start: r.start,
5080
+ end: r.end,
5081
+ style: String(r.style),
5082
+ title: r.title ?? null,
5083
+ })));
5084
+ return { applied: true };
5085
+ },
4898
5086
  "ui.interaction": (p) => void (this.mounted().locks = p),
4899
5087
  "dialogs.confirm": (p) => {
4900
5088
  this.confirms.push(p);
@@ -5482,8 +5670,18 @@ export class TestHost {
5482
5670
  "jobs.start": (p) => {
5483
5671
  if (!this.snapshots.has(Number(p.snapshotId)))
5484
5672
  throw new HostError("missing", "snapshot released");
5485
- if (!/^(raw|processed)(Png|Jpeg|Bmp|Mp4)$/.test(String(p.kind)) ||
5486
- (p.quality !== undefined && !String(p.kind).endsWith("Jpeg")))
5673
+ const kind = String(p.kind);
5674
+ const snapshot = this.snapshots.get(Number(p.snapshotId));
5675
+ const clip = /^(rawMp4|processedMp4|rawCopy)$/.test(kind);
5676
+ if (!(/^(raw|processed)(Png|Jpeg|Bmp)$/.test(kind) || clip) ||
5677
+ (p.quality !== undefined && !kind.endsWith("Jpeg")) ||
5678
+ (p.encoding !== undefined &&
5679
+ (!kind.endsWith("Mp4") || !validEncoding(p.encoding))) ||
5680
+ (p.container !== undefined &&
5681
+ (kind !== "rawCopy" ||
5682
+ !["auto", "mp4", "mkv"].includes(String(p.container)))) ||
5683
+ (clip && typeof p.end !== "number") ||
5684
+ !kind.startsWith(snapshot.kind))
5487
5685
  throw new HostError("invalid", `unknown job kind ${p.kind}`);
5488
5686
  this.exports.push(structuredClone(p));
5489
5687
  const jobId = ++this.#next.job;
@@ -5493,23 +5691,71 @@ export class TestHost {
5493
5691
  publicationStarted: false,
5494
5692
  facts: null,
5495
5693
  outcome: null,
5694
+ progress: null,
5695
+ });
5696
+ this.#exportJobs.set(jobId, {
5697
+ destination: String(p.destination),
5698
+ // A copy starts 1 s early (on a "keyframe"), the player's own way of
5699
+ // saying the start moved; a re-encode starts on the frame.
5700
+ clip: clip
5701
+ ? {
5702
+ start: kind === "rawCopy"
5703
+ ? Math.max(0, snapshot.time - 1)
5704
+ : snapshot.time,
5705
+ end: Number(p.end),
5706
+ container: kind !== "rawCopy"
5707
+ ? null
5708
+ : p.container === "mkv"
5709
+ ? "mkv"
5710
+ : "mp4",
5711
+ }
5712
+ : null,
5496
5713
  });
5497
5714
  return { jobId, status: "queued", snapshotId: p.snapshotId };
5498
5715
  },
5499
- // Each read advances a job one step: queued → running → completed.
5716
+ // Each read advances a job one step: queued → running → completed. A
5717
+ // clip reports half its progress while running.
5500
5718
  "jobs.state": (p) => {
5501
5719
  const job = this.jobs.get(Number(p.jobId));
5502
5720
  if (!job)
5503
5721
  throw new HostError("missing", "job");
5504
5722
  const current = structuredClone(job);
5505
- if (job.status === "queued")
5723
+ const made = this.#exportJobs.get(job.jobId);
5724
+ const clip = made?.clip;
5725
+ if (job.status === "queued") {
5506
5726
  job.status = "running";
5507
- else if (job.status === "running")
5727
+ if (clip) {
5728
+ const total = clip.end - clip.start;
5729
+ job.progress = { done: total / 2, total };
5730
+ }
5731
+ }
5732
+ else if (job.status === "running" && this.exportFailure)
5733
+ Object.assign(job, {
5734
+ status: "failed",
5735
+ outcome: { error: { ...this.exportFailure } },
5736
+ });
5737
+ else if (job.status === "running") {
5738
+ const destination = clip?.container === "mkv"
5739
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mkv")
5740
+ : clip?.container === "mp4"
5741
+ ? made.destination.replace(/(\.(mp4|m4v|mov|mkv|webm))?$/i, ".mp4")
5742
+ : "/tmp/output";
5508
5743
  Object.assign(job, {
5509
5744
  status: "completed",
5510
5745
  publicationStarted: true,
5511
- outcome: { destination: "/tmp/output" },
5746
+ outcome: clip
5747
+ ? { destination, start: clip.start, end: clip.end }
5748
+ : { destination },
5749
+ ...(job.progress
5750
+ ? {
5751
+ progress: {
5752
+ done: job.progress.total,
5753
+ total: job.progress.total,
5754
+ },
5755
+ }
5756
+ : {}),
5512
5757
  });
5758
+ }
5513
5759
  else if (job.status === "cancelling")
5514
5760
  Object.assign(job, {
5515
5761
  status: "cancelled",
@@ -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
  }