@vestra-player/sdk 1.5.39 → 1.5.41
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 +15 -0
- package/bin/vestra-plugin.mjs +5 -1
- package/dist/commands.d.ts +12 -2
- package/dist/commands.js +4 -2
- package/dist/plugin/commands.js +41 -8
- package/dist/plugin/runtime.d.ts +31 -1
- package/dist/plugin/runtime.js +154 -12
- package/dist/plugin/scope.d.ts +1 -0
- package/dist/plugin/scope.js +2 -0
- package/dist/plugin/testing.d.ts +57 -2
- package/dist/plugin/testing.js +121 -1
- package/dist/plugin/types.d.ts +244 -11
- package/dist/plugin/ui.js +6 -4
- package/dist/plugin/when.d.ts +46 -0
- package/dist/plugin/when.js +106 -0
- package/dist/wire.d.ts +4 -4
- package/dist/wire.js +13 -3
- package/llms-full.txt +241 -16
- package/llms.txt +19 -19
- package/package.json +1 -1
- package/slint/vestra/bar-buttons.slint +23 -6
package/dist/plugin/testing.js
CHANGED
|
@@ -86,6 +86,8 @@ import { nodeProblem, VIDEO_PROCESSING_LIMITS } from "../processing.js";
|
|
|
86
86
|
import { shaderIdProblem, shaderListProblem } from "./shaders.js";
|
|
87
87
|
import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
|
|
88
88
|
import { createPlugin } from "./index.js";
|
|
89
|
+
import { evaluate } from "./when.js";
|
|
90
|
+
import { WAKE_EVENTS } from "./runtime.js";
|
|
89
91
|
import { THEMES } from "./appearance.js";
|
|
90
92
|
import { Catalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES, fitColumns, isTextValue, LIST_SEPARATORS, loadPackageCatalog, menuKeys, renderTemplateArgs, renderText, resolveTexts, TEXT_LIMITS, textKey, textProblem, } from "../i18n.js";
|
|
91
93
|
/** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
|
|
@@ -410,6 +412,16 @@ export class TestHost {
|
|
|
410
412
|
* @since 1.5.38
|
|
411
413
|
*/
|
|
412
414
|
listening = new Set();
|
|
415
|
+
/**
|
|
416
|
+
* Why the plugin listens to each event of {@link TestHost.listening}:
|
|
417
|
+
* every listener and every binding that reads its state, as the
|
|
418
|
+
* player's log says them (`a listener added at main.ts:12`,
|
|
419
|
+
* `command "Save" enabled is a function that reads plugin.player.state
|
|
420
|
+
* (main.ts:40) — use plugin.when`), in the order they came.
|
|
421
|
+
*
|
|
422
|
+
* @since 1.5.40
|
|
423
|
+
*/
|
|
424
|
+
listeningWhy = new Map();
|
|
413
425
|
/**
|
|
414
426
|
* The player's source facts (phase, position, duration, paused, rate,
|
|
415
427
|
* audio tracks, loop, ...). Wire-level shape; change it with
|
|
@@ -3525,6 +3537,79 @@ export class TestHost {
|
|
|
3525
3537
|
},
|
|
3526
3538
|
};
|
|
3527
3539
|
}
|
|
3540
|
+
/** A command's state as the player shows it: its conditions evaluated. */
|
|
3541
|
+
#commandShown(sent, when) {
|
|
3542
|
+
if (!when)
|
|
3543
|
+
return sent;
|
|
3544
|
+
const source = this.source;
|
|
3545
|
+
const capabilities = source.capabilities;
|
|
3546
|
+
const facts = {
|
|
3547
|
+
phase: source.phase,
|
|
3548
|
+
paused: source.paused,
|
|
3549
|
+
kind: source.kind === "producer" ? "stream" : "media",
|
|
3550
|
+
can: {
|
|
3551
|
+
pause: capabilities.pause,
|
|
3552
|
+
seek: capabilities.seek,
|
|
3553
|
+
rate: capabilities.rate,
|
|
3554
|
+
restart: capabilities.restart,
|
|
3555
|
+
close: capabilities.close,
|
|
3556
|
+
snapshot: capabilities.snapshotSource,
|
|
3557
|
+
// As the player: the flags merged into the source state.
|
|
3558
|
+
next: source.sequence?.next === true,
|
|
3559
|
+
previous: source.sequence?.previous === true,
|
|
3560
|
+
},
|
|
3561
|
+
fullscreen: this.mainWindow.fullscreen,
|
|
3562
|
+
theme: this.appearance,
|
|
3563
|
+
language: this.language,
|
|
3564
|
+
};
|
|
3565
|
+
const shown = { ...sent };
|
|
3566
|
+
if (when.enabled) {
|
|
3567
|
+
shown.enabled = evaluate(when.enabled, facts);
|
|
3568
|
+
if (shown.enabled && !shown.busy)
|
|
3569
|
+
shown.reason = null;
|
|
3570
|
+
}
|
|
3571
|
+
if (when.checked)
|
|
3572
|
+
shown.checked = evaluate(when.checked, facts);
|
|
3573
|
+
return shown;
|
|
3574
|
+
}
|
|
3575
|
+
/**
|
|
3576
|
+
* Why the player would keep this plugin running while nothing uses it
|
|
3577
|
+
* (lazy activation, player 0.5.21), one line per reason, as the player's
|
|
3578
|
+
* log says it: an event it listens to that is not one the player wakes
|
|
3579
|
+
* it for (with what listens and where it was added), its mounted panel,
|
|
3580
|
+
* its open windows. Empty: the player may stop it while idle and start
|
|
3581
|
+
* it again on its next use. Filter chains, streams, jobs and the like
|
|
3582
|
+
* (which also keep a plugin running) are not listed.
|
|
3583
|
+
*
|
|
3584
|
+
* @capability None.
|
|
3585
|
+
* @since 1.5.40
|
|
3586
|
+
*
|
|
3587
|
+
* @example Check that a plugin can be stopped while idle
|
|
3588
|
+
* The test fails if the plugin keeps running for no reason after it
|
|
3589
|
+
* starts, and names the reason (a command whose `enabled` reads
|
|
3590
|
+
* `plugin.player.state`, say: give it a `plugin.when` condition). The
|
|
3591
|
+
* player stops such a plugin while nothing uses it and starts it again
|
|
3592
|
+
* on its next use, so it costs no process meanwhile.
|
|
3593
|
+
* ```ts
|
|
3594
|
+
* import assert from "node:assert/strict";
|
|
3595
|
+
* declare const host: TestHost;
|
|
3596
|
+
* // ---cut---
|
|
3597
|
+
* assert.deepEqual(host.keepsRunning, []);
|
|
3598
|
+
* ```
|
|
3599
|
+
*/
|
|
3600
|
+
get keepsRunning() {
|
|
3601
|
+
const reasons = [];
|
|
3602
|
+
for (const event of this.listening)
|
|
3603
|
+
if (!WAKE_EVENTS.includes(event))
|
|
3604
|
+
for (const why of this.listeningWhy.get(event) ?? ["a listener"])
|
|
3605
|
+
reasons.push(`listens to ${event}: ${why}`);
|
|
3606
|
+
if (this.panel)
|
|
3607
|
+
reasons.push("a mounted panel");
|
|
3608
|
+
for (const [id, window] of this.windows)
|
|
3609
|
+
if (window.visible)
|
|
3610
|
+
reasons.push(`an open window (${id})`);
|
|
3611
|
+
return reasons;
|
|
3612
|
+
}
|
|
3528
3613
|
#defaults = {
|
|
3529
3614
|
"events.subscribe": (p) => {
|
|
3530
3615
|
const event = String(p.event);
|
|
@@ -3533,10 +3618,15 @@ export class TestHost {
|
|
|
3533
3618
|
this.listening.delete(event);
|
|
3534
3619
|
else
|
|
3535
3620
|
this.listening.add(event);
|
|
3621
|
+
if (p.mirror !== true && Array.isArray(p.why))
|
|
3622
|
+
this.listeningWhy.set(event, p.why.map(String));
|
|
3623
|
+
else
|
|
3624
|
+
this.listeningWhy.delete(event);
|
|
3536
3625
|
},
|
|
3537
3626
|
"events.unsubscribe": (p) => {
|
|
3538
3627
|
this.subscriptions.delete(String(p.event));
|
|
3539
3628
|
this.listening.delete(String(p.event));
|
|
3629
|
+
this.listeningWhy.delete(String(p.event));
|
|
3540
3630
|
},
|
|
3541
3631
|
"commands.register": (p) => {
|
|
3542
3632
|
const id = String(p.id);
|
|
@@ -3577,12 +3667,19 @@ export class TestHost {
|
|
|
3577
3667
|
for (const segment of [declaration.title, ...(declaration.path ?? [])])
|
|
3578
3668
|
this.#declared(segment, "a menu text");
|
|
3579
3669
|
this.#checkReason(p.state);
|
|
3670
|
+
let sent = p.state;
|
|
3580
3671
|
this.commands.set(id, {
|
|
3581
3672
|
declaration,
|
|
3582
3673
|
get display() {
|
|
3583
3674
|
return host.#display(declaration.path ?? [], declaration.title, this.state.reason);
|
|
3584
3675
|
},
|
|
3585
|
-
|
|
3676
|
+
...(p.when ? { when: p.when } : {}),
|
|
3677
|
+
get state() {
|
|
3678
|
+
return host.#commandShown(sent, this.when);
|
|
3679
|
+
},
|
|
3680
|
+
set state(state) {
|
|
3681
|
+
sent = state;
|
|
3682
|
+
},
|
|
3586
3683
|
revision: Number(p.revision),
|
|
3587
3684
|
async run() {
|
|
3588
3685
|
if (this.state.busy || !this.state.enabled)
|
|
@@ -3604,6 +3701,10 @@ export class TestHost {
|
|
|
3604
3701
|
this.#checkReason(p.state);
|
|
3605
3702
|
command.revision = Number(p.revision);
|
|
3606
3703
|
command.state = p.state;
|
|
3704
|
+
if (p.when)
|
|
3705
|
+
command.when = p.when;
|
|
3706
|
+
else
|
|
3707
|
+
delete command.when;
|
|
3607
3708
|
return { revision: command.revision };
|
|
3608
3709
|
},
|
|
3609
3710
|
"ui.open": (p) => {
|
|
@@ -4941,6 +5042,24 @@ export class TestHost {
|
|
|
4941
5042
|
},
|
|
4942
5043
|
};
|
|
4943
5044
|
}
|
|
5045
|
+
/** Lines `vestra-plugin test` printed already (one per plugin and reasons). */
|
|
5046
|
+
const told = new Set();
|
|
5047
|
+
/**
|
|
5048
|
+
* Under `vestra-plugin test` (`VESTRA_PLUGIN_RESIDENCY=1`): say once per
|
|
5049
|
+
* test file why the plugin would keep running after it starts.
|
|
5050
|
+
*/
|
|
5051
|
+
function tellResidency(host) {
|
|
5052
|
+
if (process.env.VESTRA_PLUGIN_RESIDENCY !== "1")
|
|
5053
|
+
return;
|
|
5054
|
+
const reasons = host.keepsRunning;
|
|
5055
|
+
if (!reasons.length)
|
|
5056
|
+
return;
|
|
5057
|
+
const line = `${host.manifestId} keeps running while idle (lazy activation): ${reasons.join("; ")}`;
|
|
5058
|
+
if (told.has(line))
|
|
5059
|
+
return;
|
|
5060
|
+
told.add(line);
|
|
5061
|
+
console.log(`# ${line}`);
|
|
5062
|
+
}
|
|
4944
5063
|
/**
|
|
4945
5064
|
* Activate a plugin against a test host, the way the player's runner does:
|
|
4946
5065
|
* build the `plugin` object, call `activate(plugin)`, wait for every
|
|
@@ -5052,6 +5171,7 @@ export async function startPlugin(activate, options = {}) {
|
|
|
5052
5171
|
}
|
|
5053
5172
|
}
|
|
5054
5173
|
await host.settle();
|
|
5174
|
+
tellResidency(host);
|
|
5055
5175
|
return {
|
|
5056
5176
|
/** The test host the plugin talks to. */
|
|
5057
5177
|
host,
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -204,6 +204,13 @@ export type PluginError = Error & {
|
|
|
204
204
|
export interface Scope extends Disposable {
|
|
205
205
|
/** Menu commands and their default bindings (`commands`). */
|
|
206
206
|
readonly commands: Commands;
|
|
207
|
+
/**
|
|
208
|
+
* Conditions on the player's own state for a command's `enabled` and
|
|
209
|
+
* `checked` (SDK 1.5.40, player 0.5.21): the player evaluates them, so
|
|
210
|
+
* the menu follows playback even while the plugin is stopped. See
|
|
211
|
+
* {@link When}.
|
|
212
|
+
*/
|
|
213
|
+
readonly when: When;
|
|
207
214
|
/** Tool windows from `.slint` files (`ui.window`). */
|
|
208
215
|
readonly windows: Windows;
|
|
209
216
|
/** Docked panels: bottom bar, sidebar and backdrop (`ui.mount`). */
|
|
@@ -580,6 +587,217 @@ export interface CommandState {
|
|
|
580
587
|
* @category Commands
|
|
581
588
|
*/
|
|
582
589
|
export type Bindable<T> = T | (() => T);
|
|
590
|
+
/**
|
|
591
|
+
* A condition on the player's own state, from {@link Scope.when}, for a
|
|
592
|
+
* command's `enabled` or `checked`. The player evaluates it, again on
|
|
593
|
+
* every change of that state, whether or not the plugin runs.
|
|
594
|
+
*
|
|
595
|
+
* @remarks
|
|
596
|
+
* Combine conditions with `and`, `or` and `not`; each returns a new
|
|
597
|
+
* condition and leaves the original as it was. A condition is data: it
|
|
598
|
+
* reads nothing in the plugin and keeps no listener, so a plugin whose
|
|
599
|
+
* menu state is made of conditions may be stopped while idle (lazy
|
|
600
|
+
* activation) and its menu stays current. A function (see {@link Bindable})
|
|
601
|
+
* keeps working; one that reads `plugin.player.state` or another service's
|
|
602
|
+
* state keeps the plugin running so the SDK can evaluate it.
|
|
603
|
+
*
|
|
604
|
+
* @capability commands
|
|
605
|
+
*
|
|
606
|
+
* @example Enable an item while a frame can be saved
|
|
607
|
+
* "Save Snapshot" is greyed out with its reason unless a media file is
|
|
608
|
+
* open at a frame the player can capture. The condition is sent with the
|
|
609
|
+
* command and evaluated by the player, so the item follows playback even
|
|
610
|
+
* while the plugin is stopped; with an `enabled` condition the player
|
|
611
|
+
* shows the `reason` only while the item is disabled.
|
|
612
|
+
* ```ts
|
|
613
|
+
* declare function saveSnapshot(): Promise<void>;
|
|
614
|
+
* // ---cut---
|
|
615
|
+
* const { when } = plugin;
|
|
616
|
+
* plugin.commands.add({
|
|
617
|
+
* id: "save",
|
|
618
|
+
* path: ["@video", "Save Snapshot"],
|
|
619
|
+
* enabled: when.can("snapshot").and(when.phase("ready", "ended"), when.stream.not()),
|
|
620
|
+
* reason: "No frame to capture", // shown only while disabled
|
|
621
|
+
* run: saveSnapshot,
|
|
622
|
+
* });
|
|
623
|
+
* ```
|
|
624
|
+
*
|
|
625
|
+
* @since 1.5.40
|
|
626
|
+
* @category Commands
|
|
627
|
+
*/
|
|
628
|
+
export interface Condition {
|
|
629
|
+
/**
|
|
630
|
+
* True when this condition and every one of `others` hold.
|
|
631
|
+
*
|
|
632
|
+
* @example Combine two conditions with and
|
|
633
|
+
* The item is enabled only while a file plays in fullscreen. `and`
|
|
634
|
+
* returns a new condition; `when.playing` itself is unchanged.
|
|
635
|
+
* ```ts
|
|
636
|
+
* const { when } = plugin;
|
|
637
|
+
* const playingFullscreen = when.playing.and(when.fullscreen);
|
|
638
|
+
* ```
|
|
639
|
+
*
|
|
640
|
+
* @capability None (the command it is given to needs `commands`).
|
|
641
|
+
* @since 1.5.40
|
|
642
|
+
*/
|
|
643
|
+
and(...others: Condition[]): Condition;
|
|
644
|
+
/**
|
|
645
|
+
* True when this condition or one of `others` holds.
|
|
646
|
+
*
|
|
647
|
+
* @example Combine two conditions with or
|
|
648
|
+
* The item is enabled while a file plays or is paused. `or` returns a
|
|
649
|
+
* new condition that holds when either of the two holds.
|
|
650
|
+
* ```ts
|
|
651
|
+
* const { when } = plugin;
|
|
652
|
+
* const active = when.playing.or(when.paused);
|
|
653
|
+
* ```
|
|
654
|
+
*
|
|
655
|
+
* @capability None (the command it is given to needs `commands`).
|
|
656
|
+
* @since 1.5.40
|
|
657
|
+
*/
|
|
658
|
+
or(...others: Condition[]): Condition;
|
|
659
|
+
/**
|
|
660
|
+
* True when this condition does not hold.
|
|
661
|
+
*
|
|
662
|
+
* @example Negate a condition with not
|
|
663
|
+
* An "Enter Fullscreen" item is enabled only outside fullscreen. `not`
|
|
664
|
+
* returns the opposite condition; the player evaluates it on every
|
|
665
|
+
* window change.
|
|
666
|
+
* ```ts
|
|
667
|
+
* const { when } = plugin;
|
|
668
|
+
* const windowed = when.fullscreen.not();
|
|
669
|
+
* ```
|
|
670
|
+
*
|
|
671
|
+
* @capability None (the command it is given to needs `commands`).
|
|
672
|
+
* @since 1.5.40
|
|
673
|
+
*/
|
|
674
|
+
not(): Condition;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* `plugin.when`: the vocabulary of conditions (SDK 1.5.40, player 0.5.21).
|
|
678
|
+
* It names only state the player owns: the source's phase, kind, pause
|
|
679
|
+
* and controls (the same names as {@link PlayerState}), fullscreen, the
|
|
680
|
+
* palette and the saved interface language.
|
|
681
|
+
*
|
|
682
|
+
* @remarks
|
|
683
|
+
* Give a condition as a command's `enabled` or `checked`
|
|
684
|
+
* ({@link CommandOptions}, {@link Command.set}). The player evaluates it
|
|
685
|
+
* and shows the result in menus, on keys and on bar buttons, again
|
|
686
|
+
* whenever that state changes, also while the plugin is stopped because
|
|
687
|
+
* nothing used it. With an `enabled` condition the player shows the
|
|
688
|
+
* command's `reason` only while the condition is false (or the command
|
|
689
|
+
* is busy), so a fixed reason is enough. Players before 0.5.21 have no
|
|
690
|
+
* `plugin.when` (declare `"minPlayerVersion": "0.5.21"`).
|
|
691
|
+
*
|
|
692
|
+
* State the vocabulary does not name (a plugin's own settings, an open
|
|
693
|
+
* window, a track list) stays a function or a value set with
|
|
694
|
+
* {@link Command.set}.
|
|
695
|
+
*
|
|
696
|
+
* @capability commands
|
|
697
|
+
*
|
|
698
|
+
* @example Check a palette item and follow the palette
|
|
699
|
+
* The Appearance submenu has one radio item per palette; the player
|
|
700
|
+
* checks the one in use, also after another plugin or the user changed
|
|
701
|
+
* it, and while this plugin is stopped. Nothing in the plugin listens to
|
|
702
|
+
* the palette.
|
|
703
|
+
* ```ts
|
|
704
|
+
* const { when } = plugin;
|
|
705
|
+
* for (const [id, title] of [["vestra", "Dark"], ["vestra-light", "Light"]] as const)
|
|
706
|
+
* plugin.commands.add({
|
|
707
|
+
* id: `theme.${id}`,
|
|
708
|
+
* path: ["Settings", "Appearance", title],
|
|
709
|
+
* radio: true,
|
|
710
|
+
* checked: when.theme(id), // evaluated by the player, not the plugin
|
|
711
|
+
* run: () => plugin.appearance.set(id),
|
|
712
|
+
* });
|
|
713
|
+
* ```
|
|
714
|
+
*
|
|
715
|
+
* @since 1.5.40
|
|
716
|
+
* @category Commands
|
|
717
|
+
*/
|
|
718
|
+
export interface When {
|
|
719
|
+
/** A source is open: any phase but `empty` (loading, ready, ended, error). */
|
|
720
|
+
readonly open: Condition;
|
|
721
|
+
/** The source is loaded and can play (`phase` is `ready`). */
|
|
722
|
+
readonly ready: Condition;
|
|
723
|
+
/** The source is ready and not paused. */
|
|
724
|
+
readonly playing: Condition;
|
|
725
|
+
/** The source is ready and paused. */
|
|
726
|
+
readonly paused: Condition;
|
|
727
|
+
/** The source is a generated stream (`PlayerState.kind` is `stream`). */
|
|
728
|
+
readonly stream: Condition;
|
|
729
|
+
/** The main window is fullscreen. */
|
|
730
|
+
readonly fullscreen: Condition;
|
|
731
|
+
/**
|
|
732
|
+
* The source's `phase` is one of `phases`.
|
|
733
|
+
*
|
|
734
|
+
* @throws `TypeError` (throws) No phase, or one that is not a phase.
|
|
735
|
+
*
|
|
736
|
+
* @example Enable an item while a file is loaded or finished
|
|
737
|
+
* The item is enabled while the source is ready or has ended, as a
|
|
738
|
+
* snapshot needs a frame. The phases are those of `PlayerState.phase`.
|
|
739
|
+
* ```ts
|
|
740
|
+
* const { when } = plugin;
|
|
741
|
+
* const hasFrame = when.phase("ready", "ended");
|
|
742
|
+
* ```
|
|
743
|
+
*
|
|
744
|
+
* @capability None (the command it is given to needs `commands`).
|
|
745
|
+
* @since 1.5.40
|
|
746
|
+
*/
|
|
747
|
+
phase(...phases: PlayerState["phase"][]): Condition;
|
|
748
|
+
/**
|
|
749
|
+
* The source allows `control`: `PlayerState.can[control]` is true.
|
|
750
|
+
*
|
|
751
|
+
* @throws `TypeError` (throws) `control` is not one of `PlayerState.can`.
|
|
752
|
+
*
|
|
753
|
+
* @example Enable a seek item only where seeking works
|
|
754
|
+
* "Jump to Middle" is greyed out on live streams that cannot seek.
|
|
755
|
+
* The player reads its own `can.seek` for the current source.
|
|
756
|
+
* ```ts
|
|
757
|
+
* const { when } = plugin;
|
|
758
|
+
* const seekable = when.can("seek");
|
|
759
|
+
* ```
|
|
760
|
+
*
|
|
761
|
+
* @capability None (the command it is given to needs `commands`).
|
|
762
|
+
* @since 1.5.40
|
|
763
|
+
*/
|
|
764
|
+
can(control: keyof PlayerState["can"]): Condition;
|
|
765
|
+
/**
|
|
766
|
+
* The palette in use is `theme` (`plugin.appearance.theme`).
|
|
767
|
+
*
|
|
768
|
+
* @throws `TypeError` (throws) `theme` is not a palette of the player.
|
|
769
|
+
*
|
|
770
|
+
* @example Check the light palette's item
|
|
771
|
+
* The item carries a check mark while the light palette is in use,
|
|
772
|
+
* whoever switched to it.
|
|
773
|
+
* ```ts
|
|
774
|
+
* const { when } = plugin;
|
|
775
|
+
* const light = when.theme("vestra-light");
|
|
776
|
+
* ```
|
|
777
|
+
*
|
|
778
|
+
* @capability None (the command it is given to needs `commands`).
|
|
779
|
+
* @since 1.5.40
|
|
780
|
+
*/
|
|
781
|
+
theme(theme: ThemeId): Condition;
|
|
782
|
+
/**
|
|
783
|
+
* The saved interface language is `language`
|
|
784
|
+
* (`plugin.appearance.language`: `"system"` or a locale).
|
|
785
|
+
*
|
|
786
|
+
* @throws `TypeError` (throws) `language` is not `"system"` or a locale.
|
|
787
|
+
*
|
|
788
|
+
* @example Check the Follow System item
|
|
789
|
+
* The item carries a check mark while the player follows the system's
|
|
790
|
+
* language; the player reads its own saved preference.
|
|
791
|
+
* ```ts
|
|
792
|
+
* const { when } = plugin;
|
|
793
|
+
* const followSystem = when.language("system");
|
|
794
|
+
* ```
|
|
795
|
+
*
|
|
796
|
+
* @capability None (the command it is given to needs `commands`).
|
|
797
|
+
* @since 1.5.40
|
|
798
|
+
*/
|
|
799
|
+
language(language: LanguagePreference): Condition;
|
|
800
|
+
}
|
|
583
801
|
/**
|
|
584
802
|
* A built-in top-level menu of the player, named in a command path as
|
|
585
803
|
* `@<id>` or by its English key (see {@link CommandOptions.path}): Open,
|
|
@@ -620,9 +838,9 @@ export type BarButtonIcon = "play" | "pause" | "stop" | "back" | "forward" | "op
|
|
|
620
838
|
* "…" removed) and its first key as the tooltip ("Plugin Store (F9)"),
|
|
621
839
|
* `enabled`, `busy` and `reason` as the menu item has them: a disabled or
|
|
622
840
|
* busy command's button is drawn disabled, its tooltip adds the reason, and
|
|
623
|
-
* a click runs nothing.
|
|
624
|
-
*
|
|
625
|
-
* click on its menu item.
|
|
841
|
+
* a click runs nothing. Every button's icon is drawn in the accent colour;
|
|
842
|
+
* a command with `checked` shows a small dot under its icon while checked.
|
|
843
|
+
* A click runs the command exactly like a click on its menu item.
|
|
626
844
|
*
|
|
627
845
|
* **Limits.** One button per plugin and eight in the player
|
|
628
846
|
* (`BAR_BUTTON_LIMITS`): a button past them is left out (its command and
|
|
@@ -807,15 +1025,24 @@ export interface CommandOptions {
|
|
|
807
1025
|
* a button without a path.
|
|
808
1026
|
*/
|
|
809
1027
|
button?: CommandButton;
|
|
810
|
-
/**
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1028
|
+
/**
|
|
1029
|
+
* Whether the command can run (default true): a value, a function, or a
|
|
1030
|
+
* {@link Condition} the player evaluates (`plugin.when`, SDK 1.5.40).
|
|
1031
|
+
*/
|
|
1032
|
+
enabled?: Bindable<boolean> | Condition;
|
|
1033
|
+
/**
|
|
1034
|
+
* Check mark (or radio dot with `radio`); `null` (default) draws none.
|
|
1035
|
+
* A {@link Condition} (`plugin.when`, SDK 1.5.40) draws the mark while
|
|
1036
|
+
* it holds.
|
|
1037
|
+
*/
|
|
1038
|
+
checked?: Bindable<boolean | null> | Condition;
|
|
814
1039
|
/** Busy state (default false); the player does not call `run` while busy. */
|
|
815
1040
|
busy?: Bindable<boolean>;
|
|
816
1041
|
/**
|
|
817
1042
|
* Why the command is disabled or busy (shown to the user): English
|
|
818
1043
|
* source text, or a {@link Text} for one with data in it (SDK 1.5.11).
|
|
1044
|
+
* With an `enabled` {@link Condition} the player shows it only while the
|
|
1045
|
+
* condition is false or the command is busy, so a fixed text is enough.
|
|
819
1046
|
*/
|
|
820
1047
|
reason?: Bindable<TextValue | null>;
|
|
821
1048
|
/**
|
|
@@ -894,7 +1121,10 @@ export interface Command extends Disposable {
|
|
|
894
1121
|
* @since 1.0.0
|
|
895
1122
|
*/
|
|
896
1123
|
set(state: Partial<{
|
|
897
|
-
[K in
|
|
1124
|
+
[K in "busy" | "reason"]: Bindable<CommandState[K]>;
|
|
1125
|
+
} & {
|
|
1126
|
+
enabled: Bindable<boolean> | Condition;
|
|
1127
|
+
checked: Bindable<boolean | null> | Condition;
|
|
898
1128
|
}>): void;
|
|
899
1129
|
/**
|
|
900
1130
|
* Resolves when the host has registered the command. Rejects with
|
|
@@ -8602,14 +8832,17 @@ export interface Bindings {
|
|
|
8602
8832
|
*/
|
|
8603
8833
|
reset(ref?: BindingRef): Promise<void>;
|
|
8604
8834
|
/**
|
|
8605
|
-
*
|
|
8835
|
+
* The binding list changed (read `list()` again): a declaration or an
|
|
8836
|
+
* override. At most every 200 ms; since player 0.5.21 only when the list
|
|
8837
|
+
* itself changed, not when a plugin starts again with the same commands.
|
|
8606
8838
|
*
|
|
8607
8839
|
* @capability bindings.manage
|
|
8608
8840
|
*
|
|
8609
8841
|
* @example Refresh a key list when bindings change
|
|
8610
8842
|
* The plugin's key list stays current when the user changes a key in the
|
|
8611
|
-
* player or
|
|
8612
|
-
* at most every 200 ms; the plugin reads the
|
|
8843
|
+
* player or a plugin with other commands is installed. The listener
|
|
8844
|
+
* carries no data and runs at most every 200 ms; the plugin reads the
|
|
8845
|
+
* table again with `list`.
|
|
8613
8846
|
* ```ts
|
|
8614
8847
|
* declare function render(commands: BindingInfo[]): void;
|
|
8615
8848
|
* // ---cut---
|
package/dist/plugin/ui.js
CHANGED
|
@@ -92,7 +92,8 @@ export class ViewBase {
|
|
|
92
92
|
scope;
|
|
93
93
|
handlers = new Map();
|
|
94
94
|
#values = new Map();
|
|
95
|
-
|
|
95
|
+
/** Each binding, with where the plugin added it (for the player's log). */
|
|
96
|
+
#binders = new Map();
|
|
96
97
|
/** What the host currently shows, as far as this side knows. */
|
|
97
98
|
#sent = new Map();
|
|
98
99
|
/** Written by `set()`: sent even if equal to what was sent before. */
|
|
@@ -144,7 +145,7 @@ export class ViewBase {
|
|
|
144
145
|
this.flush();
|
|
145
146
|
}
|
|
146
147
|
bind(props) {
|
|
147
|
-
this.#binders.
|
|
148
|
+
this.#binders.set(props, this.scope.runtime.callSite());
|
|
148
149
|
this.scope.update();
|
|
149
150
|
return this.scope.onDispose(() => {
|
|
150
151
|
this.#binders.delete(props);
|
|
@@ -152,9 +153,10 @@ export class ViewBase {
|
|
|
152
153
|
}
|
|
153
154
|
/** Current desired properties (bound values win over earlier `set`). */
|
|
154
155
|
snapshot() {
|
|
155
|
-
for (const binder of this.#binders) {
|
|
156
|
+
for (const [binder, site] of this.#binders) {
|
|
156
157
|
try {
|
|
157
|
-
|
|
158
|
+
const props = this.scope.runtime.reader("a view's bind", site, binder);
|
|
159
|
+
for (const [key, value] of Object.entries(encode(props)))
|
|
158
160
|
this.#values.set(key, value);
|
|
159
161
|
}
|
|
160
162
|
catch (error) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plugin.when`: conditions on the player's own state that the player
|
|
3
|
+
* evaluates for a command's `enabled` and `checked` (player 0.5.21). The
|
|
4
|
+
* wire form is `commands.register` / `commands.update` `when`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Condition, LanguagePreference, PlayerState, ThemeId, When } from "./types.ts";
|
|
7
|
+
/** A condition in the wire form: an object of exactly one key. */
|
|
8
|
+
export type ConditionJson = {
|
|
9
|
+
phase: PlayerState["phase"][];
|
|
10
|
+
} | {
|
|
11
|
+
paused: boolean;
|
|
12
|
+
} | {
|
|
13
|
+
kind: PlayerState["kind"];
|
|
14
|
+
} | {
|
|
15
|
+
can: keyof PlayerState["can"];
|
|
16
|
+
} | {
|
|
17
|
+
fullscreen: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
theme: ThemeId;
|
|
20
|
+
} | {
|
|
21
|
+
language: LanguagePreference;
|
|
22
|
+
} | {
|
|
23
|
+
all: ConditionJson[];
|
|
24
|
+
} | {
|
|
25
|
+
any: ConditionJson[];
|
|
26
|
+
} | {
|
|
27
|
+
not: ConditionJson;
|
|
28
|
+
};
|
|
29
|
+
/** What conditions read, in the SDK's names. */
|
|
30
|
+
export interface ConditionFacts {
|
|
31
|
+
phase: PlayerState["phase"];
|
|
32
|
+
paused: boolean;
|
|
33
|
+
kind: PlayerState["kind"];
|
|
34
|
+
can: Readonly<Record<string, boolean>>;
|
|
35
|
+
fullscreen: boolean;
|
|
36
|
+
theme: string;
|
|
37
|
+
language: string;
|
|
38
|
+
}
|
|
39
|
+
/** Whether `value` is a condition made by `plugin.when`. */
|
|
40
|
+
export declare const isCondition: (value: unknown) => value is Condition;
|
|
41
|
+
/** A condition's wire form; throws for anything else. */
|
|
42
|
+
export declare function conditionJson(condition: Condition): ConditionJson;
|
|
43
|
+
/** The one `plugin.when` (conditions hold no plugin state). */
|
|
44
|
+
export declare const WHEN: When;
|
|
45
|
+
/** Evaluate a wire condition as the player does (for the test host). */
|
|
46
|
+
export declare function evaluate(json: ConditionJson, facts: ConditionFacts): boolean;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { LOCALES } from "../i18n.js";
|
|
2
|
+
import { THEMES } from "./appearance.js";
|
|
3
|
+
const PHASES = [
|
|
4
|
+
"empty",
|
|
5
|
+
"loading",
|
|
6
|
+
"ready",
|
|
7
|
+
"ended",
|
|
8
|
+
"error",
|
|
9
|
+
];
|
|
10
|
+
const CONTROLS = [
|
|
11
|
+
"pause",
|
|
12
|
+
"seek",
|
|
13
|
+
"rate",
|
|
14
|
+
"restart",
|
|
15
|
+
"close",
|
|
16
|
+
"snapshot",
|
|
17
|
+
"next",
|
|
18
|
+
"previous",
|
|
19
|
+
];
|
|
20
|
+
class ConditionImpl {
|
|
21
|
+
#json;
|
|
22
|
+
constructor(json) {
|
|
23
|
+
this.#json = json;
|
|
24
|
+
Object.freeze(this);
|
|
25
|
+
}
|
|
26
|
+
and(...others) {
|
|
27
|
+
return new ConditionImpl({
|
|
28
|
+
all: [this.#json, ...others.map(conditionJson)],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
or(...others) {
|
|
32
|
+
return new ConditionImpl({
|
|
33
|
+
any: [this.#json, ...others.map(conditionJson)],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
not() {
|
|
37
|
+
return new ConditionImpl({ not: this.#json });
|
|
38
|
+
}
|
|
39
|
+
toJSON() {
|
|
40
|
+
return this.#json;
|
|
41
|
+
}
|
|
42
|
+
static json(condition) {
|
|
43
|
+
return condition.#json;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Whether `value` is a condition made by `plugin.when`. */
|
|
47
|
+
export const isCondition = (value) => value instanceof ConditionImpl;
|
|
48
|
+
/** A condition's wire form; throws for anything else. */
|
|
49
|
+
export function conditionJson(condition) {
|
|
50
|
+
if (!(condition instanceof ConditionImpl))
|
|
51
|
+
throw new TypeError("a condition comes from plugin.when");
|
|
52
|
+
return ConditionImpl.json(condition);
|
|
53
|
+
}
|
|
54
|
+
const make = (json) => new ConditionImpl(json);
|
|
55
|
+
/** The one `plugin.when` (conditions hold no plugin state). */
|
|
56
|
+
export const WHEN = Object.freeze({
|
|
57
|
+
open: make({ not: { phase: ["empty"] } }),
|
|
58
|
+
ready: make({ phase: ["ready"] }),
|
|
59
|
+
playing: make({ all: [{ phase: ["ready"] }, { paused: false }] }),
|
|
60
|
+
paused: make({ all: [{ phase: ["ready"] }, { paused: true }] }),
|
|
61
|
+
stream: make({ kind: "stream" }),
|
|
62
|
+
fullscreen: make({ fullscreen: true }),
|
|
63
|
+
phase(...phases) {
|
|
64
|
+
if (!phases.length || phases.some((phase) => !PHASES.includes(phase)))
|
|
65
|
+
throw new TypeError(`phase is one or more of ${PHASES.join(", ")}`);
|
|
66
|
+
return make({ phase: [...phases] });
|
|
67
|
+
},
|
|
68
|
+
can(control) {
|
|
69
|
+
if (!CONTROLS.includes(control))
|
|
70
|
+
throw new TypeError(`can is one of ${CONTROLS.join(", ")}`);
|
|
71
|
+
return make({ can: control });
|
|
72
|
+
},
|
|
73
|
+
theme(theme) {
|
|
74
|
+
if (!THEMES.includes(theme))
|
|
75
|
+
throw new TypeError(`theme is ${THEMES.map((t) => `"${t}"`).join(" or ")}`);
|
|
76
|
+
return make({ theme });
|
|
77
|
+
},
|
|
78
|
+
language(language) {
|
|
79
|
+
if (language !== "system" &&
|
|
80
|
+
!LOCALES.includes(language))
|
|
81
|
+
throw new TypeError(`language is "system" or one of ${LOCALES.map((l) => `"${l}"`).join(", ")}`);
|
|
82
|
+
return make({ language });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
/** Evaluate a wire condition as the player does (for the test host). */
|
|
86
|
+
export function evaluate(json, facts) {
|
|
87
|
+
if ("phase" in json)
|
|
88
|
+
return json.phase.includes(facts.phase);
|
|
89
|
+
if ("paused" in json)
|
|
90
|
+
return facts.paused === json.paused;
|
|
91
|
+
if ("kind" in json)
|
|
92
|
+
return facts.kind === json.kind;
|
|
93
|
+
if ("can" in json)
|
|
94
|
+
return facts.can[json.can] === true;
|
|
95
|
+
if ("fullscreen" in json)
|
|
96
|
+
return facts.fullscreen === json.fullscreen;
|
|
97
|
+
if ("theme" in json)
|
|
98
|
+
return facts.theme === json.theme;
|
|
99
|
+
if ("language" in json)
|
|
100
|
+
return facts.language === json.language;
|
|
101
|
+
if ("all" in json)
|
|
102
|
+
return json.all.every((each) => evaluate(each, facts));
|
|
103
|
+
if ("any" in json)
|
|
104
|
+
return json.any.some((each) => evaluate(each, facts));
|
|
105
|
+
return !evaluate(json.not, facts);
|
|
106
|
+
}
|