@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.
- package/CHANGELOG.md +22 -1
- package/dist/api-requests.js +5 -1
- package/dist/docs-tree.json.gz +0 -0
- package/dist/host-capabilities.js +4 -1
- package/dist/media.d.ts +43 -0
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/media.js +46 -1
- package/dist/plugin/osd.d.ts +4 -0
- package/dist/plugin/osd.js +26 -1
- package/dist/plugin/testing.d.ts +117 -1
- package/dist/plugin/testing.js +256 -10
- package/dist/plugin/timeline.d.ts +40 -3
- package/dist/plugin/timeline.js +102 -10
- package/dist/plugin/types.d.ts +357 -10
- package/dist/wire.d.ts +32 -4
- package/dist/wire.js +8 -1
- package/llms.txt +5 -5
- package/package.json +1 -1
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`. */
|
|
@@ -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", {
|
|
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
|
-
*
|
|
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": () => ({
|
|
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
|
-
|
|
5486
|
-
|
|
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
|
-
|
|
5723
|
+
const made = this.#exportJobs.get(job.jobId);
|
|
5724
|
+
const clip = made?.clip;
|
|
5725
|
+
if (job.status === "queued") {
|
|
5506
5726
|
job.status = "running";
|
|
5507
|
-
|
|
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:
|
|
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 {
|
|
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
|
}
|