@vestra-player/sdk 1.5.29 → 1.5.30
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 +12 -0
- package/dist/capabilities.d.ts +13 -3
- package/dist/capabilities.js +13 -3
- package/dist/commands.d.ts +49 -9
- package/dist/commands.js +49 -9
- package/dist/index.d.ts +6 -1
- package/dist/palettes.d.ts +16 -2
- package/dist/palettes.js +16 -2
- package/dist/plugin/bindings.d.ts +27 -3
- package/dist/plugin/bindings.js +27 -3
- package/dist/plugin/osd.d.ts +13 -1
- package/dist/plugin/osd.js +13 -1
- package/dist/plugin/services.d.ts +46 -6
- package/dist/plugin/services.js +46 -6
- package/dist/plugin/testing.d.ts +353 -62
- package/dist/plugin/testing.js +343 -61
- package/dist/plugin/types.d.ts +781 -164
- package/dist/plugin/values.d.ts +27 -3
- package/dist/plugin/values.js +27 -3
- package/dist/processing.d.ts +16 -4
- package/dist/source-resolvers.d.ts +33 -8
- package/dist/subtitles.d.ts +19 -9
- package/dist/wire.d.ts +12 -3
- package/dist/wire.js +12 -3
- package/llms-full.txt +3312 -20
- package/llms.txt +20 -20
- package/package.json +1 -1
package/llms-full.txt
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# @vestra-player/sdk 1.5.
|
|
1
|
+
# @vestra-player/sdk 1.5.30: full reference
|
|
2
2
|
|
|
3
3
|
> Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
|
|
4
4
|
|
|
5
|
-
How to read this file: the declarations are TypeScript declaration files whose doc comments keep
|
|
5
|
+
How to read this file: the declarations are TypeScript declaration files whose doc comments keep the description, @remarks, the tags @capability (the manifest capability a call needs), @player (the oldest player version), @param, @returns, @throws (`code` (throws | rejects) condition) and @deprecated, and every @example (a task title, a lead saying what it does and how it works, then code). @see, @since and the rest are on the documentation site and in node_modules/@vestra-player/sdk/dist/**/*.d.ts. Only what is declared here exists; do not use other members.
|
|
6
6
|
|
|
7
7
|
Limits: 160 commands per plugin and 1024 in the player (`limit`); menu ids for the first path segment: `@open`, `@playback`, `@subtitles`, `@video`, `@audio`, `@screen`, `@settings`; manifest: id ≤ 128 bytes, name, author ≤ 64 characters, punchline ≤ 120, description ≤ 2000, tags ≤ 8 of ≤ 24 characters, license ≤ 64, sourceUrl ≤ 2048 bytes, icon PNG or JPEG ≤ 512 KB and 1024 px per side, readme, changelog ≤ 256 KB.
|
|
8
8
|
|
|
@@ -3304,6 +3304,18 @@ interface Disposable {
|
|
|
3304
3304
|
}
|
|
3305
3305
|
```
|
|
3306
3306
|
|
|
3307
|
+
Example: Stop listening once the file is ready
|
|
3308
|
+
|
|
3309
|
+
The listener reacts to the first time the source becomes ready and then
|
|
3310
|
+
stops being called. Every registration returns a `Disposable`;
|
|
3311
|
+
disposing it removes just that listener, before the scope that owns it
|
|
3312
|
+
ends.
|
|
3313
|
+
```ts
|
|
3314
|
+
const subscription = plugin.player.onChange((state) => {
|
|
3315
|
+
if (state.phase === "ready") void subscription.dispose();
|
|
3316
|
+
});
|
|
3317
|
+
```
|
|
3318
|
+
|
|
3307
3319
|
### PluginError
|
|
3308
3320
|
|
|
3309
3321
|
Every API failure is an ordinary `Error` with a stable string `code`.
|
|
@@ -3365,6 +3377,21 @@ type PluginError = Error & {
|
|
|
3365
3377
|
};
|
|
3366
3378
|
```
|
|
3367
3379
|
|
|
3380
|
+
Example: Ignore a seek that lost a race, rethrow anything else
|
|
3381
|
+
|
|
3382
|
+
The plugin seeks to 0:30 and stays quiet when another file was opened in
|
|
3383
|
+
between. Every failure is an `Error` with a stable `code`; `stale` means
|
|
3384
|
+
the source changed since the state the call was based on and nothing
|
|
3385
|
+
was done, so it is safe to drop. Other codes are real failures.
|
|
3386
|
+
```ts
|
|
3387
|
+
try {
|
|
3388
|
+
await plugin.player.seek(30);
|
|
3389
|
+
} catch (error) {
|
|
3390
|
+
if ((error as PluginError).code === "stale") return; // someone else changed the source
|
|
3391
|
+
throw error;
|
|
3392
|
+
}
|
|
3393
|
+
```
|
|
3394
|
+
|
|
3368
3395
|
### Scope
|
|
3369
3396
|
|
|
3370
3397
|
A group of handles released together.
|
|
@@ -3472,6 +3499,14 @@ interface Scope extends Disposable {
|
|
|
3472
3499
|
/**
|
|
3473
3500
|
* Run `cleanup` when this scope is disposed (for example `clearInterval` or closing a socket). It may be async; errors are logged.
|
|
3474
3501
|
* @returns A handle that cancels the registration early (without running `cleanup`).
|
|
3502
|
+
* @example Stop a timer when the plugin stops
|
|
3503
|
+
* A once-a-second refresh runs as long as the plugin does. `onDispose`
|
|
3504
|
+
* runs the cleanup when the scope is disposed, which for `plugin` is
|
|
3505
|
+
* when the player stops the plugin, so the timer never outlives it.
|
|
3506
|
+
* ```ts
|
|
3507
|
+
* const timer = setInterval(() => plugin.update(), 1000); // re-evaluate bindings
|
|
3508
|
+
* plugin.onDispose(() => clearInterval(timer));
|
|
3509
|
+
* ```
|
|
3475
3510
|
*/
|
|
3476
3511
|
onDispose(cleanup: () => void | Promise<void>): Disposable;
|
|
3477
3512
|
/**
|
|
@@ -3482,6 +3517,16 @@ interface Scope extends Disposable {
|
|
|
3482
3517
|
* Rejections of async handlers count too, and so do failures of
|
|
3483
3518
|
* registrations made after activation (a command whose `ready` rejects,
|
|
3484
3519
|
* a property write the player refused).
|
|
3520
|
+
* @example Show errors from handlers on the OSD
|
|
3521
|
+
* When a menu command, a window callback or a listener of the plugin
|
|
3522
|
+
* fails, the user sees why. The SDK catches what those handlers throw
|
|
3523
|
+
* (and their rejected promises) and passes it here instead of only
|
|
3524
|
+
* logging it; the message is data in a translated template.
|
|
3525
|
+
* ```ts
|
|
3526
|
+
* plugin.onError((error) => {
|
|
3527
|
+
* void plugin.osd.show("Bookmarks: {}", [error.message]);
|
|
3528
|
+
* });
|
|
3529
|
+
* ```
|
|
3485
3530
|
*/
|
|
3486
3531
|
onError(listener: (error: PluginError) => void): Disposable;
|
|
3487
3532
|
/**
|
|
@@ -3496,6 +3541,30 @@ interface Scope extends Disposable {
|
|
|
3496
3541
|
}
|
|
3497
3542
|
```
|
|
3498
3543
|
|
|
3544
|
+
Example: Rebuild a dynamic submenu with a child scope
|
|
3545
|
+
|
|
3546
|
+
Subtitles ▸ Track lists the current file's subtitle tracks and is
|
|
3547
|
+
rebuilt whenever they change. Commands added through a child scope
|
|
3548
|
+
belong to it; disposing the scope removes all of them at once, and a
|
|
3549
|
+
new child scope collects the next set.
|
|
3550
|
+
```ts
|
|
3551
|
+
let tracks = plugin.scope();
|
|
3552
|
+
plugin.subtitles.onChange(async (state) => {
|
|
3553
|
+
await tracks.dispose(); // removes the previous menu items
|
|
3554
|
+
tracks = plugin.scope();
|
|
3555
|
+
state.tracks.forEach((track, i) =>
|
|
3556
|
+
tracks.commands.add({
|
|
3557
|
+
id: `track.${i}`,
|
|
3558
|
+
path: ["Subtitles", "Track", track.title],
|
|
3559
|
+
order: 300 + i,
|
|
3560
|
+
radio: true,
|
|
3561
|
+
checked: () => plugin.subtitles.state?.selected?.id === track.id,
|
|
3562
|
+
run: () => plugin.subtitles.select(track),
|
|
3563
|
+
}),
|
|
3564
|
+
);
|
|
3565
|
+
});
|
|
3566
|
+
```
|
|
3567
|
+
|
|
3499
3568
|
### Plugin
|
|
3500
3569
|
|
|
3501
3570
|
The object the player passes to a plugin's `activate`: the root
|
|
@@ -3538,6 +3607,25 @@ interface Plugin extends Scope {
|
|
|
3538
3607
|
}
|
|
3539
3608
|
```
|
|
3540
3609
|
|
|
3610
|
+
Example: Write a minimal plugin with one menu item
|
|
3611
|
+
|
|
3612
|
+
Tools ▸ Say hello, or Ctrl+H, shows "Hello from" and the plugin's id.
|
|
3613
|
+
The player imports `main.ts`, calls its default export with the
|
|
3614
|
+
`Plugin` object, and keeps the registrations made during activation;
|
|
3615
|
+
the manifest declares the capabilities `commands` and `ui.osd`.
|
|
3616
|
+
```ts
|
|
3617
|
+
import type { Plugin } from "@vestra-player/sdk";
|
|
3618
|
+
|
|
3619
|
+
export default async function activate(plugin: Plugin) {
|
|
3620
|
+
plugin.commands.add({
|
|
3621
|
+
id: "hello",
|
|
3622
|
+
path: ["Tools", "Say hello"],
|
|
3623
|
+
bindings: ["Ctrl+H"], // a default the user can change
|
|
3624
|
+
run: () => plugin.osd.show("Hello from {}", [plugin.id]),
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
```
|
|
3628
|
+
|
|
3541
3629
|
### CommandState
|
|
3542
3630
|
|
|
3543
3631
|
The state of a command's menu item, as the player shows it.
|
|
@@ -3564,6 +3652,22 @@ Scope.update); only changes are sent to the player.
|
|
|
3564
3652
|
type Bindable<T> = T | (() => T);
|
|
3565
3653
|
```
|
|
3566
3654
|
|
|
3655
|
+
Example: Mix a computed state with a fixed one
|
|
3656
|
+
|
|
3657
|
+
The Loop item carries a check mark while a loop is set and is always
|
|
3658
|
+
enabled. A function is evaluated again in every update pass (after each
|
|
3659
|
+
handler and player state change) and only changes are sent; a plain
|
|
3660
|
+
value never changes.
|
|
3661
|
+
```ts
|
|
3662
|
+
plugin.commands.add({
|
|
3663
|
+
id: "loop",
|
|
3664
|
+
path: ["Playback", "Loop"],
|
|
3665
|
+
checked: () => plugin.player.state.loop != null, // re-evaluated
|
|
3666
|
+
enabled: true, // a fixed value
|
|
3667
|
+
run: () => plugin.player.setLoop(null),
|
|
3668
|
+
});
|
|
3669
|
+
```
|
|
3670
|
+
|
|
3567
3671
|
### MenuId
|
|
3568
3672
|
|
|
3569
3673
|
A built-in top-level menu of the player, named in a command path as
|
|
@@ -3589,6 +3693,32 @@ type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "open" | "
|
|
|
3589
3693
|
|
|
3590
3694
|
A command's button on the bottom bar: CommandOptions.button.
|
|
3591
3695
|
|
|
3696
|
+
**Where it shows.** The bottom bar places the player's strip of command
|
|
3697
|
+
buttons, `BarButtons` from `@vestra/bar-buttons.slint`, at its right end;
|
|
3698
|
+
the player fills it with the buttons of every plugin, left to right by
|
|
3699
|
+
`order`, and its own sidebar toggle rightmost. When the window is narrow
|
|
3700
|
+
the strip loses cells from its outer (left) end inward: the lowest order
|
|
3701
|
+
goes first, the sidebar toggle last. A
|
|
3702
|
+
bottom bar that does not place `BarButtons` shows no buttons: the
|
|
3703
|
+
command's menu entry stays, and the player logs the buttons it cannot
|
|
3704
|
+
show and whose bar hides them. The built-in bar (`vestra.playback`) places
|
|
3705
|
+
it; see
|
|
3706
|
+
Writing a bottom bar.
|
|
3707
|
+
|
|
3708
|
+
**What it shows.** The icon, the command's title (translated, a trailing
|
|
3709
|
+
"…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
|
|
3710
|
+
`enabled`, `busy` and `reason` as the menu item has them: a disabled or
|
|
3711
|
+
busy command's button is drawn disabled, its tooltip adds the reason, and
|
|
3712
|
+
a click runs nothing. A command with `checked` shows its icon in the
|
|
3713
|
+
accent colour while checked. A click runs the command exactly like a
|
|
3714
|
+
click on its menu item.
|
|
3715
|
+
|
|
3716
|
+
**Limits.** One button per plugin and eight in the player
|
|
3717
|
+
(`BAR_BUTTON_LIMITS`): a button past them is left out (its command and
|
|
3718
|
+
menu entry stay) and the player logs it with `limit`. The button goes
|
|
3719
|
+
with its command: disposing the command, or disabling, stopping or
|
|
3720
|
+
uninstalling the plugin, removes it.
|
|
3721
|
+
|
|
3592
3722
|
- @player 0.5.10; older players show the menu entry without a button (declare `"minPlayerVersion": "0.5.10"` when the button matters).
|
|
3593
3723
|
|
|
3594
3724
|
```ts
|
|
@@ -3604,6 +3734,23 @@ interface CommandButton {
|
|
|
3604
3734
|
}
|
|
3605
3735
|
```
|
|
3606
3736
|
|
|
3737
|
+
Example: Give a command a button in the bottom bar
|
|
3738
|
+
|
|
3739
|
+
A Store icon appears in the bottom bar's button strip; a click opens
|
|
3740
|
+
the store window, exactly like the menu item. The player draws the
|
|
3741
|
+
button from the command's title, keys and state, and places it by
|
|
3742
|
+
`order` (200 is where the player's own Plugin Store button sits); a
|
|
3743
|
+
bar that does not place `BarButtons` shows only the menu item.
|
|
3744
|
+
```ts
|
|
3745
|
+
const store = plugin.windows.create("ui/store.slint");
|
|
3746
|
+
plugin.commands.add({
|
|
3747
|
+
id: "open",
|
|
3748
|
+
path: ["Plugin Store…"], // a button needs a menu path
|
|
3749
|
+
button: { icon: "store", order: 200 },
|
|
3750
|
+
run: () => store.show(),
|
|
3751
|
+
});
|
|
3752
|
+
```
|
|
3753
|
+
|
|
3607
3754
|
### CommandOptions
|
|
3608
3755
|
|
|
3609
3756
|
The declaration passed to Commands.add.
|
|
@@ -3663,6 +3810,22 @@ interface CommandOptions {
|
|
|
3663
3810
|
* Text (SDK 1.5.11, player 0.4.3): `plugin.i18n.text("Track {}
|
|
3664
3811
|
* ({})", [n, codec])`. The menu shows it translated and filled in; it
|
|
3665
3812
|
* merges by its English rendering ("Track 2 (opus)").
|
|
3813
|
+
* @example Put items into built-in menus, one with a track number
|
|
3814
|
+
* "Blur" appears in the Video menu and "Track 2 (opus)" under Audio ▸
|
|
3815
|
+
* Audio Track, both in the user's language. `@video` and `@audio` name
|
|
3816
|
+
* the built-in menus by id; "Audio Track" is a submenu key that merges
|
|
3817
|
+
* with any other plugin's same key; the `text()` segment is translated
|
|
3818
|
+
* with the plugin's catalog and then filled in with the number and codec.
|
|
3819
|
+
* ```ts
|
|
3820
|
+
* plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run: toggle });
|
|
3821
|
+
* const { text } = plugin.i18n;
|
|
3822
|
+
* // A segment with data: translated as "Track {} ({})", then filled in.
|
|
3823
|
+
* plugin.commands.add({
|
|
3824
|
+
* id: "track-2",
|
|
3825
|
+
* path: ["@audio", "Audio Track", text("Track {} ({})", [2, "opus"])],
|
|
3826
|
+
* run: select,
|
|
3827
|
+
* });
|
|
3828
|
+
* ```
|
|
3666
3829
|
*/
|
|
3667
3830
|
path?: (string | Text)[];
|
|
3668
3831
|
/** Menu label (English source text, shown translated, or a Text); defaults to the last path segment. */
|
|
@@ -3730,7 +3893,28 @@ from the menu and the binding table; the id can be added again right away.
|
|
|
3730
3893
|
interface Command extends Disposable {
|
|
3731
3894
|
/** The local id given in CommandOptions.id. */
|
|
3732
3895
|
readonly id: string;
|
|
3733
|
-
/**
|
|
3896
|
+
/**
|
|
3897
|
+
* Replace some state fields (values or functions). Last write wins.
|
|
3898
|
+
* @example Show a command as busy while it works
|
|
3899
|
+
* The Export item shows as busy with "Exporting…" as its reason while the
|
|
3900
|
+
* export runs, then returns to normal. `set` replaces only the fields it
|
|
3901
|
+
* names and the SDK sends the new state to the player; a busy command's
|
|
3902
|
+
* menu item and key do not run it again until it is set back.
|
|
3903
|
+
* ```ts
|
|
3904
|
+
* const exportCommand = plugin.commands.add({
|
|
3905
|
+
* id: "export",
|
|
3906
|
+
* path: ["@video", "Export…"],
|
|
3907
|
+
* run: async () => {
|
|
3908
|
+
* exportCommand.set({ busy: true, reason: "Exporting…" });
|
|
3909
|
+
* try {
|
|
3910
|
+
* await runExport();
|
|
3911
|
+
* } finally {
|
|
3912
|
+
* exportCommand.set({ busy: false, reason: null }); // also on failure
|
|
3913
|
+
* }
|
|
3914
|
+
* },
|
|
3915
|
+
* });
|
|
3916
|
+
* ```
|
|
3917
|
+
*/
|
|
3734
3918
|
set(state: Partial<{
|
|
3735
3919
|
[K in keyof CommandState]: Bindable<CommandState[K]>;
|
|
3736
3920
|
}>): void;
|
|
@@ -3754,6 +3938,23 @@ interface Commands {
|
|
|
3754
3938
|
* Titles and paths cannot change in place: put changing items (track
|
|
3755
3939
|
* lists, presets) in a child Scope and rebuild it.
|
|
3756
3940
|
* @throws invalid synchronously for a malformed path (including an unknown `@` menu id), order, group or binding; `conflict` for an id this plugin has already declared and not disposed.
|
|
3941
|
+
* @example Pause playback from a menu item
|
|
3942
|
+
* Playback ▸ Pause (and Ctrl+P) pauses and resumes, shows a check mark
|
|
3943
|
+
* while paused and is greyed out when nothing can be paused. The player
|
|
3944
|
+
* calls `run` when the user picks the item or presses its key; `enabled`
|
|
3945
|
+
* and `checked` are functions the SDK evaluates again after every
|
|
3946
|
+
* handler and player state change, sending only what changed.
|
|
3947
|
+
* ```ts
|
|
3948
|
+
* const toggle = plugin.commands.add({
|
|
3949
|
+
* id: "toggle-pause",
|
|
3950
|
+
* path: ["Playback", "Pause"],
|
|
3951
|
+
* order: 210, // position among the Playback menu's items
|
|
3952
|
+
* bindings: ["Ctrl+P"],
|
|
3953
|
+
* enabled: () => plugin.player.state.can.pause,
|
|
3954
|
+
* checked: () => plugin.player.state.paused,
|
|
3955
|
+
* run: () => plugin.player.setPaused(!plugin.player.state.paused),
|
|
3956
|
+
* });
|
|
3957
|
+
* ```
|
|
3757
3958
|
*/
|
|
3758
3959
|
add(options: CommandOptions): Command;
|
|
3759
3960
|
}
|
|
@@ -3763,6 +3964,18 @@ interface Commands {
|
|
|
3763
3964
|
|
|
3764
3965
|
An image file for a Slint `image` property, made by Ui.image.
|
|
3765
3966
|
|
|
3967
|
+
Opaque: pass it where the Slint type is `image`, with View.set or
|
|
3968
|
+
View.bind: the whole value of an `image` property or, from SDK
|
|
3969
|
+
1.5.19 (player 0.5.1), an `image` field at any depth, such as the logo of
|
|
3970
|
+
each row of a list. Making the reference does not read the file: the
|
|
3971
|
+
player checks and decodes it each time it receives the value (a property
|
|
3972
|
+
write, a window opening; Ui.image has the rules and limits).
|
|
3973
|
+
|
|
3974
|
+
A reference belongs to the Scope that created it. Once that scope
|
|
3975
|
+
is disposed, writing the reference fails with `invalid` (reported to
|
|
3976
|
+
Scope.onError); an image already on screen stays until the
|
|
3977
|
+
property changes or the view closes.
|
|
3978
|
+
|
|
3766
3979
|
```ts
|
|
3767
3980
|
interface ImageRef {
|
|
3768
3981
|
/** The path given to Ui.image, unchanged. */
|
|
@@ -3772,6 +3985,43 @@ interface ImageRef {
|
|
|
3772
3985
|
}
|
|
3773
3986
|
```
|
|
3774
3987
|
|
|
3988
|
+
Example: Show a cover image from the data directory
|
|
3989
|
+
|
|
3990
|
+
A window opens showing `cover.png`, which the plugin saved in its data
|
|
3991
|
+
directory. `ui.image` only makes a reference, without reading the file;
|
|
3992
|
+
the player checks and decodes the file when it receives the value, so a
|
|
3993
|
+
missing file fails that write with `missing` (here: `show` rejects, as
|
|
3994
|
+
it is an initial property). A missing image nested inside a value is
|
|
3995
|
+
drawn empty instead.
|
|
3996
|
+
```ts
|
|
3997
|
+
const cover = plugin.windows.create("ui/cover.slint");
|
|
3998
|
+
// `art` is an `image` property in ui/cover.slint.
|
|
3999
|
+
cover.set({ art: plugin.ui.image(`${plugin.dataDirectory}/cover.png`) });
|
|
4000
|
+
await cover.show();
|
|
4001
|
+
```
|
|
4002
|
+
|
|
4003
|
+
Example: Show a logo in each row of a list
|
|
4004
|
+
|
|
4005
|
+
Each row of the list draws its own small logo next to its name. An
|
|
4006
|
+
`ImageRef` may sit in any field of a property value (SDK 1.5.19, player
|
|
4007
|
+
0.5.1), so the rows carry their icons; `maxSide: 64` decodes each logo
|
|
4008
|
+
at most 64 px wide, which keeps the view's image memory small.
|
|
4009
|
+
```slint
|
|
4010
|
+
export struct Row { key: string, name: string, icon: image }
|
|
4011
|
+
```
|
|
4012
|
+
```ts
|
|
4013
|
+
const items = [{ id: "a", name: "A", iconPath: `${plugin.dataDirectory}/a.png` }];
|
|
4014
|
+
const list = plugin.windows.create("ui/list.slint");
|
|
4015
|
+
list.bind(() => ({
|
|
4016
|
+
rows: items.map((item) => ({
|
|
4017
|
+
key: item.id,
|
|
4018
|
+
name: item.name,
|
|
4019
|
+
// About twice the drawn size (32 px) stays sharp on dense screens.
|
|
4020
|
+
icon: plugin.ui.image(item.iconPath, { maxSide: 64 }),
|
|
4021
|
+
})),
|
|
4022
|
+
}));
|
|
4023
|
+
```
|
|
4024
|
+
|
|
3775
4025
|
### ImageOptions
|
|
3776
4026
|
|
|
3777
4027
|
Options of Ui.image and Ui.remoteImage.
|
|
@@ -3791,6 +4041,18 @@ interface ImageOptions {
|
|
|
3791
4041
|
A Markdown document for `MarkdownView` or `MarkdownBlocks`, made by
|
|
3792
4042
|
Ui.markdown.
|
|
3793
4043
|
|
|
4044
|
+
Opaque, like ImageRef: pass it where the Slint type is
|
|
4045
|
+
`[MarkdownBlock]`: the whole value of such a property (the `blocks` of
|
|
4046
|
+
`MarkdownView` from `@vestra/markdown.slint`) or, from SDK 1.5.4, a
|
|
4047
|
+
`[MarkdownBlock]` field at any depth of a property value, such as the
|
|
4048
|
+
`body` of each row of a chat's message list. It carries the text, the
|
|
4049
|
+
base address and the local copies of its remote images; the player parses
|
|
4050
|
+
and draws it. Writing a document whose text did not change (a new
|
|
4051
|
+
reference for the same text included) costs the player no parsing, and a
|
|
4052
|
+
changed document updates only the blocks that changed. It belongs to the
|
|
4053
|
+
scope that created it: once that scope is disposed, writing it fails with
|
|
4054
|
+
`invalid`, wherever it is in the value.
|
|
4055
|
+
|
|
3794
4056
|
- @player 0.2.0; nested in a value: the player after 0.4.0
|
|
3795
4057
|
|
|
3796
4058
|
```ts
|
|
@@ -3802,6 +4064,18 @@ interface MarkdownRef {
|
|
|
3802
4064
|
}
|
|
3803
4065
|
```
|
|
3804
4066
|
|
|
4067
|
+
Example: Show chat messages, one document per row
|
|
4068
|
+
|
|
4069
|
+
The chat window shows a formatted assistant message. `ui.markdown`
|
|
4070
|
+
resolves once the document is ready (here without downloading images);
|
|
4071
|
+
the reference goes into the `body` field of a row, whose Slint type is
|
|
4072
|
+
`[MarkdownBlock]`, and the player parses and draws it.
|
|
4073
|
+
```ts
|
|
4074
|
+
const chat = plugin.windows.create("ui/chat.slint");
|
|
4075
|
+
const body = await plugin.ui.markdown("**Hello**", { remoteImages: false });
|
|
4076
|
+
chat.set({ messages: [{ id: "m1", role: "assistant", body }] });
|
|
4077
|
+
```
|
|
4078
|
+
|
|
3805
4079
|
### MarkdownOptions
|
|
3806
4080
|
|
|
3807
4081
|
Options of Ui.markdown: where relative links and images point, and
|
|
@@ -3823,6 +4097,46 @@ interface MarkdownOptions {
|
|
|
3823
4097
|
The value of one Slint property: JSON with Markdown documents and images
|
|
3824
4098
|
at any depth (PropData).
|
|
3825
4099
|
|
|
4100
|
+
JSON becomes Slint values as you would expect: booleans for `bool`,
|
|
4101
|
+
finite numbers for `int` and `float`, strings for `string`, arrays for
|
|
4102
|
+
models (`[T]`), objects for structs. Struct field names are passed
|
|
4103
|
+
unchanged, so write them as the Slint file spells them; a name with `_`
|
|
4104
|
+
is refused (Slint treats `_` and `-` as the same, so use camelCase or
|
|
4105
|
+
kebab-case).
|
|
4106
|
+
|
|
4107
|
+
A Text or TextList goes where the Slint type is `string`,
|
|
4108
|
+
at any depth (SDK 1.5.11, player 0.4.3): the player shows it translated
|
|
4109
|
+
with the plugin's catalog, so a `[string]` model (a ComboBox) can mix
|
|
4110
|
+
words and data. The limits below count it as the string it becomes.
|
|
4111
|
+
|
|
4112
|
+
A MarkdownRef goes where the Slint type is `[MarkdownBlock]`: the
|
|
4113
|
+
whole value, or a field at any depth (SDK 1.5.4; older players refuse a
|
|
4114
|
+
nested one). An ImageRef goes where the Slint type is `image`: the
|
|
4115
|
+
whole value, or a field at any depth (SDK 1.5.19, player 0.5.1; older
|
|
4116
|
+
players refuse a nested one with `invalid`). The images nested in the
|
|
4117
|
+
values of one view (a window, panel or settings page) may show at most
|
|
4118
|
+
32 MB of decoded pixels together (`IMAGE_LIMITS.viewBytes` in
|
|
4119
|
+
`@vestra-player/sdk/runtime`): a write
|
|
4120
|
+
that would pass it is refused with `limit`; use
|
|
4121
|
+
ImageOptions.maxSide for small pictures such as logos and
|
|
4122
|
+
thumbnails.
|
|
4123
|
+
|
|
4124
|
+
Limits per value (`UI_VALUE_LIMITS` in `@vestra-player/sdk/testing`,
|
|
4125
|
+
player 0.4.3; older players: 4096 values and 1024 rows): nesting depth 8,
|
|
4126
|
+
16384 values in total (every array, object, string, number, boolean and
|
|
4127
|
+
null counts, the value itself included; a document or an image is one), 4096 rows
|
|
4128
|
+
per array, 64 KiB per string or field name and 1 MiB of text in total,
|
|
4129
|
+
field names and the text of its Markdown documents included (256 KB per
|
|
4130
|
+
document). A list of rows with 8 fields is 9 values per row, so about
|
|
4131
|
+
1800 rows fit. The whole request must also fit the player's 1 MiB
|
|
4132
|
+
message. A value over a limit is refused with code `limit` and the
|
|
4133
|
+
property keeps what it showed (see View.set); `TestHost` refuses
|
|
4134
|
+
it the same way.
|
|
4135
|
+
|
|
4136
|
+
The limits bound the work of one write, not the size of your data: for
|
|
4137
|
+
a list that can grow without bound, send the rows near the view and the
|
|
4138
|
+
row count, and move that window as the user scrolls.
|
|
4139
|
+
|
|
3826
4140
|
```ts
|
|
3827
4141
|
type PropValue = PropData | ImageRef;
|
|
3828
4142
|
```
|
|
@@ -3838,34 +4152,108 @@ type PropData = null | boolean | number | string | MarkdownRef | ImageRef | Text
|
|
|
3838
4152
|
};
|
|
3839
4153
|
```
|
|
3840
4154
|
|
|
4155
|
+
Example: Type a message list whose rows carry a document
|
|
4156
|
+
|
|
4157
|
+
A property value for a chat's message list, typed so that TypeScript
|
|
4158
|
+
accepts a Markdown document inside each row. `PropData` is the JSON a
|
|
4159
|
+
property takes plus documents and images at any depth; the player turns
|
|
4160
|
+
each object into a Slint struct by its field names.
|
|
4161
|
+
```ts
|
|
4162
|
+
const messages: PropData = [
|
|
4163
|
+
{ id: "m1", role: "user", body: await plugin.ui.markdown("Hi") },
|
|
4164
|
+
];
|
|
4165
|
+
```
|
|
4166
|
+
|
|
3841
4167
|
### Props
|
|
3842
4168
|
|
|
3843
4169
|
Properties to write to a view, by name.
|
|
3844
4170
|
|
|
4171
|
+
Names may be camelCase: the SDK turns every capital letter into `-` plus
|
|
4172
|
+
the lower-case letter (`canMark` → `can-mark`); names already in
|
|
4173
|
+
kebab-case are kept. Only the property names are converted, not the
|
|
4174
|
+
fields of struct values.
|
|
4175
|
+
|
|
3845
4176
|
```ts
|
|
3846
4177
|
type Props = Record<string, PropValue>;
|
|
3847
4178
|
```
|
|
3848
4179
|
|
|
4180
|
+
Example: Prepare a window's initial properties
|
|
4181
|
+
|
|
4182
|
+
An object of property values by name, as `windows.create` and
|
|
4183
|
+
`View.set` take them. Names match the Slint file (`canMark` for
|
|
4184
|
+
`can-mark`), and each value becomes the Slint type of its property: a
|
|
4185
|
+
boolean, and an array of structs for a model.
|
|
4186
|
+
```ts
|
|
4187
|
+
const props: Props = { canMark: true, rows: [{ rowId: "a", title: "First" }] };
|
|
4188
|
+
```
|
|
4189
|
+
|
|
3849
4190
|
### Ui
|
|
3850
4191
|
|
|
3851
4192
|
Values for Slint properties that are not plain JSON: images from files and
|
|
3852
4193
|
Markdown documents. `plugin.ui`.
|
|
3853
4194
|
|
|
4195
|
+
A Slint file can only name images compiled into it (`@image-url`). To show
|
|
4196
|
+
an image the plugin picks at run time (cover art, a thumbnail it rendered,
|
|
4197
|
+
a downloaded icon), make an ImageRef with Ui.image and
|
|
4198
|
+
write it to an `image` property. To show formatted text, make a
|
|
4199
|
+
MarkdownRef with Ui.markdown and give it to `MarkdownView`.
|
|
4200
|
+
Both work in tool windows, panels (the backdrop and the floating sidebar
|
|
4201
|
+
included) and settings pages.
|
|
4202
|
+
|
|
3854
4203
|
- @capability None.
|
|
3855
4204
|
|
|
3856
4205
|
```ts
|
|
3857
4206
|
interface Ui {
|
|
3858
4207
|
/**
|
|
3859
4208
|
* Reference a PNG or JPEG file for an `image` property.
|
|
4209
|
+
*
|
|
4210
|
+
* Returns at once without touching the file. The player checks the file
|
|
4211
|
+
* each time it receives the reference (a property write, a window or
|
|
4212
|
+
* settings page opening, the main window being rebuilt): it must be inside this plugin's package
|
|
4213
|
+
* directory or Plugin.dataDirectory after following links, at most
|
|
4214
|
+
* 16 MB and 4096×4096 pixels, and PNG or JPEG by content (the extension
|
|
4215
|
+
* does not matter). A failed check of a whole-value image is a failed
|
|
4216
|
+
* property write: `missing` for a file that does not exist, `invalid`
|
|
4217
|
+
* otherwise. It goes to Scope.onError and the property keeps its
|
|
4218
|
+
* previous image; in the initial properties of a tool window it makes
|
|
4219
|
+
* Window.show reject. A nested image (a field of a row, SDK
|
|
4220
|
+
* 1.5.19) that fails its check is drawn empty instead, and the rest of
|
|
4221
|
+
* the value is shown, as a Markdown image shows its alt text.
|
|
4222
|
+
*
|
|
4223
|
+
* The player caches decoded images per plugin (up to 64 MB, least recently
|
|
4224
|
+
* used first) by path, size and modification time, and does not watch
|
|
4225
|
+
* files. After rewriting a file under the same path, call `image()` again
|
|
4226
|
+
* and write the new reference: the SDK does not send a value that did not
|
|
4227
|
+
* change, so the player would not look at the file again.
|
|
3860
4228
|
* @capability None.
|
|
3861
4229
|
* @param path - Absolute path of the image file, inside the package directory or the data directory.
|
|
3862
4230
|
* @param options - `maxSide` (SDK 1.5.19, player 0.5.1): decode the image scaled down, aspect kept, so that neither side is over it.
|
|
3863
4231
|
* @returns A new ImageRef, owned by this scope.
|
|
3864
4232
|
* @throws invalid (throws) `path` is not a non-empty string, or `maxSide` is not an integer 1–4096.
|
|
4233
|
+
* @example Show a thumbnail the plugin rendered
|
|
4234
|
+
* A window shows a PNG the plugin wrote into its data directory. The file
|
|
4235
|
+
* must be inside the package or data directory; `image` returns at once.
|
|
4236
|
+
* The player decodes the file when it receives the value and notices a
|
|
4237
|
+
* rewritten file by its size and modification time; the plugin still
|
|
4238
|
+
* calls `image()` again after rewriting it, because the SDK does not send
|
|
4239
|
+
* a value that did not change.
|
|
4240
|
+
* ```ts
|
|
4241
|
+
* const window = plugin.windows.create("ui/thumbnail.slint");
|
|
4242
|
+
* const file = `${plugin.dataDirectory}/thumbnail.png`;
|
|
4243
|
+
* // … write the PNG to `file` (for example with plugin.tools.run("ffmpeg", …)) …
|
|
4244
|
+
* window.set({ thumbnail: plugin.ui.image(file) });
|
|
4245
|
+
* await window.show();
|
|
4246
|
+
* ```
|
|
3865
4247
|
*/
|
|
3866
4248
|
image(path: string, options?: ImageOptions): ImageRef;
|
|
3867
4249
|
/**
|
|
3868
4250
|
* Download an `https:` PNG or JPEG image (a thumbnail, a repository's plugin icon) and reference the local copy, as Ui.image does.
|
|
4251
|
+
*
|
|
4252
|
+
* Downloads like Ui.markdown's remote images: Node's `fetch` in
|
|
4253
|
+
* this plugin's process, at most 4 MB and 10 s per image, 4 downloads at a
|
|
4254
|
+
* time per plugin, into `<dataDirectory>/.vestra-cache/images/`; the cache
|
|
4255
|
+
* keeps 32 MB and fetches an address again after 24 hours, so a list that
|
|
4256
|
+
* shows the same thumbnails again reads them from disk.
|
|
3869
4257
|
* @capability None (the plugin process has network access).
|
|
3870
4258
|
* @player Any (the reference is an ordinary ImageRef); nested in a value, player 0.5.1.
|
|
3871
4259
|
* @param url - The image's `https:` address.
|
|
@@ -3873,10 +4261,55 @@ interface Ui {
|
|
|
3873
4261
|
* @returns The reference, owned by this scope, once the file is in the cache.
|
|
3874
4262
|
* @throws invalid (rejects) `url` is not an `https:` address, or `maxSide` is not an integer 1–4096.
|
|
3875
4263
|
* @throws unavailable (rejects) the download failed, timed out, was too large, or is not a PNG or JPEG image; or the plugin has no data directory.
|
|
4264
|
+
* @example Show search results with downloaded thumbnails
|
|
4265
|
+
* A results window lists each hit with its thumbnail, or a placeholder
|
|
4266
|
+
* when a thumbnail cannot be fetched. `remoteImage` downloads the image
|
|
4267
|
+
* in the plugin's process into the data directory's cache (at most 4 at a
|
|
4268
|
+
* time) and resolves with a local reference; a failed download rejects
|
|
4269
|
+
* with `unavailable`, which the `catch` turns into the placeholder.
|
|
4270
|
+
* ```ts
|
|
4271
|
+
* const results = [{ title: "A", thumbnailUrl: "https://example.com/a.jpg" }];
|
|
4272
|
+
* const placeholder = plugin.ui.image(`${plugin.dataDirectory}/blank.png`);
|
|
4273
|
+
* const rows = await Promise.all(
|
|
4274
|
+
* results.map(async (r) => ({
|
|
4275
|
+
* title: r.title,
|
|
4276
|
+
* thumb: await plugin.ui
|
|
4277
|
+
* .remoteImage(r.thumbnailUrl, { maxSide: 320 })
|
|
4278
|
+
* .catch(() => placeholder), // `unavailable`: download failed
|
|
4279
|
+
* })),
|
|
4280
|
+
* );
|
|
4281
|
+
* plugin.windows.create("ui/results.slint").set({ rows });
|
|
4282
|
+
* ```
|
|
3876
4283
|
*/
|
|
3877
4284
|
remoteImage(url: string, options?: ImageOptions): Promise<ImageRef>;
|
|
3878
4285
|
/**
|
|
3879
4286
|
* Prepare a Markdown document for `MarkdownView` (`@vestra/markdown.slint`).
|
|
4287
|
+
*
|
|
4288
|
+
* The player renders headings, paragraphs (bold, italic, strikethrough,
|
|
4289
|
+
* inline code, links), lists and task lists, code blocks, block quotes,
|
|
4290
|
+
* images, tables and rules; embedded HTML is not shown. A click on a link
|
|
4291
|
+
* calls the view's `link-clicked(url)` callback with the address resolved
|
|
4292
|
+
* against `base`; opening it is up to the plugin (for example with
|
|
4293
|
+
* System.openUrl).
|
|
4294
|
+
*
|
|
4295
|
+
* Images: PNG or JPEG. Local ones follow Ui.image's rules (this
|
|
4296
|
+
* plugin's package or data directory). `https:` images are downloaded by
|
|
4297
|
+
* this call into `<dataDirectory>/.vestra-cache/markdown/` (each at most
|
|
4298
|
+
* 4 MB and 10 s, at most 64 per document; the cache keeps 32 MB and fetches
|
|
4299
|
+
* an address again after 24 hours). A failed or unsupported image (SVG,
|
|
4300
|
+
* GIF, `http:`…) shows its alt text; the call itself does not fail for it.
|
|
4301
|
+
*
|
|
4302
|
+
* The text is at most 256 KB; very long documents are cut off after 4000
|
|
4303
|
+
* blocks.
|
|
4304
|
+
*
|
|
4305
|
+
* `MarkdownView` scrolls one document. `MarkdownBlocks` (SDK 1.5.4, players
|
|
4306
|
+
* after 0.4.0) draws one without scrolling, as tall as its content, for a
|
|
4307
|
+
* layout of your own or a `ListView` row. For a list of documents, write
|
|
4308
|
+
* the whole list with one document per row: the player parses only the
|
|
4309
|
+
* documents that changed and updates the list in place (rows added or
|
|
4310
|
+
* removed at the end, a growing document's changed blocks), so re-sending
|
|
4311
|
+
* the list on every streamed chunk keeps the scroll position and costs
|
|
4312
|
+
* little.
|
|
3880
4313
|
* @capability None.
|
|
3881
4314
|
* @player 0.2.0 (set `"minPlayerVersion": "0.2.0"` in the manifest)
|
|
3882
4315
|
* @param text - The Markdown source (CommonMark with GitHub tables, task lists and strikethrough), at most 256 KB as UTF-8.
|
|
@@ -3884,56 +4317,363 @@ interface Ui {
|
|
|
3884
4317
|
* @returns The document, owned by this scope, once its remote images are downloaded (or have failed).
|
|
3885
4318
|
* @throws invalid (rejects) `text` is not a string or is over 256 KB, or `base` is neither an `http(s):` address nor an absolute path.
|
|
3886
4319
|
* @throws unsupported (rejects) The player is older than 0.2.0.
|
|
4320
|
+
* @example Build a chat with one Markdown document per row
|
|
4321
|
+
* A chat window lists messages as formatted text, with a Copy button on
|
|
4322
|
+
* each code block that puts the code on the clipboard. Each row's `body`
|
|
4323
|
+
* is a document from `ui.markdown`; the whole list is written again on
|
|
4324
|
+
* each change, and the player parses only the documents that changed.
|
|
4325
|
+
* `MarkdownBlocks` with `code-copy` calls `copy-code`, which the plugin
|
|
4326
|
+
* handles with `on("copyCode")`.
|
|
4327
|
+
*
|
|
4328
|
+
* `ui/chat.slint`:
|
|
4329
|
+
*
|
|
4330
|
+
* ```slint
|
|
4331
|
+
* import { ListView } from "std-widgets.slint";
|
|
4332
|
+
* import { MarkdownBlocks, MarkdownBlock } from "@vestra/markdown.slint";
|
|
4333
|
+
* export struct Message { id: string, role: string, body: [MarkdownBlock] }
|
|
4334
|
+
* export component Chat inherits Window {
|
|
4335
|
+
* in property <[Message]> messages;
|
|
4336
|
+
* callback copy-code(string);
|
|
4337
|
+
* ListView {
|
|
4338
|
+
* for message in root.messages: VerticalLayout {
|
|
4339
|
+
* padding: 8px;
|
|
4340
|
+
* MarkdownBlocks {
|
|
4341
|
+
* blocks: message.body;
|
|
4342
|
+
* code-copy: true;
|
|
4343
|
+
* copy-code(code) => { root.copy-code(code); }
|
|
4344
|
+
* }
|
|
4345
|
+
* }
|
|
4346
|
+
* }
|
|
4347
|
+
* }
|
|
4348
|
+
* ```
|
|
4349
|
+
*
|
|
4350
|
+
* ```ts
|
|
4351
|
+
* const chat = plugin.windows.create("ui/chat.slint");
|
|
4352
|
+
* const history: { id: string; role: string; text: string }[] = [];
|
|
4353
|
+
* chat.on("copyCode", (code: string) => plugin.clipboard.writeText(code));
|
|
4354
|
+
* // Re-send the whole list: unchanged documents cost the player nothing.
|
|
4355
|
+
* async function render() {
|
|
4356
|
+
* const messages = [];
|
|
4357
|
+
* for (const { id, role, text } of history)
|
|
4358
|
+
* messages.push({ id, role, body: await plugin.ui.markdown(text) });
|
|
4359
|
+
* chat.set({ messages });
|
|
4360
|
+
* }
|
|
4361
|
+
* history.push({ id: "1", role: "user", text: "Show me `ls`." });
|
|
4362
|
+
* await render();
|
|
4363
|
+
* await chat.show();
|
|
4364
|
+
* ```
|
|
4365
|
+
* @example Show a README from the web
|
|
4366
|
+
* A window shows a README downloaded from the web, its images included,
|
|
4367
|
+
* and clicked links open in the browser. `base` makes relative links and
|
|
4368
|
+
* images resolve against the README's address; `markdown` resolves after
|
|
4369
|
+
* its `https:` images are downloaded. A click on a link makes
|
|
4370
|
+
* `MarkdownView` call `link-clicked`, which `ui/readme.slint` forwards as
|
|
4371
|
+
* `open-link`; the plugin opens the address with `openUrl`.
|
|
4372
|
+
* ```ts
|
|
4373
|
+
* const readmeUrl = "https://example.com/plugin/README.md";
|
|
4374
|
+
* const response = await fetch(readmeUrl);
|
|
4375
|
+
* if (!response.ok) throw new Error(`README: HTTP ${response.status}`);
|
|
4376
|
+
* const text = await response.text();
|
|
4377
|
+
* const window = plugin.windows.create("ui/readme.slint");
|
|
4378
|
+
* // Bound before the first `show`, as a tool window requires.
|
|
4379
|
+
* window.on("openLink", (url: string) => plugin.system.openUrl(url));
|
|
4380
|
+
* window.set({ doc: await plugin.ui.markdown(text, { base: readmeUrl }) });
|
|
4381
|
+
* await window.show();
|
|
4382
|
+
* ```
|
|
3887
4383
|
*/
|
|
3888
4384
|
markdown(text: string, options?: MarkdownOptions): Promise<MarkdownRef>;
|
|
3889
4385
|
}
|
|
3890
4386
|
```
|
|
3891
4387
|
|
|
4388
|
+
Example: Show a Markdown file in a window (examples/markdown)
|
|
4389
|
+
|
|
4390
|
+
A menu item opens a window with a formatted Markdown file that ships
|
|
4391
|
+
with the plugin. The plugin reads the file, turns it into a document
|
|
4392
|
+
with `ui.markdown` (relative images resolve against the plugin's
|
|
4393
|
+
directory through `base`) and writes it to the window's `doc` property,
|
|
4394
|
+
which the Slint file hands to `MarkdownView`.
|
|
4395
|
+
|
|
4396
|
+
```ts
|
|
4397
|
+
// Relative links and images in sample.md resolve against this folder.
|
|
4398
|
+
const directory = fileURLToPath(new URL(".", import.meta.url));
|
|
4399
|
+
const window = plugin.windows.create("ui/document.slint");
|
|
4400
|
+
plugin.commands.add({
|
|
4401
|
+
id: "show",
|
|
4402
|
+
path: ["Examples", "Markdown example…"],
|
|
4403
|
+
run: async () => {
|
|
4404
|
+
const text = readFileSync(new URL("sample.md", import.meta.url), "utf8");
|
|
4405
|
+
// `doc` is the window's [MarkdownBlock] property, drawn by MarkdownView.
|
|
4406
|
+
window.set({ doc: await plugin.ui.markdown(text, { base: directory }) });
|
|
4407
|
+
await window.show();
|
|
4408
|
+
},
|
|
4409
|
+
});
|
|
4410
|
+
```
|
|
4411
|
+
|
|
4412
|
+
`ui/document.slint`:
|
|
4413
|
+
|
|
4414
|
+
```slint
|
|
4415
|
+
import { MarkdownView, MarkdownBlock } from "@vestra/markdown.slint";
|
|
4416
|
+
import { Theme } from "@vestra/theme.slint";
|
|
4417
|
+
|
|
4418
|
+
export component Document inherits Window {
|
|
4419
|
+
title: "Markdown example";
|
|
4420
|
+
preferred-width: 520px;
|
|
4421
|
+
preferred-height: 560px;
|
|
4422
|
+
min-width: 280px;
|
|
4423
|
+
min-height: 200px;
|
|
4424
|
+
background: Theme.bg-panel;
|
|
4425
|
+
in property <[MarkdownBlock]> doc;
|
|
4426
|
+
MarkdownView {
|
|
4427
|
+
blocks: root.doc;
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
```
|
|
4431
|
+
|
|
3892
4432
|
### View
|
|
3893
4433
|
|
|
3894
4434
|
The surface that tool windows, panels and settings pages share: properties
|
|
3895
4435
|
out to Slint, callbacks in from Slint.
|
|
3896
4436
|
|
|
4437
|
+
The plugin's TypeScript and its Slint files run in different processes and
|
|
4438
|
+
exchange two kinds of messages. Plugin → UI: `in` and `in-out` properties,
|
|
4439
|
+
written with View.set or kept up to date with View.bind.
|
|
4440
|
+
UI → plugin: Slint callbacks (returning nothing), handled with
|
|
4441
|
+
View.on. A Window declares them on its exported component;
|
|
4442
|
+
a Panel and a SettingsPage declare them on the file's
|
|
4443
|
+
exported global, the bridge. The plugin cannot call Slint functions or read
|
|
4444
|
+
a property back: a value the user changes reaches it only through a
|
|
4445
|
+
callback.
|
|
4446
|
+
|
|
4447
|
+
`bind` is the usual way: the SDK re-evaluates bound functions in every
|
|
4448
|
+
update pass (after each handler it calls, after player state changes, on
|
|
4449
|
+
Scope.update) and sends only what changed, at most one write in
|
|
4450
|
+
flight per view. `set` sends now, even an unchanged value.
|
|
4451
|
+
|
|
4452
|
+
Writes that fail (a name the Slint file does not declare, a value of the
|
|
4453
|
+
wrong type, an image that fails its check): a bound value is not sent
|
|
4454
|
+
again until it changes; a `set` value is retried with back-off (0.25 s,
|
|
4455
|
+
doubling up to 30 s); the first failure of a series is reported to
|
|
4456
|
+
Scope.onError.
|
|
4457
|
+
|
|
4458
|
+
A control whose value both the user and the plugin change needs a two-way
|
|
4459
|
+
binding to an `in-out` property (`value <=> root.level`). A one-way
|
|
4460
|
+
binding (`value: root.level`) is broken by Slint the first time the user
|
|
4461
|
+
moves the control.
|
|
4462
|
+
|
|
3897
4463
|
- @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
|
|
3898
4464
|
|
|
3899
4465
|
```ts
|
|
3900
4466
|
interface View {
|
|
3901
4467
|
/**
|
|
3902
4468
|
* Handle a Slint callback.
|
|
4469
|
+
*
|
|
4470
|
+
* The handler receives the callback's arguments as JSON (struct fields
|
|
4471
|
+
* spelled as in Slint). It may be async. An error it throws, or a rejection
|
|
4472
|
+
* of its promise, goes to Scope.onError. After it returns, and
|
|
4473
|
+
* again when its promise settles, the SDK runs an update pass, so state it
|
|
4474
|
+
* changed reaches bound properties without further calls (state changed
|
|
4475
|
+
* after an `await` appears only when the promise settles; call
|
|
4476
|
+
* Scope.update to show it earlier).
|
|
4477
|
+
*
|
|
4478
|
+
* Calling `on` again with the same name replaces the handler. A callback
|
|
4479
|
+
* the plugin has no handler for is ignored.
|
|
4480
|
+
*
|
|
4481
|
+
* **Tool windows** forward only the callback names bound when the window
|
|
4482
|
+
* opens: bind every name before the first Window.show. While the
|
|
4483
|
+
* window is open, binding a new name throws; after a close the next `show`
|
|
4484
|
+
* registers the names bound by then. A name the Slint file does not declare
|
|
4485
|
+
* makes `show` reject. **Panels and settings pages** forward every
|
|
4486
|
+
* callback of their bridge global, so bind whenever you like.
|
|
3903
4487
|
* @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
|
|
3904
4488
|
* @param callback - The Slint callback's name, in camelCase (`toggleEffect`) or as written in Slint (`toggle-effect`).
|
|
3905
4489
|
* @param handler - Receives the callback's arguments.
|
|
3906
4490
|
* @returns A handle that removes this handler (if it is still the current one for the name).
|
|
3907
4491
|
* @throws Error without a code (throws) On an open Window, for a name that has no handler yet.
|
|
4492
|
+
* @example Keep a slider and the plugin's value in step
|
|
4493
|
+
* Moving the window's brightness slider updates the plugin's value, and
|
|
4494
|
+
* the slider shows it back. The Slint callback `brightness-changed`
|
|
4495
|
+
* reaches the handler with the slider's value as JSON; after the handler
|
|
4496
|
+
* returns, the SDK runs an update pass and `bind` sends the new value.
|
|
4497
|
+
* Callbacks of a tool window must be bound before its first `show`.
|
|
4498
|
+
* ```ts
|
|
4499
|
+
* const picture = plugin.windows.create("ui/picture.slint");
|
|
4500
|
+
* let brightness = 0;
|
|
4501
|
+
* // camelCase here, `brightness-changed` in the Slint file.
|
|
4502
|
+
* picture.on("brightnessChanged", (value: number) => {
|
|
4503
|
+
* brightness = value;
|
|
4504
|
+
* });
|
|
4505
|
+
* picture.bind(() => ({ brightness }));
|
|
4506
|
+
* await picture.show();
|
|
4507
|
+
* ```
|
|
3908
4508
|
*/
|
|
3909
4509
|
on<A extends Json[]>(callback: string, handler: (...args: A) => unknown): Disposable;
|
|
3910
4510
|
/**
|
|
3911
4511
|
* Write properties now, even values that did not change.
|
|
4512
|
+
*
|
|
4513
|
+
* Each value given is sent once with the next write, even if it equals the
|
|
4514
|
+
* last value sent. That makes `set` the way to pull a control back to the
|
|
4515
|
+
* model value after the user moved it, or to put a one-off value (a
|
|
4516
|
+
* document, an image) on screen. While the view is not open (a window
|
|
4517
|
+
* before `show`, a panel before `ready`) the values are kept and become its
|
|
4518
|
+
* initial properties.
|
|
4519
|
+
*
|
|
4520
|
+
* Do not `set` a property that a View.bind function also returns:
|
|
4521
|
+
* the next update pass replaces the set value with the bound one.
|
|
4522
|
+
*
|
|
4523
|
+
* Never throws. A write that fails is retried with back-off and reported
|
|
4524
|
+
* to Scope.onError (the first failure of a run of failures; the
|
|
4525
|
+
* message names the property). A value over the limits of
|
|
4526
|
+
* PropValue fails with `limit`. As an initial property (written
|
|
4527
|
+
* while the view is not open) it makes Window.show reject; a
|
|
4528
|
+
* panel or settings page still opens without it (the property keeps its
|
|
4529
|
+
* default) and the failure goes to the plugin's `onError` (player 0.4.3;
|
|
4530
|
+
* older players left it out silently).
|
|
3912
4531
|
* @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
|
|
3913
4532
|
* @param props - Properties by name; see Props.
|
|
4533
|
+
* @example Put a drop-down back when the user picks an entry the plugin refuses
|
|
4534
|
+
* Choosing "Custom" snaps the drop-down back to the last real preset.
|
|
4535
|
+
* The plugin cannot read a property back, only hear the callback; `set`
|
|
4536
|
+
* sends the value even though it equals the one sent before, which is
|
|
4537
|
+
* what pulls a control the user moved back to the plugin's value.
|
|
4538
|
+
* ```ts
|
|
4539
|
+
* const window = plugin.windows.create("ui/presets.slint");
|
|
4540
|
+
* let presetIndex = 0;
|
|
4541
|
+
* window.on("presetChosen", (index: number) => {
|
|
4542
|
+
* if (index > 2) window.set({ presetIndex }); // "Custom" is not a preset
|
|
4543
|
+
* else presetIndex = index;
|
|
4544
|
+
* });
|
|
4545
|
+
* ```
|
|
3914
4546
|
*/
|
|
3915
4547
|
set(props: Props): void;
|
|
3916
4548
|
/**
|
|
3917
4549
|
* Keep properties equal to a function of the plugin's state.
|
|
4550
|
+
*
|
|
4551
|
+
* The SDK calls `props` in every update pass (and right after `bind`) and
|
|
4552
|
+
* sends the values that differ from the ones it sent last. Because an
|
|
4553
|
+
* unchanged value is not sent again, text the user typed into an `in-out`
|
|
4554
|
+
* property stays until the plugin's value for it changes.
|
|
4555
|
+
*
|
|
4556
|
+
* Keep the function quick and synchronous: it runs often. An error it
|
|
4557
|
+
* throws is reported to Scope.onError and the other bindings still
|
|
4558
|
+
* run. A view can have several bindings; when two return the same name,
|
|
4559
|
+
* the one bound later wins.
|
|
4560
|
+
*
|
|
4561
|
+
* Never throws. Writes that fail are not repeated until the value
|
|
4562
|
+
* changes, and are reported to Scope.onError like a failed
|
|
4563
|
+
* View.set (a value over the limits of PropValue: `limit`).
|
|
3918
4564
|
* @capability That of the view: `ui.window`, `ui.mount` or `ui.settings`.
|
|
3919
4565
|
* @param props - Returns the properties, by name.
|
|
3920
4566
|
* @returns A handle that stops this binding (the values last sent stay).
|
|
4567
|
+
* @example Show what is playing and whether it can seek
|
|
4568
|
+
* The status window always shows the current file and enables its seek
|
|
4569
|
+
* controls only for seekable sources. The SDK calls the function right
|
|
4570
|
+
* away and again in every update pass (after player state changes and
|
|
4571
|
+
* after each handler) and sends only the values that changed.
|
|
4572
|
+
* ```ts
|
|
4573
|
+
* const status = plugin.windows.create("ui/status.slint");
|
|
4574
|
+
* status.bind(() => ({
|
|
4575
|
+
* title: plugin.player.state.source ?? "Nothing playing",
|
|
4576
|
+
* canSeek: plugin.player.state.can.seek,
|
|
4577
|
+
* }));
|
|
4578
|
+
* ```
|
|
3921
4579
|
*/
|
|
3922
4580
|
bind(props: () => Props): Disposable;
|
|
3923
4581
|
}
|
|
3924
4582
|
```
|
|
3925
4583
|
|
|
4584
|
+
Example: Wire a window's label and buttons to the plugin (examples/bookmark)
|
|
4585
|
+
|
|
4586
|
+
A Bookmark window shows the saved position and two buttons that save
|
|
4587
|
+
it and jump back to it. `bind` keeps the label and the buttons' enabled
|
|
4588
|
+
state equal to the plugin's state: the SDK evaluates the function again
|
|
4589
|
+
after every handler and player change and sends only what changed. The
|
|
4590
|
+
buttons' Slint callbacks reach the plugin through `on`.
|
|
4591
|
+
|
|
4592
|
+
```ts
|
|
4593
|
+
const window = plugin.windows.create("ui/bookmark.slint");
|
|
4594
|
+
// Re-evaluated after every handler and player change; only changes are sent.
|
|
4595
|
+
window.bind(() => ({
|
|
4596
|
+
label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
|
|
4597
|
+
canMark: player.state.position !== null,
|
|
4598
|
+
canJump: mark !== null && player.state.can.seek,
|
|
4599
|
+
}));
|
|
4600
|
+
// The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
|
|
4601
|
+
window.on("mark", () => (mark = player.state.position));
|
|
4602
|
+
window.on("jump", () => player.seek(mark!));
|
|
4603
|
+
```
|
|
4604
|
+
|
|
4605
|
+
`ui/bookmark.slint`:
|
|
4606
|
+
|
|
4607
|
+
```slint
|
|
4608
|
+
import { Button, VerticalBox, HorizontalBox } from "std-widgets.slint";
|
|
4609
|
+
export component BookmarkWindow inherits Window {
|
|
4610
|
+
title: "Bookmark";
|
|
4611
|
+
preferred-width: 320px;
|
|
4612
|
+
in property <string> label: "No bookmark";
|
|
4613
|
+
in property <bool> can-mark;
|
|
4614
|
+
in property <bool> can-jump;
|
|
4615
|
+
callback mark();
|
|
4616
|
+
callback jump();
|
|
4617
|
+
VerticalBox {
|
|
4618
|
+
Text { text: root.label; }
|
|
4619
|
+
HorizontalBox {
|
|
4620
|
+
Button { text: "Mark this position"; enabled: root.can-mark; clicked => { root.mark(); } }
|
|
4621
|
+
Button { text: "Jump to bookmark"; enabled: root.can-jump; clicked => { root.jump(); } }
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
```
|
|
4626
|
+
|
|
3926
4627
|
### Window
|
|
3927
4628
|
|
|
3928
4629
|
A tool window: a separate operating-system window drawn from one of the
|
|
3929
4630
|
plugin's `.slint` files, made by Windows.create.
|
|
3930
4631
|
|
|
4632
|
+
**What it is.** A top-level window with its own title bar: the user can
|
|
4633
|
+
move it, resize it (if the Slint file allows) and close it. It is not
|
|
4634
|
+
modal and not attached to the main window: playback and the main window
|
|
4635
|
+
keep working while it is open. Its title, size and content come from the
|
|
4636
|
+
Slint `Window` component (`title`, `width` or `preferred-width`…); the
|
|
4637
|
+
player applies its palette and the system font before the window appears.
|
|
4638
|
+
The system chooses its position; the player does not remember it.
|
|
4639
|
+
MainWindow.setAlwaysOnTop also floats tool windows.
|
|
4640
|
+
|
|
4641
|
+
**Life cycle.**
|
|
4642
|
+
1. `create()` makes the handle; nothing is on screen.
|
|
4643
|
+
2. Bind callbacks with View.on and properties with
|
|
4644
|
+
View.bind or View.set.
|
|
4645
|
+
3. Window.show opens the window with those properties.
|
|
4646
|
+
Window.hide hides it and keeps its state.
|
|
4647
|
+
4. Window.close, or the user (title-bar button or the
|
|
4648
|
+
standard close keys), destroys the window instance; only the
|
|
4649
|
+
user's close calls Window.onClose. The handle stays usable: the
|
|
4650
|
+
next `show()` opens a fresh instance with the current properties.
|
|
4651
|
+
5. `dispose()`, disposing the owning scope or the plugin stopping closes
|
|
4652
|
+
it for good (without `onClose`); the id can then be used again.
|
|
4653
|
+
|
|
4654
|
+
Command key bindings do not fire while a tool window has focus; handle
|
|
4655
|
+
keys in Slint (a `FocusScope`). The standard close keys never reach the
|
|
4656
|
+
window's Slint code.
|
|
4657
|
+
|
|
4658
|
+
Testing: `host.window(id)` of `@vestra-player/sdk/testing` has the
|
|
4659
|
+
window's `props`, `visible`, `emit(callback, ...args)` and `userClose()`.
|
|
4660
|
+
|
|
3931
4661
|
- @capability ui.window
|
|
3932
4662
|
|
|
3933
4663
|
```ts
|
|
3934
4664
|
interface Window extends View, Disposable {
|
|
3935
4665
|
/**
|
|
3936
4666
|
* Open the window, or show it again if it is hidden.
|
|
4667
|
+
*
|
|
4668
|
+
* When no window instance exists (the first call, or after a close), the
|
|
4669
|
+
* player compiles the Slint file and opens a new window. Its initial
|
|
4670
|
+
* properties are the bound values and the values last passed to
|
|
4671
|
+
* View.set, so no default values flash, and the callbacks bound
|
|
4672
|
+
* so far are registered. When the window is hidden, it is shown again with
|
|
4673
|
+
* its state.
|
|
4674
|
+
*
|
|
4675
|
+
* `show`, `hide` and `close` of one window run one after another in call
|
|
4676
|
+
* order. After `dispose()`, `show` does nothing.
|
|
3937
4677
|
* @capability ui.window
|
|
3938
4678
|
* @returns Resolves once the player has shown the window.
|
|
3939
4679
|
* @throws capability (rejects) The manifest does not declare `ui.window`.
|
|
@@ -3941,20 +4681,72 @@ interface Window extends View, Disposable {
|
|
|
3941
4681
|
* @throws host (rejects) The Slint file is missing, outside the package directory or does not compile; it exports more than one component; or a bound property or callback is not declared in it. `message` has the reason, including the compiler's diagnostics.
|
|
3942
4682
|
* @throws missing (rejects) An image in the initial properties does not exist (see Ui.image).
|
|
3943
4683
|
* @throws invalid (rejects) An image in the initial properties fails another check: outside the allowed directories, too large, not PNG or JPEG.
|
|
4684
|
+
* @example Tell the user why a window did not open
|
|
4685
|
+
* When the inspector cannot open, the user sees why on the OSD instead
|
|
4686
|
+
* of nothing happening. `show` compiles the Slint file and opens the
|
|
4687
|
+
* window; it rejects with a code, such as `host` for a Slint file that
|
|
4688
|
+
* does not compile or `unavailable` without a user interface.
|
|
4689
|
+
* ```ts
|
|
4690
|
+
* const window = plugin.windows.create("ui/inspector.slint");
|
|
4691
|
+
* try {
|
|
4692
|
+
* await window.show();
|
|
4693
|
+
* } catch (error) {
|
|
4694
|
+
* // The code is data; `message` has the details for the log.
|
|
4695
|
+
* await plugin.osd.show("Cannot open the inspector ({})", [(error as PluginError).code]);
|
|
4696
|
+
* }
|
|
4697
|
+
* ```
|
|
3944
4698
|
*/
|
|
3945
4699
|
show(): Promise<void>;
|
|
3946
4700
|
/**
|
|
3947
4701
|
* Hide the window without closing it.
|
|
4702
|
+
*
|
|
4703
|
+
* The instance and everything in it stay; Window.opened stays
|
|
4704
|
+
* `true` and Window.show brings the window back as it was. Does
|
|
4705
|
+
* nothing when the window is not open.
|
|
3948
4706
|
* @capability ui.window
|
|
3949
4707
|
* @returns Resolves once the window is hidden.
|
|
3950
4708
|
* @throws missing (rejects) The user closed the window just before (the close had not reached the plugin yet).
|
|
4709
|
+
* @example Show and hide a window from a checkable menu item
|
|
4710
|
+
* Audio ▸ Levels shows the window with a check mark and hides it again.
|
|
4711
|
+
* `hide` keeps the window's instance and state, so showing it again is
|
|
4712
|
+
* instant; the check mark follows `visible`, and `onClose` resets it when
|
|
4713
|
+
* the user closes the window with its title-bar button.
|
|
4714
|
+
* ```ts
|
|
4715
|
+
* const levels = plugin.windows.create("ui/levels.slint");
|
|
4716
|
+
* let visible = false; // what the check mark shows
|
|
4717
|
+
* plugin.commands.add({
|
|
4718
|
+
* id: "levels",
|
|
4719
|
+
* path: ["@audio", "Levels"],
|
|
4720
|
+
* checked: () => visible,
|
|
4721
|
+
* run: async () => {
|
|
4722
|
+
* visible = !visible;
|
|
4723
|
+
* await (visible ? levels.show() : levels.hide());
|
|
4724
|
+
* },
|
|
4725
|
+
* });
|
|
4726
|
+
* levels.onClose(() => (visible = false));
|
|
4727
|
+
* ```
|
|
3951
4728
|
*/
|
|
3952
4729
|
hide(): Promise<void>;
|
|
3953
4730
|
/**
|
|
3954
4731
|
* Close the window and destroy its instance; the handle stays usable.
|
|
4732
|
+
*
|
|
4733
|
+
* The next Window.show opens a fresh instance with the current
|
|
4734
|
+
* properties; state that only the Slint side had (text the plugin did not
|
|
4735
|
+
* record) is gone. Window.onClose is not called. Does nothing when
|
|
4736
|
+
* the window is not open. Unlike `dispose()`, the handle, its handlers and
|
|
4737
|
+
* its bindings stay.
|
|
3955
4738
|
* @capability ui.window
|
|
3956
4739
|
* @returns Resolves once the window is closed.
|
|
3957
4740
|
* @throws missing (rejects) The user closed the window just before.
|
|
4741
|
+
* @example Close the window from its own button
|
|
4742
|
+
* The editor's Done button closes the editor window. `close` destroys
|
|
4743
|
+
* the window instance but keeps the handle, its handlers and bindings,
|
|
4744
|
+
* so the next `show` opens a fresh window; `onClose` is not called for
|
|
4745
|
+
* the plugin's own close.
|
|
4746
|
+
* ```ts
|
|
4747
|
+
* const editor = plugin.windows.create("ui/editor.slint");
|
|
4748
|
+
* editor.on("done", () => editor.close());
|
|
4749
|
+
* ```
|
|
3958
4750
|
*/
|
|
3959
4751
|
close(): Promise<void>;
|
|
3960
4752
|
/**
|
|
@@ -3963,30 +4755,128 @@ interface Window extends View, Disposable {
|
|
|
3963
4755
|
readonly opened: boolean;
|
|
3964
4756
|
/**
|
|
3965
4757
|
* Run code when the user closes the window.
|
|
4758
|
+
*
|
|
4759
|
+
* Called for the title-bar close button and the
|
|
4760
|
+
* standard close keys. When it runs, the window
|
|
4761
|
+
* instance is gone and Window.opened is already `false`; a later
|
|
4762
|
+
* `show()` opens a fresh one. It is not called for the plugin's own
|
|
4763
|
+
* Window.close or `dispose()`, nor when the plugin stops. Errors
|
|
4764
|
+
* it throws go to Scope.onError, and an update pass follows.
|
|
4765
|
+
*
|
|
4766
|
+
* Stop what the window needed here: timers, polling, a live preview.
|
|
3966
4767
|
* @capability ui.window
|
|
3967
4768
|
* @param listener - Called with no arguments; may be async.
|
|
3968
4769
|
* @returns A handle that removes the listener.
|
|
4770
|
+
* @example Remember that the user closed the window
|
|
4771
|
+
* The plugin records that the notes window was closed, so it does not
|
|
4772
|
+
* reopen it at the next start. `onClose` runs for the title-bar button,
|
|
4773
|
+
* the player's close keys (Cmd+W, Ctrl+W, Ctrl+F4) and the system's own
|
|
4774
|
+
* close request such as Alt+F4 (not for the plugin's own `close`); the
|
|
4775
|
+
* store writes the change to `window.json` shortly after.
|
|
4776
|
+
* ```ts
|
|
4777
|
+
* const store = plugin.storage.json("window", { defaults: { open: false } });
|
|
4778
|
+
* const window = plugin.windows.create("ui/notes.slint");
|
|
4779
|
+
* window.onClose(() => store.save({ open: false }));
|
|
4780
|
+
* ```
|
|
3969
4781
|
*/
|
|
3970
4782
|
onClose(listener: () => unknown): Disposable;
|
|
3971
4783
|
}
|
|
3972
4784
|
```
|
|
3973
4785
|
|
|
4786
|
+
Example: Open a window from a menu command (examples/bookmark)
|
|
4787
|
+
|
|
4788
|
+
Playback ▸ Bookmark… opens the Bookmark window. `create` only makes a
|
|
4789
|
+
handle; the command's `run` calls `show`, which compiles the Slint file
|
|
4790
|
+
and opens the window with the bound properties the first time, and
|
|
4791
|
+
brings back the same window afterwards.
|
|
4792
|
+
|
|
4793
|
+
```ts
|
|
4794
|
+
const window = plugin.windows.create("ui/bookmark.slint");
|
|
4795
|
+
// Re-evaluated after every handler and player change; only changes are sent.
|
|
4796
|
+
window.bind(() => ({
|
|
4797
|
+
label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
|
|
4798
|
+
canMark: player.state.position !== null,
|
|
4799
|
+
canJump: mark !== null && player.state.can.seek,
|
|
4800
|
+
}));
|
|
4801
|
+
// The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
|
|
4802
|
+
window.on("mark", () => (mark = player.state.position));
|
|
4803
|
+
window.on("jump", () => player.seek(mark!));
|
|
4804
|
+
|
|
4805
|
+
plugin.commands.add({
|
|
4806
|
+
id: "bookmark.open",
|
|
4807
|
+
path: ["@playback", "Bookmark…"],
|
|
4808
|
+
order: 270,
|
|
4809
|
+
run: () => window.show(), // opens it, or brings back the open one
|
|
4810
|
+
});
|
|
4811
|
+
```
|
|
4812
|
+
|
|
4813
|
+
Example: Stop work when the user closes the window
|
|
4814
|
+
|
|
4815
|
+
A clock window ticks every second while it is open and stops costing
|
|
4816
|
+
anything once the user closes it. `plugin.update()` makes the SDK
|
|
4817
|
+
evaluate the bound time again from a timer; `onClose` runs only for the
|
|
4818
|
+
user's close, so the plugin stops the timer there.
|
|
4819
|
+
```ts
|
|
4820
|
+
const clock = plugin.windows.create("ui/clock.slint");
|
|
4821
|
+
let timer: NodeJS.Timeout | undefined;
|
|
4822
|
+
clock.bind(() => ({ time: new Date().toLocaleTimeString() }));
|
|
4823
|
+
clock.onClose(() => {
|
|
4824
|
+
clearInterval(timer);
|
|
4825
|
+
timer = undefined;
|
|
4826
|
+
});
|
|
4827
|
+
plugin.commands.add({
|
|
4828
|
+
id: "clock",
|
|
4829
|
+
path: ["@screen", "Clock…"],
|
|
4830
|
+
run: async () => {
|
|
4831
|
+
await clock.show();
|
|
4832
|
+
// Re-evaluate `bind` once a second: bound values change only then.
|
|
4833
|
+
timer ??= setInterval(() => plugin.update(), 1000);
|
|
4834
|
+
},
|
|
4835
|
+
});
|
|
4836
|
+
```
|
|
4837
|
+
|
|
3974
4838
|
### Windows
|
|
3975
4839
|
|
|
3976
4840
|
Create tool windows: separate windows drawn from the plugin's `.slint`
|
|
3977
4841
|
files. `plugin.windows`.
|
|
3978
4842
|
|
|
4843
|
+
Use a tool window for something the user opens, works with and closes: a
|
|
4844
|
+
picture adjustment panel, an inspector, an editor. For content that should
|
|
4845
|
+
stay inside the player's main window, mount a panel; for
|
|
4846
|
+
preferences, add a settings page; for a yes/no
|
|
4847
|
+
question, use Dialogs.confirm. A plugin can have any number of
|
|
4848
|
+
windows.
|
|
4849
|
+
|
|
3979
4850
|
- @capability ui.window
|
|
3980
4851
|
|
|
3981
4852
|
```ts
|
|
3982
4853
|
interface Windows {
|
|
3983
4854
|
/**
|
|
3984
4855
|
* Create a window handle for a `.slint` file; nothing opens until Window.show.
|
|
4856
|
+
*
|
|
4857
|
+
* The file must export exactly one component, usually
|
|
4858
|
+
* `export component Name inherits Window`; helper components without
|
|
4859
|
+
* `export` are allowed. The window's properties and callbacks are declared
|
|
4860
|
+
* on that component. The file is not read here: a missing or broken file
|
|
4861
|
+
* makes Window.show reject.
|
|
4862
|
+
*
|
|
4863
|
+
* Bind every callback with View.on before the first `show()`.
|
|
3985
4864
|
* @capability ui.window (checked by Window.show).
|
|
3986
4865
|
* @param file - Path relative to the package directory, with `/` on every system, for example `"ui/picture.slint"`. It must stay inside the package.
|
|
3987
4866
|
* @param options - `id`: the window's name, unique among this plugin's windows until the window is disposed. Default: the file name without its extension (`ui/picture.slint` → `picture`). Give distinct ids to open the same file more than once.
|
|
3988
4867
|
* @returns A Window owned by this scope.
|
|
3989
4868
|
* @throws conflict (throws) This plugin already has a window with this id (SDK 1.3.0; before, a plain `Error` without `code`).
|
|
4869
|
+
* @example Open two windows from one Slint file
|
|
4870
|
+
* Two compare windows open side by side, one labelled A and one B. A
|
|
4871
|
+
* window's id defaults to its file name, so the second window of the
|
|
4872
|
+
* same file needs its own `id`; each handle has its own properties.
|
|
4873
|
+
* ```ts
|
|
4874
|
+
* const left = plugin.windows.create("ui/compare.slint", { id: "compare-left" });
|
|
4875
|
+
* const right = plugin.windows.create("ui/compare.slint", { id: "compare-right" });
|
|
4876
|
+
* left.set({ side: "A" });
|
|
4877
|
+
* right.set({ side: "B" });
|
|
4878
|
+
* await Promise.all([left.show(), right.show()]);
|
|
4879
|
+
* ```
|
|
3990
4880
|
*/
|
|
3991
4881
|
create(file: string, options?: {
|
|
3992
4882
|
id?: string;
|
|
@@ -3994,6 +4884,32 @@ interface Windows {
|
|
|
3994
4884
|
}
|
|
3995
4885
|
```
|
|
3996
4886
|
|
|
4887
|
+
Example: Create a window and open it from a menu (examples/bookmark)
|
|
4888
|
+
|
|
4889
|
+
A Bookmark window opens from Playback ▸ Bookmark…. `create` returns a
|
|
4890
|
+
handle without opening anything; its bindings and callbacks are set up
|
|
4891
|
+
first, and `show` opens the window when the command runs.
|
|
4892
|
+
|
|
4893
|
+
```ts
|
|
4894
|
+
const window = plugin.windows.create("ui/bookmark.slint");
|
|
4895
|
+
// Re-evaluated after every handler and player change; only changes are sent.
|
|
4896
|
+
window.bind(() => ({
|
|
4897
|
+
label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
|
|
4898
|
+
canMark: player.state.position !== null,
|
|
4899
|
+
canJump: mark !== null && player.state.can.seek,
|
|
4900
|
+
}));
|
|
4901
|
+
// The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
|
|
4902
|
+
window.on("mark", () => (mark = player.state.position));
|
|
4903
|
+
window.on("jump", () => player.seek(mark!));
|
|
4904
|
+
|
|
4905
|
+
plugin.commands.add({
|
|
4906
|
+
id: "bookmark.open",
|
|
4907
|
+
path: ["@playback", "Bookmark…"],
|
|
4908
|
+
order: 270,
|
|
4909
|
+
run: () => window.show(), // opens it, or brings back the open one
|
|
4910
|
+
});
|
|
4911
|
+
```
|
|
4912
|
+
|
|
3997
4913
|
### SlotState
|
|
3998
4914
|
|
|
3999
4915
|
The state of the bottom bar or sidebar position, whoever mounted the
|
|
@@ -4066,6 +4982,16 @@ interface PanelState {
|
|
|
4066
4982
|
The bottom bar or the sidebar position (`plugin.panels.bottom`,
|
|
4067
4983
|
`plugin.panels.sidebar`), independent of who mounted the panel there.
|
|
4068
4984
|
|
|
4985
|
+
The position does not belong to the plugin that mounted the panel: any
|
|
4986
|
+
plugin with `ui.mount` may read and toggle it, so a "Show playlist" command
|
|
4987
|
+
can live in a different plugin than the playlist. The player remembers
|
|
4988
|
+
the sidebar's visibility, width, detached state and floating window
|
|
4989
|
+
position across launches.
|
|
4990
|
+
|
|
4991
|
+
`state` is read when first used. With `events` it then follows every
|
|
4992
|
+
change (the user, other plugins, the window size); without `events` it
|
|
4993
|
+
refreshes only after this plugin's own changes.
|
|
4994
|
+
|
|
4069
4995
|
- @capability ui.mount; `events` to follow changes made elsewhere.
|
|
4070
4996
|
|
|
4071
4997
|
```ts
|
|
@@ -4081,48 +5007,138 @@ interface PanelSlot {
|
|
|
4081
5007
|
readonly panels: readonly SlotPanel[] | null;
|
|
4082
5008
|
/**
|
|
4083
5009
|
* Run code when the position's state changes.
|
|
5010
|
+
*
|
|
5011
|
+
* Needs `events`: without it the listener runs only after this plugin's own
|
|
5012
|
+
* changes. Called with `null` when the panel there is unmounted, and also
|
|
5013
|
+
* when only the tabs changed (PanelSlot.panels: a panel added or
|
|
5014
|
+
* removed, another tab current). Errors go to Scope.onError.
|
|
4084
5015
|
* @capability ui.mount and events
|
|
4085
5016
|
* @param listener - Receives the new state.
|
|
4086
5017
|
* @returns A handle that removes the listener.
|
|
5018
|
+
* @example Tell the user when the window is too narrow for the sidebar
|
|
5019
|
+
* When the sidebar should show but the window has no room for it, the
|
|
5020
|
+
* OSD says so. With `events`, the listener runs on every change of the
|
|
5021
|
+
* position, whoever caused it. The player reports `reason: "space"` when
|
|
5022
|
+
* the preference is to show the sidebar but the window is too small.
|
|
5023
|
+
* ```ts
|
|
5024
|
+
* plugin.panels.sidebar.onChange((state) => {
|
|
5025
|
+
* // `null`: no panel is mounted in the sidebar any more.
|
|
5026
|
+
* if (state?.reason === "space") void plugin.osd.show("Widen the window to see the playlist");
|
|
5027
|
+
* });
|
|
5028
|
+
* ```
|
|
4087
5029
|
*/
|
|
4088
5030
|
onChange(listener: (state: SlotState | null) => void): Disposable;
|
|
4089
5031
|
/**
|
|
4090
5032
|
* Show or hide the panel at this position: set the user's preference.
|
|
5033
|
+
*
|
|
5034
|
+
* The same as the user's menu item: the preference is remembered. Showing
|
|
5035
|
+
* may still leave the panel off screen (`reason: "space"`); in fullscreen,
|
|
5036
|
+
* `true` pops the sidebar overlay out at once.
|
|
4091
5037
|
* @capability ui.mount
|
|
4092
5038
|
* @param visible - The new preference.
|
|
4093
5039
|
* @returns Resolves once the layout has the new preference, with `state` refreshed.
|
|
4094
5040
|
* @throws missing (rejects) No panel is mounted at this position.
|
|
4095
5041
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4096
5042
|
* @throws capability (rejects) The manifest does not declare `ui.mount`.
|
|
5043
|
+
* @example Hide the bottom bar for a clean picture
|
|
5044
|
+
* The control bar disappears, as if the user had hidden it from the menu,
|
|
5045
|
+
* and stays hidden at the next start because the preference is
|
|
5046
|
+
* remembered. `setVisible` rejects with `missing` when no panel is in the
|
|
5047
|
+
* bottom bar, which this plugin treats as nothing to hide.
|
|
5048
|
+
* ```ts
|
|
5049
|
+
* await plugin.panels.bottom.setVisible(false).catch((error: PluginError) => {
|
|
5050
|
+
* if (error.code !== "missing") throw error; // no bar mounted: nothing to do
|
|
5051
|
+
* });
|
|
5052
|
+
* ```
|
|
4097
5053
|
*/
|
|
4098
5054
|
setVisible(visible: boolean): Promise<void>;
|
|
4099
5055
|
/**
|
|
4100
5056
|
* Flip the user's preference, using the player's current value.
|
|
5057
|
+
*
|
|
5058
|
+
* Reads the state from the player first, so it is right even without
|
|
5059
|
+
* `events`.
|
|
4101
5060
|
* @capability ui.mount
|
|
4102
5061
|
* @returns Resolves once the layout has the new preference.
|
|
4103
5062
|
* @throws missing (rejects) No panel is mounted at this position.
|
|
4104
5063
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4105
5064
|
* @throws capability (rejects) The manifest does not declare `ui.mount`.
|
|
5065
|
+
* @example Toggle the control bar from a checkable menu item
|
|
5066
|
+
* Screen ▸ Control bar shows or hides the bottom bar and carries a check
|
|
5067
|
+
* mark while it is shown. `toggle` reads the player's current preference
|
|
5068
|
+
* first, so it is right even without `events`; `checked` is evaluated
|
|
5069
|
+
* again after the command runs and follows `state`.
|
|
5070
|
+
* ```ts
|
|
5071
|
+
* plugin.commands.add({
|
|
5072
|
+
* id: "toggle-bar",
|
|
5073
|
+
* path: ["@screen", "Control bar"],
|
|
5074
|
+
* // The user's preference, remembered across launches.
|
|
5075
|
+
* checked: () => plugin.panels.bottom.state?.preferred ?? false,
|
|
5076
|
+
* run: () => plugin.panels.bottom.toggle(),
|
|
5077
|
+
* });
|
|
5078
|
+
* ```
|
|
4106
5079
|
*/
|
|
4107
5080
|
toggle(): Promise<void>;
|
|
4108
5081
|
/**
|
|
4109
5082
|
* Make a panel's tab the current one, without showing or hiding the position.
|
|
5083
|
+
*
|
|
5084
|
+
* The sidebar holds the panels of several plugins as tabs (see
|
|
5085
|
+
* Panels.mount); this switches which one it shows, like a click on
|
|
5086
|
+
* its tab. To show this plugin's own panel, use Panel.show. The
|
|
5087
|
+
* bottom bar has one panel: activating it changes nothing. Players
|
|
5088
|
+
* without sidebar tabs (0.4.0 and older) ignore it.
|
|
4110
5089
|
* @capability ui.mount
|
|
4111
5090
|
* @param plugin - The manifest id of the plugin whose panel to show, one of PanelSlot.panels.
|
|
4112
5091
|
* @returns Resolves once the layout has the new current tab, with `state` refreshed.
|
|
4113
5092
|
* @throws missing (rejects) No panel is mounted at this position, or none of `plugin`.
|
|
4114
5093
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4115
5094
|
* @throws capability (rejects) The manifest does not declare `ui.mount`.
|
|
5095
|
+
* @example Switch the sidebar to the playlist's tab
|
|
5096
|
+
* A command makes the playlist the sidebar's current tab without showing
|
|
5097
|
+
* or hiding the sidebar, like a click on its tab. `activate` takes the
|
|
5098
|
+
* manifest id of the plugin whose tab to show and rejects with
|
|
5099
|
+
* `missing` when that plugin has no panel there.
|
|
5100
|
+
* ```ts
|
|
5101
|
+
* plugin.commands.add({
|
|
5102
|
+
* id: "to-playlist",
|
|
5103
|
+
* title: "Show the playlist tab",
|
|
5104
|
+
* run: () => plugin.panels.sidebar.activate("vestra.playlist"),
|
|
5105
|
+
* });
|
|
5106
|
+
* ```
|
|
4116
5107
|
*/
|
|
4117
5108
|
activate(plugin: string): Promise<void>;
|
|
4118
5109
|
}
|
|
4119
5110
|
```
|
|
4120
5111
|
|
|
5112
|
+
Example: Add a "Show sidebar" item that works with any sidebar panel
|
|
5113
|
+
|
|
5114
|
+
Tools ▸ Show sidebar shows or hides the sidebar, whichever plugin's
|
|
5115
|
+
panel is in it, with a check mark while it is shown. The position does
|
|
5116
|
+
not belong to the plugin that mounted the panel: `state` is `null`
|
|
5117
|
+
while nobody has a panel there (the item is disabled then), and
|
|
5118
|
+
`toggle` flips the user's remembered preference.
|
|
5119
|
+
```ts
|
|
5120
|
+
const sidebar = plugin.panels.sidebar;
|
|
5121
|
+
plugin.commands.add({
|
|
5122
|
+
id: "sidebar",
|
|
5123
|
+
path: ["Tools", "Show sidebar"],
|
|
5124
|
+
// `state` is null while no plugin has a panel in the sidebar.
|
|
5125
|
+
checked: () => sidebar.state?.preferred ?? false,
|
|
5126
|
+
enabled: () => sidebar.state !== null,
|
|
5127
|
+
run: () => sidebar.toggle(),
|
|
5128
|
+
});
|
|
5129
|
+
```
|
|
5130
|
+
|
|
4121
5131
|
### SidebarSlot
|
|
4122
5132
|
|
|
4123
5133
|
The sidebar position: a PanelSlot that can also float in a window
|
|
4124
5134
|
of its own.
|
|
4125
5135
|
|
|
5136
|
+
Detached, the sidebar panel is shown in a separate window (300×408 by
|
|
5137
|
+
default, at least 300×250) with the panel's `title`. The plugin's
|
|
5138
|
+
properties and callbacks stay the same, but the window holds a new
|
|
5139
|
+
instance of the component. Closing the floating window (its close button
|
|
5140
|
+
or the standard close keys) is the same as `setVisible(false)`.
|
|
5141
|
+
|
|
4126
5142
|
- @capability ui.mount
|
|
4127
5143
|
|
|
4128
5144
|
```ts
|
|
@@ -4135,21 +5151,76 @@ interface SidebarSlot extends PanelSlot {
|
|
|
4135
5151
|
* @throws missing (rejects) No panel is mounted in the sidebar.
|
|
4136
5152
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4137
5153
|
* @throws capability (rejects) The manifest does not declare `ui.mount`.
|
|
5154
|
+
* @example Float the sidebar or dock it back
|
|
5155
|
+
* The sidebar moves into its own window, or back into the main window
|
|
5156
|
+
* when it already floats. `state` says whether it is detached now;
|
|
5157
|
+
* `setDetached` resolves once the layout changed, with `state` refreshed.
|
|
5158
|
+
* ```ts
|
|
5159
|
+
* const sidebar = plugin.panels.sidebar;
|
|
5160
|
+
* await sidebar.setDetached(!sidebar.state?.detached);
|
|
5161
|
+
* ```
|
|
4138
5162
|
*/
|
|
4139
5163
|
setDetached(detached: boolean): Promise<void>;
|
|
4140
5164
|
}
|
|
4141
5165
|
```
|
|
4142
5166
|
|
|
5167
|
+
Example: Add a "Detach sidebar" item
|
|
5168
|
+
|
|
5169
|
+
Tools ▸ Detach sidebar floats the sidebar in its own window and docks
|
|
5170
|
+
it again, with a check mark while it floats. `setDetached` moves all
|
|
5171
|
+
the sidebar's tabs; the floating window holds new instances of the
|
|
5172
|
+
panels, which keep the plugins' properties and callbacks.
|
|
5173
|
+
```ts
|
|
5174
|
+
const sidebar = plugin.panels.sidebar;
|
|
5175
|
+
plugin.commands.add({
|
|
5176
|
+
id: "detach",
|
|
5177
|
+
path: ["Tools", "Detach sidebar"],
|
|
5178
|
+
// Detaching moves every tab of the sidebar into the floating window.
|
|
5179
|
+
checked: () => sidebar.state?.detached ?? false,
|
|
5180
|
+
enabled: () => sidebar.state !== null,
|
|
5181
|
+
run: () => sidebar.setDetached(!sidebar.state?.detached),
|
|
5182
|
+
});
|
|
5183
|
+
```
|
|
5184
|
+
|
|
4143
5185
|
### Panel
|
|
4144
5186
|
|
|
4145
5187
|
A panel in the player's main window, made by Panels.mount.
|
|
4146
5188
|
|
|
5189
|
+
Properties and callbacks live on the file's bridge global (see
|
|
5190
|
+
Panels). Unlike a tool window, a panel forwards every callback of
|
|
5191
|
+
the global, so View.on has no "bind before show" rule.
|
|
5192
|
+
|
|
5193
|
+
**Life cycle.** `mount()` returns at once; in the next microtask the SDK
|
|
5194
|
+
sends the mount with every property bound or set so far. The player queues
|
|
5195
|
+
mounts and rebuilds its main window with the panel; Panel.ready
|
|
5196
|
+
then resolves. Rebuilding happens whenever any plugin mounts or unmounts a
|
|
5197
|
+
panel, and detaching the sidebar puts a new instance in a floating window.
|
|
5198
|
+
The player restores the bridge properties to the values last written, but
|
|
5199
|
+
state kept only inside the component (a scroll position, a property the
|
|
5200
|
+
plugin never writes) starts over: keep what matters in bridge properties.
|
|
5201
|
+
`dispose()`, disposing the scope or stopping the plugin unmounts it.
|
|
5202
|
+
|
|
5203
|
+
Testing: `host.mounted()` of `@vestra-player/sdk/testing` has the panel's
|
|
5204
|
+
`slot`, `props` and `emit(callback, ...args)`.
|
|
5205
|
+
|
|
4147
5206
|
- @capability ui.mount
|
|
4148
5207
|
|
|
4149
5208
|
```ts
|
|
4150
5209
|
interface Panel extends View, Disposable {
|
|
4151
5210
|
/**
|
|
4152
5211
|
* Resolves once the player has mounted the panel and the main window shows it; rejects when the mount failed.
|
|
5212
|
+
*
|
|
5213
|
+
* Mounts made while `activate` runs must succeed before the plugin is
|
|
5214
|
+
* ready: a rejection fails activation, except `conflict` for a taken
|
|
5215
|
+
* position (below), which the player (0.4.x after 0.4.0) and `TestHost`
|
|
5216
|
+
* do not count: the plugin runs without its panel, its commands and keys
|
|
5217
|
+
* work, and the player shows the refusal (an OSD message,
|
|
5218
|
+
* PluginInfo.warnings and the text in PluginInfo.error).
|
|
5219
|
+
* Later failures also go to Scope.onError.
|
|
5220
|
+
*
|
|
5221
|
+
* A second sidebar panel is not refused: it resolves as another tab
|
|
5222
|
+
* (see Panel.state). Built-in plugins mount first at start-up,
|
|
5223
|
+
* so the playlist's tab is first and a built-in panel keeps its position.
|
|
4153
5224
|
* @throws ui-prepare (rejects) The Slint file does not compile, or a bound property is not declared on the bridge global; `message` has the diagnostics. (Players 0.4.0 and older also answer a taken bottom bar or sidebar with it.)
|
|
4154
5225
|
* @throws conflict (rejects) Another plugin's panel holds the bottom bar or the backdrop (shown, or being mounted), or the sidebar has all the tabs it takes (8); `message` names the position and the holder (「侧栏已被 vestra.playlist 使用」). Not an activation failure.
|
|
4155
5226
|
* @throws busy (rejects) The player is still preparing this plugin's previous mount, or its queue of mounts is full.
|
|
@@ -4161,38 +5232,117 @@ interface Panel extends View, Disposable {
|
|
|
4161
5232
|
readonly ready: Promise<void>;
|
|
4162
5233
|
/**
|
|
4163
5234
|
* This panel's state: its position's, and whether its tab is the current one; `null` until Panel.ready resolved (and after `dispose`).
|
|
5235
|
+
*
|
|
5236
|
+
* Read when first used; with `events` it then follows every change (the
|
|
5237
|
+
* user switching tabs, other plugins, the window size), without `events`
|
|
5238
|
+
* only this plugin's own changes. A sidebar panel in a background tab
|
|
5239
|
+
* has `active: false`, `effective: false` and `reason: "otherTab"`.
|
|
4164
5240
|
* @throws Never; `null` stands for "not mounted".
|
|
4165
5241
|
*/
|
|
4166
5242
|
readonly state: PanelState | null;
|
|
4167
5243
|
/**
|
|
4168
5244
|
* Run code when this panel's state changes.
|
|
5245
|
+
*
|
|
5246
|
+
* Needs `events`: without it the listener runs only after this plugin's
|
|
5247
|
+
* own changes. Called with `null` when the panel is unmounted. Errors go
|
|
5248
|
+
* to Scope.onError.
|
|
4169
5249
|
* @capability ui.mount and events
|
|
4170
5250
|
* @param listener - Receives the new state.
|
|
4171
5251
|
* @returns A handle that removes the listener.
|
|
5252
|
+
* @example Notice when the list moves to a background tab
|
|
5253
|
+
* The plugin logs when the user switches the sidebar to another plugin's
|
|
5254
|
+
* tab, for example to pause work the list does. With `events` the
|
|
5255
|
+
* listener runs on every change. `reason: "otherTab"` exists only in a
|
|
5256
|
+
* panel's own state: the SDK sets it when the sidebar's current tab is
|
|
5257
|
+
* another plugin's (the position's state never has it).
|
|
5258
|
+
* ```ts
|
|
5259
|
+
* const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
5260
|
+
* panel.onChange((state) => {
|
|
5261
|
+
* if (state?.reason === "otherTab") console.error("list in a background tab");
|
|
5262
|
+
* });
|
|
5263
|
+
* ```
|
|
4172
5264
|
*/
|
|
4173
5265
|
onChange(listener: (state: PanelState | null) => void): Disposable;
|
|
4174
5266
|
/**
|
|
4175
5267
|
* Show this panel: make its tab the current one and show its position.
|
|
5268
|
+
*
|
|
5269
|
+
* Sets the position's preference like PanelSlot.setVisible`(true)`
|
|
5270
|
+
* (remembered; in fullscreen the sidebar overlay pops out). On players
|
|
5271
|
+
* without sidebar tabs (0.4.0 and older) it is just that. Waits for
|
|
5272
|
+
* Panel.ready first.
|
|
4176
5273
|
* @capability ui.mount
|
|
4177
5274
|
* @returns Resolves once the layout shows the panel's tab, with `state` refreshed.
|
|
4178
5275
|
* @throws invalid (rejects) The panel is the backdrop, which the player shows by itself.
|
|
4179
5276
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4180
5277
|
* @throws The errors of Panel.ready (rejects) The mount failed.
|
|
5278
|
+
* @example Bring the plugin's sidebar panel to the front
|
|
5279
|
+
* A command shows the List tab: it becomes the current tab and the
|
|
5280
|
+
* sidebar is shown, and the preference is remembered. `show` waits for
|
|
5281
|
+
* the mount first, so it can run right after `mount`.
|
|
5282
|
+
* ```ts
|
|
5283
|
+
* const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
5284
|
+
* plugin.commands.add({ id: "show", title: "Show the list", run: () => list.show() });
|
|
5285
|
+
* ```
|
|
4181
5286
|
*/
|
|
4182
5287
|
show(): Promise<void>;
|
|
4183
5288
|
/**
|
|
4184
5289
|
* Show this panel, or hide its position when it is shown.
|
|
5290
|
+
*
|
|
5291
|
+
* Reads the state from the player first. When the position is shown
|
|
5292
|
+
* (`preferred`) and this panel's tab is the current one, it hides the
|
|
5293
|
+
* position; otherwise it does Panel.show: a panel in a background
|
|
5294
|
+
* tab comes to the front instead of the sidebar closing. A "Show list"
|
|
5295
|
+
* command checked by `state.preferred && state.active` matches it. Waits
|
|
5296
|
+
* for Panel.ready first.
|
|
4185
5297
|
* @capability ui.mount
|
|
4186
5298
|
* @returns Resolves once the layout has changed, with `state` refreshed.
|
|
4187
5299
|
* @throws invalid (rejects) The panel is the backdrop.
|
|
4188
5300
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4189
5301
|
* @throws The errors of Panel.ready (rejects) The mount failed.
|
|
5302
|
+
* @example Toggle a sidebar panel with F6, like the playlist
|
|
5303
|
+
* F6 and Screen ▸ List show the List tab, or hide the sidebar when the
|
|
5304
|
+
* list is already in front. `toggle` reads the player's state first:
|
|
5305
|
+
* it hides the position only when it is shown and this panel's tab is
|
|
5306
|
+
* the current one, otherwise it shows the panel.
|
|
5307
|
+
* ```ts
|
|
5308
|
+
* const list = plugin.panels.mount("sidebar", "ui/list.slint", { title: "List" });
|
|
5309
|
+
* plugin.commands.add({
|
|
5310
|
+
* id: "list",
|
|
5311
|
+
* path: ["@screen", "List"],
|
|
5312
|
+
* bindings: ["F6"],
|
|
5313
|
+
* // Checked while the sidebar is shown with this list in front.
|
|
5314
|
+
* checked: () => (list.state?.preferred ?? false) && (list.state?.active ?? false),
|
|
5315
|
+
* run: () => list.toggle(),
|
|
5316
|
+
* });
|
|
5317
|
+
* ```
|
|
4190
5318
|
*/
|
|
4191
5319
|
toggle(): Promise<void>;
|
|
4192
5320
|
/**
|
|
4193
5321
|
* Keep the panel on screen while the user works in it.
|
|
5322
|
+
*
|
|
5323
|
+
* In fullscreen the bottom bar shows only while the pointer is in the bar's
|
|
5324
|
+
* own band at the bottom of the screen: at once when the pointer enters it,
|
|
5325
|
+
* hidden 500 ms after it leaves (player 0.5.1; keys and pointer motion
|
|
5326
|
+
* elsewhere do not reveal it), and the fullscreen sidebar overlay collapses
|
|
5327
|
+
* when the pointer leaves it. A lock keeps the bar shown. Set a
|
|
5328
|
+
* lock while a text field has focus, a drag is in progress or a popup is
|
|
5329
|
+
* open, and clear it afterwards. Locks are merged with the earlier ones;
|
|
5330
|
+
* the last value of each wins. The player already locks a panel while the
|
|
5331
|
+
* pointer is over it. No effect on the backdrop.
|
|
5332
|
+
*
|
|
5333
|
+
* Never throws: a failed write is reported to Scope.onError.
|
|
4194
5334
|
* @capability ui.mount
|
|
4195
5335
|
* @param locks - The locks to change; omitted ones keep their value.
|
|
5336
|
+
* @example Keep the bar on screen while a search field has focus
|
|
5337
|
+
* In fullscreen the bottom bar stays visible while the user types in
|
|
5338
|
+
* its search field, instead of hiding when the pointer leaves it. The
|
|
5339
|
+
* Slint side reports the field's focus through a callback, and
|
|
5340
|
+
* `setInteraction` sets or clears the `focused` lock; other locks keep
|
|
5341
|
+
* their values.
|
|
5342
|
+
* ```ts
|
|
5343
|
+
* const panel = plugin.panels.mount("bottom", "ui/bar.slint");
|
|
5344
|
+
* panel.on("searchFocused", (focused: boolean) => panel.setInteraction({ focused }));
|
|
5345
|
+
* ```
|
|
4196
5346
|
*/
|
|
4197
5347
|
setInteraction(locks: Partial<{
|
|
4198
5348
|
/** A text field (or another control taking keys) has focus. */
|
|
@@ -4204,6 +5354,12 @@ interface Panel extends View, Disposable {
|
|
|
4204
5354
|
}>): void;
|
|
4205
5355
|
/**
|
|
4206
5356
|
* Pop up a menu of this plugin's commands at a point in the panel.
|
|
5357
|
+
*
|
|
5358
|
+
* A one-level menu, styled like the main menu (check marks, shortcut text),
|
|
5359
|
+
* in the window that shows the panel. Picking an item runs that command;
|
|
5360
|
+
* `null` is a separator. Commands without a menu `path` work too, which
|
|
5361
|
+
* makes context-only commands possible. Call it when the right button is
|
|
5362
|
+
* released, as the main menu does. Waits for Panel.ready first.
|
|
4207
5363
|
* @capability ui.mount
|
|
4208
5364
|
* @param items - At most 32 commands of this plugin, or `null` for a separator.
|
|
4209
5365
|
* @param at - Where to open it, in the window's logical pixels: in Slint, `(self.absolute-position.x + self.mouse-x) / 1px` (and the same for `y`).
|
|
@@ -4212,6 +5368,20 @@ interface Panel extends View, Disposable {
|
|
|
4212
5368
|
* @throws unavailable (rejects) The panel is not on screen (also: its sidebar tab is not the current one), or it is the backdrop.
|
|
4213
5369
|
* @throws invalid (rejects) No items, more than 32, or `at` is not finite.
|
|
4214
5370
|
* @throws The errors of Panel.ready (rejects) The mount failed.
|
|
5371
|
+
* @example Open a context menu where the user right-clicked (examples/panel-menu)
|
|
5372
|
+
* Right-clicking the list opens a menu with "Select the first item" and
|
|
5373
|
+
* "Remove the selected item". The Slint file passes the pointer position
|
|
5374
|
+
* in window pixels with its `context-menu` callback; `showMenu` opens the
|
|
5375
|
+
* plugin's commands there, and picking one runs that command.
|
|
5376
|
+
*
|
|
5377
|
+
* ```ts
|
|
5378
|
+
* // Callbacks of the bridge global; `bind` above sends the new selection.
|
|
5379
|
+
* panel.on("select", (index: number) => (selected = index));
|
|
5380
|
+
* // x and y: the pointer in window pixels, from the Slint file.
|
|
5381
|
+
* panel.on("contextMenu", (x: number, y: number) =>
|
|
5382
|
+
* panel.showMenu([first, null, remove], { x, y }),
|
|
5383
|
+
* );
|
|
5384
|
+
* ```
|
|
4215
5385
|
*/
|
|
4216
5386
|
showMenu(items: (Command | null)[], at: {
|
|
4217
5387
|
/** Logical pixels from the window's left edge. */
|
|
@@ -4221,9 +5391,29 @@ interface Panel extends View, Disposable {
|
|
|
4221
5391
|
}): Promise<void>;
|
|
4222
5392
|
/**
|
|
4223
5393
|
* Receive the files dropped on this panel.
|
|
5394
|
+
*
|
|
5395
|
+
* Covers the docked panel and the floating sidebar window. While a listener
|
|
5396
|
+
* exists, drops on the panel come here as one group (not split by
|
|
5397
|
+
* extension) instead of going to the Drops providers. Drops on
|
|
5398
|
+
* tool windows and the Options window are ignored; in fullscreen, the
|
|
5399
|
+
* bottom bar floats over the video and drops there count as drops on the
|
|
5400
|
+
* video area.
|
|
4224
5401
|
* @capability ui.drop
|
|
4225
5402
|
* @param listener - Receives the paths in the order the file manager gave them, the point in panel coordinates (logical pixels from the panel's top-left corner) and the keys held.
|
|
4226
5403
|
* @returns A handle that removes the listener.
|
|
5404
|
+
* @example Add dropped files to a list
|
|
5405
|
+
* Files dropped on the Files panel are added to its list. While the
|
|
5406
|
+
* listener exists, drops on the panel come here as one group instead of
|
|
5407
|
+
* going to the drop providers; after the listener returns, the SDK runs
|
|
5408
|
+
* an update pass and `bind` sends the longer list.
|
|
5409
|
+
* ```ts
|
|
5410
|
+
* const files: string[] = [];
|
|
5411
|
+
* const panel = plugin.panels.mount("sidebar", "ui/list.slint", { title: "Files" });
|
|
5412
|
+
* panel.bind(() => ({ rows: files }));
|
|
5413
|
+
* panel.onDrop(({ paths }) => {
|
|
5414
|
+
* files.push(...paths); // in the order the file manager gave them
|
|
5415
|
+
* });
|
|
5416
|
+
* ```
|
|
4227
5417
|
*/
|
|
4228
5418
|
onDrop(listener: (event: {
|
|
4229
5419
|
/** Absolute paths of the dropped files and folders. */
|
|
@@ -4239,6 +5429,74 @@ interface Panel extends View, Disposable {
|
|
|
4239
5429
|
}
|
|
4240
5430
|
```
|
|
4241
5431
|
|
|
5432
|
+
Example: Mount a sidebar list with a right-click menu (examples/panel-menu)
|
|
5433
|
+
|
|
5434
|
+
The sidebar gets an "Example list" tab whose rows can be selected and
|
|
5435
|
+
have a context menu. `mount` returns at once and the player adds the
|
|
5436
|
+
panel when it rebuilds its main window; `bind` keeps the rows and the
|
|
5437
|
+
selection on the panel's bridge global, and the `context-menu` callback
|
|
5438
|
+
opens a menu of the plugin's commands where the user clicked.
|
|
5439
|
+
|
|
5440
|
+
```ts
|
|
5441
|
+
const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
|
|
5442
|
+
title: "Example list",
|
|
5443
|
+
});
|
|
5444
|
+
// Sent when the panel mounts, then whenever a value changes.
|
|
5445
|
+
panel.bind(() => ({ rows: items, selected }));
|
|
5446
|
+
|
|
5447
|
+
// Callbacks of the bridge global; `bind` above sends the new selection.
|
|
5448
|
+
panel.on("select", (index: number) => (selected = index));
|
|
5449
|
+
// x and y: the pointer in window pixels, from the Slint file.
|
|
5450
|
+
panel.on("contextMenu", (x: number, y: number) =>
|
|
5451
|
+
panel.showMenu([first, null, remove], { x, y }),
|
|
5452
|
+
);
|
|
5453
|
+
```
|
|
5454
|
+
|
|
5455
|
+
`ui/panel.slint`:
|
|
5456
|
+
|
|
5457
|
+
```slint
|
|
5458
|
+
import { Theme } from "@vestra/theme.slint";
|
|
5459
|
+
|
|
5460
|
+
export global ListBridge {
|
|
5461
|
+
in property <[string]> rows;
|
|
5462
|
+
in property <int> selected;
|
|
5463
|
+
callback select(int);
|
|
5464
|
+
callback context-menu(float, float);
|
|
5465
|
+
}
|
|
5466
|
+
|
|
5467
|
+
export component ListPanel inherits Rectangle {
|
|
5468
|
+
background: Theme.bg-list;
|
|
5469
|
+
min-width: 200px;
|
|
5470
|
+
VerticalLayout {
|
|
5471
|
+
for row[index] in ListBridge.rows: Rectangle {
|
|
5472
|
+
height: 20px;
|
|
5473
|
+
background: index == ListBridge.selected ? Theme.selected-bg : transparent;
|
|
5474
|
+
Text {
|
|
5475
|
+
x: 6px;
|
|
5476
|
+
text: (index + 1) + ". " + row;
|
|
5477
|
+
color: index == ListBridge.selected ? Theme.text : Theme.text-list;
|
|
5478
|
+
font-size: Theme.font-body;
|
|
5479
|
+
vertical-alignment: center;
|
|
5480
|
+
}
|
|
5481
|
+
TouchArea {
|
|
5482
|
+
pointer-event(event) => {
|
|
5483
|
+
if event.kind == PointerEventKind.down {
|
|
5484
|
+
ListBridge.select(index);
|
|
5485
|
+
}
|
|
5486
|
+
if event.kind == PointerEventKind.up && event.button == PointerEventButton.right {
|
|
5487
|
+
ListBridge.context-menu((self.absolute-position.x + self.mouse-x) / 1px,
|
|
5488
|
+
(self.absolute-position.y + self.mouse-y) / 1px);
|
|
5489
|
+
}
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
}
|
|
5493
|
+
// Takes the space below the rows. Without it the layout's maximum
|
|
5494
|
+
// height (three 20px rows) would cap the player's main window.
|
|
5495
|
+
Rectangle { vertical-stretch: 1; }
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
```
|
|
5499
|
+
|
|
4242
5500
|
### DropModifiers
|
|
4243
5501
|
|
|
4244
5502
|
Keys held when the files were dropped.
|
|
@@ -4280,9 +5538,30 @@ interface Drops {
|
|
|
4280
5538
|
* Take files dropped on the video area. The first provider registered for
|
|
4281
5539
|
* an extension gets it; files nobody takes are ignored.
|
|
4282
5540
|
*
|
|
4283
|
-
* Paths are grouped by extension
|
|
5541
|
+
* Paths are grouped by provider (an extension, case-insensitive, belongs
|
|
5542
|
+
* to the provider that registered it first) in the order the file
|
|
4284
5543
|
* manager gave them, one call per provider. Folders are not expanded.
|
|
4285
5544
|
* @throws invalid synchronously for malformed extensions.
|
|
5545
|
+
* @example Load dropped subtitle files
|
|
5546
|
+
* Dropping .srt or .ass files on the video loads each one as a subtitle
|
|
5547
|
+
* track. Each extension belongs to the provider that registered it first;
|
|
5548
|
+
* the player groups the dropped paths by provider and calls each provider
|
|
5549
|
+
* once with all of its paths, so .srt and .ass files dropped together
|
|
5550
|
+
* arrive in one call. (A panel under the pointer that listens with
|
|
5551
|
+
* `onDrop` takes the whole drop first.) The plugin reads each file itself
|
|
5552
|
+
* and uploads its bytes with `subtitles.load`.
|
|
5553
|
+
* ```ts
|
|
5554
|
+
* plugin.drops.provide({
|
|
5555
|
+
* extensions: ["srt", "ass"],
|
|
5556
|
+
* drop: async ({ paths }) => {
|
|
5557
|
+
* // Only .srt and .ass files reach this provider.
|
|
5558
|
+
* for (const path of paths)
|
|
5559
|
+
* await plugin.subtitles.load(await readFile(path), {
|
|
5560
|
+
* format: path.endsWith(".ass") ? "ass" : "srt",
|
|
5561
|
+
* });
|
|
5562
|
+
* },
|
|
5563
|
+
* });
|
|
5564
|
+
* ```
|
|
4286
5565
|
*/
|
|
4287
5566
|
provide(handler: DropHandler): Disposable;
|
|
4288
5567
|
}
|
|
@@ -4295,6 +5574,10 @@ The player's palettes: `vestra`, the dark default, and `vestra-light`
|
|
|
4295
5574
|
Both draw on the logo's colours. Read colours from `Theme` instead of
|
|
4296
5575
|
branching on the id: a later version may add palettes.
|
|
4297
5576
|
|
|
5577
|
+
The player treats an id it does not have as `vestra`: plugins built with an
|
|
5578
|
+
older SDK that still ask for a palette removed in SDK 1.4.0 keep working,
|
|
5579
|
+
and a player older than 0.4.0 answers `vestra-light` with `vestra`.
|
|
5580
|
+
|
|
4298
5581
|
```ts
|
|
4299
5582
|
type ThemeId = "vestra" | "vestra-light";
|
|
4300
5583
|
```
|
|
@@ -4303,6 +5586,19 @@ type ThemeId = "vestra" | "vestra-light";
|
|
|
4303
5586
|
|
|
4304
5587
|
The player's palette, which every view uses. `plugin.appearance`.
|
|
4305
5588
|
|
|
5589
|
+
The player sets the palette on every view (the main window and its panels,
|
|
5590
|
+
the floating sidebar, tool windows, the Options window) before it appears
|
|
5591
|
+
and again when the palette changes. Slint code that reads its colours from
|
|
5592
|
+
`Theme` (`import { Theme } from "@vestra/theme.slint"`) follows it with no
|
|
5593
|
+
plugin code; hard-coded colours do not. Only colours change: corner radii,
|
|
5594
|
+
font sizes and control sizes are the same in every palette. A light palette
|
|
5595
|
+
also switches the std-widgets controls (`std-widgets.slint`) to their light
|
|
5596
|
+
colours, and keeps the video area dark.
|
|
5597
|
+
|
|
5598
|
+
The player saves the choice; it stays after the plugin that set it stops.
|
|
5599
|
+
Tell the user about a change yourself (for example with
|
|
5600
|
+
Osd.show).
|
|
5601
|
+
|
|
4306
5602
|
- @capability None to read `theme`; `events` to follow changes; `ui.theme` to change it.
|
|
4307
5603
|
|
|
4308
5604
|
```ts
|
|
@@ -4311,24 +5607,62 @@ interface Appearance {
|
|
|
4311
5607
|
readonly theme: ThemeId;
|
|
4312
5608
|
/**
|
|
4313
5609
|
* Run code when the palette changes.
|
|
5610
|
+
*
|
|
5611
|
+
* Needs `events`: without it the listener never runs for other plugins'
|
|
5612
|
+
* changes. Views need no help to follow the palette; use this for things
|
|
5613
|
+
* the plugin draws itself (a rendered image, colours it computes). Errors
|
|
5614
|
+
* go to Scope.onError.
|
|
4314
5615
|
* @capability events
|
|
4315
5616
|
* @param listener - Receives the new palette.
|
|
4316
5617
|
* @returns A handle that removes the listener.
|
|
5618
|
+
* @example Redraw a picture the plugin renders when the palette changes
|
|
5619
|
+
* The plugin renders a chart image in the palette's colours and draws it
|
|
5620
|
+
* again when the user or another plugin switches palettes. Views that use
|
|
5621
|
+
* `Theme` follow on their own; this listener (which needs `events`) is
|
|
5622
|
+
* for what the plugin draws itself.
|
|
5623
|
+
* ```ts
|
|
5624
|
+
* plugin.appearance.onChange((theme) => {
|
|
5625
|
+
* void renderChart(theme === "vestra"); // the dark default
|
|
5626
|
+
* });
|
|
5627
|
+
* ```
|
|
4317
5628
|
*/
|
|
4318
5629
|
onChange(listener: (theme: ThemeId) => void): Disposable;
|
|
4319
5630
|
/**
|
|
4320
5631
|
* Switch the palette of every view.
|
|
5632
|
+
*
|
|
5633
|
+
* The player applies it to every open view without interrupting a drag or
|
|
5634
|
+
* an open menu, and saves it across restarts.
|
|
4321
5635
|
* @capability ui.theme
|
|
4322
5636
|
* @param theme - The palette to use.
|
|
4323
5637
|
* @returns Resolves once the player has accepted the change; Appearance.theme has the new value.
|
|
4324
5638
|
* @throws invalid (rejects) `theme` is not a ThemeId.
|
|
4325
5639
|
* @throws capability (rejects) The manifest does not declare `ui.theme`.
|
|
5640
|
+
* @example Switch to the light palette
|
|
5641
|
+
* Every view of the player turns light, and stays light after a restart.
|
|
5642
|
+
* The player applies the palette to every open view and saves the
|
|
5643
|
+
* choice; when the promise resolves, `appearance.theme` already has the
|
|
5644
|
+
* new value. Without `ui.theme` it rejects with `capability`.
|
|
5645
|
+
* ```ts
|
|
5646
|
+
* await plugin.appearance.set("vestra-light");
|
|
5647
|
+
* ```
|
|
4326
5648
|
*/
|
|
4327
5649
|
set(theme: ThemeId): Promise<void>;
|
|
4328
|
-
/**
|
|
5650
|
+
/**
|
|
5651
|
+
* The saved interface language: `"system"` (follow the system's languages, the default) or a Locale.
|
|
5652
|
+
*
|
|
5653
|
+
* This is the choice for the next start. The language of this run is
|
|
5654
|
+
* I18n.locale and never changes while the player runs. Read before
|
|
5655
|
+
* activation; with `events` it follows other plugins' changes, without it
|
|
5656
|
+
* only this plugin's own. Players before 0.4.3 report `"system"`.
|
|
5657
|
+
*/
|
|
4329
5658
|
readonly language: LanguagePreference;
|
|
4330
5659
|
/**
|
|
4331
5660
|
* Save the interface language for the next start.
|
|
5661
|
+
*
|
|
5662
|
+
* The player keeps the choice in its interface state; the language
|
|
5663
|
+
* changes when the player starts again (tell the user, for example "Takes
|
|
5664
|
+
* effect after restart"). Every plugin with `events` hears the change in
|
|
5665
|
+
* Appearance.language.
|
|
4332
5666
|
* @capability ui.theme
|
|
4333
5667
|
* @player 0.4.3
|
|
4334
5668
|
* @param language - `"system"` or a Locale.
|
|
@@ -4336,17 +5670,45 @@ interface Appearance {
|
|
|
4336
5670
|
* @throws invalid (rejects) `language` is not `"system"` or a Locale.
|
|
4337
5671
|
* @throws capability (rejects) The manifest does not declare `ui.theme`.
|
|
4338
5672
|
* @throws unsupported (rejects) A player before 0.4.3.
|
|
5673
|
+
* @example Choose German for the next start
|
|
5674
|
+
* The player will start in German next time, and the user is told so.
|
|
5675
|
+
* `setLanguage` only saves the choice: the language of this run
|
|
5676
|
+
* (`i18n.locale`) never changes while the player runs. Players before
|
|
5677
|
+
* 0.4.3 reject it with `unsupported`.
|
|
5678
|
+
* ```ts
|
|
5679
|
+
* await plugin.appearance.setLanguage("de");
|
|
5680
|
+
* await plugin.osd.show("Takes effect after restart"); // translated by the player
|
|
5681
|
+
* ```
|
|
4339
5682
|
*/
|
|
4340
5683
|
setLanguage(language: LanguagePreference): Promise<void>;
|
|
4341
5684
|
}
|
|
4342
5685
|
```
|
|
4343
5686
|
|
|
5687
|
+
Example: Offer the light palette as a menu item
|
|
5688
|
+
|
|
5689
|
+
Settings ▸ Light colours switches every view of the player to the light
|
|
5690
|
+
palette and carries a radio mark while it is in use. `set` asks the
|
|
5691
|
+
player to switch (it saves the choice); `theme` holds the palette in use,
|
|
5692
|
+
and `checked` reads it again after every handler.
|
|
5693
|
+
```ts
|
|
5694
|
+
plugin.commands.add({
|
|
5695
|
+
id: "light-colours",
|
|
5696
|
+
path: ["@settings", "Light colours"],
|
|
5697
|
+
radio: true, // a round mark instead of a check mark
|
|
5698
|
+
checked: () => plugin.appearance.theme === "vestra-light",
|
|
5699
|
+
run: () => plugin.appearance.set("vestra-light"),
|
|
5700
|
+
});
|
|
5701
|
+
```
|
|
5702
|
+
|
|
4344
5703
|
### Locale
|
|
4345
5704
|
|
|
4346
5705
|
An interface language of the player: English (`en`, the source language
|
|
4347
5706
|
of every plugin text) or a translation. The list is `LOCALES` in
|
|
4348
5707
|
`@vestra-player/sdk/testing`.
|
|
4349
5708
|
|
|
5709
|
+
Simplified Chinese is `zh-Hans`. There is no Traditional Chinese: a
|
|
5710
|
+
system set to Traditional Chinese (Taiwan, Hong Kong, Macao) gets English.
|
|
5711
|
+
|
|
4350
5712
|
```ts
|
|
4351
5713
|
type Locale = "en" | "zh-Hans" | "ja" | "ko" | "de" | "fr" | "es";
|
|
4352
5714
|
```
|
|
@@ -4380,6 +5742,18 @@ A text the player translates with the plugin's catalog and fills in:
|
|
|
4380
5742
|
English source text with its arguments. Made by I18n.text; plain
|
|
4381
5743
|
data, not a translation (the plugin never sees the translated string).
|
|
4382
5744
|
|
|
5745
|
+
Accepted wherever the player shows a plugin's text: command titles, path
|
|
5746
|
+
segments and reasons, Osd.show and OsdPanel.set (as the
|
|
5747
|
+
text or an argument), the texts of Dialogs.confirm, file dialog
|
|
5748
|
+
titles and `saveFile`'s `fileName`, and property values where Slint
|
|
5749
|
+
expects a `string` (at any depth: a `[string]` model, a struct field).
|
|
5750
|
+
Nested in another text's arguments it is translated too. Requires player
|
|
5751
|
+
0.4.3. Limits: `TEXT_LIMITS` in `@vestra-player/sdk/testing`.
|
|
5752
|
+
|
|
5753
|
+
The wire form `{ $vestraText, args }` is what the player reads; write it
|
|
5754
|
+
with I18n.text, so that `vestra-plugin i18n extract` finds the
|
|
5755
|
+
source text.
|
|
5756
|
+
|
|
4383
5757
|
```ts
|
|
4384
5758
|
interface Text {
|
|
4385
5759
|
/** The English source text: a template with `{}`, `{0}` or `{name}`. */
|
|
@@ -4445,6 +5819,30 @@ type TextValue = string | Text | TextList;
|
|
|
4445
5819
|
|
|
4446
5820
|
The interface language of this run. `plugin.i18n`.
|
|
4447
5821
|
|
|
5822
|
+
Plugins write no translation code. Every user-visible text is English
|
|
5823
|
+
source text in one of three places, and the player translates it with
|
|
5824
|
+
the plugin's own catalog (`locales/<lang>.po`, compiled into the package
|
|
5825
|
+
by `vestra-plugin build`):
|
|
5826
|
+
|
|
5827
|
+
1. declarations: command titles and paths, settings page and panel
|
|
5828
|
+
titles, the manifest's `name`, `punchline`, `description` and `tags`;
|
|
5829
|
+
2. Slint: `@tr("…")` in the plugin's `.slint` files;
|
|
5830
|
+
3. sentences the plugin hands the player: Osd.show,
|
|
5831
|
+
OsdPanel.set and Dialogs.confirm take a template and
|
|
5832
|
+
its arguments (`osd.show("Speed {}×", [rate])`), never a sentence
|
|
5833
|
+
composed in TypeScript.
|
|
5834
|
+
|
|
5835
|
+
Where words and data cannot be separated that way (a menu item with a
|
|
5836
|
+
number, a `[string]` model, a sentence made of translated parts), the
|
|
5837
|
+
plugin passes a text value (I18n.text, I18n.list; SDK
|
|
5838
|
+
1.5.11): data the player translates, never a translated string.
|
|
5839
|
+
|
|
5840
|
+
The language is fixed when the player starts: I18n.locale never
|
|
5841
|
+
changes during a run. Read it for what is not text, such as the order of
|
|
5842
|
+
names or a date format. `vestra-plugin i18n extract` collects the texts
|
|
5843
|
+
into `locales/messages.pot`; see the
|
|
5844
|
+
i18n guide.
|
|
5845
|
+
|
|
4448
5846
|
- @capability None.
|
|
4449
5847
|
|
|
4450
5848
|
```ts
|
|
@@ -4469,6 +5867,27 @@ interface I18n {
|
|
|
4469
5867
|
* @param args - Values for its placeholders.
|
|
4470
5868
|
* @throws invalid A malformed or over-limit value (`TEXT_LIMITS`).
|
|
4471
5869
|
* @capability None.
|
|
5870
|
+
* @example Fill a drop-down with fixed words and data
|
|
5871
|
+
* The drop-down lists "No Audio" and one "Track 7 · en" entry per track,
|
|
5872
|
+
* in the user's language. A `[string]` model may hold `text()` values: the
|
|
5873
|
+
* player translates each source text with the plugin's catalog and fills
|
|
5874
|
+
* in its arguments, so the plugin never composes translated strings.
|
|
5875
|
+
* ```ts
|
|
5876
|
+
* const { text } = plugin.i18n;
|
|
5877
|
+
* const picker = plugin.windows.create("ui/clip.slint");
|
|
5878
|
+
* const tracks = [{ id: 7, title: "en" }];
|
|
5879
|
+
* // Each entry is translated, then filled in: "Track 7 · en".
|
|
5880
|
+
* picker.set({
|
|
5881
|
+
* labels: [text("No Audio"), ...tracks.map((t) => text("Track {} · {}", [t.id, t.title]))],
|
|
5882
|
+
* });
|
|
5883
|
+
* ```
|
|
5884
|
+
* @example Build a message from translated parts
|
|
5885
|
+
* The OSD says "Download failed: Connection timed out", both parts in the
|
|
5886
|
+
* user's language. An argument made with `text()` is translated too,
|
|
5887
|
+
* while string arguments are shown as they are.
|
|
5888
|
+
* ```ts
|
|
5889
|
+
* await plugin.osd.show("Download failed: {}", [plugin.i18n.text("Connection timed out")]);
|
|
5890
|
+
* ```
|
|
4472
5891
|
*/
|
|
4473
5892
|
text(source: string, args?: TemplateArgs): Text;
|
|
4474
5893
|
/**
|
|
@@ -4477,6 +5896,15 @@ interface I18n {
|
|
|
4477
5896
|
* @param style - `"list"` by default.
|
|
4478
5897
|
* @throws invalid A malformed or over-limit value.
|
|
4479
5898
|
* @capability None.
|
|
5899
|
+
* @example List file names the way the language joins them
|
|
5900
|
+
* The OSD says "Removed missing shaders: a.glsl, b.glsl" in English and
|
|
5901
|
+
* joins the names with "、" in Chinese. `list` makes a value the player
|
|
5902
|
+
* joins per interface language; the names themselves are data and stay
|
|
5903
|
+
* as they are.
|
|
5904
|
+
* ```ts
|
|
5905
|
+
* const missing = ["a.glsl", "b.glsl"];
|
|
5906
|
+
* await plugin.osd.show("Removed missing shaders: {}", [plugin.i18n.list(missing)]);
|
|
5907
|
+
* ```
|
|
4480
5908
|
*/
|
|
4481
5909
|
list(items: readonly TextArg[], style?: ListStyle): TextList;
|
|
4482
5910
|
/**
|
|
@@ -4485,6 +5913,25 @@ interface I18n {
|
|
|
4485
5913
|
* @param args - Values for its placeholders.
|
|
4486
5914
|
* @throws invalid A malformed or over-limit value.
|
|
4487
5915
|
* @capability None.
|
|
5916
|
+
* @example Fail an open with a message the user can read
|
|
5917
|
+
* Opening a `.ch8` file the plugin cannot load shows "The program file
|
|
5918
|
+
* was not found." in the user's language. The player shows the message of
|
|
5919
|
+
* an error thrown from `resolve`; `i18n.error` makes an `Error` whose text
|
|
5920
|
+
* the player translates with this plugin's catalog, while its `message`
|
|
5921
|
+
* stays English for logs and older players.
|
|
5922
|
+
* ```ts
|
|
5923
|
+
* plugin.resolvers.add({
|
|
5924
|
+
* id: "chip8",
|
|
5925
|
+
* extensions: ["ch8"],
|
|
5926
|
+
* async resolve(path, signal) {
|
|
5927
|
+
* const program = await readFile(path, { signal }).catch(() => {
|
|
5928
|
+
* // Shown as the open's failure, translated (locales/<lang>.po).
|
|
5929
|
+
* throw plugin.i18n.error("The program file was not found.");
|
|
5930
|
+
* });
|
|
5931
|
+
* return startEmulator(program);
|
|
5932
|
+
* },
|
|
5933
|
+
* });
|
|
5934
|
+
* ```
|
|
4488
5935
|
*/
|
|
4489
5936
|
error(source: string, args?: TemplateArgs): TextError;
|
|
4490
5937
|
/**
|
|
@@ -4502,11 +5949,32 @@ interface I18n {
|
|
|
4502
5949
|
* @returns The translated string.
|
|
4503
5950
|
* @throws invalid A malformed value.
|
|
4504
5951
|
* @capability None.
|
|
5952
|
+
* @example Copy version information in the user's language
|
|
5953
|
+
* "Copy info" puts two translated lines on the clipboard. Text that
|
|
5954
|
+
* leaves the player cannot be translated by it, so `resolve` turns the
|
|
5955
|
+
* values into the string the player would show, using this plugin's
|
|
5956
|
+
* catalog; inside the player, pass the values themselves instead.
|
|
5957
|
+
* ```ts
|
|
5958
|
+
* const { text, list, resolve } = plugin.i18n;
|
|
5959
|
+
* const lines = list([text("Version {}", ["1.0"]), text("Cores: {}", [8])], "lines");
|
|
5960
|
+
* await plugin.clipboard.writeText(resolve(lines));
|
|
5961
|
+
* ```
|
|
4505
5962
|
*/
|
|
4506
5963
|
resolve(value: TextValue): string;
|
|
4507
5964
|
}
|
|
4508
5965
|
```
|
|
4509
5966
|
|
|
5967
|
+
Example: Format a date the way the interface language writes it
|
|
5968
|
+
|
|
5969
|
+
The OSD shows today's date in the user's language and date format. The
|
|
5970
|
+
sentence is a template the player translates; only the data in it, the
|
|
5971
|
+
date, is formatted by the plugin, with `i18n.locale` (the language the
|
|
5972
|
+
player runs in, fixed for the whole run).
|
|
5973
|
+
```ts
|
|
5974
|
+
const day = new Date().toLocaleDateString(plugin.i18n.locale);
|
|
5975
|
+
await plugin.osd.show("Today is {}", [day]);
|
|
5976
|
+
```
|
|
5977
|
+
|
|
4510
5978
|
### TextError
|
|
4511
5979
|
|
|
4512
5980
|
An `Error` the player shows translated (I18n.error).
|
|
@@ -4532,12 +6000,33 @@ type SettingsCategory = "general" | "playback" | "subtitles" | "video" | "audio"
|
|
|
4532
6000
|
|
|
4533
6001
|
A page in the player's Options window, made by Settings.page.
|
|
4534
6002
|
|
|
6003
|
+
A View like a panel: properties and callbacks live on the file's
|
|
6004
|
+
bridge global, every callback of the global is forwarded, and there is no
|
|
6005
|
+
"bind before show" rule. The page exists from `ready`
|
|
6006
|
+
on, whether or not the Options window is open; property writes while it is
|
|
6007
|
+
closed are kept and applied when it opens. When pages are added or removed
|
|
6008
|
+
while the window is open, the player rebuilds it and restores every page's
|
|
6009
|
+
properties to the values last written, so mirror every user change in the
|
|
6010
|
+
plugin's state (and `bind` it), or it reverts.
|
|
6011
|
+
|
|
6012
|
+
`dispose()`, disposing the scope or stopping the plugin removes the page;
|
|
6013
|
+
if it was showing, the window shows another page.
|
|
6014
|
+
|
|
6015
|
+
Testing: `host.settingsPage(id)` of `@vestra-player/sdk/testing` has the
|
|
6016
|
+
page's `props` and `emit(callback, ...args)`.
|
|
6017
|
+
|
|
4535
6018
|
- @capability ui.settings
|
|
4536
6019
|
|
|
4537
6020
|
```ts
|
|
4538
6021
|
type SettingsPage = View & Disposable & {
|
|
4539
6022
|
/**
|
|
4540
6023
|
* Resolves once the player has registered the page; rejects when it refused it.
|
|
6024
|
+
*
|
|
6025
|
+
* The player compiles the page on its own at registration, so a broken
|
|
6026
|
+
* page fails here and does not affect the Options window. Pages added
|
|
6027
|
+
* while `activate` runs must succeed before the plugin is ready: a
|
|
6028
|
+
* rejection fails activation. Later failures also go to
|
|
6029
|
+
* Scope.onError.
|
|
4541
6030
|
* @throws ui-prepare (rejects) The Slint file does not compile; `message` has the diagnostics.
|
|
4542
6031
|
* @throws invalid (rejects) The plugin already has 8 pages, or `order` is beyond ±1,000,000.
|
|
4543
6032
|
* @throws host (rejects) The file is missing or outside the package directory.
|
|
@@ -4547,22 +6036,117 @@ type SettingsPage = View & Disposable & {
|
|
|
4547
6036
|
};
|
|
4548
6037
|
```
|
|
4549
6038
|
|
|
6039
|
+
Example: Put two preferences on a page and keep them in a store
|
|
6040
|
+
|
|
6041
|
+
Options ▸ Video ▸ Blur shows two check boxes whose values survive
|
|
6042
|
+
restarts. The checkboxes are bound two-way (`<=>`) to `in-out`
|
|
6043
|
+
properties of the bridge global, so the user's clicks show at once; the
|
|
6044
|
+
`toggled` callbacks tell the plugin, which saves the new value, and
|
|
6045
|
+
`bind` writes the stored values when the page opens.
|
|
6046
|
+
```ts
|
|
6047
|
+
const store = plugin.storage.json("blur", {
|
|
6048
|
+
defaults: { restoreOnStart: false, messages: true },
|
|
6049
|
+
});
|
|
6050
|
+
const page = plugin.settings.page("ui/settings.slint", {
|
|
6051
|
+
id: "blur",
|
|
6052
|
+
category: "video",
|
|
6053
|
+
title: "Blur",
|
|
6054
|
+
});
|
|
6055
|
+
page.bind(() => ({
|
|
6056
|
+
restoreOnStart: store.value.restoreOnStart,
|
|
6057
|
+
messages: store.value.messages,
|
|
6058
|
+
}));
|
|
6059
|
+
// The bridge's `restore-toggled(bool)` callback, in camelCase.
|
|
6060
|
+
page.on("restoreToggled", (on: boolean) =>
|
|
6061
|
+
store.save({ ...store.value, restoreOnStart: on }),
|
|
6062
|
+
);
|
|
6063
|
+
page.on("messagesToggled", (on: boolean) =>
|
|
6064
|
+
store.save({ ...store.value, messages: on }),
|
|
6065
|
+
);
|
|
6066
|
+
```
|
|
6067
|
+
|
|
6068
|
+
`ui/settings.slint` exports the bridge global and the layout:
|
|
6069
|
+
|
|
6070
|
+
```slint
|
|
6071
|
+
import { CheckBox } from "std-widgets.slint";
|
|
6072
|
+
import { Theme } from "@vestra/theme.slint";
|
|
6073
|
+
|
|
6074
|
+
// The bridge: what the plugin writes and the callbacks it handles.
|
|
6075
|
+
export global BlurPage {
|
|
6076
|
+
in-out property <bool> restore-on-start;
|
|
6077
|
+
in-out property <bool> messages: true;
|
|
6078
|
+
callback restore-toggled(bool);
|
|
6079
|
+
callback messages-toggled(bool);
|
|
6080
|
+
}
|
|
6081
|
+
|
|
6082
|
+
export component BlurSettings inherits Rectangle {
|
|
6083
|
+
VerticalLayout {
|
|
6084
|
+
padding: Theme.dialog-padding;
|
|
6085
|
+
spacing: 6px;
|
|
6086
|
+
alignment: start;
|
|
6087
|
+
CheckBox {
|
|
6088
|
+
text: "Turn blur back on when the player starts";
|
|
6089
|
+
checked <=> BlurPage.restore-on-start;
|
|
6090
|
+
toggled => { BlurPage.restore-toggled(self.checked); }
|
|
6091
|
+
}
|
|
6092
|
+
CheckBox {
|
|
6093
|
+
text: "Show on-screen messages";
|
|
6094
|
+
checked <=> BlurPage.messages;
|
|
6095
|
+
toggled => { BlurPage.messages-toggled(self.checked); }
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
}
|
|
6099
|
+
```
|
|
6100
|
+
|
|
4550
6101
|
### Settings
|
|
4551
6102
|
|
|
4552
6103
|
Add pages to the player's Options window. `plugin.settings`.
|
|
4553
6104
|
|
|
6105
|
+
Use a settings page for preferences that change rarely. The player owns
|
|
6106
|
+
the Options window (640×480 by default, at least 600×420, resizable): a
|
|
6107
|
+
category tree on the left, the selected page on the right, a Close button
|
|
6108
|
+
at the bottom. The standard close keys close it, and command key bindings
|
|
6109
|
+
do not fire while it has focus. Opening it does not affect playback.
|
|
6110
|
+
|
|
6111
|
+
A page file exports **one component and one global**, like a panel: the
|
|
6112
|
+
global (the bridge) declares the properties and callbacks, the component
|
|
6113
|
+
lays the page out and reads them. Pages are 440 logical pixels wide; the
|
|
6114
|
+
window scrolls a taller page.
|
|
6115
|
+
|
|
4554
6116
|
- @capability ui.settings
|
|
4555
6117
|
|
|
4556
6118
|
```ts
|
|
4557
6119
|
interface Settings {
|
|
4558
6120
|
/**
|
|
4559
6121
|
* Add a page to the Options window.
|
|
6122
|
+
*
|
|
6123
|
+
* The SDK finds the component and the global from the file's
|
|
6124
|
+
* `export component` and `export global` lines; the file must export one
|
|
6125
|
+
* of each. In the next microtask it registers the page with every property
|
|
6126
|
+
* bound or set so far; `ready` reports the result.
|
|
6127
|
+
*
|
|
6128
|
+
* Within a category, pages are sorted by `order`, then by plugin id and
|
|
6129
|
+
* page id.
|
|
4560
6130
|
* @capability ui.settings
|
|
4561
6131
|
* @param file - Path relative to the package directory, for example `"ui/settings.slint"`.
|
|
4562
6132
|
* @param options - The page's identity and place: `id` (unique in this plugin, 1–64 letters, digits, `.`, `-` or `_`), `category`, `title` (shown in the tree, 1–24 characters) and `order` (an integer within ±1,000,000, default 0; smaller first).
|
|
4563
6133
|
* @returns A SettingsPage owned by this scope.
|
|
4564
6134
|
* @throws invalid (throws) A bad `id`, `category`, `title` or `order`, or this plugin already has a page with this `id`.
|
|
4565
6135
|
* @throws Error without a code (throws) The file does not export exactly one component and one global. A file that cannot be read throws Node's file system error (`code` `ENOENT` and so on).
|
|
6136
|
+
* @example Register a page and report when the player refuses it
|
|
6137
|
+
* An "My plugin" page appears under Plugins, or the reason it could not
|
|
6138
|
+
* be added is logged. `page` returns at once; the player compiles the
|
|
6139
|
+
* page on its own, and `ready` rejects (for example with `ui-prepare`
|
|
6140
|
+
* and the compiler's diagnostics) when it refuses it.
|
|
6141
|
+
* ```ts
|
|
6142
|
+
* const page = plugin.settings.page("ui/advanced.slint", {
|
|
6143
|
+
* id: "advanced",
|
|
6144
|
+
* category: "plugins",
|
|
6145
|
+
* title: "My plugin",
|
|
6146
|
+
* order: 10, // after pages with a smaller order in the category
|
|
6147
|
+
* });
|
|
6148
|
+
* page.ready.catch((error: PluginError) => console.error(error.message));
|
|
6149
|
+
* ```
|
|
4566
6150
|
*/
|
|
4567
6151
|
page(file: string, options: {
|
|
4568
6152
|
/** Unique in this plugin: 1–64 letters, digits, `.`, `-` or `_`. */
|
|
@@ -4576,6 +6160,10 @@ interface Settings {
|
|
|
4576
6160
|
}): SettingsPage;
|
|
4577
6161
|
/**
|
|
4578
6162
|
* Open the Options window at one of this plugin's pages, or bring it to the front.
|
|
6163
|
+
*
|
|
6164
|
+
* Without `pageId` it shows the page that is selected; when the window
|
|
6165
|
+
* first opens, that is the page the user saw last (the player remembers
|
|
6166
|
+
* it), else the first one.
|
|
4579
6167
|
* @capability ui.settings
|
|
4580
6168
|
* @param pageId - The `id` of one of this plugin's pages.
|
|
4581
6169
|
* @returns Resolves once the window is shown.
|
|
@@ -4583,16 +6171,48 @@ interface Settings {
|
|
|
4583
6171
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4584
6172
|
* @throws ui (rejects) The window could not be built (for example a page wrote a property its file does not declare).
|
|
4585
6173
|
* @throws capability (rejects) The manifest does not declare `ui.settings`.
|
|
6174
|
+
* @example Open the plugin's page from an "Options…" button
|
|
6175
|
+
* The button in the plugin's window opens the Options window at the
|
|
6176
|
+
* plugin's Blur page, or brings it to the front. `show` takes the id the
|
|
6177
|
+
* page was registered with and rejects with `invalid` for any other.
|
|
6178
|
+
* ```ts
|
|
6179
|
+
* const controls = plugin.windows.create("ui/window.slint");
|
|
6180
|
+
* controls.on("openOptions", () => plugin.settings.show("blur"));
|
|
6181
|
+
* ```
|
|
4586
6182
|
*/
|
|
4587
6183
|
show(pageId?: string): Promise<void>;
|
|
4588
6184
|
}
|
|
4589
6185
|
```
|
|
4590
6186
|
|
|
6187
|
+
Example: Add a page that edits a stored setting
|
|
6188
|
+
|
|
6189
|
+
Options ▸ Playback ▸ Resume shows the plugin's Resume switch. `page`
|
|
6190
|
+
registers the page with the Options window (compiled by the player on
|
|
6191
|
+
its own), `bind` sends the stored value, and the page's callback saves
|
|
6192
|
+
the user's change.
|
|
6193
|
+
```ts
|
|
6194
|
+
const store = plugin.storage.json("settings", { defaults: { resume: true } });
|
|
6195
|
+
const page = plugin.settings.page("ui/settings.slint", {
|
|
6196
|
+
id: "resume",
|
|
6197
|
+
category: "playback",
|
|
6198
|
+
title: "Resume",
|
|
6199
|
+
});
|
|
6200
|
+
page.bind(() => ({ resume: store.value.resume }));
|
|
6201
|
+
// The bridge's `resume-toggled(bool)`: save, and `bind` sends it back.
|
|
6202
|
+
page.on("resumeToggled", (on: boolean) => store.save({ ...store.value, resume: on }));
|
|
6203
|
+
```
|
|
6204
|
+
|
|
4591
6205
|
### BackdropSlot
|
|
4592
6206
|
|
|
4593
6207
|
The backdrop position (`plugin.panels.backdrop`): whether a backdrop is
|
|
4594
6208
|
mounted and whether it is shown now.
|
|
4595
6209
|
|
|
6210
|
+
The player decides when the backdrop shows: while the source is empty or
|
|
6211
|
+
loading, and while ready media has no video picture (audio files). The
|
|
6212
|
+
plugin decides what to draw, from Player.state and
|
|
6213
|
+
Player.stats. With `events`, `state` follows every change; without
|
|
6214
|
+
it, it refreshes only when this plugin mounts.
|
|
6215
|
+
|
|
4596
6216
|
- @capability ui.mount; `events` to follow changes.
|
|
4597
6217
|
|
|
4598
6218
|
```ts
|
|
@@ -4607,6 +6227,13 @@ interface BackdropSlot {
|
|
|
4607
6227
|
* @capability ui.mount and events
|
|
4608
6228
|
* @param listener - Receives the new state (`null`: no backdrop).
|
|
4609
6229
|
* @returns A handle that removes the listener.
|
|
6230
|
+
* @example Log when the backdrop shows or hides
|
|
6231
|
+
* The plugin's log records each change of the backdrop position. The
|
|
6232
|
+
* listener runs when the player starts or stops showing the backdrop
|
|
6233
|
+
* and with `null` when it is unmounted.
|
|
6234
|
+
* ```ts
|
|
6235
|
+
* plugin.panels.backdrop.onChange((state) => console.log("backdrop", state));
|
|
6236
|
+
* ```
|
|
4610
6237
|
*/
|
|
4611
6238
|
onChange(listener: (state: {
|
|
4612
6239
|
readonly effective: boolean;
|
|
@@ -4614,17 +6241,75 @@ interface BackdropSlot {
|
|
|
4614
6241
|
}
|
|
4615
6242
|
```
|
|
4616
6243
|
|
|
6244
|
+
Example: React when a video picture appears
|
|
6245
|
+
|
|
6246
|
+
The OSD says "Video is playing" when the backdrop gives way to a video
|
|
6247
|
+
picture. The player decides when the backdrop shows (nothing open,
|
|
6248
|
+
loading, audio without a picture); `effective` turns false when a video
|
|
6249
|
+
frame replaces it.
|
|
6250
|
+
```ts
|
|
6251
|
+
plugin.panels.backdrop.onChange((state) => {
|
|
6252
|
+
// `null`: no backdrop is mounted.
|
|
6253
|
+
if (state?.effective === false) void plugin.osd.show("Video is playing");
|
|
6254
|
+
});
|
|
6255
|
+
```
|
|
6256
|
+
|
|
4617
6257
|
### Panels
|
|
4618
6258
|
|
|
4619
6259
|
Put a panel into the player's main window: the bottom bar, the sidebar or
|
|
4620
6260
|
the backdrop. `plugin.panels`.
|
|
4621
6261
|
|
|
6262
|
+
A panel is part of the player's layout: it resizes with the main window,
|
|
6263
|
+
follows fullscreen and auto-hides with the player's own controls. Use one
|
|
6264
|
+
for content that should stay with the video (a playlist, chapters, a
|
|
6265
|
+
custom control bar, cover art); use a tool window for a
|
|
6266
|
+
tool the user opens and closes.
|
|
6267
|
+
|
|
6268
|
+
A panel file exports **one component and one global**. The global is the
|
|
6269
|
+
bridge: the plugin writes its properties and handles its callbacks. The
|
|
6270
|
+
component lays the panel out and reads and calls the global. The split
|
|
6271
|
+
exists because the player composes the component into its own windows: a
|
|
6272
|
+
new instance of the component is made whenever the main window is rebuilt
|
|
6273
|
+
or the sidebar moves to or from its floating window, while the global is
|
|
6274
|
+
one stable address.
|
|
6275
|
+
|
|
6276
|
+
Each plugin mounts at most one panel, and each position holds one panel
|
|
6277
|
+
for all plugins. Panels.bottom and Panels.sidebar describe
|
|
6278
|
+
the positions, whoever mounted there.
|
|
6279
|
+
|
|
4622
6280
|
- @capability ui.mount
|
|
4623
6281
|
|
|
4624
6282
|
```ts
|
|
4625
6283
|
interface Panels {
|
|
4626
6284
|
/**
|
|
4627
6285
|
* Mount a Slint component in the bottom bar, the sidebar or the backdrop.
|
|
6286
|
+
*
|
|
6287
|
+
* Returns at once; the panel is on screen when Panel.ready
|
|
6288
|
+
* resolves. Handlers and bindings made right after `mount()` (in the same
|
|
6289
|
+
* synchronous code) are part of the first values.
|
|
6290
|
+
*
|
|
6291
|
+
* - `"bottom"`: under the video, as wide as the video column, as tall as the
|
|
6292
|
+
* component's preferred height. It may draw above its slot into the video
|
|
6293
|
+
* area (negative `y`); the player does not clip it. In fullscreen it
|
|
6294
|
+
* floats over the video and shows only while the pointer is in its band
|
|
6295
|
+
* at the bottom of the screen (hidden 500 ms after the pointer leaves;
|
|
6296
|
+
* Panel.setInteraction locks keep it).
|
|
6297
|
+
* - `"sidebar"`: right of the video, the window's full height, as wide as the
|
|
6298
|
+
* user drags it (at least 317). The user can detach it into a floating
|
|
6299
|
+
* window titled `title`. Several plugins may mount here (player 0.4.x
|
|
6300
|
+
* after 0.4.0): each panel is a tab, labelled `title` (else the plugin's
|
|
6301
|
+
* name), and the player draws a 31px tab strip above the current one
|
|
6302
|
+
* while there are two or more (with one panel, nothing changes).
|
|
6303
|
+
* Showing, hiding and detaching apply to the whole sidebar. If the
|
|
6304
|
+
* bridge global declares `in property <bool> tabbed`, the player sets
|
|
6305
|
+
* it to `true` while it draws the strip, so a panel with a tab strip of
|
|
6306
|
+
* its own can hide it. Panel coordinates (drops) start below the strip.
|
|
6307
|
+
* - `"backdrop"`: the whole video area, under the OSD, while there is no
|
|
6308
|
+
* video picture (see Panels.backdrop). One plugin at a time (as
|
|
6309
|
+
* for the bottom bar, another one's mount rejects with `conflict`); it
|
|
6310
|
+
* takes no pointer or keyboard input. While it is mounted, the player does
|
|
6311
|
+
* not show the cover art of audio files itself (from the next file
|
|
6312
|
+
* opened on), so the plugin can draw it.
|
|
4628
6313
|
* @capability ui.mount
|
|
4629
6314
|
* @param slot - Where to mount the panel.
|
|
4630
6315
|
* @param file - Path relative to the package directory, for example `"ui/playlist.slint"`.
|
|
@@ -4632,6 +6317,19 @@ interface Panels {
|
|
|
4632
6317
|
* @returns A Panel owned by this scope; await Panel.ready for the result.
|
|
4633
6318
|
* @throws Error without a code (throws) This plugin already has a panel, or the file exports no component or global, or several and `options` does not name one. A file that cannot be read throws Node's file system error (`code` `ENOENT` and so on).
|
|
4634
6319
|
* @throws The errors of Panel.ready (rejects `ready`) The player refused the mount.
|
|
6320
|
+
* @example Mount a bottom bar with a seek slider
|
|
6321
|
+
* The plugin's own control bar appears under the video, its slider
|
|
6322
|
+
* following the playback position and seeking when dragged. Bindings and
|
|
6323
|
+
* handlers made right after `mount` are part of the first values;
|
|
6324
|
+
* `ready` resolves once the main window shows the bar, and rejects with
|
|
6325
|
+
* `conflict` when another plugin's panel holds the bottom bar.
|
|
6326
|
+
* ```ts
|
|
6327
|
+
* const bar = plugin.panels.mount("bottom", "ui/bar.slint");
|
|
6328
|
+
* // Sent again whenever the position changes.
|
|
6329
|
+
* bar.bind(() => ({ position: plugin.player.state.position ?? 0 }));
|
|
6330
|
+
* bar.on("seek", (seconds: number) => plugin.player.seek(seconds));
|
|
6331
|
+
* await bar.ready;
|
|
6332
|
+
* ```
|
|
4635
6333
|
*/
|
|
4636
6334
|
mount(slot: "bottom" | "sidebar" | "backdrop", file: string, options?: {
|
|
4637
6335
|
/** The exported component to mount, if the file exports several. */
|
|
@@ -4650,6 +6348,67 @@ interface Panels {
|
|
|
4650
6348
|
}
|
|
4651
6349
|
```
|
|
4652
6350
|
|
|
6351
|
+
Example: Mount a list in the sidebar (examples/panel-menu)
|
|
6352
|
+
|
|
6353
|
+
The sidebar gets an "Example list" tab showing three rows. `mount`
|
|
6354
|
+
returns at once; the SDK sends the mount with the values bound so far,
|
|
6355
|
+
and the player rebuilds its main window with the panel. The Slint file
|
|
6356
|
+
exports the component and the bridge global whose properties `bind`
|
|
6357
|
+
writes.
|
|
6358
|
+
|
|
6359
|
+
```ts
|
|
6360
|
+
const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
|
|
6361
|
+
title: "Example list",
|
|
6362
|
+
});
|
|
6363
|
+
// Sent when the panel mounts, then whenever a value changes.
|
|
6364
|
+
panel.bind(() => ({ rows: items, selected }));
|
|
6365
|
+
```
|
|
6366
|
+
|
|
6367
|
+
`ui/panel.slint`:
|
|
6368
|
+
|
|
6369
|
+
```slint
|
|
6370
|
+
import { Theme } from "@vestra/theme.slint";
|
|
6371
|
+
|
|
6372
|
+
export global ListBridge {
|
|
6373
|
+
in property <[string]> rows;
|
|
6374
|
+
in property <int> selected;
|
|
6375
|
+
callback select(int);
|
|
6376
|
+
callback context-menu(float, float);
|
|
6377
|
+
}
|
|
6378
|
+
|
|
6379
|
+
export component ListPanel inherits Rectangle {
|
|
6380
|
+
background: Theme.bg-list;
|
|
6381
|
+
min-width: 200px;
|
|
6382
|
+
VerticalLayout {
|
|
6383
|
+
for row[index] in ListBridge.rows: Rectangle {
|
|
6384
|
+
height: 20px;
|
|
6385
|
+
background: index == ListBridge.selected ? Theme.selected-bg : transparent;
|
|
6386
|
+
Text {
|
|
6387
|
+
x: 6px;
|
|
6388
|
+
text: (index + 1) + ". " + row;
|
|
6389
|
+
color: index == ListBridge.selected ? Theme.text : Theme.text-list;
|
|
6390
|
+
font-size: Theme.font-body;
|
|
6391
|
+
vertical-alignment: center;
|
|
6392
|
+
}
|
|
6393
|
+
TouchArea {
|
|
6394
|
+
pointer-event(event) => {
|
|
6395
|
+
if event.kind == PointerEventKind.down {
|
|
6396
|
+
ListBridge.select(index);
|
|
6397
|
+
}
|
|
6398
|
+
if event.kind == PointerEventKind.up && event.button == PointerEventButton.right {
|
|
6399
|
+
ListBridge.context-menu((self.absolute-position.x + self.mouse-x) / 1px,
|
|
6400
|
+
(self.absolute-position.y + self.mouse-y) / 1px);
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6404
|
+
}
|
|
6405
|
+
// Takes the space below the rows. Without it the layout's maximum
|
|
6406
|
+
// height (three 20px rows) would cap the player's main window.
|
|
6407
|
+
Rectangle { vertical-stretch: 1; }
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
```
|
|
6411
|
+
|
|
4653
6412
|
### MarkKind
|
|
4654
6413
|
|
|
4655
6414
|
What a timeline mark stands for; readers choose colours by it.
|
|
@@ -4683,6 +6442,19 @@ This plugin's own marks; disposing it removes them.
|
|
|
4683
6442
|
interface TimelineLayer extends Disposable {
|
|
4684
6443
|
/**
|
|
4685
6444
|
* Replace the layer (last write wins; `[]` clears it). At most 1000 marks, titles ≤ 100 characters, times within the media. Failures go to `onError`; with `ifCurrent`, a write for another source is dropped silently. A title is data (a chapter's or bookmark's own name), or a Text the player translates (`text("Chapter {}", [n])`, SDK 1.5.11); other plugins read the shown string.
|
|
6445
|
+
* @example Show the file's bookmarks on the timeline
|
|
6446
|
+
* Bookmark marks appear on the seek bar at their times, titled with the
|
|
6447
|
+
* user's notes. `set` replaces this plugin's whole layer; with `ifCurrent`
|
|
6448
|
+
* the player drops the write if another file opened since `seen` was
|
|
6449
|
+
* read, so bookmarks of the previous file never land on the new one.
|
|
6450
|
+
* ```ts
|
|
6451
|
+
* const layer = plugin.timeline.layer();
|
|
6452
|
+
* const seen = plugin.player.state; // the file the bookmarks belong to
|
|
6453
|
+
* layer.set(
|
|
6454
|
+
* bookmarks.map((b) => ({ time: b.time, kind: "bookmark", title: b.note })),
|
|
6455
|
+
* { ifCurrent: seen },
|
|
6456
|
+
* );
|
|
6457
|
+
* ```
|
|
4686
6458
|
*/
|
|
4687
6459
|
set(marks: {
|
|
4688
6460
|
time: number;
|
|
@@ -4791,11 +6563,42 @@ interface MainWindow {
|
|
|
4791
6563
|
*
|
|
4792
6564
|
* Where it is unsupported (Windows, Linux), `setVideoArea` plus
|
|
4793
6565
|
* `setAlwaysOnTop` make a reasonable "mini player" fallback.
|
|
6566
|
+
* @example Toggle picture-in-picture where the system has it
|
|
6567
|
+
* The video moves into a floating picture-in-picture window, or back.
|
|
6568
|
+
* `state.pictureInPicture` says whether the system supports it and
|
|
6569
|
+
* whether it is on; the plugin only calls `setPictureInPicture` where it
|
|
6570
|
+
* is supported.
|
|
6571
|
+
* ```ts
|
|
6572
|
+
* if (plugin.window.state.pictureInPicture.supported)
|
|
6573
|
+
* await plugin.window.setPictureInPicture(!plugin.window.state.pictureInPicture.active);
|
|
6574
|
+
* ```
|
|
4794
6575
|
*/
|
|
4795
6576
|
setPictureInPicture(on: boolean): Promise<void>;
|
|
4796
6577
|
}
|
|
4797
6578
|
```
|
|
4798
6579
|
|
|
6580
|
+
Example: Resize the window to the video's own size
|
|
6581
|
+
|
|
6582
|
+
Screen ▸ Original size makes the video area as many logical points as
|
|
6583
|
+
the video has pixels (one pixel per pixel at a display scale of 100 %).
|
|
6584
|
+
`stats()` reports the video's size;
|
|
6585
|
+
`setVideoArea` resizes the window around the video column (bars and a
|
|
6586
|
+
docked sidebar added), scales it down if it would not fit the screen,
|
|
6587
|
+
and is refused in fullscreen, hence the `enabled` check.
|
|
6588
|
+
```ts
|
|
6589
|
+
plugin.commands.add({
|
|
6590
|
+
id: "size-1x",
|
|
6591
|
+
path: ["Screen", "Original size"],
|
|
6592
|
+
enabled: () => !plugin.window.state.fullscreen,
|
|
6593
|
+
run: async () => {
|
|
6594
|
+
const stats = await plugin.player.stats();
|
|
6595
|
+
// No video stream (an audio file): nothing to size to.
|
|
6596
|
+
if (stats.video?.width && stats.video.height)
|
|
6597
|
+
await plugin.window.setVideoArea({ width: stats.video.width, height: stats.video.height });
|
|
6598
|
+
},
|
|
6599
|
+
});
|
|
6600
|
+
```
|
|
6601
|
+
|
|
4799
6602
|
### AppInfo
|
|
4800
6603
|
|
|
4801
6604
|
`plugin.app`: fixed for the life of the plugin process.
|
|
@@ -4830,6 +6633,18 @@ interface App extends AppInfo {
|
|
|
4830
6633
|
}
|
|
4831
6634
|
```
|
|
4832
6635
|
|
|
6636
|
+
Example: Describe the player and the system for a bug report
|
|
6637
|
+
|
|
6638
|
+
The plugin builds a line such as "Vestra 0.5.10 on macOS 26.2" to copy
|
|
6639
|
+
into a bug report. `app.version` is known from the start; each
|
|
6640
|
+
`system()` call asks the player, which reads the displays at that moment.
|
|
6641
|
+
The operating system's name and version it works out once, at the first
|
|
6642
|
+
call, and reuses them.
|
|
6643
|
+
```ts
|
|
6644
|
+
const system = await plugin.app.system();
|
|
6645
|
+
const text = `Vestra ${plugin.app.version} on ${system.name} ${system.version ?? ""}`;
|
|
6646
|
+
```
|
|
6647
|
+
|
|
4833
6648
|
### SystemInfo
|
|
4834
6649
|
|
|
4835
6650
|
What App.system reports.
|
|
@@ -4905,12 +6720,37 @@ interface ConfirmOptions {
|
|
|
4905
6720
|
The system's own dialogs: a confirmation alert and the open and save file
|
|
4906
6721
|
dialogs. `plugin.dialogs`.
|
|
4907
6722
|
|
|
6723
|
+
The plugin passes text and gets the answer; the system draws the dialog,
|
|
6724
|
+
so it looks and behaves like every other dialog on the system. Each call
|
|
6725
|
+
resolves when the user answers; there is no time limit and no way to close
|
|
6726
|
+
a dialog from code. Only one dialog (of any kind, from any plugin) is open
|
|
6727
|
+
at a time: another call meanwhile rejects with `busy`. Playback continues
|
|
6728
|
+
while a dialog is open; command key bindings pause.
|
|
6729
|
+
|
|
6730
|
+
**Platforms:** Dialogs.confirm needs macOS or Windows (other
|
|
6731
|
+
systems reject it with `unsupported`); the file dialogs work on every
|
|
6732
|
+
system.
|
|
6733
|
+
|
|
6734
|
+
For anything more than a question or a path, use a tool
|
|
6735
|
+
window.
|
|
6736
|
+
|
|
4908
6737
|
- @capability ui.dialog
|
|
4909
6738
|
|
|
4910
6739
|
```ts
|
|
4911
6740
|
interface Dialogs {
|
|
4912
6741
|
/**
|
|
4913
6742
|
* Ask the user to confirm something, in a system alert.
|
|
6743
|
+
*
|
|
6744
|
+
* **macOS:** an `NSAlert` sheet on the active player window (usually the
|
|
6745
|
+
* tool window the user just clicked in), else on the main window.
|
|
6746
|
+
*
|
|
6747
|
+
* **Windows:** a task dialog owned by the active player window, titled
|
|
6748
|
+
* "Vestra Player"; `detail` follows `message` after a blank line. A
|
|
6749
|
+
* `checkbox` is asked as a second yes/no dialog after the user confirms
|
|
6750
|
+
* (on cancel it is not asked and `checked` is `false`).
|
|
6751
|
+
*
|
|
6752
|
+
* Remembering "Don't ask again" is up to the plugin (see
|
|
6753
|
+
* Scope.storage).
|
|
4914
6754
|
* @capability ui.dialog
|
|
4915
6755
|
* @param options - The text and buttons; see ConfirmOptions.
|
|
4916
6756
|
* @returns The answer: `confirmed` is `true` when the user picked the confirm button (Escape, the cancel button or closing the dialog give `false`); `checked` is the checkbox's state (`false` without one).
|
|
@@ -4919,6 +6759,23 @@ interface Dialogs {
|
|
|
4919
6759
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4920
6760
|
* @throws unsupported (rejects) A system other than macOS and Windows.
|
|
4921
6761
|
* @throws capability (rejects) The manifest does not declare `ui.dialog`.
|
|
6762
|
+
* @example Confirm before replacing the playlist
|
|
6763
|
+
* The user sees "Replace the current playlist?" with a Replace button and
|
|
6764
|
+
* the playlist is replaced only after a yes. The system draws the alert
|
|
6765
|
+
* and the promise resolves when the user answers; only one dialog is
|
|
6766
|
+
* open at a time, so a second one meanwhile rejects with `busy`.
|
|
6767
|
+
* ```ts
|
|
6768
|
+
* try {
|
|
6769
|
+
* const { confirmed } = await plugin.dialogs.confirm({
|
|
6770
|
+
* message: "Replace the current playlist?",
|
|
6771
|
+
* confirm: "Replace",
|
|
6772
|
+
* });
|
|
6773
|
+
* if (confirmed) await plugin.osd.show("Playlist replaced");
|
|
6774
|
+
* } catch (error) {
|
|
6775
|
+
* if ((error as PluginError).code !== "busy") throw error;
|
|
6776
|
+
* // Another dialog is already open: let the user finish that one.
|
|
6777
|
+
* }
|
|
6778
|
+
* ```
|
|
4922
6779
|
*/
|
|
4923
6780
|
confirm(options: ConfirmOptions): Promise<{
|
|
4924
6781
|
/** The user picked the confirm button. */
|
|
@@ -4935,6 +6792,14 @@ interface Dialogs {
|
|
|
4935
6792
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4936
6793
|
* @throws invalid (rejects) `title` is longer than 256 bytes.
|
|
4937
6794
|
* @throws capability (rejects) The manifest does not declare `ui.dialog`.
|
|
6795
|
+
* @example Let the user pick a subtitle file
|
|
6796
|
+
* The system's Open dialog appears titled "Choose a subtitle file" and
|
|
6797
|
+
* the chosen path is shown on the OSD. The promise resolves when the
|
|
6798
|
+
* user answers: with the absolute path, or `null` when they cancelled.
|
|
6799
|
+
* ```ts
|
|
6800
|
+
* const path = await plugin.dialogs.openFile({ title: "Choose a subtitle file" });
|
|
6801
|
+
* if (path !== null) await plugin.osd.show("Chosen: {}", [path]); // a path is data
|
|
6802
|
+
* ```
|
|
4938
6803
|
*/
|
|
4939
6804
|
openFile(options?: {
|
|
4940
6805
|
/** The dialog's title (English source text or a Text), at most 256 bytes. */
|
|
@@ -4949,6 +6814,14 @@ interface Dialogs {
|
|
|
4949
6814
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4950
6815
|
* @throws invalid (rejects) `title` is longer than 256 bytes.
|
|
4951
6816
|
* @throws capability (rejects) The manifest does not declare `ui.dialog`.
|
|
6817
|
+
* @example Let the user pick several files at once
|
|
6818
|
+
* The Open dialog allows selecting several files; the OSD says how many
|
|
6819
|
+
* were added. The promise resolves with the absolute paths, or with an
|
|
6820
|
+
* empty array when the user cancelled.
|
|
6821
|
+
* ```ts
|
|
6822
|
+
* const paths = await plugin.dialogs.openFiles({ title: "Add to the playlist" });
|
|
6823
|
+
* if (paths.length) await plugin.osd.show("{} files added", [paths.length]);
|
|
6824
|
+
* ```
|
|
4952
6825
|
*/
|
|
4953
6826
|
openFiles(options?: {
|
|
4954
6827
|
/** The dialog's title (English source text or a Text), at most 256 bytes. */
|
|
@@ -4963,6 +6836,14 @@ interface Dialogs {
|
|
|
4963
6836
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4964
6837
|
* @throws invalid (rejects) `title` is longer than 256 bytes.
|
|
4965
6838
|
* @throws capability (rejects) The manifest does not declare `ui.dialog`.
|
|
6839
|
+
* @example Let the user choose a folder for snapshots
|
|
6840
|
+
* The system's folder dialog appears titled "Save snapshots in". It
|
|
6841
|
+
* resolves with the folder's absolute path, or `null` when the user
|
|
6842
|
+
* cancelled, in which case the plugin stops there.
|
|
6843
|
+
* ```ts
|
|
6844
|
+
* const folder = await plugin.dialogs.openDirectory({ title: "Save snapshots in" });
|
|
6845
|
+
* if (folder === null) return; // cancelled
|
|
6846
|
+
* ```
|
|
4966
6847
|
*/
|
|
4967
6848
|
openDirectory(options?: {
|
|
4968
6849
|
/** The dialog's title (English source text or a Text), at most 256 bytes. */
|
|
@@ -4970,6 +6851,8 @@ interface Dialogs {
|
|
|
4970
6851
|
}): Promise<string | null>;
|
|
4971
6852
|
/**
|
|
4972
6853
|
* Ask the user where to save a file.
|
|
6854
|
+
*
|
|
6855
|
+
* The dialog only returns a path; writing the file is up to the plugin.
|
|
4973
6856
|
* @capability ui.dialog
|
|
4974
6857
|
* @param options - `title`: the dialog's title, at most 256 bytes. `fileName`: the proposed file name (a plain name without `/` or `\`, at most 256 bytes).
|
|
4975
6858
|
* @returns The absolute path, or `null` when the user cancelled.
|
|
@@ -4977,6 +6860,14 @@ interface Dialogs {
|
|
|
4977
6860
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
4978
6861
|
* @throws invalid (rejects) `title` is too long, or `fileName` is too long or contains `/` or `\`.
|
|
4979
6862
|
* @throws capability (rejects) The manifest does not declare `ui.dialog`.
|
|
6863
|
+
* @example Ask where to save a snapshot
|
|
6864
|
+
* The system's Save dialog proposes "frame.png" and returns where the
|
|
6865
|
+
* user wants it. The dialog only returns the path (or `null` when
|
|
6866
|
+
* cancelled); writing the file is up to the plugin afterwards.
|
|
6867
|
+
* ```ts
|
|
6868
|
+
* const path = await plugin.dialogs.saveFile({ title: "Save snapshot", fileName: "frame.png" });
|
|
6869
|
+
* if (path === null) return; // cancelled
|
|
6870
|
+
* ```
|
|
4980
6871
|
*/
|
|
4981
6872
|
saveFile(options?: {
|
|
4982
6873
|
/** The dialog's title (English source text or a Text), at most 256 bytes. */
|
|
@@ -4989,6 +6880,32 @@ interface Dialogs {
|
|
|
4989
6880
|
}
|
|
4990
6881
|
```
|
|
4991
6882
|
|
|
6883
|
+
Example: Ask before deleting, remembering "Don't ask again"
|
|
6884
|
+
|
|
6885
|
+
Deleting asks for confirmation in a system alert with a "Don't ask
|
|
6886
|
+
again" box; once the user ticks it, later deletes go ahead without
|
|
6887
|
+
asking. `confirm` resolves with the button and the checkbox when the
|
|
6888
|
+
user answers; the texts are templates the player translates, so the
|
|
6889
|
+
count goes in `args`. Remembering the box is up to the plugin.
|
|
6890
|
+
```ts
|
|
6891
|
+
const store = plugin.storage.json("confirm", { defaults: { askBeforeDelete: true } });
|
|
6892
|
+
async function deleteAll(count: number) {
|
|
6893
|
+
if (store.value.askBeforeDelete) {
|
|
6894
|
+
const { confirmed, checked } = await plugin.dialogs.confirm({
|
|
6895
|
+
message: "Delete {} bookmarks?",
|
|
6896
|
+
args: [count],
|
|
6897
|
+
detail: "This cannot be undone.",
|
|
6898
|
+
confirm: "Delete",
|
|
6899
|
+
destructive: true,
|
|
6900
|
+
checkbox: "Don't ask again",
|
|
6901
|
+
});
|
|
6902
|
+
if (!confirmed) return;
|
|
6903
|
+
if (checked) store.save({ askBeforeDelete: false });
|
|
6904
|
+
}
|
|
6905
|
+
// … delete …
|
|
6906
|
+
}
|
|
6907
|
+
```
|
|
6908
|
+
|
|
4992
6909
|
### AudioTrack
|
|
4993
6910
|
|
|
4994
6911
|
One audio track of the current media (PlayerState.audioTracks).
|
|
@@ -5133,6 +7050,23 @@ interface Conditional {
|
|
|
5133
7050
|
}
|
|
5134
7051
|
```
|
|
5135
7052
|
|
|
7053
|
+
Example: Play the next file only if nothing else was opened
|
|
7054
|
+
|
|
7055
|
+
When the plugin's file ends, the next one starts, unless the user or
|
|
7056
|
+
another plugin opened something in the meantime. `onEnd` passes the
|
|
7057
|
+
state at the end; `open` with `ifCurrent: ended` is refused with `stale`
|
|
7058
|
+
when the source changed after that state, so nothing is replaced.
|
|
7059
|
+
```ts
|
|
7060
|
+
media.onEnd(async (ended) => {
|
|
7061
|
+
try {
|
|
7062
|
+
await plugin.player.open(next, { ifCurrent: ended });
|
|
7063
|
+
} catch (error) {
|
|
7064
|
+
// Someone opened another file: leave it playing.
|
|
7065
|
+
if ((error as PluginError).code !== "stale") throw error;
|
|
7066
|
+
}
|
|
7067
|
+
});
|
|
7068
|
+
```
|
|
7069
|
+
|
|
5136
7070
|
### Media
|
|
5137
7071
|
|
|
5138
7072
|
A source this plugin opened.
|
|
@@ -5179,6 +7113,15 @@ interface Scrub {
|
|
|
5179
7113
|
* the target of a keyframe seek is always sent. Omitted or `false`: exact,
|
|
5180
7114
|
* as before. Older players ignore the option and seek exactly: set
|
|
5181
7115
|
* `"minPlayerVersion": "0.5.3"` when you rely on it.
|
|
7116
|
+
* @example Scrub on keyframes for a fast picture
|
|
7117
|
+
* While dragging, the picture jumps to the nearest earlier keyframe at
|
|
7118
|
+
* once instead of decoding up to the exact frame. With `keyframes: true`
|
|
7119
|
+
* the player lands each target on the last keyframe at or before it; the
|
|
7120
|
+
* plugin follows the user's keyframe-seek setting.
|
|
7121
|
+
* ```ts
|
|
7122
|
+
* const keyframes = true; // the plugin's own setting
|
|
7123
|
+
* bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds, { keyframes }));
|
|
7124
|
+
* ```
|
|
5182
7125
|
*/
|
|
5183
7126
|
seek(seconds: number, options?: {
|
|
5184
7127
|
keyframes?: boolean;
|
|
@@ -5190,6 +7133,21 @@ interface Scrub {
|
|
|
5190
7133
|
}
|
|
5191
7134
|
```
|
|
5192
7135
|
|
|
7136
|
+
Example: Seek while the user drags a slider
|
|
7137
|
+
|
|
7138
|
+
The video follows the slider while it is dragged. `scrub()` starts a
|
|
7139
|
+
gesture from the current state; each `seek` is conditional on the
|
|
7140
|
+
previous one and only the newest target is sent while one is running,
|
|
7141
|
+
so a fast drag does not queue seeks. A change from outside (another file,
|
|
7142
|
+
the user pausing) ends the gesture and later seeks resolve `false`.
|
|
7143
|
+
```ts
|
|
7144
|
+
let scrub: Scrub | null = null;
|
|
7145
|
+
bar.on("dragStart", () => (scrub = plugin.player.scrub()));
|
|
7146
|
+
// Latest value wins: superseded targets resolve false, not an error.
|
|
7147
|
+
bar.on("dragMove", (seconds: number) => void scrub?.seek(seconds));
|
|
7148
|
+
bar.on("dragEnd", () => scrub?.end());
|
|
7149
|
+
```
|
|
7150
|
+
|
|
5193
7151
|
### StreamFrame
|
|
5194
7152
|
|
|
5195
7153
|
Frames a stream supplies on demand: RGBA pixels and 48 kHz stereo f32 PCM.
|
|
@@ -5414,6 +7372,24 @@ interface Player {
|
|
|
5414
7372
|
* TCP with a token on Windows. Rejects with a StreamStartError
|
|
5415
7373
|
* (`timeout`), `stream`, `device`, or `replaced` when another source
|
|
5416
7374
|
* replaced it (or it was closed) before it ran.
|
|
7375
|
+
* @example Play pictures the plugin draws itself
|
|
7376
|
+
* The player shows a 320×240 picture the plugin renders, with silence as
|
|
7377
|
+
* its sound, as the current source. The SDK pulls frames on the audio
|
|
7378
|
+
* clock: each `frame()` returns one picture (RGBA bytes) and the audio
|
|
7379
|
+
* that plays with it, here 1/60 s, so the picture runs at 60 frames a
|
|
7380
|
+
* second; `frame()` may run several times back to back to refill the
|
|
7381
|
+
* lead. `onStop` tells the plugin when and why the stream ended.
|
|
7382
|
+
* ```ts
|
|
7383
|
+
* const frame = new Uint8Array(320 * 240 * 4); // RGBA, reused per frame
|
|
7384
|
+
* const silence = new Float32Array(2 * 800); // 1/60 s of stereo at 48 kHz
|
|
7385
|
+
* const stream = await plugin.player.openStream({
|
|
7386
|
+
* width: 320,
|
|
7387
|
+
* height: 240,
|
|
7388
|
+
* frame: () => ({ video: render(frame), audio: silence }),
|
|
7389
|
+
* });
|
|
7390
|
+
* // `error` is null when the stream stopped normally.
|
|
7391
|
+
* stream.onStop((error) => error && plugin.osd.show(error.message));
|
|
7392
|
+
* ```
|
|
5417
7393
|
*/
|
|
5418
7394
|
openStream(options: StreamOptions): Promise<Stream>;
|
|
5419
7395
|
/** Pause or resume. */
|
|
@@ -5433,7 +7409,28 @@ interface Player {
|
|
|
5433
7409
|
volume?: number;
|
|
5434
7410
|
muted?: boolean;
|
|
5435
7411
|
}): Promise<boolean>;
|
|
5436
|
-
/**
|
|
7412
|
+
/**
|
|
7413
|
+
* Become the sequence provider (one at a time; another provider rejects with `conflict` through the scope's `onError`, or fails activation). Dispose to stop.
|
|
7414
|
+
* @example Drive the player's Next and Previous from a playlist
|
|
7415
|
+
* The player's Next and Previous items and keys play the plugin's list.
|
|
7416
|
+
* The player calls `next` and `previous` when the user asks; `canNext`
|
|
7417
|
+
* and `canPrevious` are evaluated again after every handler the SDK runs,
|
|
7418
|
+
* and while they return false the player disables its items ("No next
|
|
7419
|
+
* item").
|
|
7420
|
+
* ```ts
|
|
7421
|
+
* const playAt = async (at: number) => {
|
|
7422
|
+
* index = at;
|
|
7423
|
+
* await plugin.player.open(items[at]!);
|
|
7424
|
+
* };
|
|
7425
|
+
* // Called by the player's Next / Previous items, keys and buttons.
|
|
7426
|
+
* plugin.player.provideSequence({
|
|
7427
|
+
* next: () => playAt(index + 1),
|
|
7428
|
+
* previous: () => playAt(index - 1),
|
|
7429
|
+
* canNext: () => index + 1 < items.length,
|
|
7430
|
+
* canPrevious: () => index > 0,
|
|
7431
|
+
* });
|
|
7432
|
+
* ```
|
|
7433
|
+
*/
|
|
5437
7434
|
provideSequence(provider: SequenceProvider): Disposable;
|
|
5438
7435
|
/** Ask the provider to go forward / back. Rejects `unavailable` when it cannot. */
|
|
5439
7436
|
next(): Promise<void>;
|
|
@@ -5514,6 +7511,22 @@ interface Player {
|
|
|
5514
7511
|
}
|
|
5515
7512
|
```
|
|
5516
7513
|
|
|
7514
|
+
Example: Open a file and wait until it plays
|
|
7515
|
+
|
|
7516
|
+
The file opens and the plugin learns whether it started playing within
|
|
7517
|
+
ten seconds. `open` resolves once the player accepted the load and
|
|
7518
|
+
returns a `Media` handle; `ready` waits for playback and rejects with
|
|
7519
|
+
`failed` (load error), `replaced` (another source took over) or an
|
|
7520
|
+
`AbortError` when the timeout fires, which closes the media.
|
|
7521
|
+
```ts
|
|
7522
|
+
const media = await plugin.player.open("/Movies/clip.mp4"); // load accepted
|
|
7523
|
+
try {
|
|
7524
|
+
await media.ready({ signal: AbortSignal.timeout(10_000) });
|
|
7525
|
+
} catch (error) {
|
|
7526
|
+
void plugin.osd.show("Cannot play: {}", [(error as Error).message]);
|
|
7527
|
+
}
|
|
7528
|
+
```
|
|
7529
|
+
|
|
5517
7530
|
### HostAction
|
|
5518
7531
|
|
|
5519
7532
|
The core's basic playback actions (menu commands owned by the player),
|
|
@@ -5554,6 +7567,21 @@ interface Host {
|
|
|
5554
7567
|
* `invalid` for an unknown id. Needs `commands`.
|
|
5555
7568
|
*
|
|
5556
7569
|
* Prefer this over reimplementing playback buttons.
|
|
7570
|
+
* @example Make a bar button play and pause like Space
|
|
7571
|
+
* The plugin's own Play button behaves exactly like the player's Space
|
|
7572
|
+
* key, including its on-screen text. `host.run` runs the core action
|
|
7573
|
+
* with the player's checks; it resolves when the action finished, and
|
|
7574
|
+
* rejects with `unavailable` while the action is disabled (nothing open).
|
|
7575
|
+
* ```ts
|
|
7576
|
+
* bar.on("playPause", async () => {
|
|
7577
|
+
* try {
|
|
7578
|
+
* await plugin.host.run("play-pause");
|
|
7579
|
+
* } catch (error) {
|
|
7580
|
+
* if ((error as PluginError).code !== "unavailable") throw error;
|
|
7581
|
+
* // Nothing to play: the button simply does nothing.
|
|
7582
|
+
* }
|
|
7583
|
+
* });
|
|
7584
|
+
* ```
|
|
5557
7585
|
*/
|
|
5558
7586
|
run(action: HostAction): Promise<void>;
|
|
5559
7587
|
/**
|
|
@@ -5569,6 +7597,14 @@ interface Host {
|
|
|
5569
7597
|
* once the seek is submitted, as before. `invalid` for `seconds` out of
|
|
5570
7598
|
* range.
|
|
5571
7599
|
* @player 0.5.1 (set `"minPlayerVersion": "0.5.1"`: older players ignore `seconds` and seek by the `setStepSeek` step)
|
|
7600
|
+
* @example Add a 30-second skip that adds up with the arrow keys
|
|
7601
|
+
* The plugin's "+30 s" button seeks forward 30 seconds and shows the same
|
|
7602
|
+
* text as →. Passing `seconds` makes the core step seek use that step
|
|
7603
|
+
* with the player's mode (exact or keyframes); quick presses of it and of
|
|
7604
|
+
* → add up into one seek.
|
|
7605
|
+
* ```ts
|
|
7606
|
+
* bar.on("forward30", () => plugin.host.run("seek-forward", { seconds: 30 }));
|
|
7607
|
+
* ```
|
|
5572
7608
|
*/
|
|
5573
7609
|
run(action: "seek-backward" | "seek-forward", options: {
|
|
5574
7610
|
seconds: number;
|
|
@@ -5582,21 +7618,55 @@ This plugin's information block on the OSD: multi-line text over the
|
|
|
5582
7618
|
video that stays until the plugin changes or removes it. Made by
|
|
5583
7619
|
Osd.panel.
|
|
5584
7620
|
|
|
7621
|
+
The block sits under the OSD message line, on a translucent dark plate
|
|
7622
|
+
(see Osd for sizes), and takes no input. When
|
|
7623
|
+
several plugins have one, they are stacked from top to bottom. `dispose()`,
|
|
7624
|
+
disposing the scope or stopping the plugin removes it.
|
|
7625
|
+
|
|
5585
7626
|
- @capability ui.osd
|
|
5586
7627
|
|
|
5587
7628
|
```ts
|
|
5588
7629
|
interface OsdPanel extends Disposable {
|
|
5589
7630
|
/**
|
|
5590
7631
|
* Show the block with this text, change it, or hide it.
|
|
7632
|
+
*
|
|
7633
|
+
* Latest value wins: calling it on every state change is fine, the same
|
|
7634
|
+
* text is not sent twice and only the newest text is sent while a write is
|
|
7635
|
+
* in flight. Never throws; a failed write goes to Scope.onError
|
|
7636
|
+
* (`invalid` for too much text, `unavailable` without a user interface,
|
|
7637
|
+
* `capability` without `ui.osd`).
|
|
5591
7638
|
* @capability ui.osd
|
|
5592
7639
|
* @param text - The text, with `\n` between lines: at most 32 lines and 4096 bytes once translated and formatted. `null` hides the block. A template, like Osd.show's.
|
|
5593
7640
|
* @param args - Values for its placeholders (player 0.4.3); `null` or omitted for none.
|
|
5594
7641
|
* @param options - `maxColumns`: cut long lines (SDK 1.5.11).
|
|
7642
|
+
* @example Show two loop points, then hide the block
|
|
7643
|
+
* The block shows "A: 12.0 s" and "B: 18.5 s" on two lines, then
|
|
7644
|
+
* disappears. The text is a template the player translates and fills
|
|
7645
|
+
* in; `\n` separates lines, and `null` hides the block.
|
|
7646
|
+
* ```ts
|
|
7647
|
+
* const info = plugin.osd.panel();
|
|
7648
|
+
* info.set("A: {} s\nB: {} s", ["12.0", "18.5"]);
|
|
7649
|
+
* info.set(null); // hide it
|
|
7650
|
+
* ```
|
|
5595
7651
|
*/
|
|
5596
7652
|
set(text: TextValue | null, args?: TemplateArgs | null, options?: OsdPanelOptions): void;
|
|
5597
7653
|
}
|
|
5598
7654
|
```
|
|
5599
7655
|
|
|
7656
|
+
Example: Show live playback numbers over the video
|
|
7657
|
+
|
|
7658
|
+
A block over the video shows the position and the duration, updated
|
|
7659
|
+
as playback moves. `player.onChange` runs after the SDK updated the
|
|
7660
|
+
state (with `events`); `set` never throws and sends only the newest
|
|
7661
|
+
text while one is in flight, so calling it on every change is fine.
|
|
7662
|
+
```ts
|
|
7663
|
+
const info = plugin.osd.panel();
|
|
7664
|
+
plugin.player.onChange((state) =>
|
|
7665
|
+
// A template with data arguments; the player translates "{} / {} s".
|
|
7666
|
+
info.set("{} / {} s", [state.position?.toFixed(1) ?? "–", state.duration?.toFixed(1) ?? "–"]),
|
|
7667
|
+
);
|
|
7668
|
+
```
|
|
7669
|
+
|
|
5600
7670
|
### OsdPanelOptions
|
|
5601
7671
|
|
|
5602
7672
|
Options of OsdPanel.set.
|
|
@@ -5615,12 +7685,50 @@ interface OsdPanelOptions {
|
|
|
5615
7685
|
Text the player draws over the video: short messages and an information
|
|
5616
7686
|
block. `plugin.osd`.
|
|
5617
7687
|
|
|
7688
|
+
The on-screen display (OSD) is the lightest way to tell the user something:
|
|
7689
|
+
no Slint file, no window, visible in fullscreen. A message
|
|
7690
|
+
(Osd.show) is one line at the top left of the video that
|
|
7691
|
+
disappears after 2 seconds; a newer message, from any plugin, replaces it.
|
|
7692
|
+
The information block (Osd.panel) stays until the plugin changes
|
|
7693
|
+
or removes it.
|
|
7694
|
+
|
|
7695
|
+
How the player draws them (a player before 0.4.0 drew bold white text
|
|
7696
|
+
with a black outline instead):
|
|
7697
|
+
|
|
7698
|
+
- **Message**: white text on a translucent dark plate with rounded
|
|
7699
|
+
corners and a gradient line along its bottom edge, 10px from the
|
|
7700
|
+
video area's left and top edges.
|
|
7701
|
+
- **Font size** follows the video area: 3 % of its height, rounded and
|
|
7702
|
+
clamped to 12–48px (about 18px in the default window, 32px in
|
|
7703
|
+
fullscreen at 1080 pixels high). The plate's padding and corners scale
|
|
7704
|
+
with it.
|
|
7705
|
+
- **Information block**: 4px below the message plate, on the same kind of
|
|
7706
|
+
plate; its font scales in proportion (11px when the message is 16px),
|
|
7707
|
+
clamped to 9–28px.
|
|
7708
|
+
|
|
7709
|
+
How long a text may be is in
|
|
7710
|
+
Limits: on-screen display
|
|
7711
|
+
(OSD_LIMITS).
|
|
7712
|
+
|
|
5618
7713
|
- @capability ui.osd
|
|
5619
7714
|
|
|
5620
7715
|
```ts
|
|
5621
7716
|
interface Osd {
|
|
5622
7717
|
/**
|
|
5623
7718
|
* Show a one-line message at the top left of the video for 2 seconds.
|
|
7719
|
+
*
|
|
7720
|
+
* Replaces the message on screen, whichever plugin showed it. A message
|
|
7721
|
+
* that is on screen when the plugin stops still disappears on time.
|
|
7722
|
+
*
|
|
7723
|
+
* The text is a template in English: the player shows its translation
|
|
7724
|
+
* from the plugin's catalog, with the placeholders (`{}`, `{0}`, `{name}`;
|
|
7725
|
+
* `{{` and `}}` for braces) filled from `args`. Without `args` the text
|
|
7726
|
+
* shows as it is (braces included). Untranslated, the English template is
|
|
7727
|
+
* formatted. Never compose the sentence in TypeScript
|
|
7728
|
+
* (`` `Speed ${rate}×` ``): translators can only translate whole templates.
|
|
7729
|
+
* An argument that is itself words is a Text (SDK 1.5.11), and a
|
|
7730
|
+
* message made of a varying number of parts is a TextList of
|
|
7731
|
+
* texts; `text` may also be a `Text` (then without `args`).
|
|
5624
7732
|
* @capability ui.osd
|
|
5625
7733
|
* @param text - The template, 1–200 characters once translated and formatted; line breaks become spaces.
|
|
5626
7734
|
* @param args - Values for its placeholders (player 0.4.3; older players show the template as it is).
|
|
@@ -5628,18 +7736,51 @@ interface Osd {
|
|
|
5628
7736
|
* @throws invalid (rejects) The shown text is empty, only white space, or longer than 200 characters, or `args` is not a TemplateArgs.
|
|
5629
7737
|
* @throws unavailable (rejects) The player runs without a user interface.
|
|
5630
7738
|
* @throws capability (rejects) The manifest does not declare `ui.osd`.
|
|
7739
|
+
* @example Confirm an action and show a value
|
|
7740
|
+
* The OSD shows "Bookmark added", then "Speed 1.5×" in the user's
|
|
7741
|
+
* language. The text is English source the player translates with the
|
|
7742
|
+
* plugin's catalog; `{}` is filled in with the argument, which stays as
|
|
7743
|
+
* it is because it is data.
|
|
7744
|
+
* ```ts
|
|
7745
|
+
* await plugin.osd.show("Bookmark added");
|
|
7746
|
+
* await plugin.osd.show("Speed {}×", [plugin.player.state.rate]);
|
|
7747
|
+
* ```
|
|
5631
7748
|
*/
|
|
5632
7749
|
show(text: TextValue, args?: TemplateArgs): Promise<void>;
|
|
5633
7750
|
/**
|
|
5634
7751
|
* Create this plugin's information block.
|
|
7752
|
+
*
|
|
7753
|
+
* Nothing is shown until OsdPanel.set. A plugin has one block;
|
|
7754
|
+
* dispose it before creating another.
|
|
5635
7755
|
* @capability ui.osd
|
|
5636
7756
|
* @returns An OsdPanel owned by this scope.
|
|
5637
7757
|
* @throws Error without a code (throws) This plugin already has a block.
|
|
7758
|
+
* @example Show a status block over the video
|
|
7759
|
+
* A small block over the video says "Loop: off" until the plugin changes
|
|
7760
|
+
* or hides it. Each plugin has one block, stacked with other plugins'
|
|
7761
|
+
* blocks; a second `panel()` call throws.
|
|
7762
|
+
* ```ts
|
|
7763
|
+
* const info = plugin.osd.panel();
|
|
7764
|
+
* info.set("Loop: off");
|
|
7765
|
+
* ```
|
|
5638
7766
|
*/
|
|
5639
7767
|
panel(): OsdPanel;
|
|
5640
7768
|
}
|
|
5641
7769
|
```
|
|
5642
7770
|
|
|
7771
|
+
Example: Show on-screen messages the user can turn off
|
|
7772
|
+
|
|
7773
|
+
"Blur on" appears over the video unless the user turned messages off in
|
|
7774
|
+
the plugin's settings. `osd.show` asks the player to draw the text for a
|
|
7775
|
+
moment; the plugin checks its own stored preference before each call.
|
|
7776
|
+
```ts
|
|
7777
|
+
const store = plugin.storage.json("prefs", { defaults: { messages: true } });
|
|
7778
|
+
const say = (text: string) => {
|
|
7779
|
+
if (store.value.messages) void plugin.osd.show(text);
|
|
7780
|
+
};
|
|
7781
|
+
say("Blur on");
|
|
7782
|
+
```
|
|
7783
|
+
|
|
5643
7784
|
### FilterStatus
|
|
5644
7785
|
|
|
5645
7786
|
The state of a filter, display layer or render options handle.
|
|
@@ -5698,6 +7839,19 @@ same chain applies to processed snapshots and exports.
|
|
|
5698
7839
|
type VideoFilter = Filter<VideoNode[]>;
|
|
5699
7840
|
```
|
|
5700
7841
|
|
|
7842
|
+
Example: Brighten the picture and report when it fails
|
|
7843
|
+
|
|
7844
|
+
Every video plays a little brighter with more contrast, and the OSD
|
|
7845
|
+
says why when the player cannot apply it. `set` records the desired
|
|
7846
|
+
chain (last write wins, one request in flight); the player reapplies it
|
|
7847
|
+
for each new source, and `onStatus` reports `applied` or `failed`.
|
|
7848
|
+
```ts
|
|
7849
|
+
const picture = plugin.video.filter("picture");
|
|
7850
|
+
// `unsupported` while a generated stream plays: its frames skip filters.
|
|
7851
|
+
picture.onStatus((s) => s.state === "failed" && plugin.osd.show(s.error.message));
|
|
7852
|
+
picture.set([{ kind: "eq", brightness: 0.1, contrast: 1.1, saturation: 1, gamma: 1 }]);
|
|
7853
|
+
```
|
|
7854
|
+
|
|
5701
7855
|
### AudioFilter
|
|
5702
7856
|
|
|
5703
7857
|
This plugin's own audio chain plus its audio delay in 10 ms steps.
|
|
@@ -5717,6 +7871,16 @@ type AudioFilter = Filter<{
|
|
|
5717
7871
|
}>;
|
|
5718
7872
|
```
|
|
5719
7873
|
|
|
7874
|
+
Example: Lower the volume and delay the sound
|
|
7875
|
+
|
|
7876
|
+
Every file plays 6 dB quieter, with its sound 120 ms later to match a
|
|
7877
|
+
slow display. `set` sends the plugin's whole chain and delay; the
|
|
7878
|
+
player applies it to each source and adds the delays of all plugins.
|
|
7879
|
+
```ts
|
|
7880
|
+
const audio = plugin.audio.filter("sound");
|
|
7881
|
+
audio.set({ nodes: [{ kind: "volume", gainDb: -6 }], delayMs: 120 }); // 10 ms steps
|
|
7882
|
+
```
|
|
7883
|
+
|
|
5720
7884
|
### ViewSettings
|
|
5721
7885
|
|
|
5722
7886
|
How the video sits in the video area; not part of snapshots or exports.
|
|
@@ -5759,6 +7923,18 @@ interface VideoView extends Disposable {
|
|
|
5759
7923
|
}
|
|
5760
7924
|
```
|
|
5761
7925
|
|
|
7926
|
+
Example: Fill the window, then zoom in
|
|
7927
|
+
|
|
7928
|
+
The video fills the video area (cropped instead of letterboxed), then
|
|
7929
|
+
zooms to 150 % shifted a little to the right. The first plugin to call
|
|
7930
|
+
`view()` owns the display layer; each `set` merges into its settings,
|
|
7931
|
+
which stay across sources until the plugin resets them or stops.
|
|
7932
|
+
```ts
|
|
7933
|
+
const view = plugin.video.view();
|
|
7934
|
+
view.set({ fit: "fill" });
|
|
7935
|
+
view.set({ zoom: 1.5, panX: 0.1 }); // merged with the previous write
|
|
7936
|
+
```
|
|
7937
|
+
|
|
5762
7938
|
### RenderSettings
|
|
5763
7939
|
|
|
5764
7940
|
GPU renderer options; not part of snapshots or exports.
|
|
@@ -5801,6 +7977,21 @@ interface RenderOptions extends Disposable {
|
|
|
5801
7977
|
}
|
|
5802
7978
|
```
|
|
5803
7979
|
|
|
7980
|
+
Example: Tone-map HDR video and hide the menu where it cannot work
|
|
7981
|
+
|
|
7982
|
+
On a GPU-rendering player HDR video is tone-mapped with BT.2390 for a
|
|
7983
|
+
250-nit display; under software rendering the plugin hides its HDR menu.
|
|
7984
|
+
`set` merges the values into the plugin's render options and the
|
|
7985
|
+
player reports the result through `onStatus`: `failed` with
|
|
7986
|
+
`unsupported` means the player renders in software (Windows, Linux).
|
|
7987
|
+
```ts
|
|
7988
|
+
const render = plugin.video.render();
|
|
7989
|
+
render.onStatus((s) => {
|
|
7990
|
+
if (s.state === "failed" && s.error.code === "unsupported") hideHdrMenu();
|
|
7991
|
+
});
|
|
7992
|
+
render.set({ toneMapping: "bt.2390", targetPeak: 250 }); // nits
|
|
7993
|
+
```
|
|
7994
|
+
|
|
5804
7995
|
### SubtitleTrack
|
|
5805
7996
|
|
|
5806
7997
|
A subtitle track of the current source.
|
|
@@ -5855,7 +8046,7 @@ interface SubtitlesState {
|
|
|
5855
8046
|
/** Font family name for display. */
|
|
5856
8047
|
family: string;
|
|
5857
8048
|
}[];
|
|
5858
|
-
/** The effective style
|
|
8049
|
+
/** The effective style: among the plugins that set a layer, the one whose manifest id sorts last (not the most recent call); `null` without one. */
|
|
5859
8050
|
readonly style: SubtitleStyle | null;
|
|
5860
8051
|
/** Whether the renderer confirmed the selection. */
|
|
5861
8052
|
readonly trackConfirmation: "waiting" | "applied" | "uncertain";
|
|
@@ -5918,11 +8109,36 @@ interface Subtitles {
|
|
|
5918
8109
|
}): Promise<SubtitleTrack>;
|
|
5919
8110
|
/** Remove a track this plugin loaded. */
|
|
5920
8111
|
remove(track: SubtitleTrack, options?: SubtitleConditional): Promise<SubtitlesState>;
|
|
5921
|
-
/**
|
|
8112
|
+
/**
|
|
8113
|
+
* Set this plugin's style layer; `null` removes it. The newest call of this plugin replaces its earlier layer. When several plugins have a layer, the player uses the one whose manifest id sorts last, whatever the order of the calls.
|
|
8114
|
+
*/
|
|
5922
8115
|
setStyle(style: SubtitleStyle | null, options?: SubtitleConditional): Promise<SubtitlesState>;
|
|
5923
8116
|
}
|
|
5924
8117
|
```
|
|
5925
8118
|
|
|
8119
|
+
Example: Load an SRT file as the secondary subtitle
|
|
8120
|
+
|
|
8121
|
+
An English SRT file shows as a second subtitle line next to the
|
|
8122
|
+
film's own subtitles, in white with an outline. The plugin reads the
|
|
8123
|
+
file and uploads its bytes with `load`, which adds a track to the
|
|
8124
|
+
current source and selects it as the secondary subtitle; `refresh`
|
|
8125
|
+
reads the subtitle state for its font list, and `setStyle` sends this
|
|
8126
|
+
plugin's style layer naming one of those fonts.
|
|
8127
|
+
```ts
|
|
8128
|
+
const bytes = await readFile(path);
|
|
8129
|
+
await plugin.subtitles.load(bytes, { title: "English", select: "secondary" });
|
|
8130
|
+
const { fonts } = await plugin.subtitles.refresh();
|
|
8131
|
+
if (!fonts.length) return;
|
|
8132
|
+
await plugin.subtitles.setStyle({
|
|
8133
|
+
fontId: fonts[0]!.id,
|
|
8134
|
+
fontSize: 40, // at the 1080p reference height
|
|
8135
|
+
color: "white",
|
|
8136
|
+
outline: 2,
|
|
8137
|
+
marginPercent: 5,
|
|
8138
|
+
offsetMs: 0,
|
|
8139
|
+
});
|
|
8140
|
+
```
|
|
8141
|
+
|
|
5926
8142
|
### CaptureOptions
|
|
5927
8143
|
|
|
5928
8144
|
Options of MediaCapture.capture. Conditional on `player.state`
|
|
@@ -6053,6 +8269,28 @@ interface MediaCapture {
|
|
|
6053
8269
|
}
|
|
6054
8270
|
```
|
|
6055
8271
|
|
|
8272
|
+
Example: Save the current frame as JPEG
|
|
8273
|
+
|
|
8274
|
+
The frame on screen, with the plugin's own picture effects, is saved
|
|
8275
|
+
where the user chooses. `capture` freezes the source as it was in
|
|
8276
|
+
`seen` (and fails with `stale` if the user opened another file while
|
|
8277
|
+
the dialog was open); `export` decodes offline without disturbing
|
|
8278
|
+
playback, and `done` resolves with the job's final status.
|
|
8279
|
+
```ts
|
|
8280
|
+
const seen = plugin.player.state;
|
|
8281
|
+
const destination = await plugin.dialogs.saveFile({ fileName: "frame.jpg" });
|
|
8282
|
+
if (!destination) return;
|
|
8283
|
+
const snapshot = await plugin.media.capture({ ifCurrent: seen, processed: true });
|
|
8284
|
+
// Release the frozen frame whatever happens to the export.
|
|
8285
|
+
try {
|
|
8286
|
+
const job = await snapshot.export({ format: "jpeg", destination, quality: 85 });
|
|
8287
|
+
const end = await job.done;
|
|
8288
|
+
if (end.status !== "completed") throw new Error(`export ${end.status}`);
|
|
8289
|
+
} finally {
|
|
8290
|
+
await snapshot.dispose();
|
|
8291
|
+
}
|
|
8292
|
+
```
|
|
8293
|
+
|
|
6056
8294
|
### ResolverOptions
|
|
6057
8295
|
|
|
6058
8296
|
Declaration of Resolvers.add: a web resolver
|
|
@@ -6087,6 +8325,24 @@ playlist, next / previous, the recent list, a drop that calls `open`),
|
|
|
6087
8325
|
the player asks this plugin for a stream it generates instead of
|
|
6088
8326
|
playing the file. Needs `source.resolve` and `producer.av`.
|
|
6089
8327
|
|
|
8328
|
+
The open keeps everything of the original call: its `ifCurrent`, its
|
|
8329
|
+
operation (the opener's `open` and `media.ready()` settle as for any
|
|
8330
|
+
file), `paused` (the stream starts paused), and its path as
|
|
8331
|
+
`state.source`, with `state.kind` `"stream"`. A thrown error fails that
|
|
8332
|
+
open: `phase` `"error"` with the error's message as `state.error`,
|
|
8333
|
+
word for word. The stream belongs to this plugin, as with
|
|
8334
|
+
Player.openStream.
|
|
8335
|
+
|
|
8336
|
+
Cancellation: `signal` aborts when the open is replaced, closed
|
|
8337
|
+
(`player.close()`, F4) or times out (30 seconds), or this resolver is
|
|
8338
|
+
disposed. Cancelled after `resolve` returned but before the stream ran,
|
|
8339
|
+
the SDK releases the stream and never calls `started`.
|
|
8340
|
+
|
|
8341
|
+
One extension has one resolver: another plugin's resolver for it makes
|
|
8342
|
+
Resolver.ready reject with `conflict` without failing activation
|
|
8343
|
+
(the player shows a warning). When this plugin stops, its extensions are
|
|
8344
|
+
free again and such files go to the player as before.
|
|
8345
|
+
|
|
6090
8346
|
```ts
|
|
6091
8347
|
interface FileResolverOptions {
|
|
6092
8348
|
/** Local id, unique within the plugin. */
|
|
@@ -6100,6 +8356,32 @@ interface FileResolverOptions {
|
|
|
6100
8356
|
}
|
|
6101
8357
|
```
|
|
6102
8358
|
|
|
8359
|
+
Example: Play CHIP-8 programs with the plugin's own emulator
|
|
8360
|
+
|
|
8361
|
+
Opening a `.ch8` file anywhere in the player (Open dialog, playlist,
|
|
8362
|
+
recent list) runs it in the plugin's emulator instead of failing as an
|
|
8363
|
+
unknown format. The player calls `resolve` with the path; the plugin
|
|
8364
|
+
reads the program and returns a stream whose `frame()` the SDK pulls on
|
|
8365
|
+
the audio clock, one 1/60 s frame per call. `started` runs once the
|
|
8366
|
+
stream plays; `signal` aborts the read when the open is replaced or
|
|
8367
|
+
closed.
|
|
8368
|
+
```ts
|
|
8369
|
+
plugin.resolvers.add({
|
|
8370
|
+
id: "chip8",
|
|
8371
|
+
extensions: ["ch8"], // lower case, no dot; matches Game.CH8 too
|
|
8372
|
+
async resolve(path, signal) {
|
|
8373
|
+
const frame = emulator(await readFile(path, { signal }));
|
|
8374
|
+
return {
|
|
8375
|
+
kind: "stream",
|
|
8376
|
+
width: 64, // CHIP-8's display
|
|
8377
|
+
height: 32,
|
|
8378
|
+
frame,
|
|
8379
|
+
started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
|
|
8380
|
+
};
|
|
8381
|
+
},
|
|
8382
|
+
});
|
|
8383
|
+
```
|
|
8384
|
+
|
|
6103
8385
|
### ResolvedStream
|
|
6104
8386
|
|
|
6105
8387
|
What a file resolver returns: a stream, with the same frame contract as
|
|
@@ -6159,6 +8441,18 @@ interface Resolvers {
|
|
|
6159
8441
|
* The extensions some plugin's file resolver handles (SDK 1.5.8, player 0.4.2): lower case, without the dot, sorted, each once; not who registered them. Reading needs no capability; `[]` from older players. Without `events` it is the list from when the plugin first read it. Use it to accept such files beside the player's own media types, for example in a playlist's drop or folder scan.
|
|
6160
8442
|
* @capability None.
|
|
6161
8443
|
* @player 0.4.2
|
|
8444
|
+
* @example Accept files that a resolver plugin can play
|
|
8445
|
+
* A plugin's own file filter also accepts types other plugins turn into
|
|
8446
|
+
* streams. `fileTypes` lists the extensions every running file resolver
|
|
8447
|
+
* claims (lower case, no dot); the SDK keeps it current with `events`.
|
|
8448
|
+
* ```ts
|
|
8449
|
+
* const MEDIA = ["mp4", "mkv", "mp3"];
|
|
8450
|
+
* // Extensions compare in lower case, without the dot.
|
|
8451
|
+
* const accepted = (path: string) => {
|
|
8452
|
+
* const extension = path.split(".").pop()?.toLowerCase() ?? "";
|
|
8453
|
+
* return MEDIA.includes(extension) || plugin.resolvers.fileTypes.includes(extension);
|
|
8454
|
+
* };
|
|
8455
|
+
* ```
|
|
6162
8456
|
*/
|
|
6163
8457
|
readonly fileTypes: readonly string[];
|
|
6164
8458
|
/**
|
|
@@ -6167,11 +8461,40 @@ interface Resolvers {
|
|
|
6167
8461
|
* @returns A handle that removes the listener.
|
|
6168
8462
|
* @capability `events`
|
|
6169
8463
|
* @player 0.4.2
|
|
8464
|
+
* @example Say which file types became playable
|
|
8465
|
+
* When a plugin with a file resolver starts or stops, the OSD lists the
|
|
8466
|
+
* extra file types now accepted. The listener (which needs `events`)
|
|
8467
|
+
* receives the new list each time it changes.
|
|
8468
|
+
* ```ts
|
|
8469
|
+
* plugin.resolvers.onFileTypesChange((types) => {
|
|
8470
|
+
* void plugin.osd.show("Also accepted: {}", [plugin.i18n.list(types)]);
|
|
8471
|
+
* });
|
|
8472
|
+
* ```
|
|
6170
8473
|
*/
|
|
6171
8474
|
onFileTypesChange(listener: (types: string[]) => void): Disposable;
|
|
6172
8475
|
}
|
|
6173
8476
|
```
|
|
6174
8477
|
|
|
8478
|
+
Example: Play pages of a video site
|
|
8479
|
+
|
|
8480
|
+
Opening a `https://video.example.com/…` page plays its video. When the
|
|
8481
|
+
user opens a URL whose scheme and host this resolver declares, the
|
|
8482
|
+
player asks the plugin first; `resolve` returns the stream's address,
|
|
8483
|
+
and `signal` aborts the lookup when the open is replaced or closed.
|
|
8484
|
+
```ts
|
|
8485
|
+
plugin.resolvers.add({
|
|
8486
|
+
id: "example",
|
|
8487
|
+
schemes: ["https"],
|
|
8488
|
+
hosts: ["video.example.com"],
|
|
8489
|
+
async resolve(url, signal) {
|
|
8490
|
+
const response = await fetch(url, { signal }); // the site's JSON for the page
|
|
8491
|
+
if (!response.ok) throw new Error(`video.example.com answered ${response.status}`);
|
|
8492
|
+
const page = await response.json();
|
|
8493
|
+
return { kind: "media", originalUrl: url, title: page.title, primary: { url: page.stream } };
|
|
8494
|
+
},
|
|
8495
|
+
});
|
|
8496
|
+
```
|
|
8497
|
+
|
|
6175
8498
|
### PackageManifest
|
|
6176
8499
|
|
|
6177
8500
|
The manifest of a package, as validated. The display fields and
|
|
@@ -6440,6 +8763,21 @@ interface Packages {
|
|
|
6440
8763
|
}
|
|
6441
8764
|
```
|
|
6442
8765
|
|
|
8766
|
+
Example: Install a plugin from an unpacked package
|
|
8767
|
+
|
|
8768
|
+
The plugin installs a package directory it downloaded and unpacked, and
|
|
8769
|
+
reports why when it does not work. `validate` asks the player to check
|
|
8770
|
+
the package and returns a candidate; `install` starts the change and
|
|
8771
|
+
`done` resolves with its final status: `committed` only once the new
|
|
8772
|
+
plugin reported that it is running, otherwise `failed`, `cancelled`,
|
|
8773
|
+
`failedDegraded` or `tooLate`.
|
|
8774
|
+
```ts
|
|
8775
|
+
const candidate = await plugin.packages.validate(unpackedDirectory);
|
|
8776
|
+
const change = await candidate.install();
|
|
8777
|
+
// `message` says why a change did not commit.
|
|
8778
|
+
if ((await change.done) !== "committed") throw new Error(change.message ?? "install failed");
|
|
8779
|
+
```
|
|
8780
|
+
|
|
6443
8781
|
### BindingRef
|
|
6444
8782
|
|
|
6445
8783
|
A command: manifest id (`"host"` for the player's own) and command id.
|
|
@@ -6517,6 +8855,18 @@ interface Bindings {
|
|
|
6517
8855
|
}
|
|
6518
8856
|
```
|
|
6519
8857
|
|
|
8858
|
+
Example: Unbind the player's Mute key, then restore it
|
|
8859
|
+
|
|
8860
|
+
The player's Mute command loses its default key, and later gets it
|
|
8861
|
+
back. `set` with `[]` saves an override that unbinds the command
|
|
8862
|
+
(remembered across launches); `reset` removes the override, so the
|
|
8863
|
+
command's own default bindings apply again.
|
|
8864
|
+
```ts
|
|
8865
|
+
await plugin.bindings.set({ plugin: "host", command: "mute" }, []);
|
|
8866
|
+
// later
|
|
8867
|
+
await plugin.bindings.reset({ plugin: "host", command: "mute" });
|
|
8868
|
+
```
|
|
8869
|
+
|
|
6520
8870
|
### Clipboard
|
|
6521
8871
|
|
|
6522
8872
|
The system clipboard (`clipboard`). Unavailable without a user interface.
|
|
@@ -6537,6 +8887,21 @@ interface Clipboard {
|
|
|
6537
8887
|
}
|
|
6538
8888
|
```
|
|
6539
8889
|
|
|
8890
|
+
Example: Copy the current file and position
|
|
8891
|
+
|
|
8892
|
+
The clipboard holds "path @ seconds" for pasting into a note. The
|
|
8893
|
+
player writes the system clipboard; it rejects with `unsupported` on
|
|
8894
|
+
systems other than macOS and Windows and `failed` when the system
|
|
8895
|
+
refuses, which the plugin reports.
|
|
8896
|
+
```ts
|
|
8897
|
+
const { source, position } = plugin.player.state;
|
|
8898
|
+
try {
|
|
8899
|
+
await plugin.clipboard.writeText(`${source} @ ${position}`); // data, not shown
|
|
8900
|
+
} catch (error) {
|
|
8901
|
+
void plugin.osd.show("Could not copy: {}", [(error as PluginError).code]);
|
|
8902
|
+
}
|
|
8903
|
+
```
|
|
8904
|
+
|
|
6540
8905
|
### Power
|
|
6541
8906
|
|
|
6542
8907
|
Keeping the computer awake (`playback.control`).
|
|
@@ -6598,6 +8963,25 @@ interface Tools {
|
|
|
6598
8963
|
}
|
|
6599
8964
|
```
|
|
6600
8965
|
|
|
8966
|
+
Example: Read a file's format with ffprobe
|
|
8967
|
+
|
|
8968
|
+
The plugin gets a media file's container details as JSON. `available`
|
|
8969
|
+
says whether ffprobe can be found (`VESTRA_FFPROBE`, then `PATH`); `run`
|
|
8970
|
+
starts it from the plugin's own process without a shell and resolves
|
|
8971
|
+
with its output as bytes once it exits, or rejects when it fails
|
|
8972
|
+
(`failed`, with the last stderr line) or takes longer than `timeoutMs`.
|
|
8973
|
+
```ts
|
|
8974
|
+
if (await plugin.tools.available("ffprobe")) {
|
|
8975
|
+
const { stdout } = await plugin.tools.run(
|
|
8976
|
+
"ffprobe",
|
|
8977
|
+
["-v", "error", "-show_format", "-of", "json", path],
|
|
8978
|
+
{ timeoutMs: 10_000 }, // give up on a stuck file after 10 s
|
|
8979
|
+
);
|
|
8980
|
+
const info = JSON.parse(new TextDecoder().decode(stdout));
|
|
8981
|
+
void info.format; // container, duration, bit rate…
|
|
8982
|
+
}
|
|
8983
|
+
```
|
|
8984
|
+
|
|
6601
8985
|
### JsonStore
|
|
6602
8986
|
|
|
6603
8987
|
A JSON file `dataDirectory/<name>.json`, loaded once and saved debounced.
|
|
@@ -6627,6 +9011,22 @@ interface Storage {
|
|
|
6627
9011
|
*
|
|
6628
9012
|
* The file is read synchronously on creation; `debounceMs` defaults to
|
|
6629
9013
|
* 300. Files are created with mode 0600. `invalid` for a bad name.
|
|
9014
|
+
* @example Keep settings and a recent list in a JSON file
|
|
9015
|
+
* The plugin remembers whether to resume and the last ten files across
|
|
9016
|
+
* restarts. `json` reads `settings.json` from the data directory at once
|
|
9017
|
+
* (or uses `defaults` when it is missing or fails `validate`); `save`
|
|
9018
|
+
* replaces the value and writes the file 300 ms later, and stopping the
|
|
9019
|
+
* plugin writes a pending save first.
|
|
9020
|
+
* ```ts
|
|
9021
|
+
* type Settings = { resume: boolean; recent: string[] };
|
|
9022
|
+
* const store = plugin.storage.json<Settings>("settings", {
|
|
9023
|
+
* defaults: { resume: true, recent: [] },
|
|
9024
|
+
* validate: (v): v is Settings =>
|
|
9025
|
+
* typeof v === "object" && v !== null && typeof (v as Settings).resume === "boolean",
|
|
9026
|
+
* });
|
|
9027
|
+
* // Only `save` writes: changing `store.value` in place is not saved.
|
|
9028
|
+
* store.save({ ...store.value, recent: [path, ...store.value.recent].slice(0, 10) });
|
|
9029
|
+
* ```
|
|
6630
9030
|
*/
|
|
6631
9031
|
json<T>(name: string, options: {
|
|
6632
9032
|
defaults: T;
|
|
@@ -6700,6 +9100,20 @@ interface SessionStorage {
|
|
|
6700
9100
|
}
|
|
6701
9101
|
```
|
|
6702
9102
|
|
|
9103
|
+
Example: Keep a work queue across a plugin restart
|
|
9104
|
+
|
|
9105
|
+
A download queue survives the plugin being reloaded or crashing, and is
|
|
9106
|
+
empty again when the player starts next time. Session storage lives in
|
|
9107
|
+
the player process for one player run; each `set` is kept once its
|
|
9108
|
+
promise resolves, so nothing is lost if the plugin dies right after.
|
|
9109
|
+
```ts
|
|
9110
|
+
// A queue the plugin is working through: after a reload or a crash it
|
|
9111
|
+
// goes on where it stopped, after the player quits it starts afresh.
|
|
9112
|
+
const pending = (await plugin.storage.session.get<string[]>("queue")) ?? [];
|
|
9113
|
+
pending.push("https://example.com/a.mkv");
|
|
9114
|
+
await plugin.storage.session.set("queue", pending);
|
|
9115
|
+
```
|
|
9116
|
+
|
|
6703
9117
|
### System
|
|
6704
9118
|
|
|
6705
9119
|
`plugin.system`: files and links, executed by the player. No capability.
|
|
@@ -6718,6 +9132,22 @@ interface System {
|
|
|
6718
9132
|
}
|
|
6719
9133
|
```
|
|
6720
9134
|
|
|
9135
|
+
Example: Show an exported file and open the help page
|
|
9136
|
+
|
|
9137
|
+
The file manager opens with the exported file selected, then the
|
|
9138
|
+
browser opens the plugin's help page. The player performs both on the
|
|
9139
|
+
plugin's behalf; they reject with `unsupported` on systems other than
|
|
9140
|
+
macOS and Windows, which the plugin turns into a message.
|
|
9141
|
+
```ts
|
|
9142
|
+
try {
|
|
9143
|
+
await plugin.system.reveal(exportedFile);
|
|
9144
|
+
await plugin.system.openUrl("https://example.com/help");
|
|
9145
|
+
} catch (error) {
|
|
9146
|
+
if ((error as PluginError).code !== "unsupported") throw error;
|
|
9147
|
+
await plugin.osd.show(`Saved to ${exportedFile}`); // no file manager to open
|
|
9148
|
+
}
|
|
9149
|
+
```
|
|
9150
|
+
|
|
6721
9151
|
### Secrets
|
|
6722
9152
|
|
|
6723
9153
|
Per-plugin secrets (API keys, passwords) in the system's secret store:
|
|
@@ -6741,6 +9171,27 @@ interface Secrets {
|
|
|
6741
9171
|
}
|
|
6742
9172
|
```
|
|
6743
9173
|
|
|
9174
|
+
Example: Ask for an API token once and keep it in the keychain
|
|
9175
|
+
|
|
9176
|
+
The user types the token the first time; later runs read it from the
|
|
9177
|
+
system's secret store. `get` resolves `null` when nothing is stored; a
|
|
9178
|
+
store that is missing or refuses access rejects (`unsupported`,
|
|
9179
|
+
`unavailable`, `failed`), and then the plugin keeps the token for this
|
|
9180
|
+
run only and asks again next time.
|
|
9181
|
+
```ts
|
|
9182
|
+
let token: string | null = null;
|
|
9183
|
+
try {
|
|
9184
|
+
token = await plugin.secrets.get("api-token");
|
|
9185
|
+
} catch {
|
|
9186
|
+
// No usable store: fall through and ask.
|
|
9187
|
+
}
|
|
9188
|
+
if (token === null) {
|
|
9189
|
+
token = await askUser();
|
|
9190
|
+
if (token)
|
|
9191
|
+
await plugin.secrets.set("api-token", token).catch(() => {}); // best effort
|
|
9192
|
+
}
|
|
9193
|
+
```
|
|
9194
|
+
|
|
6744
9195
|
### VideoNode
|
|
6745
9196
|
|
|
6746
9197
|
One node of a VideoFilter chain, by `kind`. Values outside the
|
|
@@ -6879,6 +9330,24 @@ type VideoNode =
|
|
|
6879
9330
|
};
|
|
6880
9331
|
```
|
|
6881
9332
|
|
|
9333
|
+
Example: Brighten, sharpen and rotate the picture
|
|
9334
|
+
|
|
9335
|
+
The video plays a little brighter and more saturated, slightly sharper
|
|
9336
|
+
and turned a quarter clockwise. The nodes run in array order; `set` sends
|
|
9337
|
+
the whole chain as this plugin's desired value (last write wins), and the
|
|
9338
|
+
player applies it to every source until it changes, needing
|
|
9339
|
+
`processing.video`, `playback.read` and `events`.
|
|
9340
|
+
```ts
|
|
9341
|
+
const chain: VideoNode[] = [
|
|
9342
|
+
// Live fields: moving them later changes the picture in place.
|
|
9343
|
+
{ kind: "eq", brightness: 0.05, contrast: 1.1, saturation: 1.2, gamma: 1 },
|
|
9344
|
+
// Not live: changing `amount` later rebuilds the stage (a one-frame stall).
|
|
9345
|
+
{ kind: "sharpen", amount: 0.5, size: "small" },
|
|
9346
|
+
{ kind: "transform", rotate: 90, flipH: false, flipV: false },
|
|
9347
|
+
];
|
|
9348
|
+
plugin.video.filter("picture").set(chain);
|
|
9349
|
+
```
|
|
9350
|
+
|
|
6882
9351
|
### AudioNode
|
|
6883
9352
|
|
|
6884
9353
|
One node of an AudioFilter chain, by `kind`. Values outside the
|
|
@@ -6935,6 +9404,22 @@ type AudioNode =
|
|
|
6935
9404
|
};
|
|
6936
9405
|
```
|
|
6937
9406
|
|
|
9407
|
+
Example: Lift the voices and play in mono
|
|
9408
|
+
|
|
9409
|
+
Speech around 1 kHz is 3 dB louder and both speakers play the same mixed
|
|
9410
|
+
channel. `set` sends this plugin's whole audio chain (the nodes in order,
|
|
9411
|
+
plus an optional `delayMs`); the player joins it with other plugins'
|
|
9412
|
+
chains and applies it to every source (`processing.audio`,
|
|
9413
|
+
`playback.read`, `events`).
|
|
9414
|
+
```ts
|
|
9415
|
+
plugin.audio.filter("sound").set({
|
|
9416
|
+
nodes: [
|
|
9417
|
+
{ kind: "equalizer", frequency: 1000, q: 1, gainDb: 3 }, // +3 dB at 1 kHz
|
|
9418
|
+
{ kind: "channels", mode: "mono" }, // both channels get (L + R) / 2
|
|
9419
|
+
],
|
|
9420
|
+
});
|
|
9421
|
+
```
|
|
9422
|
+
|
|
6938
9423
|
### ResolvedMedia
|
|
6939
9424
|
|
|
6940
9425
|
What a resolver returns (WebResolverOptions.resolve): the playable
|
|
@@ -6956,12 +9441,50 @@ type ResolvedMedia = {
|
|
|
6956
9441
|
};
|
|
6957
9442
|
```
|
|
6958
9443
|
|
|
9444
|
+
Example: Play a page whose video and audio are separate streams
|
|
9445
|
+
|
|
9446
|
+
Opening a `https://video.example/…` page plays it with its title. When
|
|
9447
|
+
the user opens a URL whose scheme and host a web resolver declares, the
|
|
9448
|
+
player calls its `resolve(url, signal)`; the plugin looks up the streams
|
|
9449
|
+
and returns them, and the player plays `primary` with the separate
|
|
9450
|
+
`audio` track, sending the `headers` with every request for both.
|
|
9451
|
+
```ts
|
|
9452
|
+
plugin.resolvers.add({
|
|
9453
|
+
id: "video-example",
|
|
9454
|
+
schemes: ["https"],
|
|
9455
|
+
hosts: ["video.example"],
|
|
9456
|
+
async resolve(url, signal) {
|
|
9457
|
+
// The site's own API (made up here) names the streams of a page.
|
|
9458
|
+
const api = `https://video.example/api/streams?page=${encodeURIComponent(url)}`;
|
|
9459
|
+
const response = await fetch(api, { signal });
|
|
9460
|
+
// The player shows a failed resolve's message to the user.
|
|
9461
|
+
if (!response.ok)
|
|
9462
|
+
throw new Error(`video.example answered ${response.status} for this page`);
|
|
9463
|
+
const info = (await response.json()) as {
|
|
9464
|
+
title: string;
|
|
9465
|
+
video: string;
|
|
9466
|
+
audio: string;
|
|
9467
|
+
};
|
|
9468
|
+
const headers = { Referer: url }; // identical for both streams
|
|
9469
|
+
const media: ResolvedMedia = {
|
|
9470
|
+
kind: "media",
|
|
9471
|
+
originalUrl: url,
|
|
9472
|
+
title: info.title,
|
|
9473
|
+
primary: { url: info.video, headers },
|
|
9474
|
+
audio: { url: info.audio, headers },
|
|
9475
|
+
};
|
|
9476
|
+
return media;
|
|
9477
|
+
},
|
|
9478
|
+
});
|
|
9479
|
+
```
|
|
9480
|
+
|
|
6959
9481
|
### SubtitleStyle
|
|
6960
9482
|
|
|
6961
9483
|
A subtitle style layer (Subtitles.setStyle). Sizes are at the
|
|
6962
|
-
1080p reference height and scale with the video.
|
|
6963
|
-
|
|
6964
|
-
|
|
9484
|
+
1080p reference height and scale with the video. Each plugin has one
|
|
9485
|
+
layer; when several plugins set one, the effective style is the layer of
|
|
9486
|
+
the plugin whose manifest id sorts last (the order of the calls does not
|
|
9487
|
+
matter). It also applies to the secondary subtitle.
|
|
6965
9488
|
|
|
6966
9489
|
```ts
|
|
6967
9490
|
type SubtitleStyle = {
|
|
@@ -6992,6 +9515,30 @@ type SubtitleStyle = {
|
|
|
6992
9515
|
};
|
|
6993
9516
|
```
|
|
6994
9517
|
|
|
9518
|
+
Example: Show large yellow subtitles a quarter second earlier
|
|
9519
|
+
|
|
9520
|
+
Subtitles turn yellow at size 44 on a 60 % opaque box, 6 % above the
|
|
9521
|
+
bottom of the video, and appear 250 ms earlier. `refresh` reads the
|
|
9522
|
+
subtitle state from the player, whose `fonts` lists the font ids a style
|
|
9523
|
+
may name; `setStyle` then sends this plugin's style layer
|
|
9524
|
+
(`subtitles.control`, `subtitles.read`). It shows unless another plugin
|
|
9525
|
+
with a manifest id that sorts later has a layer too: the player picks the
|
|
9526
|
+
effective layer by manifest id, not by which call came last.
|
|
9527
|
+
```ts
|
|
9528
|
+
const { fonts } = await plugin.subtitles.refresh();
|
|
9529
|
+
if (!fonts.length) return; // no font to name: leave the style alone
|
|
9530
|
+
const style: SubtitleStyle = {
|
|
9531
|
+
fontId: fonts[0]!.id, // a font the player offers
|
|
9532
|
+
fontSize: 44, // at the 1080p reference height; scales with the video
|
|
9533
|
+
color: "yellow",
|
|
9534
|
+
outline: 2,
|
|
9535
|
+
marginPercent: 6, // bottom margin, % of the video height
|
|
9536
|
+
offsetMs: -250, // negative: shown earlier
|
|
9537
|
+
box: 60, // background box opacity
|
|
9538
|
+
};
|
|
9539
|
+
await plugin.subtitles.setStyle(style);
|
|
9540
|
+
```
|
|
9541
|
+
|
|
6995
9542
|
### Json
|
|
6996
9543
|
|
|
6997
9544
|
A JSON value: what crosses the wire to the player. Slint property values,
|
|
@@ -7067,6 +9614,22 @@ interface FakeWindow {
|
|
|
7067
9614
|
}
|
|
7068
9615
|
```
|
|
7069
9616
|
|
|
9617
|
+
Example: Change a setting in a tool window from a test
|
|
9618
|
+
|
|
9619
|
+
The test opens the plugin's Picture window, moves its brightness control
|
|
9620
|
+
and checks that the plugin wrote the value back. `emit` fires a Slint
|
|
9621
|
+
callback as if the user used the control; the plugin's `on` handler runs
|
|
9622
|
+
and sets properties, which reach the host as requests, so `settle()`
|
|
9623
|
+
waits for them before `props` is read.
|
|
9624
|
+
```ts
|
|
9625
|
+
await host.command("open-picture").run(); // the plugin calls window.show()
|
|
9626
|
+
const picture = host.window("picture");
|
|
9627
|
+
assert.equal(picture.visible, true);
|
|
9628
|
+
picture.emit("adjust", "brightness", 40); // fire a Slint callback
|
|
9629
|
+
await host.settle();
|
|
9630
|
+
assert.equal(picture.props["brightness"], 40);
|
|
9631
|
+
```
|
|
9632
|
+
|
|
7070
9633
|
### FileDialogText
|
|
7071
9634
|
|
|
7072
9635
|
A file dialog as the player shows it (TestHost.fileDialogs).
|
|
@@ -7125,6 +9688,21 @@ interface FakeCommand {
|
|
|
7125
9688
|
}
|
|
7126
9689
|
```
|
|
7127
9690
|
|
|
9691
|
+
Example: Run a checkable menu item from a test
|
|
9692
|
+
|
|
9693
|
+
The test checks the Loop item's menu place, picks it and sees its check
|
|
9694
|
+
mark turn on. `host.command(id)` is the host's record of what the plugin
|
|
9695
|
+
declared and the state it last sent; `run()` calls the plugin's `run`
|
|
9696
|
+
like the menu, after which the SDK re-evaluates the command's state and
|
|
9697
|
+
sends it, and `run()` resolves once the host is idle again.
|
|
9698
|
+
```ts
|
|
9699
|
+
const toggle = host.command("toggle-loop");
|
|
9700
|
+
assert.deepEqual(toggle.declaration.path, ["Playback", "Loop"]);
|
|
9701
|
+
assert.equal(toggle.state.checked, false);
|
|
9702
|
+
await toggle.run();
|
|
9703
|
+
assert.equal(host.command("toggle-loop").state.checked, true);
|
|
9704
|
+
```
|
|
9705
|
+
|
|
7128
9706
|
### FakeBarButton
|
|
7129
9707
|
|
|
7130
9708
|
A command's bottom-bar button (`CommandOptions.button`) as the player shows
|
|
@@ -7261,6 +9839,16 @@ class TestHost implements Transport {
|
|
|
7261
9839
|
readonly coreCommands: Map<string, FakeCommand>;
|
|
7262
9840
|
/**
|
|
7263
9841
|
* Both panel positions as the player reports them (`null` while no panel is mounted there). You may change a slot directly and then call `host.emit("window.state", {})` to simulate a change made elsewhere.
|
|
9842
|
+
* @example Hide the sidebar and check the player's layout
|
|
9843
|
+
* With the plugin's panel in the sidebar, toggling the sidebar records
|
|
9844
|
+
* that the user prefers it hidden. `toggle()` reads the slot from the
|
|
9845
|
+
* player, asks for the opposite of its `preferred` flag and resolves once
|
|
9846
|
+
* the player answered, so the host's `layout` already shows the new value.
|
|
9847
|
+
* ```ts
|
|
9848
|
+
* assert.equal(host.layout.sidebar?.preferred, true);
|
|
9849
|
+
* await plugin.panels.sidebar.toggle();
|
|
9850
|
+
* assert.equal(host.layout.sidebar?.preferred, false);
|
|
9851
|
+
* ```
|
|
7264
9852
|
*/
|
|
7265
9853
|
readonly layout: {
|
|
7266
9854
|
/** The bottom bar position: `preferred`, `effective`, `reason`, `detached`, `owner` and `panels` (its one panel). */
|
|
@@ -7289,6 +9877,17 @@ class TestHost implements Transport {
|
|
|
7289
9877
|
* `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
|
|
7290
9878
|
* players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
|
|
7291
9879
|
* passing; set `locale` to test what a user sees now.
|
|
9880
|
+
* @example Check a menu path in Simplified Chinese
|
|
9881
|
+
* The test starts the plugin with a Chinese interface and checks the
|
|
9882
|
+
* menu path its Blur item shows. The `locale` option sets
|
|
9883
|
+
* `host.locale` before activation, so the plugin's `locales/zh-Hans.po`
|
|
9884
|
+
* is loaded; the built-in menu `@video` shows in the player's own words
|
|
9885
|
+
* (视频), the item's title in the plugin's translation.
|
|
9886
|
+
* ```ts
|
|
9887
|
+
* const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
|
|
9888
|
+
* assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
|
|
9889
|
+
* await stop();
|
|
9890
|
+
* ```
|
|
7292
9891
|
*/
|
|
7293
9892
|
get locale(): Locale;
|
|
7294
9893
|
set locale(locale: Locale);
|
|
@@ -7302,6 +9901,13 @@ class TestHost implements Transport {
|
|
|
7302
9901
|
* @param text - English source text or template.
|
|
7303
9902
|
* @param args - Values for its placeholders.
|
|
7304
9903
|
* @returns The shown text.
|
|
9904
|
+
* @example Compare with a translated message
|
|
9905
|
+
* The test expects the text a Chinese user sees for a template the plugin
|
|
9906
|
+
* shows. `translate` looks the English source up in the plugin's catalog
|
|
9907
|
+
* for `host.locale` and fills in the arguments, the way the player does.
|
|
9908
|
+
* ```ts
|
|
9909
|
+
* assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
|
|
9910
|
+
* ```
|
|
7305
9911
|
*/
|
|
7306
9912
|
translate(text: TextValue, args?: TemplateArgs): string;
|
|
7307
9913
|
/** The saved interface language (`plugin.appearance.language`), changed by `appearance.setLanguage` or TestHost.setLanguage. */
|
|
@@ -7337,7 +9943,20 @@ class TestHost implements Transport {
|
|
|
7337
9943
|
/** The provider's `canPrevious`, as last evaluated. */
|
|
7338
9944
|
canPrevious: boolean;
|
|
7339
9945
|
} | null;
|
|
7340
|
-
/**
|
|
9946
|
+
/**
|
|
9947
|
+
* What `player.stats()` returns. Fields added in later rounds may be left out; the SDK fills them with `null`.
|
|
9948
|
+
* @example Pretend a 1080p H.264 video is playing
|
|
9949
|
+
* The plugin's `player.stats()` now reports a 1920×1080 H.264 stream at
|
|
9950
|
+
* 24 fps. The host answers `stats()` with this object as it stands, so a
|
|
9951
|
+
* test sets it before the plugin asks; fields it leaves out come back as
|
|
9952
|
+
* `null` from the SDK.
|
|
9953
|
+
* ```ts
|
|
9954
|
+
* host.stats = {
|
|
9955
|
+
* ...host.stats, // keep the other fields
|
|
9956
|
+
* video: { codec: "h264", width: 1920, height: 1080, fps: 24 },
|
|
9957
|
+
* } as typeof host.stats;
|
|
9958
|
+
* ```
|
|
9959
|
+
*/
|
|
7341
9960
|
stats: PlaybackStats;
|
|
7342
9961
|
/** Every `subtitles.load`: its options and bytes, newest last. */
|
|
7343
9962
|
readonly subtitleLoads: {
|
|
@@ -7359,7 +9978,27 @@ class TestHost implements Transport {
|
|
|
7359
9978
|
};
|
|
7360
9979
|
/** The effective `power.preventSleep` mode. */
|
|
7361
9980
|
power: "off" | "playing";
|
|
7362
|
-
/**
|
|
9981
|
+
/**
|
|
9982
|
+
* The fake `ffmpeg` / `ffprobe` behind `plugin.tools`. A tool without a handler is not found (`available()` is false, `run()` rejects with `unavailable`).
|
|
9983
|
+
* @example Fake ffprobe and a failing ffmpeg
|
|
9984
|
+
* The plugin's Probe command gets an empty stream list from ffprobe, and
|
|
9985
|
+
* any ffmpeg run fails as it would on a broken file. `handle` makes the
|
|
9986
|
+
* tool available and answers each `tools.run` with the handler's result;
|
|
9987
|
+
* a thrown `HostError` makes the run reject with that code. `calls`
|
|
9988
|
+
* records every run with its arguments.
|
|
9989
|
+
* ```ts
|
|
9990
|
+
* host.tools.handle("ffprobe", (args) => {
|
|
9991
|
+
* assert.ok(args.includes("-show_streams")); // what the plugin asked for
|
|
9992
|
+
* return { stdout: JSON.stringify({ streams: [] }) }; // a file with no streams
|
|
9993
|
+
* });
|
|
9994
|
+
* // Every ffmpeg run rejects with code `failed`.
|
|
9995
|
+
* host.tools.handle("ffmpeg", () => {
|
|
9996
|
+
* throw new HostError("failed", "Invalid data found when processing input");
|
|
9997
|
+
* });
|
|
9998
|
+
* await host.command("probe").run();
|
|
9999
|
+
* assert.equal(host.tools.calls[0]?.tool, "ffprobe");
|
|
10000
|
+
* ```
|
|
10001
|
+
*/
|
|
7363
10002
|
readonly tools: {
|
|
7364
10003
|
/** Every run: the tool name and its arguments, newest last. */
|
|
7365
10004
|
calls: {
|
|
@@ -7432,6 +10071,20 @@ class TestHost implements Transport {
|
|
|
7432
10071
|
* Simulate another plugin publishing its timeline layer (or clearing it with `[]`); pushes `timeline.changed` when the plugin subscribed.
|
|
7433
10072
|
* @param pluginId - The other plugin's manifest id.
|
|
7434
10073
|
* @param marks - Its whole layer.
|
|
10074
|
+
* @example Let another plugin publish chapters
|
|
10075
|
+
* The plugin under test sees two chapter marks another plugin put on the
|
|
10076
|
+
* timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
|
|
10077
|
+
* `timeline.changed`; once the plugin uses `plugin.timeline` (with
|
|
10078
|
+
* `events`) the SDK keeps `marks` current from that event, and `settle()`
|
|
10079
|
+
* lets it arrive.
|
|
10080
|
+
* ```ts
|
|
10081
|
+
* host.setTimelineLayer("community.chapters", [
|
|
10082
|
+
* { time: 0, kind: "chapter", title: "Intro" },
|
|
10083
|
+
* { time: 95, kind: "chapter", title: "Part 1" },
|
|
10084
|
+
* ]); // replaces that plugin's whole layer; [] would clear it
|
|
10085
|
+
* await host.settle();
|
|
10086
|
+
* assert.equal(plugin.timeline.marks.length, 2);
|
|
10087
|
+
* ```
|
|
7435
10088
|
*/
|
|
7436
10089
|
setTimelineLayer(pluginId: string, marks: {
|
|
7437
10090
|
time: number;
|
|
@@ -7451,6 +10104,16 @@ class TestHost implements Transport {
|
|
|
7451
10104
|
}[];
|
|
7452
10105
|
/**
|
|
7453
10106
|
* The file an image property shows, or `null` when the value is not a runtime image. View properties hold images in their wire form; this reads the path back.
|
|
10107
|
+
* @example Check which file a window shows as its cover
|
|
10108
|
+
* The test checks that the Cover window's image comes from the plugin's
|
|
10109
|
+
* data directory. An image property holds the SDK's wire form of
|
|
10110
|
+
* `plugin.ui.image(path)`, not a path; `imagePath` reads the path back.
|
|
10111
|
+
* ```ts
|
|
10112
|
+
* assert.equal(
|
|
10113
|
+
* host.imagePath(host.window("cover").props["cover"]),
|
|
10114
|
+
* join(plugin.dataDirectory, "cover.png"),
|
|
10115
|
+
* );
|
|
10116
|
+
* ```
|
|
7454
10117
|
*/
|
|
7455
10118
|
imagePath(value: Json | undefined): string | null;
|
|
7456
10119
|
/** `drops.provide` registrations of this plugin, in order (`id` is the SDK's). */
|
|
@@ -7463,9 +10126,24 @@ class TestHost implements Transport {
|
|
|
7463
10126
|
/** The panel has an `onDrop` listener. */
|
|
7464
10127
|
panelDrops: boolean;
|
|
7465
10128
|
/**
|
|
7466
|
-
* Drop files like the player: on a panel with an `onDrop` listener the whole list goes there; otherwise (and on the video area)
|
|
10129
|
+
* Drop files like the player: on a panel with an `onDrop` listener the whole list goes there; otherwise (and on the video area) each path goes to the first provider of its extension (`folder` for existing directories), one call per provider with all of its paths. Files nobody takes are ignored. Resolves once the host is idle again.
|
|
7467
10130
|
* @param paths - The dropped paths, in the order the file manager gives.
|
|
7468
10131
|
* @param options - `target`: `"video"` (default) or `"panel"`; `at`: the drop point in panel coordinates (default `{ x: 0, y: 0 }`); `modifiers`: keys held (default all `false`).
|
|
10132
|
+
* @example Drop files on the video and on the panel
|
|
10133
|
+
* The first drop lands on the video area: each extension belongs to the
|
|
10134
|
+
* provider that registered it first, and each provider gets one call with
|
|
10135
|
+
* all of its paths. The second lands on the panel at a point, with Alt
|
|
10136
|
+
* held, and the panel's `onDrop` gets the whole list. `drop` resolves
|
|
10137
|
+
* once the host is idle.
|
|
10138
|
+
* ```ts
|
|
10139
|
+
* await host.drop(["/media/a.srt", "/media/b.mkv"]); // on the video area
|
|
10140
|
+
* // On the panel, 10 px from its left and 48 px from its top, Alt held.
|
|
10141
|
+
* await host.drop(["/media/list.m3u"], {
|
|
10142
|
+
* target: "panel",
|
|
10143
|
+
* at: { x: 10, y: 48 },
|
|
10144
|
+
* modifiers: { alt: true },
|
|
10145
|
+
* });
|
|
10146
|
+
* ```
|
|
7469
10147
|
*/
|
|
7470
10148
|
drop(paths: string[], options?: {
|
|
7471
10149
|
target?: "video" | "panel";
|
|
@@ -7511,7 +10189,20 @@ class TestHost implements Transport {
|
|
|
7511
10189
|
/** Whether the options window is open. */
|
|
7512
10190
|
open: boolean;
|
|
7513
10191
|
};
|
|
7514
|
-
/**
|
|
10192
|
+
/**
|
|
10193
|
+
* One settings page by id. Throws when it was never registered.
|
|
10194
|
+
* @example Change a value on the plugin's settings page
|
|
10195
|
+
* The test sets the step on the General page and checks the page shows
|
|
10196
|
+
* it. `emit` fires the page's callback as the user's input would; the
|
|
10197
|
+
* plugin's handler stores the value and its `bind` function sends it back
|
|
10198
|
+
* as a property, which `settle()` waits for.
|
|
10199
|
+
* ```ts
|
|
10200
|
+
* const page = host.settingsPage("general");
|
|
10201
|
+
* page.emit("setStep", 10);
|
|
10202
|
+
* await host.settle();
|
|
10203
|
+
* assert.equal(page.props["step"], 10);
|
|
10204
|
+
* ```
|
|
10205
|
+
*/
|
|
7515
10206
|
settingsPage(id: string): {
|
|
7516
10207
|
/** The page's settings category. */
|
|
7517
10208
|
category: string;
|
|
@@ -7544,6 +10235,18 @@ class TestHost implements Transport {
|
|
|
7544
10235
|
* @param slot - The position.
|
|
7545
10236
|
* @param plugin - The other plugin's manifest id.
|
|
7546
10237
|
* @param options - `title`: its tab label (default: `plugin`).
|
|
10238
|
+
* @example Put the playlist next to this plugin's sidebar panel
|
|
10239
|
+
* The sidebar ends up with two tabs: the playlist, mounted first, and the
|
|
10240
|
+
* plugin's own panel after it. `mountOther` before `startPlugin` mounts
|
|
10241
|
+
* another plugin's panel as the player would, so the plugin's mount
|
|
10242
|
+
* during activation becomes the second tab (in the bottom bar or the
|
|
10243
|
+
* backdrop it would be refused with `conflict` instead).
|
|
10244
|
+
* ```ts
|
|
10245
|
+
* const host = new TestHost();
|
|
10246
|
+
* host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
|
|
10247
|
+
* await startPlugin(activate, { host, directory });
|
|
10248
|
+
* assert.equal(host.layout.sidebar?.panels?.length, 2);
|
|
10249
|
+
* ```
|
|
7547
10250
|
*/
|
|
7548
10251
|
mountOther(slot: "bottom" | "sidebar" | "backdrop", plugin: string, options?: {
|
|
7549
10252
|
title?: string;
|
|
@@ -7582,10 +10285,32 @@ class TestHost implements Transport {
|
|
|
7582
10285
|
setVideoRenderOwner(pluginId: string | null): void;
|
|
7583
10286
|
/**
|
|
7584
10287
|
* What the player pushes when mpv cannot compile a shader (code `failed`) or switches to software rendering (`unsupported`, which also sets `videoRender.gpu` to false). Pushed only when this plugin is the owner.
|
|
10288
|
+
* @example Make a shader fail to compile
|
|
10289
|
+
* The plugin's render options report `failed`, as when mpv rejects a
|
|
10290
|
+
* shader. The host pushes the failure only while this plugin owns the
|
|
10291
|
+
* render options, which its first accepted `set` made it; the SDK updates
|
|
10292
|
+
* the handle's `status` when the push arrives, so the test settles first.
|
|
10293
|
+
* ```ts
|
|
10294
|
+
* const render = plugin.video.render();
|
|
10295
|
+
* render.set({ toneMapping: "bt.2390" }); // makes the plugin the owner
|
|
10296
|
+
* await host.settle();
|
|
10297
|
+
* host.failVideoRender("failed", "shader compile failed: ERROR: 0:39: ...");
|
|
10298
|
+
* await host.settle();
|
|
10299
|
+
* assert.equal(render.status.state, "failed");
|
|
10300
|
+
* ```
|
|
7585
10301
|
*/
|
|
7586
10302
|
failVideoRender(code: "failed" | "unsupported", message: string): void;
|
|
7587
10303
|
/**
|
|
7588
10304
|
* The player's processing (filter) state. Wire-level; may change in minor versions. `resources` holds each `video.filter` / `audio.filter` chain the plugin applied, keyed by the SDK's resource id.
|
|
10305
|
+
* @example Check the filter chain the plugin applied
|
|
10306
|
+
* After the plugin set its picture chain, the test reads the one chain
|
|
10307
|
+
* the host holds and compares its nodes. `resources` keeps each applied
|
|
10308
|
+
* `video.filter` / `audio.filter` chain by the SDK's resource id, after
|
|
10309
|
+
* the host validated it with the player's value ranges.
|
|
10310
|
+
* ```ts
|
|
10311
|
+
* const [chain] = [...host.processing.resources.values()];
|
|
10312
|
+
* assert.deepEqual(chain?.nodes, [{ kind: "hue", degrees: 30 }]);
|
|
10313
|
+
* ```
|
|
7589
10314
|
*/
|
|
7590
10315
|
readonly processing: {
|
|
7591
10316
|
/** Increases with every accepted change. */
|
|
@@ -7660,6 +10385,15 @@ class TestHost implements Transport {
|
|
|
7660
10385
|
readonly resolvers: Map<string, Json>;
|
|
7661
10386
|
/**
|
|
7662
10387
|
* Resolvers other plugins hold, as `resolvers.add` declares them plus the holder's id: a registration of this plugin that claims one of their extensions (or scheme + host pairs) is refused with `conflict`, which does not fail activation, like the player.
|
|
10388
|
+
* @example Start with another plugin holding a file type
|
|
10389
|
+
* The plugin starts in a player where another plugin already resolves
|
|
10390
|
+
* `.ch8` files. Pushed before `startPlugin`, the entry makes the plugin's
|
|
10391
|
+
* own resolver for `.ch8` fail with `conflict` during activation, which
|
|
10392
|
+
* (as in the player) does not fail the activation.
|
|
10393
|
+
* ```ts
|
|
10394
|
+
* const host = new TestHost();
|
|
10395
|
+
* host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
|
|
10396
|
+
* ```
|
|
7663
10397
|
*/
|
|
7664
10398
|
readonly otherResolvers: {
|
|
7665
10399
|
/** The manifest id of the plugin holding them (shown in the refusal). */
|
|
@@ -7675,6 +10409,17 @@ class TestHost implements Transport {
|
|
|
7675
10409
|
fileTypes(): string[];
|
|
7676
10410
|
/**
|
|
7677
10411
|
* Push `resolvers.fileTypes` when TestHost.fileTypes changed: called after this plugin's resolvers come and go; call it yourself after changing TestHost.otherResolvers (another plugin started or stopped).
|
|
10412
|
+
* @example Let another plugin's file type appear while running
|
|
10413
|
+
* Another plugin starts and adds `.ch8`; the plugin under test then sees
|
|
10414
|
+
* it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
|
|
10415
|
+
* nothing: `updateFileTypes` sends the new list as the player does when
|
|
10416
|
+
* a plugin starts or stops, and `settle()` lets the SDK apply it.
|
|
10417
|
+
* ```ts
|
|
10418
|
+
* host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
|
|
10419
|
+
* host.updateFileTypes();
|
|
10420
|
+
* await host.settle();
|
|
10421
|
+
* assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
|
|
10422
|
+
* ```
|
|
7678
10423
|
*/
|
|
7679
10424
|
updateFileTypes(): void;
|
|
7680
10425
|
/**
|
|
@@ -7696,6 +10441,18 @@ class TestHost implements Transport {
|
|
|
7696
10441
|
*
|
|
7697
10442
|
* For `openFile` / `openDirectory` / `saveFile` queue a one-path array;
|
|
7698
10443
|
* for `openFiles` any number of paths.
|
|
10444
|
+
* @example Answer the plugin's dialogs in advance
|
|
10445
|
+
* The plugin's Open command gets a picked file, then a cancelled file
|
|
10446
|
+
* dialog, then a confirmed question. All answers share one queue, taken
|
|
10447
|
+
* from the front in the order the plugin opens dialogs of any kind: if
|
|
10448
|
+
* the next queued answer is missing or of the wrong kind, the dialog
|
|
10449
|
+
* fails like a lost reply, so queue them in the order the plugin asks.
|
|
10450
|
+
* ```ts
|
|
10451
|
+
* host.dialogAnswers.push(["/media/movie.mkv"]); // openFile picks it
|
|
10452
|
+
* host.dialogAnswers.push(null); // the next file dialog is cancelled
|
|
10453
|
+
* host.dialogAnswers.push({ confirmed: true, checked: false }); // confirm
|
|
10454
|
+
* await host.command("open").run();
|
|
10455
|
+
* ```
|
|
7699
10456
|
*/
|
|
7700
10457
|
readonly dialogAnswers: (string[] | null | {
|
|
7701
10458
|
/** The user clicked the confirm button. */
|
|
@@ -7776,6 +10533,20 @@ class TestHost implements Transport {
|
|
|
7776
10533
|
stepSeekKeyframes: boolean;
|
|
7777
10534
|
/**
|
|
7778
10535
|
* Test data: the keyframe times (seconds, ascending) of the current source, for keyframe step seeks. `null` (the default): none modelled, and step seeks are exact even with `stepSeekKeyframes`.
|
|
10536
|
+
* @example Step forward to the next keyframe
|
|
10537
|
+
* With keyframe step seeks on, Forward from 0:12 lands on the keyframe at
|
|
10538
|
+
* 0:20, not at 0:17, and the OSD says so. `keyframes` gives the source's
|
|
10539
|
+
* keyframe times; `setStepSeek` turns on keyframe steps of 5 s, and the
|
|
10540
|
+
* core action `seek-forward` seeks to the first keyframe at or after
|
|
10541
|
+
* the target, as the player does.
|
|
10542
|
+
* ```ts
|
|
10543
|
+
* host.setSource({ phase: "ready", duration: 45, position: 12 });
|
|
10544
|
+
* host.keyframes = [0, 10, 20, 30, 40];
|
|
10545
|
+
* await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
|
|
10546
|
+
* await plugin.host.run("seek-forward"); // target 17 s, next keyframe 20 s
|
|
10547
|
+
* assert.equal(host.source.position, 20);
|
|
10548
|
+
* assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
|
|
10549
|
+
* ```
|
|
7779
10550
|
*/
|
|
7780
10551
|
keyframes: number[] | null;
|
|
7781
10552
|
/**
|
|
@@ -7811,6 +10582,17 @@ class TestHost implements Transport {
|
|
|
7811
10582
|
readonly coreRuns: string[];
|
|
7812
10583
|
/**
|
|
7813
10584
|
* One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...) that the test host registers like the player, as `host/<id>`. Throws for an unknown id. `run()` executes it with the player's checks and writes its on-screen text to TestHost.osd.
|
|
10585
|
+
* @example Pause through the player's own action
|
|
10586
|
+
* The test pauses playback the way the Space key does and checks that the
|
|
10587
|
+
* host recorded it. Core actions belong to the player (`host/<id>`);
|
|
10588
|
+
* `run()` applies the player's checks, so a source must be ready first,
|
|
10589
|
+
* and every run is appended to `coreRuns`.
|
|
10590
|
+
* ```ts
|
|
10591
|
+
* host.setSource({ phase: "ready", duration: 100, position: 10 });
|
|
10592
|
+
* await host.coreAction("play-pause").run();
|
|
10593
|
+
* assert.equal(host.source.paused, true);
|
|
10594
|
+
* assert.deepEqual(host.coreRuns, ["play-pause"]);
|
|
10595
|
+
* ```
|
|
7814
10596
|
*/
|
|
7815
10597
|
coreAction(id: string): FakeCommand;
|
|
7816
10598
|
/**
|
|
@@ -7826,6 +10608,20 @@ class TestHost implements Transport {
|
|
|
7826
10608
|
* find the one a call uses.
|
|
7827
10609
|
* @param method - The wire method name.
|
|
7828
10610
|
* @param handler - Produces the reply from the request parameters.
|
|
10611
|
+
* @example Make opening a folder fail
|
|
10612
|
+
* The plugin's Open Folder command meets a missing folder and must tell
|
|
10613
|
+
* the user. `handle` replaces the host's answer to one wire method; the
|
|
10614
|
+
* `HostError` it throws reaches the plugin as a rejection with code
|
|
10615
|
+
* `missing`, like the player's refusal. `reset` restores the default
|
|
10616
|
+
* behaviour for the rest of the test.
|
|
10617
|
+
* ```ts
|
|
10618
|
+
* host.handle("system.openPath", () => {
|
|
10619
|
+
* throw new HostError("missing", "no such file");
|
|
10620
|
+
* });
|
|
10621
|
+
* await host.command("open-folder").run(); // the plugin catches `missing`
|
|
10622
|
+
* assert.deepEqual(host.osd, ["Folder not found"]);
|
|
10623
|
+
* host.reset("system.openPath");
|
|
10624
|
+
* ```
|
|
7829
10625
|
*/
|
|
7830
10626
|
handle(method: string, handler: Handler): void;
|
|
7831
10627
|
/** Restore the default behaviour of a method replaced with TestHost.handle. */
|
|
@@ -7839,10 +10635,34 @@ class TestHost implements Transport {
|
|
|
7839
10635
|
/**
|
|
7840
10636
|
* Change the source facts and broadcast them, as if the player's state changed (the user paused, playback reached the end, a file finished loading). Any phase other than `empty` also turns on the source capabilities (pause, seek, rate, ...) unless the patch sets them. `phase: "loading"` makes a mounted backdrop effective, like the player.
|
|
7841
10637
|
* @param patch - Wire-level source fields, e.g. `phase`, `position`, `duration`, `paused`, `audioTracks`.
|
|
10638
|
+
* @example Load a file, then let it play to the end
|
|
10639
|
+
* The plugin sees a one-minute source become ready, then end on its own.
|
|
10640
|
+
* `setSource` changes the host's source facts and broadcasts them like
|
|
10641
|
+
* the player's events; the SDK updates `player.state` when the event
|
|
10642
|
+
* arrives, so the test settles before reading it.
|
|
10643
|
+
* ```ts
|
|
10644
|
+
* host.setSource({ phase: "ready", duration: 60, position: 0 });
|
|
10645
|
+
* await host.settle();
|
|
10646
|
+
* assert.equal(plugin.player.state.phase, "ready");
|
|
10647
|
+
* host.setSource({ phase: "ended", terminalReason: "natural" });
|
|
10648
|
+
* await host.settle();
|
|
10649
|
+
* ```
|
|
7842
10650
|
*/
|
|
7843
10651
|
setSource(patch: Partial<SourceState>): void;
|
|
7844
10652
|
/**
|
|
7845
10653
|
* Simulate the user changing the main window (resizing it, entering or leaving fullscreen, closing the picture-in-picture window) and broadcast it. Like the player, entering fullscreen collapses an attached, visible sidebar (`effective: false`, `reason: "fullscreen"`) and leaving fullscreen docks it again.
|
|
10654
|
+
* @example Go fullscreen, then report picture-in-picture support
|
|
10655
|
+
* The plugin sees the main window fullscreen at 1920×1080, then learns
|
|
10656
|
+
* that picture-in-picture is available. `setMainWindow` changes the
|
|
10657
|
+
* host's window facts and broadcasts them as the player does when the
|
|
10658
|
+
* user resizes or toggles fullscreen; entering fullscreen also collapses
|
|
10659
|
+
* an attached sidebar.
|
|
10660
|
+
* ```ts
|
|
10661
|
+
* host.setMainWindow({ fullscreen: true, videoArea: { width: 1920, height: 1080 } });
|
|
10662
|
+
* await host.settle();
|
|
10663
|
+
* assert.equal(plugin.window.state.fullscreen, true);
|
|
10664
|
+
* host.setMainWindow({ pictureInPicture: { supported: true, active: false } });
|
|
10665
|
+
* ```
|
|
7846
10666
|
*/
|
|
7847
10667
|
setMainWindow(patch: Partial<{
|
|
7848
10668
|
fullscreen: boolean;
|
|
@@ -7863,22 +10683,61 @@ class TestHost implements Transport {
|
|
|
7863
10683
|
* Simulate another plugin (or the user) opening a source: a new `sourceId`, then the given phase. Calls the plugin made against the old state now fail with `stale`.
|
|
7864
10684
|
* @param source - The path or URL.
|
|
7865
10685
|
* @param phase - The phase to end in (default `"ready"`).
|
|
10686
|
+
* @example Let the user open another file meanwhile
|
|
10687
|
+
* A state the plugin captured before stops being current once another
|
|
10688
|
+
* file opens. `openElsewhere` gives the source a new `sourceId`, so
|
|
10689
|
+
* `isCurrent` turns false. Promise-returning calls made with
|
|
10690
|
+
* `ifCurrent: before` then reject with `stale`; calls that return nothing
|
|
10691
|
+
* drop the write silently instead (`TimelineLayer.set` with `ifCurrent`).
|
|
10692
|
+
* ```ts
|
|
10693
|
+
* const before = plugin.player.state;
|
|
10694
|
+
* host.openElsewhere("/media/other.mp4");
|
|
10695
|
+
* await host.settle();
|
|
10696
|
+
* assert.equal(plugin.player.isCurrent(before), false);
|
|
10697
|
+
* ```
|
|
7866
10698
|
*/
|
|
7867
10699
|
openElsewhere(source: string, phase?: SourceState["phase"]): void;
|
|
7868
10700
|
/**
|
|
7869
10701
|
* Simulate a generated stream failing in the player (its audio device lost, its frame connection broken), like the player: the source fails with `message` (`phase: "error"`) and the producer is retired at once. The stream's `onStop` listeners get a `stream` error with `message`.
|
|
7870
10702
|
* @param producerId - The producer, from TestHost.producers.
|
|
7871
10703
|
* @param message - The player's failure text.
|
|
10704
|
+
* @example Break the plugin's generated stream
|
|
10705
|
+
* The plugin's stream stops with an error, as when the player loses its
|
|
10706
|
+
* audio device. `failStream` puts the source into `error` with the
|
|
10707
|
+
* message and retires the producer at once; the stream's `onStop`
|
|
10708
|
+
* listeners get a `stream` error carrying the message.
|
|
10709
|
+
* ```ts
|
|
10710
|
+
* const [producer] = host.producers.values();
|
|
10711
|
+
* host.failStream(producer!.id, "producer transport closed");
|
|
10712
|
+
* ```
|
|
7872
10713
|
*/
|
|
7873
10714
|
failStream(producerId: number, message: string): void;
|
|
7874
10715
|
/**
|
|
7875
10716
|
* Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; within a scope the earliest registration first (the player's own commands before the plugin's), as the conflict rule orders them. A disabled command lets the key pass to the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it shadowed, then the global scope. The first enabled one runs; `press` resolves after it ran (`"ran"`), or rejects when it is busy. When only disabled commands hold the key, the first one with a `reason` shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without a reason the key goes on to the focused control (`"passed"`, nothing runs). Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
|
|
7876
10717
|
* @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
|
|
10718
|
+
* @example Press a global key and a panel key
|
|
10719
|
+
* Ctrl+B runs whatever is bound globally; Delete is pressed while the
|
|
10720
|
+
* plugin's panel has focus, so panel bindings are tried first. `press`
|
|
10721
|
+
* follows the player's binding table and resolves with what happened:
|
|
10722
|
+
* `"ran"`, `"reason"` (a disabled command showed why) or `"passed"`.
|
|
10723
|
+
* ```ts
|
|
10724
|
+
* await host.press("Ctrl+B"); // a global binding
|
|
10725
|
+
* await host.press("Delete", { panel: true }); // while the panel has focus
|
|
10726
|
+
* ```
|
|
7877
10727
|
*/
|
|
7878
10728
|
press(binding: string, options?: {
|
|
7879
10729
|
panel?: boolean;
|
|
7880
10730
|
}): Promise<"ran" | "reason" | "passed">;
|
|
7881
|
-
/**
|
|
10731
|
+
/**
|
|
10732
|
+
* Bindings declared by more than one command, each as `[binding, winner, ...ignored]` (command ids; core actions as `host/<id>`). Empty when there are none.
|
|
10733
|
+
* @example Check that no default keys collide
|
|
10734
|
+
* The test fails when two commands, the player's or the plugin's, declare
|
|
10735
|
+
* the same binding. Each entry is `[binding, winner, ...ignored]`, so an
|
|
10736
|
+
* empty list means every key does what its command says.
|
|
10737
|
+
* ```ts
|
|
10738
|
+
* assert.deepEqual(host.conflicts, []);
|
|
10739
|
+
* ```
|
|
10740
|
+
*/
|
|
7882
10741
|
get conflicts(): string[][];
|
|
7883
10742
|
/** The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"` to inputs; preset it to simulate saved overrides). `press` honours it. */
|
|
7884
10743
|
readonly bindings: {
|
|
@@ -7927,6 +10786,17 @@ class TestHost implements Transport {
|
|
|
7927
10786
|
}[];
|
|
7928
10787
|
/**
|
|
7929
10788
|
* Packages `validate` and `validateArchive` accept: path (directory or `.vxp` file) → its manifest. Other paths fail with `missing`. `install` adds or upgrades the entry in TestHost.packages following the player's source rules: the same source upgrades, another source needs `replace` (`conflict` otherwise), a built-in id is `unsupported`.
|
|
10789
|
+
* @example Offer a package file the plugin can install
|
|
10790
|
+
* The plugin can validate and install `/downloads/someone.demo-1.0.0.vxp`
|
|
10791
|
+
* as if the file existed. The host accepts only paths listed here (others
|
|
10792
|
+
* fail with `missing`) and returns this manifest from `validate`; an
|
|
10793
|
+
* install then follows the player's source rules.
|
|
10794
|
+
* ```ts
|
|
10795
|
+
* host.candidates.set("/downloads/someone.demo-1.0.0.vxp", {
|
|
10796
|
+
* id: "someone.demo", version: "1.0.0", apiVersion: "vestra/1",
|
|
10797
|
+
* entry: "main.js", capabilities: [],
|
|
10798
|
+
* });
|
|
10799
|
+
* ```
|
|
7930
10800
|
*/
|
|
7931
10801
|
readonly candidates: Map<string, PackageManifest>;
|
|
7932
10802
|
/**
|
|
@@ -7935,6 +10805,14 @@ class TestHost implements Transport {
|
|
|
7935
10805
|
readonly remoteImages: Map<string, string | null>;
|
|
7936
10806
|
/**
|
|
7937
10807
|
* The Markdown document a value holds (its wire form read back), or `null` when the value is not one. `images` maps each remote address to the downloaded file. Pass a whole property value or a field of one: a document nested in rows is read the same way.
|
|
10808
|
+
* @example Read back a Markdown message in a chat window
|
|
10809
|
+
* The test checks the Markdown source of the first chat message. Rows
|
|
10810
|
+
* hold the document in its wire form, as `plugin.ui.markdown` produced
|
|
10811
|
+
* it; `markdown` reads it back, nested in a row or not.
|
|
10812
|
+
* ```ts
|
|
10813
|
+
* const rows = host.window("chat").props["messages"] as { body: Json }[];
|
|
10814
|
+
* assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
|
|
10815
|
+
* ```
|
|
7938
10816
|
*/
|
|
7939
10817
|
markdown(value: Json | undefined): MarkdownWire | null;
|
|
7940
10818
|
/** What `player.audioDevices()` lists. `setAudioDevice` updates `host.source.audioOutput`. */
|
|
@@ -7954,14 +10832,46 @@ class TestHost implements Transport {
|
|
|
7954
10832
|
command(id: string): FakeCommand;
|
|
7955
10833
|
/**
|
|
7956
10834
|
* The plugin's bottom-bar buttons (`CommandOptions.button`) as the player shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a later one stays a menu item only). Empty once the command is disposed. Whether the bar places them is the bar's business: the list is what the player hands to `BarButtons`.
|
|
10835
|
+
* @example Click the plugin's bottom-bar button in a test
|
|
10836
|
+
* The test finds the plugin's Store button, clicks it and checks that the
|
|
10837
|
+
* Store window opened. `barButtons` lists the buttons of the plugin's
|
|
10838
|
+
* commands declared with `button`, as the bar shows them; `click()` runs
|
|
10839
|
+
* the command like its menu item and waits until the host is idle, and
|
|
10840
|
+
* rejects while the command is disabled or busy.
|
|
10841
|
+
* ```ts
|
|
10842
|
+
* const [button] = host.barButtons; // one button per plugin
|
|
10843
|
+
* assert.equal(button?.icon, "store");
|
|
10844
|
+
* await button?.click();
|
|
10845
|
+
* assert.equal(host.window("store").visible, true);
|
|
10846
|
+
* ```
|
|
7957
10847
|
*/
|
|
7958
10848
|
get barButtons(): readonly FakeBarButton[];
|
|
7959
10849
|
/**
|
|
7960
10850
|
* The plugin's tool window with this id (by default the file name without extension: `ui/picture.slint` → `picture`). Throws when it was never opened with `show()`.
|
|
10851
|
+
* @example Press a window's Reset button from a test
|
|
10852
|
+
* The Picture window's Reset callback runs and the brightness returns to
|
|
10853
|
+
* 0. The window is found by its id (the `.slint` file name by default);
|
|
10854
|
+
* `emit` fires the callback the plugin bound, and `settle()` waits for
|
|
10855
|
+
* the property the handler wrote.
|
|
10856
|
+
* ```ts
|
|
10857
|
+
* host.window("picture").emit("reset");
|
|
10858
|
+
* await host.settle();
|
|
10859
|
+
* assert.equal(host.window("picture").props["brightness"], 0);
|
|
10860
|
+
* ```
|
|
7961
10861
|
*/
|
|
7962
10862
|
window(id: string): FakeWindow;
|
|
7963
10863
|
/**
|
|
7964
10864
|
* The plugin's mounted panel (bottom bar, sidebar or backdrop). Throws when none is mounted. Its `props` use Slint (kebab-case) names; `emit` fires a callback on the panel's global.
|
|
10865
|
+
* @example Select a row in the plugin's sidebar panel
|
|
10866
|
+
* The test checks the panel sits in the sidebar and selects its third
|
|
10867
|
+
* row. `emit` fires a callback on the panel's global as a click would;
|
|
10868
|
+
* the plugin's handler runs, and `settle()` waits for what it sent.
|
|
10869
|
+
* ```ts
|
|
10870
|
+
* const panel = host.mounted();
|
|
10871
|
+
* assert.equal(panel.slot, "sidebar");
|
|
10872
|
+
* panel.emit("select", 2);
|
|
10873
|
+
* await host.settle();
|
|
10874
|
+
* ```
|
|
7965
10875
|
*/
|
|
7966
10876
|
mounted(): Omit<FakeWindow, "callbacks" | "userClose"> & {
|
|
7967
10877
|
/** `"bottom"`, `"sidebar"` or `"backdrop"`. */
|
|
@@ -7971,7 +10881,16 @@ class TestHost implements Transport {
|
|
|
7971
10881
|
/** The last `panel.setInteraction(...)` locks, `null` before any. */
|
|
7972
10882
|
locks: Json;
|
|
7973
10883
|
};
|
|
7974
|
-
/**
|
|
10884
|
+
/**
|
|
10885
|
+
* The parameters of every request of one wire method, oldest first.
|
|
10886
|
+
* @example Check the request behind an OSD message
|
|
10887
|
+
* The test checks the last OSD request the plugin sent, before
|
|
10888
|
+
* translation. `sent` lists the parameters of every request of one wire
|
|
10889
|
+
* method, oldest first; `host.osd` holds the texts as shown instead.
|
|
10890
|
+
* ```ts
|
|
10891
|
+
* assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
|
|
10892
|
+
* ```
|
|
10893
|
+
*/
|
|
7975
10894
|
sent(method: string): Record<string, Json>[];
|
|
7976
10895
|
/**
|
|
7977
10896
|
* Wait until no request is in flight (and the SDK's follow-up passes ran),
|
|
@@ -7984,6 +10903,29 @@ class TestHost implements Transport {
|
|
|
7984
10903
|
* for: on a loaded machine it can finish after `settle()` returns. Poll
|
|
7985
10904
|
* the result you assert on instead (second example).
|
|
7986
10905
|
* @param ms - Extra time to wait, for debounces and timers (default 0).
|
|
10906
|
+
* @example Wait past a debounced save
|
|
10907
|
+
* The plugin's store writes its file 300 ms after the last `save`, so
|
|
10908
|
+
* the test waits 400 ms before it checks what the plugin did next.
|
|
10909
|
+
* `settle(ms)` returns when no request is in flight and at least `ms`
|
|
10910
|
+
* have passed, so the store's timer has fired. The file write itself is
|
|
10911
|
+
* the plugin's own work, which `settle` does not wait for: a test that
|
|
10912
|
+
* reads the file calls `await store.flush()` instead.
|
|
10913
|
+
* ```ts
|
|
10914
|
+
* const store = plugin.storage.json("prefs", { defaults: { step: 5 } });
|
|
10915
|
+
* store.save({ step: 10 });
|
|
10916
|
+
* await host.settle(400); // past storage.json's 300 ms debounce
|
|
10917
|
+
* ```
|
|
10918
|
+
* @example Poll for work the plugin does on its own
|
|
10919
|
+
* The plugin reads and parses a file before it shows a status; `settle`
|
|
10920
|
+
* does not wait for that (it only waits for host requests), so the test
|
|
10921
|
+
* polls the property it asserts on, 20 ms at a time, for up to 2 seconds.
|
|
10922
|
+
* ```ts
|
|
10923
|
+
* const window = host.window("import");
|
|
10924
|
+
* window.emit("load-file"); // the plugin reads and parses the file itself
|
|
10925
|
+
* for (let i = 0; i < 100 && window.props["status-text"] !== expected; i++)
|
|
10926
|
+
* await host.settle(20);
|
|
10927
|
+
* assert.equal(window.props["status-text"], expected);
|
|
10928
|
+
* ```
|
|
7987
10929
|
*/
|
|
7988
10930
|
settle(ms?: number): Promise<void>;
|
|
7989
10931
|
/** Close the fake stream servers and delete the host's temporary directories. `stop()` from startPlugin calls it. */
|
|
@@ -7991,6 +10933,34 @@ class TestHost implements Transport {
|
|
|
7991
10933
|
}
|
|
7992
10934
|
```
|
|
7993
10935
|
|
|
10936
|
+
Example: Preset the player, then react to another plugin's file
|
|
10937
|
+
|
|
10938
|
+
The test starts the plugin in a player with the dark theme, opens a file
|
|
10939
|
+
the way another plugin would, and checks the plugin's panel title. A host
|
|
10940
|
+
created first can be preset with data the plugin reads during
|
|
10941
|
+
activation; `startPlugin` then uses it. `openElsewhere` changes the
|
|
10942
|
+
source and broadcasts it, the plugin's listener updates its panel, and
|
|
10943
|
+
`settle()` waits for those requests before the assertion.
|
|
10944
|
+
```ts
|
|
10945
|
+
import assert from "node:assert/strict";
|
|
10946
|
+
import test from "node:test";
|
|
10947
|
+
import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
|
|
10948
|
+
import activate from "../main.ts";
|
|
10949
|
+
|
|
10950
|
+
test("shows the playing file in the panel", async () => {
|
|
10951
|
+
const host = new TestHost();
|
|
10952
|
+
host.appearance = "vestra"; // read during activation
|
|
10953
|
+
const { stop } = await startPlugin(activate, {
|
|
10954
|
+
directory: new URL("..", import.meta.url),
|
|
10955
|
+
host,
|
|
10956
|
+
});
|
|
10957
|
+
host.openElsewhere("/media/clip.mp4"); // another plugin opened a file
|
|
10958
|
+
await host.settle();
|
|
10959
|
+
assert.equal(host.mounted().props["title"], "clip.mp4");
|
|
10960
|
+
await stop();
|
|
10961
|
+
});
|
|
10962
|
+
```
|
|
10963
|
+
|
|
7994
10964
|
### startPlugin
|
|
7995
10965
|
|
|
7996
10966
|
Activate a plugin against a test host, the way the player's runner does:
|
|
@@ -8044,6 +11014,47 @@ function startPlugin(activate: (plugin: Plugin) => unknown, options?: {
|
|
|
8044
11014
|
}>;
|
|
8045
11015
|
```
|
|
8046
11016
|
|
|
11017
|
+
Example: Test that a setting survives a restart
|
|
11018
|
+
|
|
11019
|
+
The test turns on the 10-step option, stops the plugin, starts it again
|
|
11020
|
+
on the same data directory and checks the option is still on. Both runs
|
|
11021
|
+
get a directory the test made (`dataDirectory`): `stop()` disposes the
|
|
11022
|
+
plugin like the player, which writes its pending stores there, and only
|
|
11023
|
+
removes the host's own temporary directory, so the second run reads what
|
|
11024
|
+
the first one saved.
|
|
11025
|
+
```ts
|
|
11026
|
+
import assert from "node:assert/strict";
|
|
11027
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11028
|
+
import { tmpdir } from "node:os";
|
|
11029
|
+
import { join } from "node:path";
|
|
11030
|
+
import test from "node:test";
|
|
11031
|
+
import { startPlugin } from "@vestra-player/sdk/testing";
|
|
11032
|
+
import activate from "../main.ts";
|
|
11033
|
+
|
|
11034
|
+
test("remembers the step across restarts", async () => {
|
|
11035
|
+
const directory = new URL("..", import.meta.url);
|
|
11036
|
+
const data = mkdtempSync(join(tmpdir(), "data-"));
|
|
11037
|
+
try {
|
|
11038
|
+
const first = await startPlugin(activate, { directory, dataDirectory: data });
|
|
11039
|
+
try {
|
|
11040
|
+
await first.host.command("step-10").run();
|
|
11041
|
+
} finally {
|
|
11042
|
+
await first.stop(); // flushes the plugin's stores into `data`
|
|
11043
|
+
}
|
|
11044
|
+
|
|
11045
|
+
// A restart: a new host and a new plugin instance on the same data.
|
|
11046
|
+
const second = await startPlugin(activate, { directory, dataDirectory: data });
|
|
11047
|
+
try {
|
|
11048
|
+
assert.equal(second.host.command("step-10").state.checked, true);
|
|
11049
|
+
} finally {
|
|
11050
|
+
await second.stop();
|
|
11051
|
+
}
|
|
11052
|
+
} finally {
|
|
11053
|
+
rmSync(data, { recursive: true, force: true });
|
|
11054
|
+
}
|
|
11055
|
+
});
|
|
11056
|
+
```
|
|
11057
|
+
|
|
8047
11058
|
### CapabilityInfo
|
|
8048
11059
|
|
|
8049
11060
|
What one manifest capability is for: the members it opens, the
|
|
@@ -8090,6 +11101,27 @@ and `plugin.packages.list`).
|
|
|
8090
11101
|
const CAPABILITY_INFO: Readonly<Record<string, CapabilityInfo>>;
|
|
8091
11102
|
```
|
|
8092
11103
|
|
|
11104
|
+
Example: Check in a test that the manifest declares every required capability
|
|
11105
|
+
|
|
11106
|
+
The test fails, naming the missing capability, when the manifest declares
|
|
11107
|
+
a capability without one its members also need. It reads the plugin's own
|
|
11108
|
+
`manifest.json` and looks each declared name up in `CAPABILITY_INFO`,
|
|
11109
|
+
whose `requires` lists the capabilities that must be declared with it;
|
|
11110
|
+
at run time the player would refuse such a call with code `capability`.
|
|
11111
|
+
```ts
|
|
11112
|
+
import assert from "node:assert/strict";
|
|
11113
|
+
import { readFile } from "node:fs/promises";
|
|
11114
|
+
import { CAPABILITY_INFO } from "@vestra-player/sdk/testing";
|
|
11115
|
+
|
|
11116
|
+
const manifest: { capabilities: string[] } = JSON.parse(
|
|
11117
|
+
await readFile(new URL("../manifest.json", import.meta.url), "utf8"),
|
|
11118
|
+
);
|
|
11119
|
+
for (const capability of manifest.capabilities)
|
|
11120
|
+
// An unknown name has no entry: `vestra-plugin check` reports it.
|
|
11121
|
+
for (const needed of CAPABILITY_INFO[capability]?.requires ?? [])
|
|
11122
|
+
assert.ok(manifest.capabilities.includes(needed), needed);
|
|
11123
|
+
```
|
|
11124
|
+
|
|
8093
11125
|
### MESSAGE_LIMIT
|
|
8094
11126
|
|
|
8095
11127
|
The longest message a plugin may send the player, in UTF-8 bytes of one
|
|
@@ -8106,8 +11138,9 @@ const MESSAGE_LIMIT: number;
|
|
|
8106
11138
|
|
|
8107
11139
|
The player's command registry caps, the same values as the player's own
|
|
8108
11140
|
table (the player's tests check that they agree). They are runaway guards,
|
|
8109
|
-
not product limits: a command added past them
|
|
8110
|
-
(its `ready` rejects; during activation, activation
|
|
11141
|
+
not product limits: a command added past them is refused by the player
|
|
11142
|
+
with code `limit` (its `ready` rejects; during activation, activation
|
|
11143
|
+
fails; later the failure goes to `Scope.onError`), commands
|
|
8111
11144
|
already registered are unaffected, and disposing a command frees its slot
|
|
8112
11145
|
at once. TestHost enforces the same caps.
|
|
8113
11146
|
|
|
@@ -8123,6 +11156,22 @@ const COMMAND_LIMITS: {
|
|
|
8123
11156
|
};
|
|
8124
11157
|
```
|
|
8125
11158
|
|
|
11159
|
+
Example: Cap a chapter menu well below the command limit
|
|
11160
|
+
|
|
11161
|
+
The Chapters submenu lists at most 50 chapters, however many the file
|
|
11162
|
+
has. Every menu item is a command, and the player counts them per plugin.
|
|
11163
|
+
`add` itself returns at once and never throws `limit`: past `perPlugin`
|
|
11164
|
+
the player refuses the registration, which fails the activation or, later,
|
|
11165
|
+
reaches `Scope.onError`. A try/catch around `add` cannot catch that, so a
|
|
11166
|
+
menu built from media content keeps its own cap and leaves room for the
|
|
11167
|
+
plugin's fixed commands.
|
|
11168
|
+
```ts
|
|
11169
|
+
import { COMMAND_LIMITS } from "@vestra-player/sdk/testing";
|
|
11170
|
+
|
|
11171
|
+
// 20 slots stay free for the plugin's own commands.
|
|
11172
|
+
const shown = chapters.slice(0, Math.min(50, COMMAND_LIMITS.perPlugin - 20));
|
|
11173
|
+
```
|
|
11174
|
+
|
|
8126
11175
|
### MENU_IDS
|
|
8127
11176
|
|
|
8128
11177
|
The player's built-in top-level menus, by id. Write `@<id>` as the first
|
|
@@ -8137,6 +11186,21 @@ agrees with the player's.
|
|
|
8137
11186
|
const MENU_IDS: readonly ["open", "playback", "subtitles", "video", "audio", "screen", "settings"];
|
|
8138
11187
|
```
|
|
8139
11188
|
|
|
11189
|
+
Example: Put a command into the built-in Video menu
|
|
11190
|
+
|
|
11191
|
+
"Blur" appears in the player's Video menu in every interface language.
|
|
11192
|
+
The player replaces a leading `@video` with that menu's key ("Video")
|
|
11193
|
+
before it merges paths, so the item joins the built-in menu; an id that
|
|
11194
|
+
is not in `MENU_IDS` makes `add` fail with `invalid`, which a test can
|
|
11195
|
+
rule out ahead of time.
|
|
11196
|
+
```ts
|
|
11197
|
+
import assert from "node:assert/strict";
|
|
11198
|
+
import { MENU_IDS } from "@vestra-player/sdk/testing";
|
|
11199
|
+
|
|
11200
|
+
plugin.commands.add({ id: "blur", path: ["@video", "Blur"], run() {} });
|
|
11201
|
+
assert.ok(MENU_IDS.includes("video")); // a menu id, not a title
|
|
11202
|
+
```
|
|
11203
|
+
|
|
8140
11204
|
### BAR_BUTTON_ICONS
|
|
8141
11205
|
|
|
8142
11206
|
The icons a bottom-bar button may name (`CommandOptions.button.icon`):
|
|
@@ -8148,6 +11212,19 @@ that this list is its icon set.
|
|
|
8148
11212
|
const BAR_BUTTON_ICONS: readonly ["play", "pause", "stop", "back", "forward", "open", "volume", "mute", "playlist", "detach", "attach", "close", "failed", "move-top", "move-up", "move-down", "move-bottom", "search", "chapter", "bookmark", "check-task", "check-box", "menu-check", "submenu", "scroll-up", "scroll-down", "caret-down", "sort-asc", "sort-desc", "tag-remove", "preview-play", "add", "remove", "sort", "fullscreen", "exit-fullscreen", "store", "ai"];
|
|
8149
11213
|
```
|
|
8150
11214
|
|
|
11215
|
+
Example: Check in a test that the button's icon exists
|
|
11216
|
+
|
|
11217
|
+
The test fails before release if the plugin's bar button names an icon
|
|
11218
|
+
the player does not have. `commands.add` would refuse an unknown name
|
|
11219
|
+
with `invalid`; the player's tests keep `BAR_BUTTON_ICONS` equal to its
|
|
11220
|
+
icon set, so checking against it is the same check without a player.
|
|
11221
|
+
```ts
|
|
11222
|
+
import assert from "node:assert/strict";
|
|
11223
|
+
import { BAR_BUTTON_ICONS } from "@vestra-player/sdk/testing";
|
|
11224
|
+
|
|
11225
|
+
assert.ok(BAR_BUTTON_ICONS.includes("store")); // the icon main.ts uses
|
|
11226
|
+
```
|
|
11227
|
+
|
|
8151
11228
|
### BAR_BUTTON_LIMITS
|
|
8152
11229
|
|
|
8153
11230
|
The player's limits on bottom-bar buttons (`CommandOptions.button`), the
|
|
@@ -8201,6 +11278,25 @@ built-in plugins declare.
|
|
|
8201
11278
|
const BUILTIN_SUBMENUS: readonly BuiltinSubmenu[];
|
|
8202
11279
|
```
|
|
8203
11280
|
|
|
11281
|
+
Example: Add an item to the built-in Snapshot submenu
|
|
11282
|
+
|
|
11283
|
+
"Copy Snapshot" appears in Video ▸ Snapshot next to the built-in capture
|
|
11284
|
+
plugin's items. Paths merge by their English keys, so a command whose
|
|
11285
|
+
path continues with the submenu's keys (`["Video", "Snapshot"]`, the menu
|
|
11286
|
+
written as `@video`) lands in the same submenu whatever the interface
|
|
11287
|
+
language.
|
|
11288
|
+
```ts
|
|
11289
|
+
import { BUILTIN_SUBMENUS } from "@vestra-player/sdk/testing";
|
|
11290
|
+
|
|
11291
|
+
const snapshot = BUILTIN_SUBMENUS.find((menu) => menu.path.at(-1) === "Snapshot")!;
|
|
11292
|
+
plugin.commands.add({
|
|
11293
|
+
id: "snapshot-to-clipboard",
|
|
11294
|
+
// ["@video", "Snapshot", "Copy Snapshot"]; @video stands for "Video".
|
|
11295
|
+
path: ["@video", ...snapshot.path.slice(1), "Copy Snapshot"],
|
|
11296
|
+
run: copySnapshot,
|
|
11297
|
+
});
|
|
11298
|
+
```
|
|
11299
|
+
|
|
8204
11300
|
### BuiltinCommand
|
|
8205
11301
|
|
|
8206
11302
|
A command the player or one of its built-in plugins registers: an entry
|
|
@@ -8240,6 +11336,22 @@ list agrees with what the player and its built-in plugins register.
|
|
|
8240
11336
|
const BUILTIN_COMMANDS: readonly BuiltinCommand[];
|
|
8241
11337
|
```
|
|
8242
11338
|
|
|
11339
|
+
Example: Check in a test that a default key is still free
|
|
11340
|
+
|
|
11341
|
+
The test fails if the player or a built-in plugin already binds Ctrl+K
|
|
11342
|
+
globally, before users ever see a conflict. It collects the default
|
|
11343
|
+
bindings of every built-in command that is not limited to its panel's
|
|
11344
|
+
focus (`focus: null`) and looks for the key the plugin wants; bindings
|
|
11345
|
+
are canonical, so compare them as the player writes them.
|
|
11346
|
+
```ts
|
|
11347
|
+
import assert from "node:assert/strict";
|
|
11348
|
+
import { BUILTIN_COMMANDS } from "@vestra-player/sdk/testing";
|
|
11349
|
+
|
|
11350
|
+
const taken = BUILTIN_COMMANDS.filter((command) => command.focus === null)
|
|
11351
|
+
.flatMap((command) => command.bindings);
|
|
11352
|
+
assert.ok(!taken.includes("Ctrl+K"));
|
|
11353
|
+
```
|
|
11354
|
+
|
|
8243
11355
|
### LOCALES
|
|
8244
11356
|
|
|
8245
11357
|
The player's interface languages: English (the source language) first,
|
|
@@ -8299,6 +11411,28 @@ palettes against its own.
|
|
|
8299
11411
|
const THEME_PALETTES: Readonly<Record<"vestra" | "vestra-light", Readonly<Record<string, string>>>>;
|
|
8300
11412
|
```
|
|
8301
11413
|
|
|
11414
|
+
Example: Check in a test that a colour stays readable in both themes
|
|
11415
|
+
|
|
11416
|
+
The test fails if the plugin's own highlight colour gets too close to
|
|
11417
|
+
the panel background of either palette. Plugins draw with `Theme` tokens
|
|
11418
|
+
in Slint and the player switches palettes; a colour the plugin picks
|
|
11419
|
+
itself does not switch, so a test compares it with each palette's values
|
|
11420
|
+
(hex strings, `#rrggbb`).
|
|
11421
|
+
```ts
|
|
11422
|
+
import assert from "node:assert/strict";
|
|
11423
|
+
import { THEME_PALETTES } from "@vestra-player/sdk/testing";
|
|
11424
|
+
|
|
11425
|
+
const highlight = "#e07000"; // the plugin's own orange
|
|
11426
|
+
const luminance = (hex: string) => {
|
|
11427
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
11428
|
+
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; // rough, without gamma
|
|
11429
|
+
};
|
|
11430
|
+
for (const [name, palette] of Object.entries(THEME_PALETTES)) {
|
|
11431
|
+
const gap = Math.abs(luminance(highlight) - luminance(palette["bg-panel"]!));
|
|
11432
|
+
assert.ok(gap > 0.3, `${name}: highlight too close to bg-panel`);
|
|
11433
|
+
}
|
|
11434
|
+
```
|
|
11435
|
+
|
|
8302
11436
|
### BINDING_INPUTS
|
|
8303
11437
|
|
|
8304
11438
|
The names a binding (`CommandOptions.bindings`, `Bindings.set`) is made
|
|
@@ -8330,6 +11464,39 @@ const BINDING_INPUTS: Readonly<{
|
|
|
8330
11464
|
}>;
|
|
8331
11465
|
```
|
|
8332
11466
|
|
|
11467
|
+
Example: Check in a test that the plugin's default keys are well formed
|
|
11468
|
+
|
|
11469
|
+
The test fails on a binding the player would refuse with `invalid`, such
|
|
11470
|
+
as modifiers out of order or a misspelt key name. `isBinding` applies the
|
|
11471
|
+
same grammar as the player's parser: the part after the last `+` is the
|
|
11472
|
+
input, everything before it modifiers in the order of `modifiers`; an
|
|
11473
|
+
input is a listed name or one other printable character, and `Shift`
|
|
11474
|
+
never goes with a digit or such a character.
|
|
11475
|
+
```ts
|
|
11476
|
+
import assert from "node:assert/strict";
|
|
11477
|
+
import { BINDING_INPUTS } from "@vestra-player/sdk/testing";
|
|
11478
|
+
|
|
11479
|
+
const { modifiers, letters, digits, functionKeys, named, mouse } = BINDING_INPUTS;
|
|
11480
|
+
const names = new Set([...letters, ...functionKeys, ...named, ...mouse]);
|
|
11481
|
+
function isBinding(binding: string): boolean {
|
|
11482
|
+
// "Ctrl++" is not a binding: the + key is written "Plus".
|
|
11483
|
+
const parts = binding.split("+");
|
|
11484
|
+
const input = parts.pop()!;
|
|
11485
|
+
const order = parts.map((m) => (modifiers as readonly string[]).indexOf(m));
|
|
11486
|
+
// Each modifier known, at most once, in the listed order.
|
|
11487
|
+
if (order.some((at, i) => at < 0 || (i > 0 && at <= order[i - 1]!)))
|
|
11488
|
+
return false;
|
|
11489
|
+
if (names.has(input)) return true; // letters are upper case: "A"
|
|
11490
|
+
const typed = digits.includes(input) || /^[^\p{L}\p{N}\p{C}\s+]$/u.test(input);
|
|
11491
|
+
// ">" rather than "Shift+.": the character already says Shift.
|
|
11492
|
+
return typed && !parts.includes("Shift");
|
|
11493
|
+
}
|
|
11494
|
+
for (const binding of ["Ctrl+Shift+PageUp", "Alt+Mouse:WheelUp", ">"])
|
|
11495
|
+
assert.ok(isBinding(binding), binding);
|
|
11496
|
+
for (const binding of ["Shift+Ctrl+A", "Ctrl+a", "Shift+."])
|
|
11497
|
+
assert.ok(!isBinding(binding), binding);
|
|
11498
|
+
```
|
|
11499
|
+
|
|
8333
11500
|
### OSD_LIMITS
|
|
8334
11501
|
|
|
8335
11502
|
The player's limits on the on-screen display (Osd), counted on
|
|
@@ -8353,6 +11520,23 @@ const OSD_LIMITS: Readonly<{
|
|
|
8353
11520
|
}>;
|
|
8354
11521
|
```
|
|
8355
11522
|
|
|
11523
|
+
Example: Keep a long list inside the information block's limits
|
|
11524
|
+
|
|
11525
|
+
The block shows the first 32 rows of a list, each cut at 60 columns. The
|
|
11526
|
+
player counts the text as shown (after it is translated and formatted); a
|
|
11527
|
+
block over `panelLines` lines or `panelBytes` bytes is refused whole with
|
|
11528
|
+
`invalid`, reported to `Scope.onError` because `set` never throws, so
|
|
11529
|
+
the plugin trims the list itself (for very long non-ASCII rows, check the
|
|
11530
|
+
bytes too).
|
|
11531
|
+
```ts
|
|
11532
|
+
import { OSD_LIMITS } from "@vestra-player/sdk/testing";
|
|
11533
|
+
|
|
11534
|
+
const panel = plugin.osd.panel();
|
|
11535
|
+
const lines = rows.slice(0, OSD_LIMITS.panelLines);
|
|
11536
|
+
// maxColumns cuts each longer line and ends it with "…".
|
|
11537
|
+
panel.set(lines.join("\n"), null, { maxColumns: 60 });
|
|
11538
|
+
```
|
|
11539
|
+
|
|
8356
11540
|
### SESSION_STORAGE_LIMITS
|
|
8357
11541
|
|
|
8358
11542
|
The player's limits on SessionStorage. A key outside them fails
|
|
@@ -8374,6 +11558,29 @@ const SESSION_STORAGE_LIMITS: Readonly<{
|
|
|
8374
11558
|
}>;
|
|
8375
11559
|
```
|
|
8376
11560
|
|
|
11561
|
+
Example: Remember a position per URL without breaking the limits
|
|
11562
|
+
|
|
11563
|
+
The plugin keeps the last position of each URL for this player session
|
|
11564
|
+
and skips a URL too long to be a key. Keys are counted in UTF-8 bytes, so
|
|
11565
|
+
the length is measured with `Buffer.byteLength`, not `length`; a `set`
|
|
11566
|
+
that would take the plugin's entries over 1 MiB rejects with `limit` and
|
|
11567
|
+
changes nothing, so the plugin clears the old entries and goes on.
|
|
11568
|
+
```ts
|
|
11569
|
+
import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
|
|
11570
|
+
|
|
11571
|
+
const session = plugin.storage.session;
|
|
11572
|
+
if (Buffer.byteLength(url) <= SESSION_STORAGE_LIMITS.keyBytes) {
|
|
11573
|
+
try {
|
|
11574
|
+
await session.set(url, position);
|
|
11575
|
+
} catch (error) {
|
|
11576
|
+
if ((error as PluginError).code !== "limit") throw error;
|
|
11577
|
+
// Full: start over rather than lose the newest position.
|
|
11578
|
+
for (const key of await session.keys()) await session.delete(key);
|
|
11579
|
+
await session.set(url, position);
|
|
11580
|
+
}
|
|
11581
|
+
}
|
|
11582
|
+
```
|
|
11583
|
+
|
|
8377
11584
|
### SECRET_LIMITS
|
|
8378
11585
|
|
|
8379
11586
|
The player's limits on Secrets. A name or value outside them
|
|
@@ -8391,6 +11598,32 @@ const SECRET_LIMITS: Readonly<{
|
|
|
8391
11598
|
}>;
|
|
8392
11599
|
```
|
|
8393
11600
|
|
|
11601
|
+
Example: Store an API token only when it fits
|
|
11602
|
+
|
|
11603
|
+
The plugin keeps a token the user pasted, or tells them it is empty or
|
|
11604
|
+
too long. Values are 1–1024 bytes counted in UTF-8, so the check uses
|
|
11605
|
+
`Buffer.byteLength`; `secrets.set` would refuse other values with
|
|
11606
|
+
`invalid`. The store itself can still refuse (`unsupported` without a
|
|
11607
|
+
system store, `unavailable` or `failed` when it denies access): only
|
|
11608
|
+
those codes get the "could not be saved" message, anything else (a
|
|
11609
|
+
missing `secrets` capability) is a bug and is thrown on.
|
|
11610
|
+
```ts
|
|
11611
|
+
import { SECRET_LIMITS } from "@vestra-player/sdk/testing";
|
|
11612
|
+
|
|
11613
|
+
if (!token || Buffer.byteLength(token) > SECRET_LIMITS.valueBytes) {
|
|
11614
|
+
await plugin.osd.show("Paste a token of at most 1024 bytes.");
|
|
11615
|
+
} else {
|
|
11616
|
+
try {
|
|
11617
|
+
await plugin.secrets.set("api-token", token);
|
|
11618
|
+
} catch (error) {
|
|
11619
|
+
const { code } = error as PluginError;
|
|
11620
|
+
if (!["unsupported", "unavailable", "failed"].includes(code)) throw error;
|
|
11621
|
+
// No keychain or access denied: ask again next time.
|
|
11622
|
+
await plugin.osd.show("The token could not be saved.");
|
|
11623
|
+
}
|
|
11624
|
+
}
|
|
11625
|
+
```
|
|
11626
|
+
|
|
8394
11627
|
### UI_VALUE_LIMITS
|
|
8395
11628
|
|
|
8396
11629
|
The player's limits on one property value (a `PropValue` as written
|
|
@@ -8424,6 +11657,35 @@ const UI_VALUE_LIMITS: {
|
|
|
8424
11657
|
};
|
|
8425
11658
|
```
|
|
8426
11659
|
|
|
11660
|
+
Example: Send a long playlist as a window of rows
|
|
11661
|
+
|
|
11662
|
+
A list view shows any number of entries while each write stays small:
|
|
11663
|
+
the plugin sends only the rows around the scroll position plus the total,
|
|
11664
|
+
and the view reports where it scrolled to. After the `scrolled` handler
|
|
11665
|
+
returns, the SDK evaluates the `bind` function again and sends the new
|
|
11666
|
+
window. Each row object and each of its fields count as one value toward
|
|
11667
|
+
`values`, and so does the array holding them.
|
|
11668
|
+
```ts
|
|
11669
|
+
import { UI_VALUE_LIMITS } from "@vestra-player/sdk/testing";
|
|
11670
|
+
|
|
11671
|
+
// 200 rows, or fewer if the limits were smaller: 2 fields per row make
|
|
11672
|
+
// 3 values (the row and its fields), plus 1 for the array.
|
|
11673
|
+
const size = Math.min(
|
|
11674
|
+
200,
|
|
11675
|
+
UI_VALUE_LIMITS.arrayRows,
|
|
11676
|
+
Math.floor((UI_VALUE_LIMITS.values - 1) / 3),
|
|
11677
|
+
);
|
|
11678
|
+
let first = 0;
|
|
11679
|
+
list.bind(() => ({
|
|
11680
|
+
total: entries.length,
|
|
11681
|
+
rows: entries.slice(first, first + size),
|
|
11682
|
+
}));
|
|
11683
|
+
// The Slint view reports its first visible row; the window follows it.
|
|
11684
|
+
list.on("scrolled", (row: number) => {
|
|
11685
|
+
first = Math.max(0, row - Math.floor(size / 2));
|
|
11686
|
+
});
|
|
11687
|
+
```
|
|
11688
|
+
|
|
8427
11689
|
### IMAGE_LIMITS
|
|
8428
11690
|
|
|
8429
11691
|
The player's runtime image limits (`host/src/ui_image.rs`): a file's
|
|
@@ -8474,6 +11736,29 @@ class HostError extends Error {
|
|
|
8474
11736
|
}
|
|
8475
11737
|
```
|
|
8476
11738
|
|
|
11739
|
+
Example: Simulate the player refusing a clipboard write in a test
|
|
11740
|
+
|
|
11741
|
+
The test proves the plugin sees a refused write as an error with code
|
|
11742
|
+
`failed`. `host.handle` replaces the test host's handler for one request
|
|
11743
|
+
method; the `HostError` it throws travels back like the player's own
|
|
11744
|
+
refusal, so the call rejects with that `code` and is not retried.
|
|
11745
|
+
`host.reset` puts the default handler back for later steps.
|
|
11746
|
+
```ts
|
|
11747
|
+
import assert from "node:assert/strict";
|
|
11748
|
+
import { HostError, startPlugin } from "@vestra-player/sdk/testing";
|
|
11749
|
+
import activate from "../main.ts";
|
|
11750
|
+
|
|
11751
|
+
const { host, plugin, stop } = await startPlugin(activate, {
|
|
11752
|
+
directory: new URL("..", import.meta.url),
|
|
11753
|
+
});
|
|
11754
|
+
host.handle("clipboard.writeText", () => {
|
|
11755
|
+
throw new HostError("failed", "the system refused the write");
|
|
11756
|
+
});
|
|
11757
|
+
await assert.rejects(plugin.clipboard.writeText("x"), { code: "failed" });
|
|
11758
|
+
host.reset("clipboard.writeText"); // writes succeed again
|
|
11759
|
+
await stop();
|
|
11760
|
+
```
|
|
11761
|
+
|
|
8477
11762
|
## Internal types the API refers to
|
|
8478
11763
|
|
|
8479
11764
|
Not exported (do not import them); listed because public declarations use them.
|
|
@@ -9704,11 +12989,13 @@ export default async function activate(plugin: Plugin) {
|
|
|
9704
12989
|
let mark: number | null = null;
|
|
9705
12990
|
|
|
9706
12991
|
const window = plugin.windows.create("ui/bookmark.slint");
|
|
12992
|
+
// Re-evaluated after every handler and player change; only changes are sent.
|
|
9707
12993
|
window.bind(() => ({
|
|
9708
12994
|
label: mark === null ? "No bookmark" : `Bookmark: ${mark.toFixed(1)} s`,
|
|
9709
12995
|
canMark: player.state.position !== null,
|
|
9710
12996
|
canJump: mark !== null && player.state.can.seek,
|
|
9711
12997
|
}));
|
|
12998
|
+
// The Slint callbacks `mark` and `jump` of ui/bookmark.slint.
|
|
9712
12999
|
window.on("mark", () => (mark = player.state.position));
|
|
9713
13000
|
window.on("jump", () => player.seek(mark!));
|
|
9714
13001
|
|
|
@@ -9716,7 +13003,7 @@ export default async function activate(plugin: Plugin) {
|
|
|
9716
13003
|
id: "bookmark.open",
|
|
9717
13004
|
path: ["@playback", "Bookmark…"],
|
|
9718
13005
|
order: 270,
|
|
9719
|
-
run: () => window.show(),
|
|
13006
|
+
run: () => window.show(), // opens it, or brings back the open one
|
|
9720
13007
|
});
|
|
9721
13008
|
}
|
|
9722
13009
|
```
|
|
@@ -9872,6 +13159,7 @@ export default async function activate(plugin: Plugin) {
|
|
|
9872
13159
|
const panel = plugin.panels.mount("sidebar", "ui/panel.slint", {
|
|
9873
13160
|
title: "Example list",
|
|
9874
13161
|
});
|
|
13162
|
+
// Sent when the panel mounts, then whenever a value changes.
|
|
9875
13163
|
panel.bind(() => ({ rows: items, selected }));
|
|
9876
13164
|
|
|
9877
13165
|
const first = plugin.commands.add({
|
|
@@ -9889,7 +13177,9 @@ export default async function activate(plugin: Plugin) {
|
|
|
9889
13177
|
},
|
|
9890
13178
|
});
|
|
9891
13179
|
|
|
13180
|
+
// Callbacks of the bridge global; `bind` above sends the new selection.
|
|
9892
13181
|
panel.on("select", (index: number) => (selected = index));
|
|
13182
|
+
// x and y: the pointer in window pixels, from the Slint file.
|
|
9893
13183
|
panel.on("contextMenu", (x: number, y: number) =>
|
|
9894
13184
|
panel.showMenu([first, null, remove], { x, y }),
|
|
9895
13185
|
);
|
|
@@ -10400,6 +13690,7 @@ import type { Plugin } from "@vestra-player/sdk";
|
|
|
10400
13690
|
|
|
10401
13691
|
/** A tool window that shows a Markdown document. */
|
|
10402
13692
|
export default async function activate(plugin: Plugin) {
|
|
13693
|
+
// Relative links and images in sample.md resolve against this folder.
|
|
10403
13694
|
const directory = fileURLToPath(new URL(".", import.meta.url));
|
|
10404
13695
|
const window = plugin.windows.create("ui/document.slint");
|
|
10405
13696
|
plugin.commands.add({
|
|
@@ -10407,6 +13698,7 @@ export default async function activate(plugin: Plugin) {
|
|
|
10407
13698
|
path: ["Examples", "Markdown example…"],
|
|
10408
13699
|
run: async () => {
|
|
10409
13700
|
const text = readFileSync(new URL("sample.md", import.meta.url), "utf8");
|
|
13701
|
+
// `doc` is the window's [MarkdownBlock] property, drawn by MarkdownView.
|
|
10410
13702
|
window.set({ doc: await plugin.ui.markdown(text, { base: directory }) });
|
|
10411
13703
|
await window.show();
|
|
10412
13704
|
},
|