@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.
- package/CHANGELOG.md +18 -0
- package/bin/vestra-plugin.mjs +1 -84
- package/dist/api-requests.js +5 -1
- package/dist/commands.d.ts +6 -7
- package/dist/commands.js +6 -7
- package/dist/docs-tree.json.gz +0 -0
- package/dist/host-capabilities.js +4 -1
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/osd.d.ts +4 -0
- package/dist/plugin/osd.js +26 -1
- package/dist/plugin/testing.d.ts +100 -2
- package/dist/plugin/testing.js +160 -5
- package/dist/plugin/timeline.d.ts +40 -3
- package/dist/plugin/timeline.js +102 -10
- package/dist/plugin/types.d.ts +204 -21
- package/dist/wire.d.ts +27 -4
- package/dist/wire.js +8 -1
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/slint/vestra/bar-buttons.slint +72 -16
package/dist/plugin/testing.js
CHANGED
|
@@ -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", {
|
|
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
|
|
3409
|
-
* the
|
|
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": () => ({
|
|
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 {
|
|
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(
|
|
53
|
+
accept(lists: WireTimeline): void;
|
|
17
54
|
read(): Promise<void>;
|
|
18
55
|
watch(listener: (marks: readonly TimelineMark[]) => void): {
|
|
19
56
|
dispose: () => Promise<void>;
|
package/dist/plugin/timeline.js
CHANGED
|
@@ -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
|
|
33
|
-
if (Array.isArray(marks))
|
|
34
|
-
this.accept(
|
|
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(
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
}
|