@vestra-player/sdk 1.5.44 → 1.5.46
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 +26 -0
- package/bin/vestra-plugin.mjs +59 -1
- package/dist/plugin/index.js +4 -1
- package/dist/plugin/patch.d.ts +52 -1
- package/dist/plugin/patch.js +104 -6
- package/dist/plugin/player.d.ts +11 -0
- package/dist/plugin/player.js +80 -7
- package/dist/plugin/runtime.d.ts +58 -1
- package/dist/plugin/runtime.js +216 -16
- package/dist/plugin/scope.d.ts +9 -1
- package/dist/plugin/scope.js +24 -0
- package/dist/plugin/testing.d.ts +7 -0
- package/dist/plugin/testing.js +67 -3
- package/dist/plugin/types.d.ts +41 -29
- package/dist/plugin/ui.d.ts +13 -3
- package/dist/plugin/ui.js +184 -16
- package/dist/plugin/values.d.ts +21 -36
- package/dist/plugin/values.js +22 -37
- package/dist/wire.d.ts +3 -0
- package/llms-full.txt +92 -76
- package/llms.txt +23 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.46
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody.** No changes to the TypeScript API, `apiVersion` stays `vestra/1`, no new capability. The new limits and the split writes take effect with player 0.6.0 (large lists P3); with older players arrays are sent whole as before and the old limits apply there. **A plugin that binds a whole long list depends on them: set `"minPlayerVersion": "0.6.0"`.**
|
|
6
|
+
|
|
7
|
+
- **Whole lists.** `UI_VALUE_LIMITS` follow player 0.6.0: 100,000 rows per array (was 4,096), 1,048,576 values (was 16,384) and 32 MiB of text (was 1 MiB) in one property value. A plugin binds its whole list; a long list should be drawn with a Slint `ListView` (a `for` builds every row). The windowing example is replaced by a whole-list one.
|
|
8
|
+
- **Writes larger than one request are split.** The protocol line stays 1 MiB (`MESSAGE_LIMIT`). With a player that takes row patches:
|
|
9
|
+
- an array whose rows exceed 512 KiB (`CHUNK_BYTES`, internal) is sent as a whole write of its first rows and `insert` patches of the rest, each applying on the one before; every part but the last carries `more: true`, and the player holds them and writes the whole value at the last, so a list it already shows changes once (its scroll position and selection stay);
|
|
10
|
+
- a patch carrying more is sent as several patches, each applying on the one before; a diff may now plan up to 16,384 operations (at most 1,024 moves), sent 1,024 at most per patch, before the value goes whole;
|
|
11
|
+
- the initial properties of `panels.mount`, `ui.open` and `settings.page` carry at most 512 KiB of rows together: the first rows show at once and the rest follows as patches on the first update;
|
|
12
|
+
- only a property's top-level array is split, and not one whose rows hold Markdown documents (sent whole as before: the player merges documents in place); any other value, and each row, must fit in one request. A row of more than 512 KiB, or a value over the limits, is refused with `limit` before any part is sent;
|
|
13
|
+
- when the view closes or shows its initial properties again while a write goes in parts, the rest is dropped without an error and the view's next update sends what it shows; any other failure part-way leaves no base, so the next write of the property is whole (in parts again).
|
|
14
|
+
- **`vestra-plugin check` warns** (a reminder, never a failure) where a plugin's .slint file puts a `for` over an array it binds (an `in` or `in-out` array property) outside a `ListView`: such a `for` builds every row up front.
|
|
15
|
+
- **`null` for an image in a row** shows no picture (player 0.6.0), so rows without a thumbnail need no placeholder image.
|
|
16
|
+
- **`TestHost`** enforces the new limits and takes the split writes like the player (each line within `MESSAGE_LIMIT`, parts with `more` held until the last); `props` hold whole values as before.
|
|
17
|
+
|
|
18
|
+
## 1.5.45
|
|
19
|
+
|
|
20
|
+
**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.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **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).
|
|
24
|
+
- **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.
|
|
25
|
+
- **`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).
|
|
26
|
+
- **`Scrub.active`** turning false (a new source, a failed seek) now causes a full pass, so a binding that reads it follows.
|
|
27
|
+
- **`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).
|
|
28
|
+
|
|
3
29
|
## 1.5.44
|
|
4
30
|
|
|
5
31
|
**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).
|
package/bin/vestra-plugin.mjs
CHANGED
|
@@ -357,7 +357,10 @@ async function check(target, report = null) {
|
|
|
357
357
|
}
|
|
358
358
|
say(`${target.name}: capabilities declared for every API use`);
|
|
359
359
|
if (report) return;
|
|
360
|
-
for (const line of
|
|
360
|
+
for (const line of [
|
|
361
|
+
...(await bottomBarWarnings(target)),
|
|
362
|
+
...(await forWarnings(target)),
|
|
363
|
+
])
|
|
361
364
|
console.log(`${target.name}: warning: ${line}`);
|
|
362
365
|
// Translation warnings never fail the check.
|
|
363
366
|
if (present(join(target.directory, "locales"))) {
|
|
@@ -502,6 +505,61 @@ async function bottomBarWarnings(target) {
|
|
|
502
505
|
return warnings;
|
|
503
506
|
}
|
|
504
507
|
|
|
508
|
+
/**
|
|
509
|
+
* A `for` over an array the plugin binds (an `in` or `in-out` property of
|
|
510
|
+
* array type of a component or global in the same file), outside a
|
|
511
|
+
* `ListView`, in the plugin's .slint files: a reminder, not a failure
|
|
512
|
+
* (SDK 1.5.46). Such a `for` builds every row of the array up front; since
|
|
513
|
+
* player 0.6.0 a plugin may bind a whole long list, which belongs in a
|
|
514
|
+
* `ListView` (it builds only the rows on screen). A text scan: imported
|
|
515
|
+
* components and `@children` are not followed.
|
|
516
|
+
*/
|
|
517
|
+
async function forWarnings(target) {
|
|
518
|
+
const { slint } = await sourceFiles(target);
|
|
519
|
+
const warnings = [];
|
|
520
|
+
for (const file of slint) {
|
|
521
|
+
const text = slintCode(
|
|
522
|
+
await readFile(join(target.directory, file), "utf8"),
|
|
523
|
+
);
|
|
524
|
+
const arrays = new Set(
|
|
525
|
+
[
|
|
526
|
+
...text.matchAll(
|
|
527
|
+
/\b(?:in|in-out)\s+property\s*<\s*\[[^\]]*\]\s*>\s*([A-Za-z_][\w-]*)/g,
|
|
528
|
+
),
|
|
529
|
+
].map((match) => match[1]),
|
|
530
|
+
);
|
|
531
|
+
if (!arrays.size) continue;
|
|
532
|
+
// The elements open at each point: the name before each `{`.
|
|
533
|
+
const open = [];
|
|
534
|
+
let line = 1;
|
|
535
|
+
for (let i = 0; i < text.length; i++) {
|
|
536
|
+
const c = text[i];
|
|
537
|
+
if (c === "\n") line++;
|
|
538
|
+
else if (c === "{") {
|
|
539
|
+
const name = /([A-Za-z_][\w-]*)\s*$/.exec(
|
|
540
|
+
text.slice(Math.max(0, i - 80), i),
|
|
541
|
+
);
|
|
542
|
+
open.push(name ? name[1] : "");
|
|
543
|
+
} else if (c === "}") open.pop();
|
|
544
|
+
else if (
|
|
545
|
+
text.startsWith("for", i) &&
|
|
546
|
+
!/[\w-]/.test(text[i - 1] ?? " ") &&
|
|
547
|
+
/\s/.test(text[i + 3] ?? "")
|
|
548
|
+
) {
|
|
549
|
+
const loop =
|
|
550
|
+
/^for\s+[\w-]+(?:\s*\[\s*[\w-]+\s*\])?\s+in\s+([\w.-]+)/.exec(
|
|
551
|
+
text.slice(i, i + 200),
|
|
552
|
+
);
|
|
553
|
+
const array = loop?.[1].split(".").at(-1);
|
|
554
|
+
if (array && arrays.has(array) && !open.includes("ListView"))
|
|
555
|
+
warnings.push(
|
|
556
|
+
`${file.replaceAll("\\", "/")}:${line}: \`for\` over the bound array \`${array}\` outside a ListView builds every row; a long list should use a ListView (docs/reference/limits.md)`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return warnings;
|
|
562
|
+
}
|
|
505
563
|
/** The plugin's own production .ts / .mts and .slint files. */
|
|
506
564
|
async function sourceFiles(target) {
|
|
507
565
|
const all = await files(target.directory);
|
package/dist/plugin/index.js
CHANGED
package/dist/plugin/patch.d.ts
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
export declare const PATCH_FEATURE = "ui.patch";
|
|
3
3
|
/** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
|
|
4
4
|
export declare const PATCH_OPS = 1024;
|
|
5
|
+
/**
|
|
6
|
+
* The most operations one diff plans (SDK 1.5.46): its patch is sent as
|
|
7
|
+
* several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
|
|
8
|
+
* (in parts, which the player writes at once).
|
|
9
|
+
*/
|
|
10
|
+
export declare const DIFF_OPS: number;
|
|
5
11
|
/**
|
|
6
12
|
* Fields that identify a row, in the order they are tried. A field is used
|
|
7
13
|
* when every row has it, as a string or a number, and no two rows share it.
|
|
@@ -61,7 +67,8 @@ export declare function countRow(row: unknown): RowCount;
|
|
|
61
67
|
export declare function withinLimits(counts: readonly RowCount[]): boolean;
|
|
62
68
|
/**
|
|
63
69
|
* The operations that turn the old rows into the new ones (`null`: more
|
|
64
|
-
* than {@link
|
|
70
|
+
* than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
|
|
71
|
+
* moves), and for each new row the old row
|
|
65
72
|
* whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
|
|
66
73
|
* have keys from the same field; otherwise by index.
|
|
67
74
|
*/
|
|
@@ -99,3 +106,47 @@ export declare function applyOps<T>(rows: T[], ops: readonly ({
|
|
|
99
106
|
count: number;
|
|
100
107
|
to: number;
|
|
101
108
|
})[]): T[];
|
|
109
|
+
/**
|
|
110
|
+
* The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
|
|
111
|
+
* half the 1 MiB protocol line, which leaves room for the request around
|
|
112
|
+
* them. A larger whole write is sent as its first rows and `insert`
|
|
113
|
+
* patches of the rest; a larger patch as several patches, each applying on
|
|
114
|
+
* the one before (the player's doc/architecture/large-lists.md §4).
|
|
115
|
+
*/
|
|
116
|
+
export declare const CHUNK_BYTES: number;
|
|
117
|
+
/** The bytes a row adds to a write (its JSON and a separator). */
|
|
118
|
+
export declare const rowBytes: (row: unknown) => number;
|
|
119
|
+
/**
|
|
120
|
+
* How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
|
|
121
|
+
* bytes; at least one when there are rows (a row over the budget alone is
|
|
122
|
+
* left to the player's limits).
|
|
123
|
+
*/
|
|
124
|
+
export declare function prefixWithin(sizes: readonly number[], budget: number): number;
|
|
125
|
+
/** A wire operation (`set` and `insert` carry rows). */
|
|
126
|
+
export type WireOp = {
|
|
127
|
+
op: "set";
|
|
128
|
+
at: number;
|
|
129
|
+
row: unknown;
|
|
130
|
+
} | {
|
|
131
|
+
op: "insert";
|
|
132
|
+
at: number;
|
|
133
|
+
rows: unknown[];
|
|
134
|
+
} | {
|
|
135
|
+
op: "remove";
|
|
136
|
+
at: number;
|
|
137
|
+
count: number;
|
|
138
|
+
} | {
|
|
139
|
+
op: "move";
|
|
140
|
+
from: number;
|
|
141
|
+
count: number;
|
|
142
|
+
to: number;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
|
|
146
|
+
* {@link PATCH_OPS} operations each, in order: applying them one after the
|
|
147
|
+
* other gives what `ops` give. An `insert` of more rows than fit is split
|
|
148
|
+
* into inserts of consecutive runs.
|
|
149
|
+
*/
|
|
150
|
+
export declare function chunkOps<T extends WireOp>(ops: readonly T[]): T[][];
|
|
151
|
+
/** The bytes a row already in JSON adds to a write (and a separator). */
|
|
152
|
+
export declare const jsonBytes: (json: string) => number;
|
package/dist/plugin/patch.js
CHANGED
|
@@ -9,6 +9,12 @@ import { UI_VALUE_LIMITS } from "./values.js";
|
|
|
9
9
|
export const PATCH_FEATURE = "ui.patch";
|
|
10
10
|
/** The most operations one patch carries (the player's `MAX_PATCH_OPS`). */
|
|
11
11
|
export const PATCH_OPS = 1024;
|
|
12
|
+
/**
|
|
13
|
+
* The most operations one diff plans (SDK 1.5.46): its patch is sent as
|
|
14
|
+
* several of at most {@link PATCH_OPS} each (`chunkOps`); more is sent whole
|
|
15
|
+
* (in parts, which the player writes at once).
|
|
16
|
+
*/
|
|
17
|
+
export const DIFF_OPS = 16 * PATCH_OPS;
|
|
12
18
|
/**
|
|
13
19
|
* Fields that identify a row, in the order they are tried. A field is used
|
|
14
20
|
* when every row has it, as a string or a number, and no two rows share it.
|
|
@@ -159,7 +165,8 @@ function increasing(values) {
|
|
|
159
165
|
}
|
|
160
166
|
/**
|
|
161
167
|
* The operations that turn the old rows into the new ones (`null`: more
|
|
162
|
-
* than {@link
|
|
168
|
+
* than {@link DIFF_OPS} would be needed, or more than {@link PATCH_OPS}
|
|
169
|
+
* moves), and for each new row the old row
|
|
163
170
|
* whose JSON it keeps (`-1`: a new or changed row). Keyed when both arrays
|
|
164
171
|
* have keys from the same field; otherwise by index.
|
|
165
172
|
*/
|
|
@@ -168,7 +175,7 @@ export function diff(old, next) {
|
|
|
168
175
|
const ops = old.keys && next.keys
|
|
169
176
|
? keyed(old.enc, old.keys, next.enc, next.keys, from)
|
|
170
177
|
: byIndex(old.enc, next.enc, from);
|
|
171
|
-
return ops && ops.length <=
|
|
178
|
+
return ops && ops.length <= DIFF_OPS ? { ops, from } : null;
|
|
172
179
|
}
|
|
173
180
|
function byIndex(old, next, from) {
|
|
174
181
|
const min = Math.min(old.length, next.length);
|
|
@@ -236,7 +243,7 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
|
|
|
236
243
|
}
|
|
237
244
|
else
|
|
238
245
|
ops.push({ op: "remove", at: i, count: 1 });
|
|
239
|
-
if (ops.length >
|
|
246
|
+
if (ops.length > DIFF_OPS)
|
|
240
247
|
return null;
|
|
241
248
|
}
|
|
242
249
|
for (let i = p; i < oldEnd; i++)
|
|
@@ -248,7 +255,10 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
|
|
|
248
255
|
// positions goes right after the kept row before it in the new order.
|
|
249
256
|
const positions = kept.map((key) => target.get(key));
|
|
250
257
|
const stay = increasing(positions);
|
|
251
|
-
|
|
258
|
+
// Moves cost a search each: at most PATCH_OPS of them.
|
|
259
|
+
if (kept.length - stay.size > PATCH_OPS)
|
|
260
|
+
return null;
|
|
261
|
+
if (kept.length - stay.size + ops.length > DIFF_OPS)
|
|
252
262
|
return null;
|
|
253
263
|
const stays = new Set();
|
|
254
264
|
for (const index of stay)
|
|
@@ -281,13 +291,13 @@ function keyed(oldEnc, oldKeys, newEnc, newKeys, from) {
|
|
|
281
291
|
last.rows.push(j);
|
|
282
292
|
else
|
|
283
293
|
ops.push({ op: "insert", at: j, rows: [j] });
|
|
284
|
-
if (ops.length >
|
|
294
|
+
if (ops.length > DIFF_OPS)
|
|
285
295
|
return null;
|
|
286
296
|
}
|
|
287
297
|
sets.sort((a, b) => a.at - b.at);
|
|
288
298
|
for (const set of sets) {
|
|
289
299
|
ops.push(set);
|
|
290
|
-
if (ops.length >
|
|
300
|
+
if (ops.length > DIFF_OPS)
|
|
291
301
|
return null;
|
|
292
302
|
}
|
|
293
303
|
return ops;
|
|
@@ -335,3 +345,91 @@ export function applyOps(rows, ops) {
|
|
|
335
345
|
}
|
|
336
346
|
return out;
|
|
337
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* The bytes of rows one write carries at most (SDK 1.5.46, player 0.6.0):
|
|
350
|
+
* half the 1 MiB protocol line, which leaves room for the request around
|
|
351
|
+
* them. A larger whole write is sent as its first rows and `insert`
|
|
352
|
+
* patches of the rest; a larger patch as several patches, each applying on
|
|
353
|
+
* the one before (the player's doc/architecture/large-lists.md §4).
|
|
354
|
+
*/
|
|
355
|
+
export const CHUNK_BYTES = 512 * 1024;
|
|
356
|
+
/** About what an operation adds to a request besides its rows. */
|
|
357
|
+
const OP_BYTES = 64;
|
|
358
|
+
/** UTF-8 bytes of `text` (JSON of a row), without encoding it. */
|
|
359
|
+
function utf8Bytes(text) {
|
|
360
|
+
let bytes = text.length;
|
|
361
|
+
for (let i = 0; i < text.length; i++) {
|
|
362
|
+
const code = text.charCodeAt(i);
|
|
363
|
+
if (code >= 0x80)
|
|
364
|
+
bytes += code >= 0x800 && (code < 0xd800 || code > 0xdfff) ? 2 : 1;
|
|
365
|
+
}
|
|
366
|
+
return bytes;
|
|
367
|
+
}
|
|
368
|
+
/** The bytes a row adds to a write (its JSON and a separator). */
|
|
369
|
+
export const rowBytes = (row) => utf8Bytes(JSON.stringify(row) ?? "null") + 1;
|
|
370
|
+
/**
|
|
371
|
+
* How many of the first rows (by their sizes, `rowBytes`) fit in `budget`
|
|
372
|
+
* bytes; at least one when there are rows (a row over the budget alone is
|
|
373
|
+
* left to the player's limits).
|
|
374
|
+
*/
|
|
375
|
+
export function prefixWithin(sizes, budget) {
|
|
376
|
+
let used = 0;
|
|
377
|
+
for (let i = 0; i < sizes.length; i++) {
|
|
378
|
+
used += sizes[i];
|
|
379
|
+
if (used > budget)
|
|
380
|
+
return Math.max(i, 1);
|
|
381
|
+
}
|
|
382
|
+
return sizes.length;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* `ops` as consecutive patches of at most {@link CHUNK_BYTES} of rows and
|
|
386
|
+
* {@link PATCH_OPS} operations each, in order: applying them one after the
|
|
387
|
+
* other gives what `ops` give. An `insert` of more rows than fit is split
|
|
388
|
+
* into inserts of consecutive runs.
|
|
389
|
+
*/
|
|
390
|
+
export function chunkOps(ops) {
|
|
391
|
+
const groups = [];
|
|
392
|
+
let group = [];
|
|
393
|
+
let bytes = 0;
|
|
394
|
+
const push = (op, size) => {
|
|
395
|
+
if (group.length &&
|
|
396
|
+
(bytes + size > CHUNK_BYTES || group.length >= PATCH_OPS)) {
|
|
397
|
+
groups.push(group);
|
|
398
|
+
group = [];
|
|
399
|
+
bytes = 0;
|
|
400
|
+
}
|
|
401
|
+
group.push(op);
|
|
402
|
+
bytes += size;
|
|
403
|
+
};
|
|
404
|
+
for (const op of ops) {
|
|
405
|
+
if (op.op !== "insert") {
|
|
406
|
+
push(op, OP_BYTES + (op.op === "set" ? rowBytes(op.row) : 0));
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const sizes = op.rows.map(rowBytes);
|
|
410
|
+
let start = 0;
|
|
411
|
+
while (start < sizes.length) {
|
|
412
|
+
const room = Math.max(CHUNK_BYTES - bytes - OP_BYTES, 0);
|
|
413
|
+
let count = prefixWithin(sizes.slice(start), room);
|
|
414
|
+
// Nothing fits beside this group's rows: the run starts a new patch.
|
|
415
|
+
if (room === 0 ||
|
|
416
|
+
sizes.slice(start, start + count).reduce((a, b) => a + b, 0) > room) {
|
|
417
|
+
if (group.length) {
|
|
418
|
+
groups.push(group);
|
|
419
|
+
group = [];
|
|
420
|
+
bytes = 0;
|
|
421
|
+
}
|
|
422
|
+
count = prefixWithin(sizes.slice(start), CHUNK_BYTES - OP_BYTES);
|
|
423
|
+
}
|
|
424
|
+
const rows = op.rows.slice(start, start + count);
|
|
425
|
+
const size = OP_BYTES + sizes.slice(start, start + count).reduce((a, b) => a + b, 0);
|
|
426
|
+
push({ op: "insert", at: op.at + start, rows }, size);
|
|
427
|
+
start += count;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (group.length)
|
|
431
|
+
groups.push(group);
|
|
432
|
+
return groups;
|
|
433
|
+
}
|
|
434
|
+
/** The bytes a row already in JSON adds to a write (and a separator). */
|
|
435
|
+
export const jsonBytes = (json) => utf8Bytes(json) + 1;
|
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 = {}) {
|
package/dist/plugin/runtime.d.ts
CHANGED
|
@@ -23,6 +23,20 @@ export declare const failure: (code: string, message?: string) => PluginError;
|
|
|
23
23
|
export declare const uncertain: (error: unknown) => boolean;
|
|
24
24
|
/** Mark a promise as observed so an unused `ready` never crashes the runner. */
|
|
25
25
|
export declare function quiet<T>(promise: Promise<T>): Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* The fields of the SDK's state copies that a binding or listener read, by
|
|
28
|
+
* state (`player`: `plugin.player.state`). An empty set: the state was
|
|
29
|
+
* taken whole (no field read was seen), so any change of it counts.
|
|
30
|
+
*/
|
|
31
|
+
export type Deps = Map<string, Set<string>>;
|
|
32
|
+
/** A change of the SDK's state copies: the fields that changed, by state. */
|
|
33
|
+
export type Change = ReadonlyMap<string, ReadonlySet<string>>;
|
|
34
|
+
/**
|
|
35
|
+
* The longest a binding waits for a full pass while precise passes skip
|
|
36
|
+
* it: state a plugin changes outside code the SDK runs (its own timer,
|
|
37
|
+
* without `update()`) still shows within about this long.
|
|
38
|
+
*/
|
|
39
|
+
export declare const SAFETY_PASS_MS = 1000;
|
|
26
40
|
export declare const same: (a: Json | undefined, b: Json | undefined) => boolean;
|
|
27
41
|
/** One value in flight, only the newest waiting. Each push learns its fate. */
|
|
28
42
|
export declare class Latest<T> {
|
|
@@ -76,6 +90,36 @@ export declare class Runtime {
|
|
|
76
90
|
* on it, so the event counts as listened to while the binding reads it.
|
|
77
91
|
*/
|
|
78
92
|
reading(event: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* A field of the SDK's copy of a state (`key`) is read; without `field`,
|
|
95
|
+
* the state is taken (`plugin.player.state`). What is being evaluated
|
|
96
|
+
* then depends on it: a precise pass after a change of that field runs
|
|
97
|
+
* it again.
|
|
98
|
+
*/
|
|
99
|
+
depends(key: string, field?: string): void;
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate one part of the binding being evaluated (one `view.bind`
|
|
102
|
+
* function), identified by `token`. A precise pass skips it when nothing
|
|
103
|
+
* it read last time changed, and returns `undefined`; what it read then
|
|
104
|
+
* still counts for the binding.
|
|
105
|
+
*/
|
|
106
|
+
part<T>(token: object, read: () => T): {
|
|
107
|
+
value: T;
|
|
108
|
+
} | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Run plugin code (a state listener) and return the state copy fields it
|
|
111
|
+
* read; they are not counted as a binding's.
|
|
112
|
+
*/
|
|
113
|
+
observe<T>(run: () => T): {
|
|
114
|
+
value: T;
|
|
115
|
+
deps: Deps;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Whether code that read `deps` while `change` was delivered needs a full
|
|
119
|
+
* pass after it: it read a field that changed, or no field of that state
|
|
120
|
+
* at all (so the SDK cannot tell what it depends on).
|
|
121
|
+
*/
|
|
122
|
+
needsFull(deps: Deps, change: Change): boolean;
|
|
79
123
|
/**
|
|
80
124
|
* Evaluate `read` as `label`, added at `where`: the state it reads is
|
|
81
125
|
* reported as read by it. `field`: a command's `enabled` or `checked`,
|
|
@@ -88,8 +132,21 @@ export declare class Runtime {
|
|
|
88
132
|
* line (`main.ts:353`). Captured now, formatted when asked.
|
|
89
133
|
*/
|
|
90
134
|
callSite(): () => string | undefined;
|
|
91
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* A full pass: every binding is evaluated. Requested after plugin code
|
|
137
|
+
* the SDK ran, by `plugin.update()`, and on any change other than a state
|
|
138
|
+
* copy's fields. Coalesced: one pass per microtask turn, however many
|
|
139
|
+
* requests.
|
|
140
|
+
*/
|
|
92
141
|
invalidate(): void;
|
|
142
|
+
/**
|
|
143
|
+
* Fields of the SDK's copy of a state (`key`) changed: a precise pass
|
|
144
|
+
* evaluates only the bindings (and view bind functions) that read one of
|
|
145
|
+
* them, unless a full pass is due anyway.
|
|
146
|
+
*/
|
|
147
|
+
changed(key: string, fields: Iterable<string>): void;
|
|
148
|
+
/** The plugin was disposed: cancel the pending safety pass, arm no more. */
|
|
149
|
+
stop(): void;
|
|
93
150
|
/**
|
|
94
151
|
* Host events with one `events.subscribe` per name, however many listeners.
|
|
95
152
|
* Without the `events` capability the listener simply never fires.
|