@vestra-player/sdk 1.5.43 → 1.5.45
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 +21 -1
- package/dist/player-runtime.d.ts +1 -0
- package/dist/player-runtime.js +1 -0
- package/dist/plugin/index.js +4 -1
- package/dist/plugin/patch.d.ts +101 -0
- package/dist/plugin/patch.js +337 -0
- package/dist/plugin/player.d.ts +11 -0
- package/dist/plugin/player.js +80 -7
- package/dist/plugin/runtime.d.ts +64 -1
- package/dist/plugin/runtime.js +227 -16
- package/dist/plugin/scope.d.ts +9 -1
- package/dist/plugin/scope.js +24 -0
- package/dist/plugin/settings.js +2 -2
- package/dist/plugin/testing.d.ts +7 -0
- package/dist/plugin/testing.js +139 -14
- package/dist/plugin/types.d.ts +24 -15
- package/dist/plugin/ui.d.ts +7 -3
- package/dist/plugin/ui.js +236 -22
- package/dist/wire.d.ts +42 -3
- package/dist/wire.js +3 -3
- package/llms-full.txt +148 -26
- 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,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.45
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability, no player version needed. Only when the SDK re-evaluates bindings changes; what a plugin's views and commands show stays the same. One case can show in tests: a plugin that changes state outside a handler (a timer, a promise continuation after the handler returned, a child process) without calling `plugin.update()`, and a test that relied on `host.setSource(...)` to refresh it. Have the plugin call `plugin.update()` after such a change (as the documentation always said), or wait `await host.settle(1000)` in the test; `settle()` does not wait for the once-a-second full pass.
|
|
6
|
+
|
|
7
|
+
- **Precise update passes.** While media plays, the player sends its state about ten times a second, and each change used to re-run every `view.bind` function and every command `enabled`, `checked`, `busy` and `reason` function of the plugin. Now the SDK records which fields of `plugin.player.state` each of them read (`position`, `paused`, `phase`, `source`, …) and, after a player state change, re-runs only those that read a field that changed: a list bound in a panel no longer re-runs on every position tick. `isCurrent` and `Media.current` count as reading the source's identity.
|
|
8
|
+
- **Full passes as before** after any code of the plugin the SDK runs (a command's `run`, a window, panel or settings page callback, a listener, `onEnd`, and so on), when the promise it returned settles, after `plugin.update()`, and after every other state change (window, subtitles, panels, timeline, appearance). One exception keeps a playing list cheap: a `player.onChange` listener that returned synchronously and read only fields of the state that did not change is not followed by a full pass (a listener that read a changed field, no field at all, or returned a promise still is).
|
|
9
|
+
- **A full pass at least once a second while precise passes skip something.** State a plugin changes outside code the SDK runs (its own `setInterval`, a socket callback) without calling `plugin.update()` used to show at the next player state change (within about 0.1 s while playing); now it shows at the latest about 1 s later. Call `plugin.update()` after such a change, as the documentation always said.
|
|
10
|
+
- **`plugin.player.state`** reads as before (the same keys in the same order, frozen, JSON, spread, `structuredClone`, `util.inspect` and `assert.deepEqual` give the same values); its fields are getters now, so `Object.getOwnPropertyDescriptor` shows `get` instead of `value`, and `Reflect.ownKeys` and `Object.getOwnPropertySymbols` also list `Symbol(nodejs.util.inspect.custom)` (not enumerable).
|
|
11
|
+
- **`Scrub.active`** turning false (a new source, a failed seek) now causes a full pass, so a binding that reads it follows.
|
|
12
|
+
- **`TestHost`** runs the same code: the plugin's tests see the same passes as the player. `settle()` does not wait for the once-a-second full pass; `settle(1000)` does (`TestHost.settle`, _Test a plugin_ say so).
|
|
13
|
+
|
|
14
|
+
## 1.5.44
|
|
15
|
+
|
|
16
|
+
**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).
|
|
17
|
+
|
|
18
|
+
- **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).
|
|
19
|
+
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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **`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.
|
|
22
|
+
- **`@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.
|
|
23
|
+
|
|
3
24
|
## 1.5.43
|
|
4
25
|
|
|
5
26
|
**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.
|
|
@@ -10,7 +31,6 @@
|
|
|
10
31
|
- **`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.
|
|
11
32
|
- The player's capability table copy (`video.upscaling.changed` under `video.upscaling`) and `CAPABILITY_INFO` (`VideoUpscaling.onChange`) follow.
|
|
12
33
|
|
|
13
|
-
|
|
14
34
|
## 1.5.42
|
|
15
35
|
|
|
16
36
|
**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/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";
|
package/dist/plugin/index.js
CHANGED
|
@@ -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/player.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { Latest } from "./runtime.ts";
|
|
|
3
3
|
import type { Runtime } from "./runtime.ts";
|
|
4
4
|
import type { ScopeImpl } from "./scope.ts";
|
|
5
5
|
import type { Conditional, Disposable, Player, PlayerState, PluginError } from "./types.ts";
|
|
6
|
+
/**
|
|
7
|
+
* The key of `plugin.player.state` in the runtime's field tracking
|
|
8
|
+
* (`Runtime.depends`). Its fields are the state's own, plus `stamp` for
|
|
9
|
+
* what `isCurrent` and `Media.current` compare.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PLAYER = "player";
|
|
6
12
|
export declare const rawOf: (state: PlayerState) => SourceState | undefined;
|
|
7
13
|
/** A step-seek length the player accepts: 0.1–3600 seconds in tenths. */
|
|
8
14
|
export declare const validStep: (seconds: unknown) => seconds is number;
|
|
@@ -14,6 +20,11 @@ export declare class PlayerService {
|
|
|
14
20
|
readonly runtime: Runtime;
|
|
15
21
|
raw: SourceState;
|
|
16
22
|
state: PlayerState;
|
|
23
|
+
/**
|
|
24
|
+
* The fields the last accepted state changed (`onChange` listeners are
|
|
25
|
+
* told so a listener that read none of them needs no full pass).
|
|
26
|
+
*/
|
|
27
|
+
change: ReadonlyMap<string, ReadonlySet<string>>;
|
|
17
28
|
readonly output: Latest<{
|
|
18
29
|
volume?: number;
|
|
19
30
|
muted?: boolean;
|
package/dist/plugin/player.js
CHANGED
|
@@ -40,9 +40,63 @@ const audioTrack = (track) => Object.freeze({
|
|
|
40
40
|
title: track.title,
|
|
41
41
|
language: track.language,
|
|
42
42
|
});
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The key of `plugin.player.state` in the runtime's field tracking
|
|
45
|
+
* (`Runtime.depends`). Its fields are the state's own, plus `stamp` for
|
|
46
|
+
* what `isCurrent` and `Media.current` compare.
|
|
47
|
+
*/
|
|
48
|
+
export const PLAYER = "player";
|
|
49
|
+
/** `util.inspect` shows a state's values, not its getters. */
|
|
50
|
+
const INSPECT = Symbol.for("nodejs.util.inspect.custom");
|
|
51
|
+
const values = new WeakMap();
|
|
52
|
+
/**
|
|
53
|
+
* The state a plugin sees: each field a getter over the same values, so the
|
|
54
|
+
* SDK learns which fields a binding or listener read (`Runtime.depends`).
|
|
55
|
+
* It behaves like the plain frozen object it was: same keys in the same
|
|
56
|
+
* order, enumerable, JSON, spread, `structuredClone` and `util.inspect`
|
|
57
|
+
* give the values.
|
|
58
|
+
*/
|
|
59
|
+
function tracked(runtime, data) {
|
|
60
|
+
const state = {};
|
|
61
|
+
for (const key of Object.keys(data)) {
|
|
62
|
+
const value = data[key];
|
|
63
|
+
Object.defineProperty(state, key, {
|
|
64
|
+
enumerable: true,
|
|
65
|
+
get: () => {
|
|
66
|
+
runtime?.depends(PLAYER, key);
|
|
67
|
+
return value;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
Object.defineProperty(state, INSPECT, { value: () => data });
|
|
72
|
+
const frozen = Object.freeze(state);
|
|
73
|
+
values.set(frozen, data);
|
|
74
|
+
return frozen;
|
|
75
|
+
}
|
|
76
|
+
/** The fields of `next` that differ from `last` (`stamp` included). */
|
|
77
|
+
function changes(last, lastRaw, next, nextRaw) {
|
|
78
|
+
const a = values.get(last);
|
|
79
|
+
const b = values.get(next);
|
|
80
|
+
const changed = [];
|
|
81
|
+
for (const key of Object.keys(b)) {
|
|
82
|
+
const x = a[key];
|
|
83
|
+
const y = b[key];
|
|
84
|
+
if (x === y)
|
|
85
|
+
continue;
|
|
86
|
+
if (x === null ||
|
|
87
|
+
y === null ||
|
|
88
|
+
typeof x !== "object" ||
|
|
89
|
+
typeof y !== "object" ||
|
|
90
|
+
JSON.stringify(x) !== JSON.stringify(y))
|
|
91
|
+
changed.push(key);
|
|
92
|
+
}
|
|
93
|
+
if (!sameStamp(lastRaw.stamp, nextRaw.stamp))
|
|
94
|
+
changed.push("stamp");
|
|
95
|
+
return changed;
|
|
96
|
+
}
|
|
97
|
+
function view(raw, runtime) {
|
|
44
98
|
const reason = raw.terminalReason;
|
|
45
|
-
const
|
|
99
|
+
const data = Object.freeze({
|
|
46
100
|
sourceId: raw.stamp.generation,
|
|
47
101
|
kind: raw.kind === "producer" ? "stream" : "media",
|
|
48
102
|
phase: raw.phase,
|
|
@@ -78,6 +132,7 @@ function view(raw) {
|
|
|
78
132
|
active: raw.audioOutput?.active ?? null,
|
|
79
133
|
}),
|
|
80
134
|
});
|
|
135
|
+
const state = tracked(runtime, data);
|
|
81
136
|
raws.set(state, raw);
|
|
82
137
|
return state;
|
|
83
138
|
}
|
|
@@ -110,12 +165,18 @@ export function abortError() {
|
|
|
110
165
|
export class PlayerService {
|
|
111
166
|
runtime;
|
|
112
167
|
raw = empty;
|
|
113
|
-
state
|
|
168
|
+
state;
|
|
169
|
+
/**
|
|
170
|
+
* The fields the last accepted state changed (`onChange` listeners are
|
|
171
|
+
* told so a listener that read none of them needs no full pass).
|
|
172
|
+
*/
|
|
173
|
+
change = new Map();
|
|
114
174
|
#watchers = new Set();
|
|
115
175
|
#operation = 0;
|
|
116
176
|
output;
|
|
117
177
|
constructor(runtime) {
|
|
118
178
|
this.runtime = runtime;
|
|
179
|
+
this.state = view(empty, runtime);
|
|
119
180
|
// The SDK's copy of the state (a mirror): a plugin listens through
|
|
120
181
|
// `watch` and the bindings that read `state`.
|
|
121
182
|
runtime.on("source.state", (data) => this.accept(data), "events", true);
|
|
@@ -137,12 +198,16 @@ export class PlayerService {
|
|
|
137
198
|
return;
|
|
138
199
|
if (JSON.stringify(raw) === JSON.stringify(this.raw))
|
|
139
200
|
return;
|
|
201
|
+
const last = this.state;
|
|
202
|
+
const lastRaw = this.raw;
|
|
140
203
|
this.raw = raw;
|
|
141
|
-
this.state = view(raw);
|
|
204
|
+
this.state = view(raw, this.runtime);
|
|
205
|
+
const changed = changes(last, lastRaw, this.state, raw);
|
|
206
|
+
this.change = new Map([[PLAYER, new Set(changed)]]);
|
|
142
207
|
for (const watcher of [...this.#watchers])
|
|
143
208
|
watcher(raw);
|
|
144
|
-
//
|
|
145
|
-
this.runtime.
|
|
209
|
+
// A precise pass: only the bindings that read a changed field.
|
|
210
|
+
this.runtime.changed(PLAYER, changed);
|
|
146
211
|
}
|
|
147
212
|
watch(watcher) {
|
|
148
213
|
this.#watchers.add(watcher);
|
|
@@ -244,6 +309,7 @@ class MediaHandle {
|
|
|
244
309
|
this.#ticket = ticket;
|
|
245
310
|
}
|
|
246
311
|
get current() {
|
|
312
|
+
this.#service.runtime.depends(PLAYER, "stamp");
|
|
247
313
|
return this.#service.raw.stamp.generation === this.#ticket.stamp.generation;
|
|
248
314
|
}
|
|
249
315
|
ready(options = {}) {
|
|
@@ -313,7 +379,9 @@ class ScrubHandle {
|
|
|
313
379
|
#pending = null;
|
|
314
380
|
#watch;
|
|
315
381
|
#queue;
|
|
382
|
+
#service;
|
|
316
383
|
constructor(service) {
|
|
384
|
+
this.#service = service;
|
|
317
385
|
this.#allowed = { ...service.raw.stamp };
|
|
318
386
|
this.#watch = service.watch((raw) => {
|
|
319
387
|
if (!sameStamp(raw.stamp, this.#allowed) &&
|
|
@@ -377,6 +445,9 @@ class ScrubHandle {
|
|
|
377
445
|
this.#active = false;
|
|
378
446
|
this.#queue.stop();
|
|
379
447
|
void this.#watch.dispose();
|
|
448
|
+
// `active` changed, maybe outside plugin code (a new source, a failed
|
|
449
|
+
// seek): bindings that read it see it in a full pass.
|
|
450
|
+
this.#service.runtime.invalidate();
|
|
380
451
|
}
|
|
381
452
|
}
|
|
382
453
|
const superseded = new HostError("superseded", "gesture ended");
|
|
@@ -434,10 +505,11 @@ export function player(scope) {
|
|
|
434
505
|
return {
|
|
435
506
|
get state() {
|
|
436
507
|
scope.runtime.reading("source.state");
|
|
508
|
+
scope.runtime.depends(PLAYER);
|
|
437
509
|
return service().state;
|
|
438
510
|
},
|
|
439
511
|
onChange(listener) {
|
|
440
|
-
const watch = service().watch(() => scope.
|
|
512
|
+
const watch = service().watch(() => scope.runOn(service().change, listener, service().state));
|
|
441
513
|
return scope.add({
|
|
442
514
|
dispose: async () => {
|
|
443
515
|
scope.forget(watch);
|
|
@@ -448,6 +520,7 @@ export function player(scope) {
|
|
|
448
520
|
refresh: () => service().refresh(),
|
|
449
521
|
isCurrent(state) {
|
|
450
522
|
const raw = raws.get(state);
|
|
523
|
+
scope.runtime.depends(PLAYER, "stamp");
|
|
451
524
|
return !!raw && sameStamp(raw.stamp, service().raw.stamp);
|
|
452
525
|
},
|
|
453
526
|
async open(source, options = {}) {
|