@vestra-player/sdk 1.5.42 → 1.5.44
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 +20 -0
- package/dist/api-requests.js +1 -0
- package/dist/host-capabilities.js +2 -0
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/patch.d.ts +101 -0
- package/dist/plugin/patch.js +337 -0
- package/dist/plugin/runtime.d.ts +6 -0
- package/dist/plugin/runtime.js +11 -0
- package/dist/plugin/services.js +24 -2
- package/dist/plugin/settings.js +2 -2
- package/dist/plugin/testing.d.ts +65 -1
- package/dist/plugin/testing.js +238 -15
- package/dist/plugin/types.d.ts +192 -1
- package/dist/plugin/ui.d.ts +2 -2
- package/dist/plugin/ui.js +217 -17
- package/dist/wire.d.ts +73 -7
- package/dist/wire.js +3 -3
- package/llms-full.txt +305 -6
- package/llms.txt +23 -23
- package/package.json +1 -1
- package/slint/vestra/markdown.slint +17 -11
- package/slint/vestra/widgets.slint +770 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.44
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. How the SDK sends array properties changes, on players that take it (player 0.6.0); with older players it sends whole values as before. The read-only copy of the widget library gains components that need player 0.6.0 (`"minPlayerVersion": "0.6.0"` for a plugin whose Slint uses them; an older player fails to compile such a file).
|
|
6
|
+
|
|
7
|
+
- **Row patches.** When a bound or set array changes, the SDK sends the rows that changed instead of the whole array: `set`, `insert`, `remove` and `move` operations by index (wire: `ui.setMount`, `ui.set` and `settings.set` with `patch: { base, seq, ops }` instead of `value`; whole writes carry `seq`). Rows are matched by a `key`, `id`, `rowId` or `row-id` field when every row has one, unique, as a string or number (moves are kept to a minimum); otherwise by index, with the unchanged rows at both ends left out. The player writes the rows into its live model in place, without comparing the whole array: selecting one row of a 200-row list costs one row. A player that cannot apply a patch answers `stale` and the SDK sends the whole value at once. Whole values are sent as before when the player does not advertise the feature (`features: ["ui.patch"]` in its start-up data), after a failed write, for rows written into an empty array, for rows holding a Markdown document, and for a value over the limits (refused with `limit`, as before).
|
|
8
|
+
After two `stale` answers in a row for a property, it is sent whole until the view shows its initial properties again. The limit counts see what is sent: an image or document handle is one value, any other object with `toJSON` (a `Date`) counts as what it returns, and a hole in a sparse array is `null`. A released image is refused in any row of the array, also in rows a patch keeps, as in a whole write. Your Slint code must not write into the rows of an array property: a patch does not overwrite rows whose value did not change ([Arrays: only the changed rows are sent](docs/explanation/ui.md#arrays-only-the-changed-rows-are-sent)). `set()` with an unchanged array sends it whole.
|
|
9
|
+
- **Cheaper update passes.** A scalar property is compared with what was sent without `JSON.stringify`; an array is encoded row by row, the same work as before.
|
|
10
|
+
- **`TestHost`** takes patches as the player does (`base`, `stale`, the checks on operations and the value limits on the result) and keeps whole values in `props`, so tests see no difference. It advertises the feature; `host.app = { ...host.app, features: [] }` before `startPlugin` gives an older player's behaviour.
|
|
11
|
+
- **`@vestra/widgets.slint`** (the read-only copy of the player's widget library, player 0.6.0): the widget library copy gains `Check`, `Radio`, `RadioMark`, `RadioGroup`, `Tab`, `Tabs` and `Choice` (a drop-down), drawn in the player's theme with a keyboard focus ring; `Slider` takes the keyboard (arrow keys by `step`, a twentieth of the range by default) and reports `has-focus`. A Slint file that imports them needs player 0.6.0. `Check` takes `self-toggle: false` for a state the plugin decides (a confirmation first); `Tabs` takes `emphasis` per tab; `Slider` takes `focusable: false` to stay out of the Tab order; `Choice` takes `window-height` so its list fits a short window. `@vestra/markdown.slint`: `MarkdownView` builds only the blocks on screen (a `ListView`), so long documents cost what is visible.
|
|
12
|
+
|
|
13
|
+
## 1.5.43
|
|
14
|
+
|
|
15
|
+
**Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`, no new capability. The new API needs player 0.5.25 (`"minPlayerVersion": "0.5.25"`); older players leave the new fields out and refuse the new event (the listener never fires). The `decoding` reason code stays as it is: the cause is a new optional field beside it.
|
|
16
|
+
|
|
17
|
+
- **`UpscalingReason.cause`** (player 0.5.25): with code `decoding`, why the video is not decoded on the GPU that renders it, as a stable `UpscalingDecodingCause`: `copy-back` (decoded on the GPU and copied back to memory, usually because a plugin's video filter needs the frames there), `hwdec-off` (hardware decoding off), `software` (this video is decoded in software: a cover picture, an unsupported format), `other-hwdec` (another hardware decoder). A newer player may add causes. With `copy-back`, **`UpscalingReason.plugins`** lists whose video filters made the player switch (`UpscalingPlugin`: manifest `id` and `name`; empty when the copy-back mode is the user's own setting), and the message names them ("…because the video filter of Picture Adjustments needs the frames in memory").
|
|
18
|
+
- **Monitoring fields in `UpscalingState`** (player 0.5.25, optional: absent from older players): `video` (the video's stored and display size as the upscaler gets it, `UpscalingVideoSize`), `area` (the video area in pixels), `gpu` (the rendering GPU's name), `hwdecCurrent` (mpv's decoder for this video), `inChain` (the upscaler is in the filter chain now, read from the chain). The driver still gives no sign that it upscales.
|
|
19
|
+
- **`plugin.video.upscaling.onChange(listener)`** (player 0.5.25, `video.upscaling`): the state whenever it changes while some plugin has upscaling on (the player looks about five times a second and sends only differences), so a window can show it live without polling. Wire: event `video.upscaling.changed` with the state without `enabled` (the SDK adds this plugin's own); subscribing needs `video.upscaling`, not `events`. A listener keeps the plugin running (it is not an event the player wakes a stopped plugin for): listen only while a window shows the state. The manifest names in `plugins` and the message are not translated.
|
|
20
|
+
- **`TestHost`**: `upscalingDecoding(cause, plugins?)` fakes each cause with the player's message (and a matching `upscaling.hwdecCurrent`); `upscaling.video`, `area`, `gpu`, `hwdecCurrent` (defaults: 1080p on a 4K area, an RTX 4070, `d3d11va`; `inChain` follows `requested`); `upscalingChanged()` sends the event to a listening plugin.
|
|
21
|
+
- The player's capability table copy (`video.upscaling.changed` under `video.upscaling`) and `CAPABILITY_INFO` (`VideoUpscaling.onChange`) follow.
|
|
22
|
+
|
|
3
23
|
## 1.5.42
|
|
4
24
|
|
|
5
25
|
**Who must change: nobody.** Documentation only: no API changes, `apiVersion` stays `vestra/1`, no player version needed. The package's code is the same as 1.5.41; the doc comments' `@see` links (27 in `src/plugin/types.ts`) now point at `docs/explanation/` instead of `docs/concepts/`. The site's page addresses stay the same, and the old _Concepts_ group address redirects to _Explanation_; `llms.txt` and `llms-full.txt` follow the new pages.
|
package/dist/api-requests.js
CHANGED
|
@@ -146,4 +146,5 @@ export const API_REQUESTS = Object.freeze({
|
|
|
146
146
|
"Rendering.restartPlayer": [{ method: "rendering.restart" }],
|
|
147
147
|
"VideoUpscaling.state": [{ method: "video.upscaling.state" }],
|
|
148
148
|
"VideoUpscaling.set": [{ method: "video.upscaling.set" }],
|
|
149
|
+
"VideoUpscaling.onChange": [listen("video.upscaling.changed")],
|
|
149
150
|
});
|
|
@@ -85,6 +85,7 @@ export const HOST_CAPABILITIES = {
|
|
|
85
85
|
cases: {
|
|
86
86
|
"bindings.changed": "bindings.manage",
|
|
87
87
|
"packages.changed": null,
|
|
88
|
+
"video.upscaling.changed": "video.upscaling",
|
|
88
89
|
},
|
|
89
90
|
otherwise: "events",
|
|
90
91
|
param: "event",
|
|
@@ -96,6 +97,7 @@ export const HOST_CAPABILITIES = {
|
|
|
96
97
|
cases: {
|
|
97
98
|
"bindings.changed": "bindings.manage",
|
|
98
99
|
"packages.changed": null,
|
|
100
|
+
"video.upscaling.changed": "video.upscaling",
|
|
99
101
|
},
|
|
100
102
|
otherwise: "events",
|
|
101
103
|
param: "event",
|
package/dist/player-runtime.d.ts
CHANGED
|
@@ -24,3 +24,4 @@ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale
|
|
|
24
24
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
|
|
25
25
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
|
|
26
26
|
export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.ts";
|
|
27
|
+
export { PATCH_FEATURE, PATCH_OPS } from "./plugin/patch.ts";
|
package/dist/player-runtime.js
CHANGED
|
@@ -22,3 +22,4 @@ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale
|
|
|
22
22
|
export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
|
|
23
23
|
export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
|
|
24
24
|
export { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./plugin/values.js";
|
|
25
|
+
export { PATCH_FEATURE, PATCH_OPS } from "./plugin/patch.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** The player feature that takes row patches. */
|
|
2
|
+
export declare const PATCH_FEATURE = "ui.patch";
|
|
3
|
+
/** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
|
|
4
|
+
export declare const PATCH_OPS = 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Fields that identify a row, in the order they are tried. A field is used
|
|
7
|
+
* when every row has it, as a string or a number, and no two rows share it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const KEY_FIELDS: readonly string[];
|
|
10
|
+
export type RowKey = string | number;
|
|
11
|
+
/** One planned operation; `set` and `insert` name rows of the new array. */
|
|
12
|
+
export type PlannedOp = {
|
|
13
|
+
op: "set";
|
|
14
|
+
at: number;
|
|
15
|
+
row: number;
|
|
16
|
+
} | {
|
|
17
|
+
op: "insert";
|
|
18
|
+
at: number;
|
|
19
|
+
rows: number[];
|
|
20
|
+
} | {
|
|
21
|
+
op: "remove";
|
|
22
|
+
at: number;
|
|
23
|
+
count: number;
|
|
24
|
+
} | {
|
|
25
|
+
op: "move";
|
|
26
|
+
from: number;
|
|
27
|
+
count: number;
|
|
28
|
+
to: number;
|
|
29
|
+
};
|
|
30
|
+
/** What one row counts toward the value limits (as a row of an array). */
|
|
31
|
+
export interface RowCount {
|
|
32
|
+
values: number;
|
|
33
|
+
text: number;
|
|
34
|
+
/** Over a per-row limit (a string, the depth): the whole value is sent. */
|
|
35
|
+
over: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** An array as sent: each row's JSON, its key field and keys, its counts. */
|
|
38
|
+
export interface Rows {
|
|
39
|
+
enc: string[];
|
|
40
|
+
field: string | null;
|
|
41
|
+
keys: RowKey[] | null;
|
|
42
|
+
/** Filled in as patches need them. */
|
|
43
|
+
counts: (RowCount | undefined)[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The field that identifies `rows`, and each row's key; `null` when no
|
|
47
|
+
* field of {@link KEY_FIELDS} does (rows are then matched by index).
|
|
48
|
+
*/
|
|
49
|
+
export declare function keyOf(rows: readonly unknown[]): {
|
|
50
|
+
field: string;
|
|
51
|
+
keys: RowKey[];
|
|
52
|
+
} | null;
|
|
53
|
+
/**
|
|
54
|
+
* What `row` counts toward the value limits as a row of an array (depth
|
|
55
|
+
* 1), like `valueLimitProblem` on the row as sent: every value one, an
|
|
56
|
+
* image one, a document one and its text, strings and field names their
|
|
57
|
+
* UTF-8 bytes. Any other object with `toJSON` counts as what it returns.
|
|
58
|
+
*/
|
|
59
|
+
export declare function countRow(row: unknown): RowCount;
|
|
60
|
+
/** The new array is within the value limits (counted per row). */
|
|
61
|
+
export declare function withinLimits(counts: readonly RowCount[]): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* The operations that turn the old rows into the new ones (`null`: more
|
|
64
|
+
* than {@link PATCH_OPS} would be needed), and for each new row the old row
|
|
65
|
+
* whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
|
|
66
|
+
* have keys from the same field; otherwise by index.
|
|
67
|
+
*/
|
|
68
|
+
export declare function diff(old: {
|
|
69
|
+
enc: readonly string[];
|
|
70
|
+
keys: readonly RowKey[] | null;
|
|
71
|
+
}, next: {
|
|
72
|
+
enc: readonly string[];
|
|
73
|
+
keys: readonly RowKey[] | null;
|
|
74
|
+
}): {
|
|
75
|
+
ops: PlannedOp[];
|
|
76
|
+
from: Int32Array;
|
|
77
|
+
} | null;
|
|
78
|
+
/**
|
|
79
|
+
* Apply planned operations to a copy of `old` (the rows' JSON), taking new
|
|
80
|
+
* rows from `next`: the check that a diff produces exactly the new rows.
|
|
81
|
+
*/
|
|
82
|
+
export declare function replay(old: readonly string[], ops: readonly PlannedOp[], next: readonly string[]): string[];
|
|
83
|
+
/** Apply wire operations to an array (the TestHost's copy of a value). */
|
|
84
|
+
export declare function applyOps<T>(rows: T[], ops: readonly ({
|
|
85
|
+
op: "set";
|
|
86
|
+
at: number;
|
|
87
|
+
row: T;
|
|
88
|
+
} | {
|
|
89
|
+
op: "insert";
|
|
90
|
+
at: number;
|
|
91
|
+
rows: T[];
|
|
92
|
+
} | {
|
|
93
|
+
op: "remove";
|
|
94
|
+
at: number;
|
|
95
|
+
count: number;
|
|
96
|
+
} | {
|
|
97
|
+
op: "move";
|
|
98
|
+
from: number;
|
|
99
|
+
count: number;
|
|
100
|
+
to: number;
|
|
101
|
+
})[]): T[];
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row patches of array properties (player 0.6.0, feature `ui.patch`): the
|
|
3
|
+
* diff between the rows last sent and the rows bound now, as `set`,
|
|
4
|
+
* `insert`, `remove` and `move` operations by index. Internal; plugins see
|
|
5
|
+
* none of it (the player's doc/architecture/large-lists.md §2).
|
|
6
|
+
*/
|
|
7
|
+
import { UI_VALUE_LIMITS } from "./values.js";
|
|
8
|
+
/** The player feature that takes row patches. */
|
|
9
|
+
export const PATCH_FEATURE = "ui.patch";
|
|
10
|
+
/** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
|
|
11
|
+
export const PATCH_OPS = 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Fields that identify a row, in the order they are tried. A field is used
|
|
14
|
+
* when every row has it, as a string or a number, and no two rows share it.
|
|
15
|
+
*/
|
|
16
|
+
export const KEY_FIELDS = ["key", "id", "rowId", "row-id"];
|
|
17
|
+
const plain = (value) => {
|
|
18
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
return false;
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
return proto === Object.prototype || proto === null;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The field that identifies `rows`, and each row's key; `null` when no
|
|
25
|
+
* field of {@link KEY_FIELDS} does (rows are then matched by index).
|
|
26
|
+
*/
|
|
27
|
+
export function keyOf(rows) {
|
|
28
|
+
const first = rows[0];
|
|
29
|
+
if (!plain(first))
|
|
30
|
+
return null;
|
|
31
|
+
for (const field of KEY_FIELDS) {
|
|
32
|
+
if (!(field in first))
|
|
33
|
+
continue;
|
|
34
|
+
const keys = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
let ok = true;
|
|
37
|
+
for (let i = 0; i < rows.length; i++) {
|
|
38
|
+
const row = rows[i];
|
|
39
|
+
const key = plain(row) ? row[field] : undefined;
|
|
40
|
+
if ((typeof key !== "string" && typeof key !== "number") ||
|
|
41
|
+
seen.has(key)) {
|
|
42
|
+
ok = false;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
seen.add(key);
|
|
46
|
+
keys.push(key);
|
|
47
|
+
}
|
|
48
|
+
if (ok)
|
|
49
|
+
return { field, keys };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const bytes = (text) => Buffer.byteLength(text, "utf8");
|
|
54
|
+
/**
|
|
55
|
+
* `value` as `JSON.stringify` writes it: an object's `toJSON` result
|
|
56
|
+
* (once), `undefined` for what it leaves out (a function, a symbol).
|
|
57
|
+
*/
|
|
58
|
+
function jsonOf(value) {
|
|
59
|
+
if (value !== null &&
|
|
60
|
+
typeof value === "object" &&
|
|
61
|
+
!("__vestraImage" in value) &&
|
|
62
|
+
!("__vestraMarkdown" in value) &&
|
|
63
|
+
typeof value.toJSON === "function")
|
|
64
|
+
return value.toJSON();
|
|
65
|
+
return typeof value === "function" || typeof value === "symbol"
|
|
66
|
+
? undefined
|
|
67
|
+
: value;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What `row` counts toward the value limits as a row of an array (depth
|
|
71
|
+
* 1), like `valueLimitProblem` on the row as sent: every value one, an
|
|
72
|
+
* image one, a document one and its text, strings and field names their
|
|
73
|
+
* UTF-8 bytes. Any other object with `toJSON` counts as what it returns.
|
|
74
|
+
*/
|
|
75
|
+
export function countRow(row) {
|
|
76
|
+
const count = { values: 0, text: 0, over: false };
|
|
77
|
+
const L = UI_VALUE_LIMITS;
|
|
78
|
+
const text = (s) => {
|
|
79
|
+
const n = bytes(s);
|
|
80
|
+
if (n > L.stringBytes)
|
|
81
|
+
count.over = true;
|
|
82
|
+
count.text += n;
|
|
83
|
+
};
|
|
84
|
+
const walk = (value, depth) => {
|
|
85
|
+
if (count.over)
|
|
86
|
+
return;
|
|
87
|
+
if (depth > L.depth) {
|
|
88
|
+
count.over = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
count.values += 1;
|
|
92
|
+
if (value !== null && typeof value === "object") {
|
|
93
|
+
if ("__vestraImage" in value)
|
|
94
|
+
return;
|
|
95
|
+
if ("__vestraMarkdown" in value) {
|
|
96
|
+
count.text += bytes(value.text);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === "string")
|
|
101
|
+
return text(value);
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
if (value.length > L.arrayRows)
|
|
104
|
+
count.over = true;
|
|
105
|
+
// Array.from: a hole is `null`, as `JSON.stringify` writes it.
|
|
106
|
+
for (const item of Array.from(value))
|
|
107
|
+
walk(jsonOf(item) ?? null, depth + 1);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (value !== null && typeof value === "object")
|
|
111
|
+
for (const [name, raw] of Object.entries(value)) {
|
|
112
|
+
const item = jsonOf(raw);
|
|
113
|
+
if (item === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
text(name);
|
|
116
|
+
walk(item, depth + 1);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(jsonOf(row) ?? null, 1);
|
|
120
|
+
return count;
|
|
121
|
+
}
|
|
122
|
+
/** The new array is within the value limits (counted per row). */
|
|
123
|
+
export function withinLimits(counts) {
|
|
124
|
+
const L = UI_VALUE_LIMITS;
|
|
125
|
+
if (counts.length > L.arrayRows)
|
|
126
|
+
return false;
|
|
127
|
+
let values = 1;
|
|
128
|
+
let text = 0;
|
|
129
|
+
for (const count of counts) {
|
|
130
|
+
if (count.over)
|
|
131
|
+
return false;
|
|
132
|
+
values += count.values;
|
|
133
|
+
text += count.text;
|
|
134
|
+
}
|
|
135
|
+
return values <= L.values && text <= L.textBytes;
|
|
136
|
+
}
|
|
137
|
+
/** The longest increasing subsequence of `values`, as a set of indices. */
|
|
138
|
+
function increasing(values) {
|
|
139
|
+
const tails = [];
|
|
140
|
+
const before = new Int32Array(values.length).fill(-1);
|
|
141
|
+
for (let i = 0; i < values.length; i++) {
|
|
142
|
+
let lo = 0;
|
|
143
|
+
let hi = tails.length;
|
|
144
|
+
while (lo < hi) {
|
|
145
|
+
const mid = (lo + hi) >> 1;
|
|
146
|
+
if (values[tails[mid]] < values[i])
|
|
147
|
+
lo = mid + 1;
|
|
148
|
+
else
|
|
149
|
+
hi = mid;
|
|
150
|
+
}
|
|
151
|
+
if (lo > 0)
|
|
152
|
+
before[i] = tails[lo - 1];
|
|
153
|
+
tails[lo] = i;
|
|
154
|
+
}
|
|
155
|
+
const out = new Set();
|
|
156
|
+
for (let i = tails.at(-1) ?? -1; i >= 0; i = before[i])
|
|
157
|
+
out.add(i);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The operations that turn the old rows into the new ones (`null`: more
|
|
162
|
+
* than {@link PATCH_OPS} would be needed), and for each new row the old row
|
|
163
|
+
* whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
|
|
164
|
+
* have keys from the same field; otherwise by index.
|
|
165
|
+
*/
|
|
166
|
+
export function diff(old, next) {
|
|
167
|
+
const from = new Int32Array(next.enc.length).fill(-1);
|
|
168
|
+
const ops = old.keys && next.keys
|
|
169
|
+
? keyed(old.enc, old.keys, next.enc, next.keys, from)
|
|
170
|
+
: byIndex(old.enc, next.enc, from);
|
|
171
|
+
return ops && ops.length <= PATCH_OPS ? { ops, from } : null;
|
|
172
|
+
}
|
|
173
|
+
function byIndex(old, next, from) {
|
|
174
|
+
const min = Math.min(old.length, next.length);
|
|
175
|
+
let p = 0;
|
|
176
|
+
while (p < min && old[p] === next[p])
|
|
177
|
+
from[p] = p++;
|
|
178
|
+
let s = 0;
|
|
179
|
+
while (s < min - p && old[old.length - 1 - s] === next[next.length - 1 - s]) {
|
|
180
|
+
from[next.length - 1 - s] = old.length - 1 - s;
|
|
181
|
+
s++;
|
|
182
|
+
}
|
|
183
|
+
const ops = [];
|
|
184
|
+
const middle = Math.min(old.length, next.length) - p - s;
|
|
185
|
+
for (let i = p; i < p + middle; i++)
|
|
186
|
+
if (old[i] === next[i])
|
|
187
|
+
from[i] = i;
|
|
188
|
+
else
|
|
189
|
+
ops.push({ op: "set", at: i, row: i });
|
|
190
|
+
const at = p + middle;
|
|
191
|
+
if (next.length > old.length)
|
|
192
|
+
ops.push({
|
|
193
|
+
op: "insert",
|
|
194
|
+
at,
|
|
195
|
+
rows: Array.from({ length: next.length - old.length }, (_, k) => at + k),
|
|
196
|
+
});
|
|
197
|
+
else if (old.length > next.length)
|
|
198
|
+
ops.push({ op: "remove", at, count: old.length - next.length });
|
|
199
|
+
return ops;
|
|
200
|
+
}
|
|
201
|
+
function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
|
|
202
|
+
const sets = [];
|
|
203
|
+
const keep = (j, i) => {
|
|
204
|
+
if (oldEnc[i] === newEnc[j])
|
|
205
|
+
from[j] = i;
|
|
206
|
+
else
|
|
207
|
+
sets.push({ op: "set", at: j, row: j });
|
|
208
|
+
};
|
|
209
|
+
const min = Math.min(oldKeys.length, newKeys.length);
|
|
210
|
+
let p = 0;
|
|
211
|
+
while (p < min && oldKeys[p] === newKeys[p])
|
|
212
|
+
keep(p, p++);
|
|
213
|
+
let s = 0;
|
|
214
|
+
while (s < min - p &&
|
|
215
|
+
oldKeys[oldKeys.length - 1 - s] === newKeys[newKeys.length - 1 - s]) {
|
|
216
|
+
keep(newKeys.length - 1 - s, oldKeys.length - 1 - s);
|
|
217
|
+
s++;
|
|
218
|
+
}
|
|
219
|
+
const oldEnd = oldKeys.length - s;
|
|
220
|
+
const newEnd = newKeys.length - s;
|
|
221
|
+
// The new middle's keys, by position (relative to p).
|
|
222
|
+
const target = new Map();
|
|
223
|
+
for (let j = p; j < newEnd; j++)
|
|
224
|
+
target.set(newKeys[j], j - p);
|
|
225
|
+
const ops = [];
|
|
226
|
+
// Removes, from the end so that indices stay valid; runs merged.
|
|
227
|
+
const kept = [];
|
|
228
|
+
const oldAt = new Map();
|
|
229
|
+
for (let i = oldEnd - 1; i >= p; i--) {
|
|
230
|
+
if (target.has(oldKeys[i]))
|
|
231
|
+
continue;
|
|
232
|
+
const last = ops.at(-1);
|
|
233
|
+
if (last?.op === "remove" && last.at === i + 1) {
|
|
234
|
+
last.at = i;
|
|
235
|
+
last.count++;
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
ops.push({ op: "remove", at: i, count: 1 });
|
|
239
|
+
if (ops.length > PATCH_OPS)
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
for (let i = p; i < oldEnd; i++)
|
|
243
|
+
if (target.has(oldKeys[i])) {
|
|
244
|
+
oldAt.set(oldKeys[i], i);
|
|
245
|
+
kept.push(oldKeys[i]);
|
|
246
|
+
}
|
|
247
|
+
// Moves: every kept row outside a longest increasing run of target
|
|
248
|
+
// positions goes right after the kept row before it in the new order.
|
|
249
|
+
const positions = kept.map((key) => target.get(key));
|
|
250
|
+
const stay = increasing(positions);
|
|
251
|
+
if (kept.length - stay.size + ops.length > PATCH_OPS)
|
|
252
|
+
return null;
|
|
253
|
+
const stays = new Set();
|
|
254
|
+
for (const index of stay)
|
|
255
|
+
stays.add(kept[index]);
|
|
256
|
+
const current = kept.slice();
|
|
257
|
+
let previous;
|
|
258
|
+
for (let j = p; j < newEnd; j++) {
|
|
259
|
+
const key = newKeys[j];
|
|
260
|
+
if (!oldAt.has(key))
|
|
261
|
+
continue;
|
|
262
|
+
if (!stays.has(key)) {
|
|
263
|
+
const at = current.indexOf(key);
|
|
264
|
+
current.splice(at, 1);
|
|
265
|
+
const to = previous === undefined ? 0 : current.indexOf(previous) + 1;
|
|
266
|
+
current.splice(to, 0, key);
|
|
267
|
+
if (at !== to)
|
|
268
|
+
ops.push({ op: "move", from: p + at, count: 1, to: p + to });
|
|
269
|
+
}
|
|
270
|
+
previous = key;
|
|
271
|
+
}
|
|
272
|
+
// Inserts, from the top; runs merged.
|
|
273
|
+
for (let j = p; j < newEnd; j++) {
|
|
274
|
+
const key = newKeys[j];
|
|
275
|
+
if (oldAt.has(key)) {
|
|
276
|
+
keep(j, oldAt.get(key));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const last = ops.at(-1);
|
|
280
|
+
if (last?.op === "insert" && last.at + last.rows.length === j)
|
|
281
|
+
last.rows.push(j);
|
|
282
|
+
else
|
|
283
|
+
ops.push({ op: "insert", at: j, rows: [j] });
|
|
284
|
+
if (ops.length > PATCH_OPS)
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
sets.sort((a, b) => a.at - b.at);
|
|
288
|
+
for (const set of sets) {
|
|
289
|
+
ops.push(set);
|
|
290
|
+
if (ops.length > PATCH_OPS)
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return ops;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Apply planned operations to a copy of `old` (the rows' JSON), taking new
|
|
297
|
+
* rows from `next`: the check that a diff produces exactly the new rows.
|
|
298
|
+
*/
|
|
299
|
+
export function replay(old, ops, next) {
|
|
300
|
+
const rows = old.slice();
|
|
301
|
+
for (const op of ops)
|
|
302
|
+
switch (op.op) {
|
|
303
|
+
case "set":
|
|
304
|
+
rows[op.at] = next[op.row];
|
|
305
|
+
break;
|
|
306
|
+
case "insert":
|
|
307
|
+
rows.splice(op.at, 0, ...op.rows.map((j) => next[j]));
|
|
308
|
+
break;
|
|
309
|
+
case "remove":
|
|
310
|
+
rows.splice(op.at, op.count);
|
|
311
|
+
break;
|
|
312
|
+
case "move":
|
|
313
|
+
rows.splice(op.to, 0, ...rows.splice(op.from, op.count));
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
return rows;
|
|
317
|
+
}
|
|
318
|
+
/** Apply wire operations to an array (the TestHost's copy of a value). */
|
|
319
|
+
export function applyOps(rows, ops) {
|
|
320
|
+
const out = rows.slice();
|
|
321
|
+
for (const op of ops)
|
|
322
|
+
switch (op.op) {
|
|
323
|
+
case "set":
|
|
324
|
+
out[op.at] = op.row;
|
|
325
|
+
break;
|
|
326
|
+
case "insert":
|
|
327
|
+
out.splice(op.at, 0, ...op.rows);
|
|
328
|
+
break;
|
|
329
|
+
case "remove":
|
|
330
|
+
out.splice(op.at, op.count);
|
|
331
|
+
break;
|
|
332
|
+
case "move":
|
|
333
|
+
out.splice(op.to, 0, ...out.splice(op.from, op.count));
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
package/dist/plugin/runtime.d.ts
CHANGED
|
@@ -54,6 +54,12 @@ export declare class Runtime {
|
|
|
54
54
|
readonly app: App;
|
|
55
55
|
/** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
|
|
56
56
|
readonly locale: Locale;
|
|
57
|
+
/**
|
|
58
|
+
* Protocol additions the player advertises at start-up (`features` in the
|
|
59
|
+
* handshake's app data, player 0.6.0): `ui.patch` (row patches of
|
|
60
|
+
* property writes). Not part of the plugin API; empty for older players.
|
|
61
|
+
*/
|
|
62
|
+
readonly features: ReadonlySet<string>;
|
|
57
63
|
constructor(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string, app?: unknown);
|
|
58
64
|
can(capability: string): boolean;
|
|
59
65
|
/** Lazily created singletons shared by every scope of this plugin. */
|
package/dist/plugin/runtime.js
CHANGED
|
@@ -132,6 +132,12 @@ export class Runtime {
|
|
|
132
132
|
app;
|
|
133
133
|
/** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
|
|
134
134
|
locale;
|
|
135
|
+
/**
|
|
136
|
+
* Protocol additions the player advertises at start-up (`features` in the
|
|
137
|
+
* handshake's app data, player 0.6.0): `ui.patch` (row patches of
|
|
138
|
+
* property writes). Not part of the plugin API; empty for older players.
|
|
139
|
+
*/
|
|
140
|
+
features;
|
|
135
141
|
constructor(transport, manifest, directory, dataDirectory, app) {
|
|
136
142
|
this.#transport = transport;
|
|
137
143
|
this.dataDirectory = dataDirectory;
|
|
@@ -139,6 +145,11 @@ export class Runtime {
|
|
|
139
145
|
this.host = createWire(transport);
|
|
140
146
|
const host = this.host;
|
|
141
147
|
this.app = appInfo(app, () => host.app.system());
|
|
148
|
+
const features = app
|
|
149
|
+
?.features;
|
|
150
|
+
this.features = new Set(Array.isArray(features)
|
|
151
|
+
? features.filter((name) => typeof name === "string")
|
|
152
|
+
: []);
|
|
142
153
|
this.locale = LOCALES.includes(transport.locale ?? "")
|
|
143
154
|
? transport.locale
|
|
144
155
|
: "en";
|
package/dist/plugin/services.js
CHANGED
|
@@ -508,16 +508,38 @@ export const videoUpscaling = (scope) => {
|
|
|
508
508
|
if (!scope.runtime.can("video.upscaling"))
|
|
509
509
|
throw failure("capability", "video.upscaling must be declared in manifest");
|
|
510
510
|
};
|
|
511
|
+
// This plugin's own request, kept from every answer for `onChange`: the
|
|
512
|
+
// event leaves it out (it is each plugin's own), and only this plugin's
|
|
513
|
+
// `set` changes it.
|
|
514
|
+
const noted = (state) => {
|
|
515
|
+
enabledOf.set(scope.runtime, state.enabled);
|
|
516
|
+
return state;
|
|
517
|
+
};
|
|
511
518
|
return {
|
|
512
519
|
async state() {
|
|
513
520
|
declared();
|
|
514
|
-
return (await scope.runtime.host.video.upscalingState());
|
|
521
|
+
return noted(await scope.runtime.host.video.upscalingState());
|
|
515
522
|
},
|
|
516
523
|
async set(enabled) {
|
|
517
524
|
declared();
|
|
518
525
|
if (typeof enabled !== "boolean")
|
|
519
526
|
throw failure("invalid", "enabled must be true or false");
|
|
520
|
-
return (await scope.runtime.host.video.upscalingSet(enabled));
|
|
527
|
+
return noted(await scope.runtime.host.video.upscalingSet(enabled));
|
|
528
|
+
},
|
|
529
|
+
onChange(listener) {
|
|
530
|
+
declared();
|
|
531
|
+
const watch = scope.runtime.on("video.upscaling.changed", (data) => scope.run(listener, {
|
|
532
|
+
...data,
|
|
533
|
+
enabled: enabledOf.get(scope.runtime) ?? false,
|
|
534
|
+
}), "video.upscaling");
|
|
535
|
+
return scope.add({
|
|
536
|
+
dispose: async () => {
|
|
537
|
+
scope.forget(watch);
|
|
538
|
+
await watch.dispose();
|
|
539
|
+
},
|
|
540
|
+
});
|
|
521
541
|
},
|
|
522
542
|
};
|
|
523
543
|
};
|
|
544
|
+
/** Each plugin's own upscaling request, as its last answer said. */
|
|
545
|
+
const enabledOf = new WeakMap();
|
package/dist/plugin/settings.js
CHANGED
|
@@ -61,8 +61,8 @@ class Page extends ViewBase {
|
|
|
61
61
|
live() {
|
|
62
62
|
return this.#registered && !this.closed;
|
|
63
63
|
}
|
|
64
|
-
write(property,
|
|
65
|
-
return this.scope.runtime.host.settings.set(this.#id, property,
|
|
64
|
+
write(property, write) {
|
|
65
|
+
return this.scope.runtime.host.settings.set(this.#id, property, write);
|
|
66
66
|
}
|
|
67
67
|
async dispose() {
|
|
68
68
|
if (this.closed)
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { CommandDeclaration } from "../commands.ts";
|
|
|
16
16
|
import type { SourceState } from "../source.ts";
|
|
17
17
|
import type { SubtitleState } from "../subtitles.ts";
|
|
18
18
|
import { VIDEO_PROCESSING_LIMITS } from "../processing.ts";
|
|
19
|
-
import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingReason, ViewSettings } from "./types.ts";
|
|
19
|
+
import type { RenderingMode, RenderingPreference, RenderingReason, RenderSettings, UpscalingDecodingCause, UpscalingPlugin, UpscalingReason, UpscalingVideoSize, ViewSettings } from "./types.ts";
|
|
20
20
|
import type { MediaJob } from "../media.ts";
|
|
21
21
|
import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
|
|
22
22
|
import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
|
|
@@ -1862,7 +1862,71 @@ export declare class TestHost implements Transport {
|
|
|
1862
1862
|
checked: boolean;
|
|
1863
1863
|
/** GPU rendering is still starting: a `rendering` reason then lets `set(true)` keep the request. */
|
|
1864
1864
|
renderingStarting: boolean;
|
|
1865
|
+
/** The video's size the state reports (player 0.5.25); `null`: no video. */
|
|
1866
|
+
video: UpscalingVideoSize | null;
|
|
1867
|
+
/** The video area the state reports (player 0.5.25); `null`: none. */
|
|
1868
|
+
area: {
|
|
1869
|
+
/** Width in physical pixels. */
|
|
1870
|
+
width: number;
|
|
1871
|
+
/** Height in physical pixels. */
|
|
1872
|
+
height: number;
|
|
1873
|
+
} | null;
|
|
1874
|
+
/** The rendering GPU's name the state reports (player 0.5.25). */
|
|
1875
|
+
gpu: string | null;
|
|
1876
|
+
/** mpv's decoder the state reports (player 0.5.25); `null`: no video. */
|
|
1877
|
+
hwdecCurrent: string | null;
|
|
1865
1878
|
};
|
|
1879
|
+
/**
|
|
1880
|
+
* Make the upscaler's reason `decoding` with `cause`, with the player's
|
|
1881
|
+
* message (player 0.5.25), and set `upscaling.hwdecCurrent` to match
|
|
1882
|
+
* (`d3d11va-copy`, `no`, `nvdec`). `plugins` are named for `copy-back`
|
|
1883
|
+
* only, as the player does. Returns the reason; call
|
|
1884
|
+
* {@link TestHost.upscalingChanged} to tell a listening plugin.
|
|
1885
|
+
*
|
|
1886
|
+
* @example Test the window's advice for each cause
|
|
1887
|
+
* The plugin's window explains why upscaling waits. The test fakes the
|
|
1888
|
+
* copy-back cause that Picture Adjustments' filters cause and checks
|
|
1889
|
+
* that the window names the plugin.
|
|
1890
|
+
* ```ts
|
|
1891
|
+
* import assert from "node:assert/strict";
|
|
1892
|
+
* declare const host: TestHost;
|
|
1893
|
+
* // ---cut---
|
|
1894
|
+
* host.upscalingDecoding("copy-back", [{ id: "vestra.video", name: "Picture Adjustments" }]);
|
|
1895
|
+
* host.upscalingChanged();
|
|
1896
|
+
* await host.settle();
|
|
1897
|
+
* assert.match(String(host.window("upscale").props.blocker), /Picture Adjustments/);
|
|
1898
|
+
* ```
|
|
1899
|
+
*
|
|
1900
|
+
* @capability None.
|
|
1901
|
+
* @since 1.5.43
|
|
1902
|
+
*/
|
|
1903
|
+
upscalingDecoding(cause: UpscalingDecodingCause, plugins?: readonly UpscalingPlugin[]): UpscalingReason;
|
|
1904
|
+
/**
|
|
1905
|
+
* Send `video.upscaling.changed` with the state {@link TestHost.upscaling}
|
|
1906
|
+
* describes now (without `enabled`), as the player does when it changes
|
|
1907
|
+
* (player 0.5.25); a plugin listening with
|
|
1908
|
+
* `plugin.video.upscaling.onChange` receives it. Nothing reaches a plugin
|
|
1909
|
+
* that does not listen.
|
|
1910
|
+
*
|
|
1911
|
+
* @capability None.
|
|
1912
|
+
*
|
|
1913
|
+
* @example Show the new factor in the plugin's window
|
|
1914
|
+
* The window resized: the test changes the area and the factor, sends
|
|
1915
|
+
* the change, and checks that the plugin's window shows the new factor.
|
|
1916
|
+
* ```ts
|
|
1917
|
+
* import assert from "node:assert/strict";
|
|
1918
|
+
* declare const host: TestHost;
|
|
1919
|
+
* // ---cut---
|
|
1920
|
+
* host.upscaling.area = { width: 2560, height: 1440 };
|
|
1921
|
+
* host.upscaling.factor = 1.33; // 1080p in a 1440p area
|
|
1922
|
+
* host.upscalingChanged(); // as the player after the resize settled
|
|
1923
|
+
* await host.settle();
|
|
1924
|
+
* assert.equal(host.window("upscale").props.factor, "×1.33");
|
|
1925
|
+
* ```
|
|
1926
|
+
*
|
|
1927
|
+
* @since 1.5.43
|
|
1928
|
+
*/
|
|
1929
|
+
upscalingChanged(): void;
|
|
1866
1930
|
/** What `player.chapters()` lists while media is ready. */
|
|
1867
1931
|
chapters: {
|
|
1868
1932
|
/** The chapter title, `null` without one. */
|