@vestra-player/sdk 1.5.2 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/plugin/resolvers.js +16 -4
- package/dist/plugin/stream.d.ts +11 -1
- package/dist/plugin/stream.js +142 -68
- package/dist/plugin/testing.d.ts +39 -0
- package/dist/plugin/testing.js +209 -14
- package/dist/plugin/types.d.ts +123 -11
- package/dist/source-resolvers.d.ts +33 -6
- package/dist/source-resolvers.js +42 -14
- package/dist/wire.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.3
|
|
4
|
+
|
|
5
|
+
**File resolvers: local files as generated streams** (player 0.4.1). Additive; `apiVersion` stays `vestra/1`. Plugins that register one set `"minPlayerVersion": "0.4.1"`: an older player refuses the declaration (`invalid`).
|
|
6
|
+
|
|
7
|
+
- **`resolvers.add({ id, extensions, resolve })`** (`FileResolverOptions`). When anyone opens a local file of those extensions with `player.open` (the Open dialog, the playlist, next / previous, the recent list), the player asks this plugin for a stream instead of playing the file. `resolve(path, signal)` returns a `ResolvedStream`: `{ kind: "stream", width, height, frame, started? }`, with the frame contract of `openStream`. Needs `source.resolve` and `producer.av`. `ResolverOptions` is now the union of `WebResolverOptions` (the existing form) and `FileResolverOptions`.
|
|
8
|
+
- The open keeps its arbitration: its `ifCurrent` and operation (the opener's `open` / `media.ready()` settle as usual), and `paused` reaches the stream's start. `state.source` stays the path, `state.kind` is `"stream"`.
|
|
9
|
+
- Cancellation: `signal` aborts when the open is replaced or closed (`player.close()` included), times out (30 s) or the resolver is disposed. Cancelled after `resolve` returned but before the stream ran, the SDK releases the stream and never calls `started`.
|
|
10
|
+
- `started(stream)` is called synchronously right after activation, before any `onStop`; the `Stream` is the same as `openStream`'s and belongs to the resolver's plugin.
|
|
11
|
+
- Extensions: 1–16 entries of 1–16 lower-case letters or digits, not `"folder"`; only absolute local paths match (any case), URLs never do.
|
|
12
|
+
- **One extension, and one scheme + host pair, has one resolver.** Another plugin's makes `Resolver.ready` reject with `conflict` (reason `resolver-taken` on the wire), and that no longer fails activation, for web resolvers too (as for panels and the sequence since 1.5.2); the player shows a warning (`PluginInfo.warnings`: `code` `"resolver-taken"`, `owner`, the new `target`). The plugin's own second claim still fails activation.
|
|
13
|
+
- **Resolver failures are sent as the error's message** (`error.message`, was `String(error)`, e.g. `"Error: …"`), and the player shows them word for word (it prefixed `resolver failed: `). This changes the failure text of web resolvers too.
|
|
14
|
+
- **Docs**: `PlayerState.source` documents the naming of generated streams: `producer://<n>` (opaque) for `openStream`, the path for a file resolver's; tell streams apart by `kind`.
|
|
15
|
+
- **`TestHost`** simulates file resolutions: opening a matching path (`player.open`, `openElsewhere`) sends `source.resolveRequest` and keeps the open pending; the answer selects the stream (or fails the open); any later source change cancels it (`source.resolveCancel`). `resolutions` lists the pending ones, `otherResolvers` simulates other plugins' registrations (conflicts). `startPlugin` does not fail activation for such a conflict. Web resolutions are still driven by the test (emit `source.resolveRequest`).
|
|
16
|
+
- Internals: `openStream` is split into prepare / activate / abandon steps shared with file resolvers; the wire client (`sourceResolvers.register`) takes a `{ id, extensions }` declaration and a handler answer with the prepared producer.
|
|
17
|
+
|
|
3
18
|
## 1.5.2
|
|
4
19
|
|
|
5
20
|
**Sidebar tabs** (the player after 0.4.0). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.1 need no change. The new members work against older players too (see each one).
|
package/dist/plugin/resolvers.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Source resolvers: page URLs to stream URLs, or local files to a stream
|
|
3
|
+
* this plugin generates, for `player.open`.
|
|
4
|
+
*/
|
|
5
|
+
import { failure, quiet } from "./runtime.js";
|
|
6
|
+
import { playerOf } from "./player.js";
|
|
7
|
+
import { resolvedStream } from "./stream.js";
|
|
8
|
+
const isFiles = (options) => "extensions" in options;
|
|
3
9
|
export const resolvers = (scope) => ({
|
|
4
10
|
add(options) {
|
|
5
|
-
const { resolve, ...declaration } = options;
|
|
6
11
|
const runtime = scope.runtime;
|
|
7
|
-
const registration =
|
|
12
|
+
const registration = isFiles(options)
|
|
13
|
+
? runtime.host.sourceResolvers.register({ id: options.id, extensions: options.extensions }, async ({ url, signal }) => {
|
|
14
|
+
const stream = await options.resolve(url, signal);
|
|
15
|
+
if (stream?.kind !== "stream")
|
|
16
|
+
throw failure("invalid", 'a file resolver returns { kind: "stream", width, height, frame }');
|
|
17
|
+
return resolvedStream(scope, playerOf(runtime), stream, signal);
|
|
18
|
+
})
|
|
19
|
+
: runtime.host.sourceResolvers.register({ id: options.id, schemes: options.schemes, hosts: options.hosts }, ({ url, signal }) => options.resolve(url, signal));
|
|
8
20
|
let disposed = false;
|
|
9
21
|
const handle = {
|
|
10
22
|
ready: quiet(runtime
|
package/dist/plugin/stream.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { PlayerService } from "./player.ts";
|
|
2
2
|
import type { ScopeImpl } from "./scope.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ResolvedProducer } from "../source-resolvers.ts";
|
|
4
|
+
import type { ResolvedStream, Stream, StreamOptions } from "./types.ts";
|
|
4
5
|
export declare function openStream(scope: ScopeImpl, service: PlayerService, options: StreamOptions): Promise<Stream>;
|
|
6
|
+
/**
|
|
7
|
+
* A file resolver's stream (`ResolvedStream`): prepared and prerolled while
|
|
8
|
+
* the open waits, then handed to the host as the answer. Once the host
|
|
9
|
+
* selected it, the start control is acknowledged and `started` gets the
|
|
10
|
+
* running stream, synchronously, before any `onStop`. Cancelled before or
|
|
11
|
+
* during activation (the open was replaced or closed, the plugin stopped),
|
|
12
|
+
* the producer is released and `started` is never called.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolvedStream(scope: ScopeImpl, service: PlayerService, stream: ResolvedStream, signal: AbortSignal): Promise<ResolvedProducer>;
|
package/dist/plugin/stream.js
CHANGED
|
@@ -154,17 +154,11 @@ async function sendFrame(socket, frame, clock, epoch) {
|
|
|
154
154
|
clock.advance(samples.length / 2);
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
};
|
|
163
|
-
let prepared;
|
|
164
|
-
let socket;
|
|
165
|
-
let generation;
|
|
166
|
-
/** The request selecting the stream, once sent. */
|
|
167
|
-
let opened;
|
|
157
|
+
/**
|
|
158
|
+
* Prepare a producer, connect and preroll it until the host could select it.
|
|
159
|
+
* `check` throws to give up (an abort); the producer is then released.
|
|
160
|
+
*/
|
|
161
|
+
async function prepare(host, options, check) {
|
|
168
162
|
const bytes = options.width * options.height * 4;
|
|
169
163
|
/** The plugin's frame, checked against the declared size. */
|
|
170
164
|
const frame = () => {
|
|
@@ -173,103 +167,183 @@ export async function openStream(scope, service, options) {
|
|
|
173
167
|
throw failure("invalid", `frame().video must be ${options.width}×${options.height}×4 = ${bytes} bytes of RGBA`);
|
|
174
168
|
return next;
|
|
175
169
|
};
|
|
170
|
+
let producer;
|
|
171
|
+
let socket;
|
|
176
172
|
try {
|
|
177
173
|
check();
|
|
178
174
|
const spec = await host.producer.prepare({
|
|
179
175
|
width: options.width,
|
|
180
176
|
height: options.height,
|
|
181
177
|
});
|
|
182
|
-
|
|
178
|
+
producer = spec.producerId;
|
|
183
179
|
check();
|
|
184
180
|
socket = await host.producer.connect(spec);
|
|
185
181
|
check();
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
advance: (n) => (sample += n),
|
|
194
|
-
nextSequence: () => sequence++,
|
|
182
|
+
const p = {
|
|
183
|
+
producer,
|
|
184
|
+
socket,
|
|
185
|
+
frame,
|
|
186
|
+
epoch: spec.epoch,
|
|
187
|
+
sample: 0,
|
|
188
|
+
sequence: 0,
|
|
195
189
|
};
|
|
190
|
+
const clock = clockOf(p);
|
|
196
191
|
// A prepared producer is paused at sample zero. Supply a frame at zero and
|
|
197
192
|
// enough contiguous PCM before asking the host to select it.
|
|
198
|
-
while (sample < PREROLL) {
|
|
193
|
+
while (p.sample < PREROLL) {
|
|
199
194
|
check();
|
|
200
|
-
await writeFrame(socket, frame(), clock, epoch);
|
|
195
|
+
await writeFrame(socket, frame(), clock, p.epoch);
|
|
201
196
|
}
|
|
202
197
|
for (const deadline = Date.now() + 10_000;; await sleep(10)) {
|
|
203
198
|
check();
|
|
204
|
-
const state = await host.producer.state(
|
|
199
|
+
const state = await host.producer.state(producer);
|
|
205
200
|
if (state.error)
|
|
206
201
|
throw failure("stream", state.error);
|
|
207
|
-
if (state.epoch !== epoch)
|
|
202
|
+
if (state.epoch !== p.epoch)
|
|
208
203
|
throw failure("stream", "producer epoch changed before activation");
|
|
209
204
|
if (!["ok", "recoverableXrun", "deviceChanged"].includes(state.health))
|
|
210
205
|
throw failure("device", `audio device is not ready: ${state.health}`);
|
|
211
206
|
if (state.acknowledged && state.receivedFrames >= 1024 && state.hasVideo)
|
|
212
|
-
|
|
207
|
+
return p;
|
|
213
208
|
if (Date.now() >= deadline)
|
|
214
209
|
throw timeout("prefill", "timed out preparing the first buffers");
|
|
215
210
|
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
socket?.close();
|
|
214
|
+
if (producer !== undefined)
|
|
215
|
+
await host.producer.release(producer).catch(() => { });
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const clockOf = (p) => ({
|
|
220
|
+
get sample() {
|
|
221
|
+
return p.sample;
|
|
222
|
+
},
|
|
223
|
+
advance: (n) => (p.sample += n),
|
|
224
|
+
nextSequence: () => p.sequence++,
|
|
225
|
+
});
|
|
226
|
+
/**
|
|
227
|
+
* Wait until the host runs the selected producer: acknowledge its start
|
|
228
|
+
* control. `selected` says whether the host has accepted the selection yet
|
|
229
|
+
* (`"selected"`: it has, and a producer no longer active was replaced);
|
|
230
|
+
* `check` throws to give up.
|
|
231
|
+
*/
|
|
232
|
+
async function activate(host, p, check, selected) {
|
|
233
|
+
for (const deadline = Date.now() + 10_000;; await sleep(10)) {
|
|
234
|
+
check();
|
|
235
|
+
const state = await host.producer.state(p.producer);
|
|
236
|
+
if (state.error)
|
|
237
|
+
throw failure("stream", state.error);
|
|
238
|
+
if (selected === "selected" && !state.active)
|
|
239
|
+
throw abortError();
|
|
240
|
+
if (state.pendingControl) {
|
|
241
|
+
p.epoch = state.pendingControl.epoch;
|
|
242
|
+
p.sample = state.pendingControl.baseSample;
|
|
243
|
+
p.sequence = 0;
|
|
244
|
+
await host.producer.ack(state.pendingControl);
|
|
245
|
+
}
|
|
246
|
+
if ((selected === "selected" || selected()) &&
|
|
247
|
+
state.active &&
|
|
248
|
+
!state.pendingControl &&
|
|
249
|
+
state.acknowledged &&
|
|
250
|
+
state.epoch === p.epoch)
|
|
251
|
+
return;
|
|
252
|
+
if (Date.now() >= deadline)
|
|
253
|
+
throw timeout("activation", "timed out waiting for stream activation");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Give a prepared producer up: its source (if still ours), connection, producer. */
|
|
257
|
+
async function abandon(service, p, generation) {
|
|
258
|
+
// A cancelled open may already have selected this producer: close only
|
|
259
|
+
// that source, before its frame connection (as in `stop`).
|
|
260
|
+
if (generation !== undefined)
|
|
261
|
+
await service.closeIfCurrent(generation).catch(() => { });
|
|
262
|
+
p.socket.close();
|
|
263
|
+
await service.runtime.host.producer.release(p.producer).catch(() => { });
|
|
264
|
+
}
|
|
265
|
+
const handleOf = (scope, service, p, generation) => new StreamHandle(scope, service, {
|
|
266
|
+
producer: p.producer,
|
|
267
|
+
socket: p.socket,
|
|
268
|
+
generation,
|
|
269
|
+
frame: p.frame,
|
|
270
|
+
epoch: p.epoch,
|
|
271
|
+
sample: p.sample,
|
|
272
|
+
sequence: p.sequence,
|
|
273
|
+
});
|
|
274
|
+
export async function openStream(scope, service, options) {
|
|
275
|
+
const host = service.runtime.host;
|
|
276
|
+
const check = () => {
|
|
277
|
+
if (options.signal?.aborted || scope.disposed)
|
|
278
|
+
throw abortError();
|
|
279
|
+
};
|
|
280
|
+
const p = await prepare(host, options, check);
|
|
281
|
+
let generation;
|
|
282
|
+
/** The request selecting the stream. */
|
|
283
|
+
let opened;
|
|
284
|
+
try {
|
|
216
285
|
let accepted = false;
|
|
286
|
+
let failed;
|
|
217
287
|
opened = service
|
|
218
|
-
.submit({ kind: "openProvider", producerId:
|
|
288
|
+
.submit({ kind: "openProvider", producerId: p.producer, startPaused: false }, { accepted: (ticket) => (generation = ticket.stamp.generation) })
|
|
219
289
|
.then((ticket) => {
|
|
220
290
|
generation = ticket.stamp.generation;
|
|
221
291
|
accepted = true;
|
|
222
292
|
});
|
|
223
|
-
let failed;
|
|
224
293
|
opened.catch((error) => (failed = error));
|
|
225
294
|
// Activation needs this side to acknowledge the host's start control.
|
|
226
|
-
|
|
295
|
+
await activate(host, p, () => {
|
|
227
296
|
check();
|
|
228
297
|
if (failed)
|
|
229
298
|
throw failed;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
throw failure("stream", state.error);
|
|
233
|
-
if (state.pendingControl) {
|
|
234
|
-
epoch = state.pendingControl.epoch;
|
|
235
|
-
sample = state.pendingControl.baseSample;
|
|
236
|
-
sequence = 0;
|
|
237
|
-
await host.producer.ack(state.pendingControl);
|
|
238
|
-
}
|
|
239
|
-
if (accepted &&
|
|
240
|
-
state.active &&
|
|
241
|
-
!state.pendingControl &&
|
|
242
|
-
state.acknowledged &&
|
|
243
|
-
state.epoch === epoch)
|
|
244
|
-
break;
|
|
245
|
-
if (Date.now() >= deadline)
|
|
246
|
-
throw timeout("activation", "timed out waiting for stream activation");
|
|
247
|
-
}
|
|
248
|
-
const handle = new StreamHandle(scope, service, {
|
|
249
|
-
producer: prepared,
|
|
250
|
-
socket,
|
|
251
|
-
generation: generation,
|
|
252
|
-
frame,
|
|
253
|
-
epoch,
|
|
254
|
-
sample,
|
|
255
|
-
sequence,
|
|
256
|
-
});
|
|
257
|
-
prepared = undefined;
|
|
258
|
-
socket = undefined;
|
|
259
|
-
return handle;
|
|
299
|
+
}, () => accepted);
|
|
300
|
+
return handleOf(scope, service, p, generation);
|
|
260
301
|
}
|
|
261
302
|
catch (error) {
|
|
262
303
|
// An abort can arrive before the host answered the selecting request: wait
|
|
263
304
|
// for that answer so a source selected afterwards is still closed.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// A cancelled open may already have selected this producer: close only
|
|
267
|
-
// that source, before its frame connection (as in `stop`).
|
|
268
|
-
if (generation !== undefined && prepared !== undefined)
|
|
269
|
-
await service.closeIfCurrent(generation).catch(() => { });
|
|
270
|
-
socket?.close();
|
|
271
|
-
if (prepared !== undefined)
|
|
272
|
-
await host.producer.release(prepared).catch(() => { });
|
|
305
|
+
await opened?.catch(() => { });
|
|
306
|
+
await abandon(service, p, generation);
|
|
273
307
|
throw error;
|
|
274
308
|
}
|
|
275
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* A file resolver's stream (`ResolvedStream`): prepared and prerolled while
|
|
312
|
+
* the open waits, then handed to the host as the answer. Once the host
|
|
313
|
+
* selected it, the start control is acknowledged and `started` gets the
|
|
314
|
+
* running stream, synchronously, before any `onStop`. Cancelled before or
|
|
315
|
+
* during activation (the open was replaced or closed, the plugin stopped),
|
|
316
|
+
* the producer is released and `started` is never called.
|
|
317
|
+
*/
|
|
318
|
+
export async function resolvedStream(scope, service, stream, signal) {
|
|
319
|
+
const host = service.runtime.host;
|
|
320
|
+
const p = await prepare(host, stream, () => {
|
|
321
|
+
if (signal.aborted || scope.disposed)
|
|
322
|
+
throw abortError();
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
kind: "stream",
|
|
326
|
+
producerId: p.producer,
|
|
327
|
+
abandoned: () => abandon(service, p, undefined),
|
|
328
|
+
accepted: async ({ stamp }) => {
|
|
329
|
+
try {
|
|
330
|
+
await activate(host, p, () => {
|
|
331
|
+
if (scope.disposed)
|
|
332
|
+
throw abortError();
|
|
333
|
+
}, "selected");
|
|
334
|
+
// Replaced before it ran: the source is no longer this stream.
|
|
335
|
+
if (service.raw.stamp.generation > stamp.generation)
|
|
336
|
+
throw abortError();
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
await abandon(service, p, stamp.generation);
|
|
340
|
+
if (error.code !== "aborted")
|
|
341
|
+
scope.report(error);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const handle = handleOf(scope, service, p, stamp.generation);
|
|
345
|
+
if (stream.started)
|
|
346
|
+
scope.run(stream.started, handle);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -744,6 +744,45 @@ export declare class TestHost implements Transport {
|
|
|
744
744
|
}>;
|
|
745
745
|
/** Registered source resolvers (`resolvers.add`) by id: their declarations. */
|
|
746
746
|
readonly resolvers: Map<string, Json>;
|
|
747
|
+
/**
|
|
748
|
+
* Resolvers other plugins hold, as `resolvers.add` declares them plus the
|
|
749
|
+
* holder's id: a registration of this plugin that claims one of their
|
|
750
|
+
* extensions (or scheme + host pairs) is refused with `conflict`, which
|
|
751
|
+
* does not fail activation, like the player.
|
|
752
|
+
*
|
|
753
|
+
* @example
|
|
754
|
+
* ```ts
|
|
755
|
+
* const host = new TestHost();
|
|
756
|
+
* host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
|
|
757
|
+
* ```
|
|
758
|
+
*/
|
|
759
|
+
readonly otherResolvers: {
|
|
760
|
+
/** The manifest id of the plugin holding them (shown in the refusal). */
|
|
761
|
+
owner: string;
|
|
762
|
+
/** File extensions it resolves (a file resolver), lower case, no dot. */
|
|
763
|
+
extensions?: string[];
|
|
764
|
+
/** URL schemes it resolves (a web resolver), with `hosts`. */
|
|
765
|
+
schemes?: string[];
|
|
766
|
+
/** Exact host names it resolves (a web resolver), with `schemes`. */
|
|
767
|
+
hosts?: string[];
|
|
768
|
+
}[];
|
|
769
|
+
/**
|
|
770
|
+
* File opens waiting for this plugin's file resolver (`extensions`), by
|
|
771
|
+
* request id: the path and how it was opened. Opening a matching path
|
|
772
|
+
* (`player.open`, {@link TestHost.openElsewhere}) sends
|
|
773
|
+
* `source.resolveRequest`; the answer selects the stream, fails the open
|
|
774
|
+
* or, once the source changed, is `stale`. Any later source change
|
|
775
|
+
* cancels it (`source.resolveCancel`). Web resolvers are not simulated:
|
|
776
|
+
* emit `source.resolveRequest` yourself.
|
|
777
|
+
*/
|
|
778
|
+
readonly resolutions: Map<number, {
|
|
779
|
+
/** The id of this plugin's resolver asked (`resolvers.add` `id`). */
|
|
780
|
+
resolverId: string;
|
|
781
|
+
/** The path being opened, as sent to `resolve`. */
|
|
782
|
+
source: string;
|
|
783
|
+
/** The open asked for a paused start (`player.open(path, { paused })`). */
|
|
784
|
+
paused: boolean;
|
|
785
|
+
}>;
|
|
747
786
|
/**
|
|
748
787
|
* Answers for `dialogs.open` (paths, `null` = cancelled) and
|
|
749
788
|
* `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
|
package/dist/plugin/testing.js
CHANGED
|
@@ -61,7 +61,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
|
61
61
|
import { createServer } from "node:net";
|
|
62
62
|
import { randomBytes } from "node:crypto";
|
|
63
63
|
import { tmpdir } from "node:os";
|
|
64
|
-
import { join } from "node:path";
|
|
64
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
65
65
|
import { fileURLToPath } from "node:url";
|
|
66
66
|
import { HostError } from "../wire.js";
|
|
67
67
|
import { COMMAND_LIMITS, MENU_IDS } from "../commands.js";
|
|
@@ -110,7 +110,10 @@ const readyCapabilities = {
|
|
|
110
110
|
close: true,
|
|
111
111
|
snapshotSource: true,
|
|
112
112
|
};
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Refusals for a position or resolver another plugin holds: not activation
|
|
115
|
+
* failures (`startPlugin`).
|
|
116
|
+
*/
|
|
114
117
|
const takenErrors = new WeakSet();
|
|
115
118
|
const taken = (message) => {
|
|
116
119
|
const error = new HostError("conflict", message);
|
|
@@ -757,6 +760,29 @@ export class TestHost {
|
|
|
757
760
|
producers = new Map();
|
|
758
761
|
/** Registered source resolvers (`resolvers.add`) by id: their declarations. */
|
|
759
762
|
resolvers = new Map();
|
|
763
|
+
/**
|
|
764
|
+
* Resolvers other plugins hold, as `resolvers.add` declares them plus the
|
|
765
|
+
* holder's id: a registration of this plugin that claims one of their
|
|
766
|
+
* extensions (or scheme + host pairs) is refused with `conflict`, which
|
|
767
|
+
* does not fail activation, like the player.
|
|
768
|
+
*
|
|
769
|
+
* @example
|
|
770
|
+
* ```ts
|
|
771
|
+
* const host = new TestHost();
|
|
772
|
+
* host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
otherResolvers = [];
|
|
776
|
+
/**
|
|
777
|
+
* File opens waiting for this plugin's file resolver (`extensions`), by
|
|
778
|
+
* request id: the path and how it was opened. Opening a matching path
|
|
779
|
+
* (`player.open`, {@link TestHost.openElsewhere}) sends
|
|
780
|
+
* `source.resolveRequest`; the answer selects the stream, fails the open
|
|
781
|
+
* or, once the source changed, is `stale`. Any later source change
|
|
782
|
+
* cancels it (`source.resolveCancel`). Web resolvers are not simulated:
|
|
783
|
+
* emit `source.resolveRequest` yourself.
|
|
784
|
+
*/
|
|
785
|
+
resolutions = new Map();
|
|
760
786
|
/**
|
|
761
787
|
* Answers for `dialogs.open` (paths, `null` = cancelled) and
|
|
762
788
|
* `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
|
|
@@ -850,8 +876,12 @@ export class TestHost {
|
|
|
850
876
|
#overrides = new Map();
|
|
851
877
|
#listeners = new Map();
|
|
852
878
|
#inFlight = 0;
|
|
853
|
-
#next = { snapshot: 0, job: 0, producer: 0, source: 0 };
|
|
879
|
+
#next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
|
|
854
880
|
#sourceBook = new Map();
|
|
881
|
+
/** The pending resolutions' stamps and operations (beside `resolutions`). */
|
|
882
|
+
#resolving = new Map();
|
|
883
|
+
/** The producer the source is (`openProvider`, a resolved stream). */
|
|
884
|
+
#selected = null;
|
|
855
885
|
#scratch = mkdtempSync(join(tmpdir(), "vestra-test-host-"));
|
|
856
886
|
/**
|
|
857
887
|
* `plugin.dataDirectory`: a fresh empty temporary directory, removed with
|
|
@@ -1351,7 +1381,9 @@ export class TestHost {
|
|
|
1351
1381
|
*/
|
|
1352
1382
|
openElsewhere(source, phase = "ready") {
|
|
1353
1383
|
this.#intent({ kind: "open", source, startPaused: false });
|
|
1354
|
-
this
|
|
1384
|
+
// A file this plugin's file resolver claims waits for its answer.
|
|
1385
|
+
if (this.#resolve(source, false) === null)
|
|
1386
|
+
this.setSource({ phase });
|
|
1355
1387
|
}
|
|
1356
1388
|
/**
|
|
1357
1389
|
* Press a binding like the host's table would: panel-focus bindings first
|
|
@@ -1682,14 +1714,166 @@ export class TestHost {
|
|
|
1682
1714
|
if (control.kind === "rate")
|
|
1683
1715
|
next.rate = Number(control.rate);
|
|
1684
1716
|
}
|
|
1717
|
+
if (intent.kind === "openProvider")
|
|
1718
|
+
this.#selected = Number(intent.producerId);
|
|
1719
|
+
if (intent.kind === "open" || intent.kind === "close")
|
|
1720
|
+
this.#selected = null;
|
|
1685
1721
|
this.source = next;
|
|
1686
1722
|
this.subtitles.source = {
|
|
1687
1723
|
generation: next.stamp.generation,
|
|
1688
1724
|
phase: next.phase,
|
|
1689
1725
|
};
|
|
1690
1726
|
this.emit("source.state", this.source);
|
|
1727
|
+
this.#cancelResolutions();
|
|
1691
1728
|
return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
|
|
1692
1729
|
}
|
|
1730
|
+
/** The file resolver of this plugin that claims `source`, if any. */
|
|
1731
|
+
#fileResolver(source) {
|
|
1732
|
+
if (source.includes("://") || !isAbsolute(source))
|
|
1733
|
+
return null;
|
|
1734
|
+
const extension = extname(source).slice(1).toLowerCase();
|
|
1735
|
+
if (!extension)
|
|
1736
|
+
return null;
|
|
1737
|
+
for (const [id, declaration] of this.resolvers) {
|
|
1738
|
+
const extensions = declaration.extensions;
|
|
1739
|
+
if (extensions?.includes(extension))
|
|
1740
|
+
return id;
|
|
1741
|
+
}
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
/** Whether producer `id` is the player's source. */
|
|
1745
|
+
#current(id) {
|
|
1746
|
+
return (this.source.kind === "producer" &&
|
|
1747
|
+
(this.#selected === id || this.source.source === `producer://${id}`));
|
|
1748
|
+
}
|
|
1749
|
+
/** Start resolving `source` for the open just made; its request id, or null. */
|
|
1750
|
+
#resolve(source, paused, operation) {
|
|
1751
|
+
const resolverId = this.#fileResolver(source);
|
|
1752
|
+
if (resolverId === null)
|
|
1753
|
+
return null;
|
|
1754
|
+
const requestId = ++this.#next.resolution;
|
|
1755
|
+
this.resolutions.set(requestId, { resolverId, source, paused });
|
|
1756
|
+
this.#resolving.set(requestId, {
|
|
1757
|
+
stamp: { ...this.source.stamp },
|
|
1758
|
+
...(operation === undefined ? {} : { operation }),
|
|
1759
|
+
});
|
|
1760
|
+
this.emit("source.resolveRequest", { requestId, resolverId, url: source });
|
|
1761
|
+
return requestId;
|
|
1762
|
+
}
|
|
1763
|
+
/** Settle a resolution's open; `error` fails it. */
|
|
1764
|
+
#settle(requestId, error) {
|
|
1765
|
+
const operation = this.#resolving.get(requestId)?.operation;
|
|
1766
|
+
this.resolutions.delete(requestId);
|
|
1767
|
+
this.#resolving.delete(requestId);
|
|
1768
|
+
if (operation === undefined)
|
|
1769
|
+
return;
|
|
1770
|
+
const entry = this.#sourceBook.get(operation);
|
|
1771
|
+
this.#sourceBook.set(operation, error === undefined
|
|
1772
|
+
? { status: "completed", ticket: entry?.ticket ?? null }
|
|
1773
|
+
: { status: "failed", error: { backend: error } });
|
|
1774
|
+
}
|
|
1775
|
+
/** Resolutions whose open is no longer the source (or whose resolver left). */
|
|
1776
|
+
#cancelResolutions() {
|
|
1777
|
+
const stamp = this.source.stamp;
|
|
1778
|
+
for (const [requestId, resolution] of [...this.resolutions]) {
|
|
1779
|
+
const pending = this.#resolving.get(requestId);
|
|
1780
|
+
if (pending.stamp.generation === stamp.generation &&
|
|
1781
|
+
pending.stamp.intentVersion === stamp.intentVersion &&
|
|
1782
|
+
this.resolvers.has(resolution.resolverId))
|
|
1783
|
+
continue;
|
|
1784
|
+
this.#settle(requestId, "source resolution cancelled, expired or superseded");
|
|
1785
|
+
this.emit("source.resolveCancel", {
|
|
1786
|
+
requestId,
|
|
1787
|
+
resolverId: resolution.resolverId,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
/** `sourceResolvers.register`: the player's rules for both forms. */
|
|
1792
|
+
#register(p) {
|
|
1793
|
+
const id = String(p.id);
|
|
1794
|
+
const extensions = p.extensions;
|
|
1795
|
+
if (this.resolvers.has(id))
|
|
1796
|
+
throw new HostError("conflict", "resolver id already registered");
|
|
1797
|
+
if (extensions !== undefined) {
|
|
1798
|
+
if (p.schemes !== undefined ||
|
|
1799
|
+
p.hosts !== undefined ||
|
|
1800
|
+
!Array.isArray(extensions) ||
|
|
1801
|
+
extensions.length < 1 ||
|
|
1802
|
+
extensions.length > 16 ||
|
|
1803
|
+
extensions.some((e) => typeof e !== "string" ||
|
|
1804
|
+
!/^[a-z0-9]{1,16}$/.test(e) ||
|
|
1805
|
+
e === "folder"))
|
|
1806
|
+
throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
|
|
1807
|
+
}
|
|
1808
|
+
const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
|
|
1809
|
+
(other.hosts?.some((h) => p.hosts?.includes(h)) &&
|
|
1810
|
+
other.schemes?.some((x) => p.schemes?.includes(x))
|
|
1811
|
+
? "host"
|
|
1812
|
+
: undefined);
|
|
1813
|
+
for (const own of this.resolvers.values())
|
|
1814
|
+
if (claims(own))
|
|
1815
|
+
throw new HostError("conflict", "already registered by this plugin");
|
|
1816
|
+
for (const other of this.otherResolvers) {
|
|
1817
|
+
const what = claims(other);
|
|
1818
|
+
if (what) {
|
|
1819
|
+
const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
|
|
1820
|
+
throw taken(`${target} 已由 ${other.owner} 解析`);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
this.resolvers.set(id, p);
|
|
1824
|
+
}
|
|
1825
|
+
/** `sourceResolvers.respond` for a simulated file resolution. */
|
|
1826
|
+
#respond(p) {
|
|
1827
|
+
const requestId = Number(p.requestId);
|
|
1828
|
+
const resolution = this.resolutions.get(requestId);
|
|
1829
|
+
if (!resolution) {
|
|
1830
|
+
// Web resolutions are not simulated: their answers are only recorded.
|
|
1831
|
+
if (requestId > this.#next.resolution)
|
|
1832
|
+
return null;
|
|
1833
|
+
throw new HostError("stale", "resolution no longer pending");
|
|
1834
|
+
}
|
|
1835
|
+
const fail = (message) => {
|
|
1836
|
+
this.#settle(requestId, message);
|
|
1837
|
+
this.setSource({
|
|
1838
|
+
phase: "error",
|
|
1839
|
+
terminalReason: { failed: message },
|
|
1840
|
+
});
|
|
1841
|
+
throw new HostError("resolution", message);
|
|
1842
|
+
};
|
|
1843
|
+
if (typeof p.error === "string")
|
|
1844
|
+
fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
|
|
1845
|
+
const result = (p.result ?? {});
|
|
1846
|
+
const producer = this.producers.get(Number(result.producerId));
|
|
1847
|
+
if (result.kind !== "stream")
|
|
1848
|
+
fail("invalid resolved stream descriptor");
|
|
1849
|
+
if (!producer || producer.released)
|
|
1850
|
+
fail("unavailable: producer absent");
|
|
1851
|
+
// Like openProvider, but the source keeps the path and `startPaused`.
|
|
1852
|
+
this.#selected = producer.id;
|
|
1853
|
+
producer.active = true;
|
|
1854
|
+
producer.acknowledged = false;
|
|
1855
|
+
producer.pending = {
|
|
1856
|
+
producerId: producer.id,
|
|
1857
|
+
epoch: producer.epoch + 1,
|
|
1858
|
+
baseSample: producer.receivedFrames,
|
|
1859
|
+
paused: resolution.paused,
|
|
1860
|
+
};
|
|
1861
|
+
this.#settle(requestId);
|
|
1862
|
+
this.setSource({
|
|
1863
|
+
kind: "producer",
|
|
1864
|
+
phase: "ready",
|
|
1865
|
+
paused: resolution.paused,
|
|
1866
|
+
capabilities: {
|
|
1867
|
+
pause: true,
|
|
1868
|
+
seek: false,
|
|
1869
|
+
rate: false,
|
|
1870
|
+
restart: false,
|
|
1871
|
+
close: true,
|
|
1872
|
+
snapshotSource: false,
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
return { stamp: { ...this.source.stamp } };
|
|
1876
|
+
}
|
|
1693
1877
|
#processingState() {
|
|
1694
1878
|
const list = (stage) => [...this.processing.resources]
|
|
1695
1879
|
.filter(([, value]) => value.stage === stage)
|
|
@@ -2277,8 +2461,17 @@ export class TestHost {
|
|
|
2277
2461
|
"source.state": () => this.source,
|
|
2278
2462
|
"source.submit": (p) => {
|
|
2279
2463
|
const intent = p.intent;
|
|
2280
|
-
const
|
|
2281
|
-
|
|
2464
|
+
const ticket = this.#intent(intent);
|
|
2465
|
+
const operation = Number(p.operationId);
|
|
2466
|
+
// A file this plugin's file resolver claims: pending until it answers.
|
|
2467
|
+
if (intent.kind === "open" &&
|
|
2468
|
+
this.#resolve(String(intent.source), intent.startPaused === true, operation) !== null) {
|
|
2469
|
+
const pending = { status: "pending", ...ticket };
|
|
2470
|
+
this.#sourceBook.set(operation, pending);
|
|
2471
|
+
return pending;
|
|
2472
|
+
}
|
|
2473
|
+
const result = { status: "completed", ...ticket };
|
|
2474
|
+
this.#sourceBook.set(operation, result);
|
|
2282
2475
|
// Selecting a provider asks it to acknowledge a start control.
|
|
2283
2476
|
const producer = this.producers.get(Number(intent.producerId));
|
|
2284
2477
|
if (intent.kind === "openProvider" && producer) {
|
|
@@ -2706,9 +2899,12 @@ export class TestHost {
|
|
|
2706
2899
|
return { jobId: job.jobId, cancel: "requested" };
|
|
2707
2900
|
},
|
|
2708
2901
|
"jobs.operation": () => ({ status: "unknown" }),
|
|
2709
|
-
"sourceResolvers.register": (p) => void this
|
|
2710
|
-
"sourceResolvers.unregister": (p) =>
|
|
2711
|
-
|
|
2902
|
+
"sourceResolvers.register": (p) => void this.#register(p),
|
|
2903
|
+
"sourceResolvers.unregister": (p) => {
|
|
2904
|
+
this.resolvers.delete(String(p.id));
|
|
2905
|
+
this.#cancelResolutions();
|
|
2906
|
+
},
|
|
2907
|
+
"sourceResolvers.respond": (p) => this.#respond(p),
|
|
2712
2908
|
"producer.prepare": async () => {
|
|
2713
2909
|
const id = ++this.#next.producer;
|
|
2714
2910
|
const tcp = this.producerTransport === "tcp";
|
|
@@ -2754,8 +2950,7 @@ export class TestHost {
|
|
|
2754
2950
|
});
|
|
2755
2951
|
socket.on("error", () => { });
|
|
2756
2952
|
socket.on("close", () => {
|
|
2757
|
-
if (this.
|
|
2758
|
-
this.source.source === `producer://${producer.id}`)
|
|
2953
|
+
if (this.#current(producer.id))
|
|
2759
2954
|
producer.droppedWhileSelected = true;
|
|
2760
2955
|
});
|
|
2761
2956
|
});
|
|
@@ -2786,8 +2981,7 @@ export class TestHost {
|
|
|
2786
2981
|
const producer = this.producers.get(Number(p.producerId));
|
|
2787
2982
|
if (!producer)
|
|
2788
2983
|
throw new HostError("missing", "producer");
|
|
2789
|
-
const current = this.
|
|
2790
|
-
this.source.source === `producer://${producer.id}`;
|
|
2984
|
+
const current = this.#current(producer.id);
|
|
2791
2985
|
return {
|
|
2792
2986
|
input: null,
|
|
2793
2987
|
pendingControl: producer.pending,
|
|
@@ -2889,7 +3083,8 @@ export async function startPlugin(activate, options = {}) {
|
|
|
2889
3083
|
mkdirSync(data, { recursive: true });
|
|
2890
3084
|
const instance = await createPlugin(host, manifest, directory, data, host.app);
|
|
2891
3085
|
await activate(instance.plugin);
|
|
2892
|
-
// Like the player: a position another plugin holds is not an
|
|
3086
|
+
// Like the player: a position or resolver another plugin holds is not an
|
|
3087
|
+
// activation failure.
|
|
2893
3088
|
for (;;) {
|
|
2894
3089
|
try {
|
|
2895
3090
|
await instance.activated();
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -2771,7 +2771,12 @@ export interface PlayerState {
|
|
|
2771
2771
|
readonly kind: "media" | "stream";
|
|
2772
2772
|
/** The source's life cycle. */
|
|
2773
2773
|
readonly phase: "empty" | "loading" | "ready" | "ended" | "error";
|
|
2774
|
-
/**
|
|
2774
|
+
/**
|
|
2775
|
+
* Path or URL; `null` when empty. A generated stream started with
|
|
2776
|
+
* {@link Player.openStream} has the opaque `producer://<n>` (do not parse
|
|
2777
|
+
* it); one a file resolver generates ({@link FileResolverOptions}) keeps
|
|
2778
|
+
* the path that was opened. Tell streams apart by `kind`, not by `source`.
|
|
2779
|
+
*/
|
|
2775
2780
|
readonly source: string | null;
|
|
2776
2781
|
/** Playback is paused. */
|
|
2777
2782
|
readonly paused: boolean;
|
|
@@ -3087,7 +3092,9 @@ export interface Player {
|
|
|
3087
3092
|
*
|
|
3088
3093
|
* It does not wait for playback: use {@link Media.ready}. `paused` opens
|
|
3089
3094
|
* it paused; aborting `signal` closes it. URLs matching a registered
|
|
3090
|
-
* {@link Resolver} are resolved first
|
|
3095
|
+
* {@link Resolver} are resolved first, and so are local files of an
|
|
3096
|
+
* extension a file resolver claims (the source is then a stream that
|
|
3097
|
+
* plugin generates; `paused` reaches it).
|
|
3091
3098
|
*/
|
|
3092
3099
|
open(source: string, options?: {
|
|
3093
3100
|
paused?: boolean;
|
|
@@ -3811,8 +3818,13 @@ export interface MediaCapture {
|
|
|
3811
3818
|
*/
|
|
3812
3819
|
capture(options?: CaptureOptions): Promise<Snapshot>;
|
|
3813
3820
|
}
|
|
3814
|
-
/**
|
|
3815
|
-
|
|
3821
|
+
/**
|
|
3822
|
+
* Declaration of {@link Resolvers.add}: a web resolver
|
|
3823
|
+
* ({@link WebResolverOptions}) or a file resolver ({@link FileResolverOptions}).
|
|
3824
|
+
*/
|
|
3825
|
+
export type ResolverOptions = WebResolverOptions | FileResolverOptions;
|
|
3826
|
+
/** A resolver for web pages: `https://host/…` to playable stream URLs. */
|
|
3827
|
+
export interface WebResolverOptions {
|
|
3816
3828
|
/** Local id, unique within the plugin. */
|
|
3817
3829
|
id: string;
|
|
3818
3830
|
/** URL schemes handled. */
|
|
@@ -3822,14 +3834,104 @@ export interface ResolverOptions {
|
|
|
3822
3834
|
/** Turn a page URL into playable stream URLs. */
|
|
3823
3835
|
resolve(url: string, signal: AbortSignal): Promise<ResolvedMedia>;
|
|
3824
3836
|
}
|
|
3837
|
+
/**
|
|
3838
|
+
* A resolver for local files by extension (SDK 1.5.3, player 0.4.1): when
|
|
3839
|
+
* anyone opens such a file with {@link Player.open} (the Open dialog, the
|
|
3840
|
+
* playlist, next / previous, the recent list, a drop that calls `open`),
|
|
3841
|
+
* the player asks this plugin for a stream it generates instead of
|
|
3842
|
+
* playing the file. Needs `source.resolve` and `producer.av`.
|
|
3843
|
+
*
|
|
3844
|
+
* @remarks
|
|
3845
|
+
* The open keeps everything of the original call: its `ifCurrent`, its
|
|
3846
|
+
* operation (the opener's `open` and `media.ready()` settle as for any
|
|
3847
|
+
* file), `paused` (the stream starts paused), and its path as
|
|
3848
|
+
* `state.source`, with `state.kind` `"stream"`. A thrown error fails that
|
|
3849
|
+
* open: `phase` `"error"` with the error's message as `state.error`,
|
|
3850
|
+
* word for word. The stream belongs to this plugin, as with
|
|
3851
|
+
* {@link Player.openStream}.
|
|
3852
|
+
*
|
|
3853
|
+
* Cancellation: `signal` aborts when the open is replaced, closed
|
|
3854
|
+
* (`player.close()`, F4) or times out (30 seconds), or this resolver is
|
|
3855
|
+
* disposed. Cancelled after `resolve` returned but before the stream ran,
|
|
3856
|
+
* the SDK releases the stream and never calls `started`.
|
|
3857
|
+
*
|
|
3858
|
+
* One extension has one resolver: another plugin's resolver for it makes
|
|
3859
|
+
* {@link Resolver.ready} reject with `conflict` without failing activation
|
|
3860
|
+
* (the player shows a warning). When this plugin stops, its extensions are
|
|
3861
|
+
* free again and such files go to the player as before.
|
|
3862
|
+
*
|
|
3863
|
+
* @example
|
|
3864
|
+
* ```ts
|
|
3865
|
+
* const { readFile } = await import("node:fs/promises");
|
|
3866
|
+
* // The plugin's own emulator: one frame per call.
|
|
3867
|
+
* const emulator = (rom: Uint8Array): (() => StreamFrame) => {
|
|
3868
|
+
* const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
|
|
3869
|
+
* return () => ({ video, audio: new Float32Array(1600) });
|
|
3870
|
+
* };
|
|
3871
|
+
* plugin.resolvers.add({
|
|
3872
|
+
* id: "nes",
|
|
3873
|
+
* extensions: ["nes"],
|
|
3874
|
+
* async resolve(path, signal) {
|
|
3875
|
+
* const frame = emulator(await readFile(path, { signal }));
|
|
3876
|
+
* return {
|
|
3877
|
+
* kind: "stream",
|
|
3878
|
+
* width: 256,
|
|
3879
|
+
* height: 240,
|
|
3880
|
+
* frame,
|
|
3881
|
+
* started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
|
|
3882
|
+
* };
|
|
3883
|
+
* },
|
|
3884
|
+
* });
|
|
3885
|
+
* ```
|
|
3886
|
+
*/
|
|
3887
|
+
export interface FileResolverOptions {
|
|
3888
|
+
/** Local id, unique within the plugin. */
|
|
3889
|
+
id: string;
|
|
3890
|
+
/**
|
|
3891
|
+
* Lower case, without the dot: 1–16 entries of 1–16 lower-case letters or
|
|
3892
|
+
* digits (not `"folder"`). Only absolute local paths match, by their
|
|
3893
|
+
* extension in any case (`/roms/Game.NES`); URLs never do.
|
|
3894
|
+
*/
|
|
3895
|
+
extensions: string[];
|
|
3896
|
+
/** Read the file and describe the stream to generate for it. */
|
|
3897
|
+
resolve(path: string, signal: AbortSignal): Promise<ResolvedStream>;
|
|
3898
|
+
}
|
|
3899
|
+
/**
|
|
3900
|
+
* What a file resolver returns: a stream, with the same frame contract as
|
|
3901
|
+
* {@link StreamOptions} (size limits, `frame()` on the audio clock).
|
|
3902
|
+
*/
|
|
3903
|
+
export interface ResolvedStream {
|
|
3904
|
+
/** Always `"stream"`. */
|
|
3905
|
+
kind: "stream";
|
|
3906
|
+
/** Frame width in pixels. */
|
|
3907
|
+
width: number;
|
|
3908
|
+
/** Frame height in pixels. */
|
|
3909
|
+
height: number;
|
|
3910
|
+
/** Produce the next frame. Never called concurrently or while paused. */
|
|
3911
|
+
frame(): StreamFrame;
|
|
3912
|
+
/**
|
|
3913
|
+
* Called with the running stream once the player plays it, synchronously
|
|
3914
|
+
* after activation and before any {@link Stream.onStop}. Not called when
|
|
3915
|
+
* the open was cancelled first. `stream.stop()` closes the source (if
|
|
3916
|
+
* still current); the plugin stopping stops it too.
|
|
3917
|
+
*/
|
|
3918
|
+
started?(stream: Stream): void;
|
|
3919
|
+
}
|
|
3825
3920
|
/** A registered resolver. Disposing it unregisters it and aborts resolutions in flight. */
|
|
3826
3921
|
export interface Resolver extends Disposable {
|
|
3827
|
-
/**
|
|
3922
|
+
/**
|
|
3923
|
+
* Resolves when the player registered it; rejects otherwise: `conflict`
|
|
3924
|
+
* when another resolver has the extension or the scheme + host (another
|
|
3925
|
+
* plugin's: activation goes on; this plugin's own: activation fails),
|
|
3926
|
+
* `invalid` for a malformed declaration (a player before 0.4.1 also for
|
|
3927
|
+
* `extensions`).
|
|
3928
|
+
*/
|
|
3828
3929
|
readonly ready: Promise<void>;
|
|
3829
3930
|
}
|
|
3830
3931
|
/**
|
|
3831
|
-
* `plugin.resolvers`: turn web page URLs into playable streams
|
|
3832
|
-
* `source.resolve
|
|
3932
|
+
* `plugin.resolvers`: turn web page URLs into playable streams, or local
|
|
3933
|
+
* files into streams this plugin generates. Needs `source.resolve` (file
|
|
3934
|
+
* resolvers also `producer.av`).
|
|
3833
3935
|
*
|
|
3834
3936
|
* @example
|
|
3835
3937
|
* ```ts
|
|
@@ -3846,9 +3948,11 @@ export interface Resolver extends Disposable {
|
|
|
3846
3948
|
*/
|
|
3847
3949
|
export interface Resolvers {
|
|
3848
3950
|
/**
|
|
3849
|
-
* Register a resolver. When anyone opens a matching URL
|
|
3850
|
-
* `resolve`; `signal` aborts when the open is cancelled or
|
|
3851
|
-
* scheme + host pair has one resolver
|
|
3951
|
+
* Register a resolver. When anyone opens a matching URL or file the
|
|
3952
|
+
* player calls `resolve`; `signal` aborts when the open is cancelled or
|
|
3953
|
+
* replaced. One scheme + host pair, and one extension, has one resolver
|
|
3954
|
+
* (`conflict` otherwise). A rejected `resolve` fails the open with the
|
|
3955
|
+
* error's message.
|
|
3852
3956
|
*/
|
|
3853
3957
|
add(options: ResolverOptions): Resolver;
|
|
3854
3958
|
}
|
|
@@ -4029,10 +4133,18 @@ export interface PluginInfo {
|
|
|
4029
4133
|
* @since 1.5.2
|
|
4030
4134
|
*/
|
|
4031
4135
|
export interface PluginWarning {
|
|
4032
|
-
/**
|
|
4136
|
+
/**
|
|
4137
|
+
* `"slot-occupied"`, `"sequence-taken"` or `"resolver-taken"` (player
|
|
4138
|
+
* 0.4.1); a newer player may add codes.
|
|
4139
|
+
*/
|
|
4033
4140
|
code: string;
|
|
4034
4141
|
/** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
|
|
4035
4142
|
slot?: string;
|
|
4143
|
+
/**
|
|
4144
|
+
* What was taken, for `resolver-taken`: 「文件类型 .nes」 or the
|
|
4145
|
+
* `scheme://host` of a web resolver.
|
|
4146
|
+
*/
|
|
4147
|
+
target?: string;
|
|
4036
4148
|
/** The manifest id of the plugin holding it (absent for a full sidebar). */
|
|
4037
4149
|
owner?: string;
|
|
4038
4150
|
}
|
|
@@ -8,7 +8,7 @@ export type ResolvedResource = {
|
|
|
8
8
|
headers?: Record<string, string>;
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
|
-
* What a resolver returns ({@link
|
|
11
|
+
* What a resolver returns ({@link WebResolverOptions.resolve}): the playable
|
|
12
12
|
* stream(s) behind a page URL. Both streams must use identical `headers`;
|
|
13
13
|
* different maps fail explicitly (nothing is dropped silently).
|
|
14
14
|
*
|
|
@@ -35,21 +35,48 @@ export type ResolvedMedia = {
|
|
|
35
35
|
/** A separate audio stream, when audio and video are split. */
|
|
36
36
|
audio?: ResolvedResource;
|
|
37
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* A resolver declaration on the wire: a web resolver (`schemes` + `hosts`)
|
|
40
|
+
* or, from player 0.4.1, a file resolver (`extensions`), never both.
|
|
41
|
+
*/
|
|
38
42
|
export type SourceResolverDeclaration = {
|
|
39
43
|
id: string;
|
|
40
44
|
/** Finite initial contract: http and/or https, exact normalized host names. */
|
|
41
45
|
schemes: ("http" | "https")[];
|
|
42
46
|
hosts: string[];
|
|
47
|
+
} | {
|
|
48
|
+
id: string;
|
|
49
|
+
/** Local files by extension: 1–16, lower-case letters or digits, no dot. */
|
|
50
|
+
extensions: string[];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A file resolver's answer as the handler gives it: the producer it prepared
|
|
54
|
+
* (sent to the host), `accepted` once the host selected it (with the stamp of
|
|
55
|
+
* the source it became) and `abandoned` when it never will be: the request
|
|
56
|
+
* was cancelled or answered too late. Exactly one of the two is called.
|
|
57
|
+
*/
|
|
58
|
+
export type ResolvedProducer = {
|
|
59
|
+
kind: "stream";
|
|
60
|
+
producerId: number;
|
|
61
|
+
accepted(reply: {
|
|
62
|
+
stamp: {
|
|
63
|
+
generation: number;
|
|
64
|
+
intentVersion: number;
|
|
65
|
+
};
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
abandoned(): Promise<void>;
|
|
43
68
|
};
|
|
44
69
|
export declare function sourceResolverClient(transport: Transport): {
|
|
45
|
-
/** Requires source.resolve. One registered host/scheme pair
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
70
|
+
/** Requires source.resolve. One registered host/scheme pair, and one file
|
|
71
|
+
* extension, has one owner (another plugin: `conflict`, reason
|
|
72
|
+
* `resolver-taken`). Resolution shares source.submit arbitration and the
|
|
73
|
+
* original operation ID. This backend supports paired streams with
|
|
74
|
+
* identical header maps; different maps fail explicitly. No header is
|
|
75
|
+
* silently discarded or made global. A failure is sent as its message. */
|
|
49
76
|
register(declaration: SourceResolverDeclaration, handler: (request: {
|
|
50
77
|
url: string;
|
|
51
78
|
signal: AbortSignal;
|
|
52
|
-
}) => Promise<ResolvedMedia>): Promise<{
|
|
79
|
+
}) => Promise<ResolvedMedia | ResolvedProducer>): Promise<{
|
|
53
80
|
dispose(): Promise<void>;
|
|
54
81
|
}>;
|
|
55
82
|
};
|
package/dist/source-resolvers.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
const isProducer = (result) => result?.kind === "stream";
|
|
2
|
+
/** The words of a failure, as the user will read them. */
|
|
3
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 2048);
|
|
1
4
|
export function sourceResolverClient(transport) {
|
|
2
5
|
return {
|
|
3
|
-
/** Requires source.resolve. One registered host/scheme pair
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
/** Requires source.resolve. One registered host/scheme pair, and one file
|
|
7
|
+
* extension, has one owner (another plugin: `conflict`, reason
|
|
8
|
+
* `resolver-taken`). Resolution shares source.submit arbitration and the
|
|
9
|
+
* original operation ID. This backend supports paired streams with
|
|
10
|
+
* identical header maps; different maps fail explicitly. No header is
|
|
11
|
+
* silently discarded or made global. A failure is sent as its message. */
|
|
7
12
|
async register(declaration, handler) {
|
|
8
13
|
const active = new Map();
|
|
9
14
|
let disposed = false;
|
|
@@ -13,33 +18,56 @@ export function sourceResolverClient(transport) {
|
|
|
13
18
|
return;
|
|
14
19
|
const controller = new AbortController();
|
|
15
20
|
active.set(request.requestId, controller);
|
|
21
|
+
const live = () => !controller.signal.aborted && !disposed;
|
|
16
22
|
void (async () => {
|
|
23
|
+
let result;
|
|
17
24
|
try {
|
|
18
|
-
|
|
25
|
+
result = await handler({
|
|
19
26
|
url: request.url,
|
|
20
27
|
signal: controller.signal,
|
|
21
28
|
});
|
|
22
|
-
if (!controller.signal.aborted && !disposed) {
|
|
23
|
-
await transport.request("sourceResolvers.respond", {
|
|
24
|
-
requestId: request.requestId,
|
|
25
|
-
result,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
29
|
}
|
|
29
30
|
catch (error) {
|
|
30
|
-
if (
|
|
31
|
+
if (live())
|
|
31
32
|
await transport
|
|
32
33
|
.request("sourceResolvers.respond", {
|
|
33
34
|
requestId: request.requestId,
|
|
34
|
-
error:
|
|
35
|
+
error: reason(error),
|
|
35
36
|
})
|
|
36
37
|
.catch(() => { });
|
|
38
|
+
active.delete(request.requestId);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (!isProducer(result)) {
|
|
43
|
+
if (live())
|
|
44
|
+
await transport.request("sourceResolvers.respond", {
|
|
45
|
+
requestId: request.requestId,
|
|
46
|
+
result,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!live())
|
|
51
|
+
return void (await result.abandoned());
|
|
52
|
+
let reply;
|
|
53
|
+
try {
|
|
54
|
+
// Answered: the host no longer cancels this request.
|
|
55
|
+
active.delete(request.requestId);
|
|
56
|
+
reply = await transport.request("sourceResolvers.respond", {
|
|
57
|
+
requestId: request.requestId,
|
|
58
|
+
result: { kind: "stream", producerId: result.producerId },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await result.abandoned();
|
|
63
|
+
throw error;
|
|
37
64
|
}
|
|
65
|
+
await result.accepted(reply);
|
|
38
66
|
}
|
|
39
67
|
finally {
|
|
40
68
|
active.delete(request.requestId);
|
|
41
69
|
}
|
|
42
|
-
})();
|
|
70
|
+
})().catch(() => { });
|
|
43
71
|
});
|
|
44
72
|
const offCancel = transport.on("source.resolveCancel", (value) => {
|
|
45
73
|
const request = value;
|
package/dist/wire.d.ts
CHANGED
|
@@ -593,7 +593,7 @@ export declare function createWire(transport: Transport): {
|
|
|
593
593
|
register(declaration: import("./source-resolvers.ts").SourceResolverDeclaration, handler: (request: {
|
|
594
594
|
url: string;
|
|
595
595
|
signal: AbortSignal;
|
|
596
|
-
}) => Promise<import("./source-resolvers.ts").ResolvedMedia>): Promise<{
|
|
596
|
+
}) => Promise<import("./source-resolvers.ts").ResolvedMedia | import("./source-resolvers.ts").ResolvedProducer>): Promise<{
|
|
597
597
|
dispose(): Promise<void>;
|
|
598
598
|
}>;
|
|
599
599
|
};
|