avbridge 1.0.0

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.
Files changed (103) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/dist/avi-M5B4SHRM.cjs +164 -0
  5. package/dist/avi-M5B4SHRM.cjs.map +1 -0
  6. package/dist/avi-POCGZ4JX.js +162 -0
  7. package/dist/avi-POCGZ4JX.js.map +1 -0
  8. package/dist/chunk-5ISVAODK.js +80 -0
  9. package/dist/chunk-5ISVAODK.js.map +1 -0
  10. package/dist/chunk-F7YS2XOA.cjs +2966 -0
  11. package/dist/chunk-F7YS2XOA.cjs.map +1 -0
  12. package/dist/chunk-FKM7QBZU.js +2957 -0
  13. package/dist/chunk-FKM7QBZU.js.map +1 -0
  14. package/dist/chunk-J5MCMN3S.js +27 -0
  15. package/dist/chunk-J5MCMN3S.js.map +1 -0
  16. package/dist/chunk-L4NPOJ36.cjs +180 -0
  17. package/dist/chunk-L4NPOJ36.cjs.map +1 -0
  18. package/dist/chunk-NZU7W256.cjs +29 -0
  19. package/dist/chunk-NZU7W256.cjs.map +1 -0
  20. package/dist/chunk-PQTZS7OA.js +147 -0
  21. package/dist/chunk-PQTZS7OA.js.map +1 -0
  22. package/dist/chunk-WD2ZNQA7.js +177 -0
  23. package/dist/chunk-WD2ZNQA7.js.map +1 -0
  24. package/dist/chunk-Y5FYF5KG.cjs +153 -0
  25. package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
  26. package/dist/chunk-Z2FJ5TJC.cjs +82 -0
  27. package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
  28. package/dist/element.cjs +433 -0
  29. package/dist/element.cjs.map +1 -0
  30. package/dist/element.d.cts +158 -0
  31. package/dist/element.d.ts +158 -0
  32. package/dist/element.js +431 -0
  33. package/dist/element.js.map +1 -0
  34. package/dist/index.cjs +576 -0
  35. package/dist/index.cjs.map +1 -0
  36. package/dist/index.d.cts +80 -0
  37. package/dist/index.d.ts +80 -0
  38. package/dist/index.js +554 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
  41. package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
  42. package/dist/libav-http-reader-NQJVY273.js +3 -0
  43. package/dist/libav-http-reader-NQJVY273.js.map +1 -0
  44. package/dist/libav-import-2JURFHEW.js +8 -0
  45. package/dist/libav-import-2JURFHEW.js.map +1 -0
  46. package/dist/libav-import-GST2AMPL.cjs +30 -0
  47. package/dist/libav-import-GST2AMPL.cjs.map +1 -0
  48. package/dist/libav-loader-KA2MAWLM.js +3 -0
  49. package/dist/libav-loader-KA2MAWLM.js.map +1 -0
  50. package/dist/libav-loader-ZHOERPHW.cjs +12 -0
  51. package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
  52. package/dist/player-BBwbCkdL.d.cts +365 -0
  53. package/dist/player-BBwbCkdL.d.ts +365 -0
  54. package/dist/source-SC6ZEQYR.cjs +28 -0
  55. package/dist/source-SC6ZEQYR.cjs.map +1 -0
  56. package/dist/source-ZFS4H7J3.js +3 -0
  57. package/dist/source-ZFS4H7J3.js.map +1 -0
  58. package/dist/variant-routing-GOHB2RZN.cjs +12 -0
  59. package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
  60. package/dist/variant-routing-JOBWXYKD.js +3 -0
  61. package/dist/variant-routing-JOBWXYKD.js.map +1 -0
  62. package/package.json +95 -0
  63. package/src/classify/index.ts +1 -0
  64. package/src/classify/rules.ts +214 -0
  65. package/src/convert/index.ts +2 -0
  66. package/src/convert/remux.ts +522 -0
  67. package/src/convert/transcode.ts +329 -0
  68. package/src/diagnostics.ts +99 -0
  69. package/src/element/avbridge-player.ts +576 -0
  70. package/src/element.ts +19 -0
  71. package/src/events.ts +71 -0
  72. package/src/index.ts +42 -0
  73. package/src/libav-stubs.d.ts +24 -0
  74. package/src/player.ts +455 -0
  75. package/src/plugins/builtin.ts +37 -0
  76. package/src/plugins/registry.ts +32 -0
  77. package/src/probe/avi.ts +242 -0
  78. package/src/probe/index.ts +59 -0
  79. package/src/probe/mediabunny.ts +194 -0
  80. package/src/strategies/fallback/audio-output.ts +293 -0
  81. package/src/strategies/fallback/clock.ts +7 -0
  82. package/src/strategies/fallback/decoder.ts +660 -0
  83. package/src/strategies/fallback/index.ts +170 -0
  84. package/src/strategies/fallback/libav-import.ts +27 -0
  85. package/src/strategies/fallback/libav-loader.ts +190 -0
  86. package/src/strategies/fallback/variant-routing.ts +43 -0
  87. package/src/strategies/fallback/video-renderer.ts +216 -0
  88. package/src/strategies/hybrid/decoder.ts +641 -0
  89. package/src/strategies/hybrid/index.ts +139 -0
  90. package/src/strategies/native.ts +107 -0
  91. package/src/strategies/remux/annexb.ts +112 -0
  92. package/src/strategies/remux/index.ts +79 -0
  93. package/src/strategies/remux/mse.ts +234 -0
  94. package/src/strategies/remux/pipeline.ts +254 -0
  95. package/src/subtitles/index.ts +91 -0
  96. package/src/subtitles/render.ts +62 -0
  97. package/src/subtitles/srt.ts +62 -0
  98. package/src/subtitles/vtt.ts +5 -0
  99. package/src/types-shim.d.ts +3 -0
  100. package/src/types.ts +360 -0
  101. package/src/util/codec-strings.ts +86 -0
  102. package/src/util/libav-http-reader.ts +315 -0
  103. package/src/util/source.ts +274 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Type stubs for the optional libav.js peer dependencies. These are referenced
3
+ * via the `paths` mapping in tsconfig.json so that TypeScript never follows
4
+ * into the actual `node_modules/libavjs-webcodecs-bridge/src/bridge.ts` source
5
+ * (which transitively pulls in `libavjs-webcodecs-polyfill` files that don't
6
+ * type-check under TS 5.7+'s stricter ArrayBuffer typing).
7
+ *
8
+ * Vite resolves the real packages at runtime through its own resolver — it
9
+ * does not honor tsconfig `paths` for module resolution.
10
+ */
11
+
12
+ declare module "@libav.js/variant-webcodecs" {
13
+ export const LibAV: (opts?: Record<string, unknown>) => Promise<Record<string, unknown>>;
14
+ const _default: { LibAV: typeof LibAV };
15
+ export default _default;
16
+ }
17
+
18
+ declare module "libavjs-webcodecs-bridge" {
19
+ export function videoStreamToConfig(libav: unknown, stream: unknown): Promise<VideoDecoderConfig | null>;
20
+ export function audioStreamToConfig(libav: unknown, stream: unknown): Promise<AudioDecoderConfig | null>;
21
+ export function packetToEncodedVideoChunk(pkt: unknown, stream: unknown): EncodedVideoChunk;
22
+ export function packetToEncodedAudioChunk(pkt: unknown, stream: unknown): EncodedAudioChunk;
23
+ export function libavFrameToVideoFrame(frame: unknown, stream: unknown): VideoFrame | null;
24
+ }
package/src/player.ts ADDED
@@ -0,0 +1,455 @@
1
+ import { TypedEmitter } from "./events.js";
2
+ import { probe } from "./probe/index.js";
3
+ import { classify } from "./classify/index.js";
4
+ import { Diagnostics } from "./diagnostics.js";
5
+ import { PluginRegistry } from "./plugins/registry.js";
6
+ import { registerBuiltins } from "./plugins/builtin.js";
7
+ import { discoverSidecar, attachSubtitleTracks } from "./subtitles/index.js";
8
+ import type {
9
+ Classification,
10
+ CreatePlayerOptions,
11
+ DiagnosticsSnapshot,
12
+ MediaContext,
13
+ PlaybackSession,
14
+ PlayerEventMap,
15
+ PlayerEventName,
16
+ StrategyName,
17
+ Listener,
18
+ } from "./types.js";
19
+
20
+ export class UnifiedPlayer {
21
+ private emitter = new TypedEmitter<PlayerEventMap>();
22
+ private session: PlaybackSession | null = null;
23
+ private diag = new Diagnostics();
24
+ private timeupdateInterval: ReturnType<typeof setInterval> | null = null;
25
+
26
+ // Saved from bootstrap for strategy switching
27
+ private mediaContext: MediaContext | null = null;
28
+ private classification: Classification | null = null;
29
+
30
+ // Stall detection
31
+ private stallTimer: ReturnType<typeof setInterval> | null = null;
32
+ private lastProgressTime = 0;
33
+ private lastProgressPosition = -1;
34
+ private errorListener: (() => void) | null = null;
35
+
36
+ // Serializes escalation / setStrategy calls
37
+ private switchingPromise: Promise<void> = Promise.resolve();
38
+
39
+ /**
40
+ * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
41
+ */
42
+ private constructor(
43
+ private readonly options: CreatePlayerOptions,
44
+ private readonly registry: PluginRegistry,
45
+ ) {}
46
+
47
+ static async create(options: CreatePlayerOptions): Promise<UnifiedPlayer> {
48
+ const registry = new PluginRegistry();
49
+ registerBuiltins(registry);
50
+ if (options.plugins) {
51
+ for (const p of options.plugins) registry.register(p, /* prepend */ true);
52
+ }
53
+ const player = new UnifiedPlayer(options, registry);
54
+ try {
55
+ await player.bootstrap();
56
+ } catch (err) {
57
+ (err as Error & { player?: UnifiedPlayer }).player = player;
58
+ throw err;
59
+ }
60
+ return player;
61
+ }
62
+
63
+ private async bootstrap(): Promise<void> {
64
+ try {
65
+ const ctx = await probe(this.options.source);
66
+ this.diag.recordProbe(ctx);
67
+ this.mediaContext = ctx;
68
+
69
+ // Merge sidecar / explicit subtitles
70
+ if (this.options.subtitles) {
71
+ for (const s of this.options.subtitles) {
72
+ ctx.subtitleTracks.push({
73
+ id: ctx.subtitleTracks.length,
74
+ format: s.format ?? (s.url.endsWith(".srt") ? "srt" : "vtt"),
75
+ language: s.language,
76
+ sidecarUrl: s.url,
77
+ });
78
+ }
79
+ }
80
+ if (this.options.directory && this.options.source instanceof File) {
81
+ const found = await discoverSidecar(this.options.source, this.options.directory);
82
+ for (const s of found) {
83
+ ctx.subtitleTracks.push({
84
+ id: ctx.subtitleTracks.length,
85
+ format: s.format,
86
+ language: s.language,
87
+ sidecarUrl: s.url,
88
+ });
89
+ }
90
+ }
91
+
92
+ const decision = this.options.forceStrategy
93
+ ? {
94
+ class: "NATIVE" as const,
95
+ strategy: this.options.forceStrategy,
96
+ reason: `forced via options.forceStrategy=${this.options.forceStrategy}`,
97
+ }
98
+ : classify(ctx);
99
+ this.classification = decision;
100
+ this.diag.recordClassification(decision);
101
+
102
+ this.emitter.emitSticky("strategy", {
103
+ strategy: decision.strategy,
104
+ reason: decision.reason,
105
+ });
106
+
107
+ // Try the primary strategy, falling through the chain on failure
108
+ await this.startSession(decision.strategy, decision.reason);
109
+
110
+ // Apply subtitles for non-canvas strategies
111
+ if (this.session!.strategy !== "fallback" && this.session!.strategy !== "hybrid") {
112
+ attachSubtitleTracks(this.options.target, ctx.subtitleTracks);
113
+ }
114
+
115
+ this.emitter.emitSticky("tracks", {
116
+ video: ctx.videoTracks,
117
+ audio: ctx.audioTracks,
118
+ subtitle: ctx.subtitleTracks,
119
+ });
120
+
121
+ this.startTimeupdateLoop();
122
+ this.options.target.addEventListener("ended", () => this.emitter.emit("ended", undefined));
123
+ this.emitter.emitSticky("ready", undefined);
124
+ } catch (err) {
125
+ const e = err instanceof Error ? err : new Error(String(err));
126
+ this.diag.recordError(e);
127
+ this.emitter.emit("error", e);
128
+ throw e;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Try to start a session with the given strategy. On failure, walk the
134
+ * fallback chain. Throws only if all strategies are exhausted.
135
+ */
136
+ private async startSession(strategy: StrategyName, reason: string): Promise<void> {
137
+ const plugin = this.registry.findFor(this.mediaContext!, strategy);
138
+ if (!plugin) {
139
+ throw new Error(`no plugin available for strategy "${strategy}"`);
140
+ }
141
+
142
+ try {
143
+ this.session = await plugin.execute(this.mediaContext!, this.options.target);
144
+ } catch (err) {
145
+ // Try the fallback chain
146
+ const chain = this.classification?.fallbackChain;
147
+ if (chain && chain.length > 0) {
148
+ const next = chain.shift()!;
149
+ console.warn(`[avbridge] ${strategy} failed (${(err as Error).message}), escalating to ${next}`);
150
+ this.emitter.emit("strategychange", {
151
+ from: strategy,
152
+ to: next,
153
+ reason: `${strategy} failed: ${(err as Error).message}`,
154
+ currentTime: 0,
155
+ });
156
+ this.diag.recordStrategySwitch(next, `${strategy} failed: ${(err as Error).message}`);
157
+ return this.startSession(next, `escalated from ${strategy}`);
158
+ }
159
+ throw err;
160
+ }
161
+
162
+ // Wire up fatal error handler for hybrid/fallback escalation
163
+ this.session.onFatalError?.((fatalReason) => {
164
+ void this.escalate(fatalReason);
165
+ });
166
+
167
+ // Attach stall supervisor
168
+ this.attachSupervisor();
169
+
170
+ // Update sticky strategy event if we ended up on a different strategy
171
+ if (this.session.strategy !== strategy) {
172
+ this.emitter.emitSticky("strategy", {
173
+ strategy: this.session.strategy,
174
+ reason,
175
+ });
176
+ }
177
+ }
178
+
179
+ // ── Escalation ──────────────────────────────────────────────────────────
180
+
181
+ private async escalate(reason: string): Promise<void> {
182
+ // Serialize with other switch operations
183
+ this.switchingPromise = this.switchingPromise.then(() =>
184
+ this.doEscalate(reason),
185
+ ).catch((err) => {
186
+ this.emitter.emit("error", err instanceof Error ? err : new Error(String(err)));
187
+ });
188
+ await this.switchingPromise;
189
+ }
190
+
191
+ private async doEscalate(reason: string): Promise<void> {
192
+ const chain = this.classification?.fallbackChain;
193
+ if (!chain || chain.length === 0) {
194
+ this.emitter.emit("error", new Error(
195
+ `strategy "${this.session?.strategy}" failed: ${reason} (no fallback available)`,
196
+ ));
197
+ return;
198
+ }
199
+
200
+ const currentTime = this.session?.getCurrentTime() ?? 0;
201
+ const wasPlaying = this.session ? !this.options.target.paused : false;
202
+ const fromStrategy = this.session?.strategy ?? "native";
203
+ const nextStrategy = chain.shift()!;
204
+
205
+ console.warn(`[avbridge] escalating from ${fromStrategy} to ${nextStrategy}: ${reason}`);
206
+
207
+ this.emitter.emit("strategychange", {
208
+ from: fromStrategy,
209
+ to: nextStrategy,
210
+ reason,
211
+ currentTime,
212
+ });
213
+ this.diag.recordStrategySwitch(nextStrategy, reason);
214
+
215
+ // Tear down current session
216
+ this.clearSupervisor();
217
+ if (this.session) {
218
+ try { await this.session.destroy(); } catch { /* ignore */ }
219
+ this.session = null;
220
+ }
221
+
222
+ // Create new session
223
+ const plugin = this.registry.findFor(this.mediaContext!, nextStrategy);
224
+ if (!plugin) {
225
+ this.emitter.emit("error", new Error(`no plugin for fallback strategy "${nextStrategy}"`));
226
+ return;
227
+ }
228
+
229
+ try {
230
+ this.session = await plugin.execute(this.mediaContext!, this.options.target);
231
+ } catch (err) {
232
+ this.emitter.emit("error", err instanceof Error ? err : new Error(String(err)));
233
+ return;
234
+ }
235
+
236
+ this.emitter.emitSticky("strategy", {
237
+ strategy: nextStrategy,
238
+ reason: `escalated: ${reason}`,
239
+ });
240
+
241
+ // Wire up fatal error handler + supervisor for the new session
242
+ this.session.onFatalError?.((fatalReason) => {
243
+ void this.escalate(fatalReason);
244
+ });
245
+ this.attachSupervisor();
246
+
247
+ // Restore position and play state
248
+ try {
249
+ await this.session.seek(currentTime);
250
+ if (wasPlaying) await this.session.play();
251
+ } catch (err) {
252
+ console.warn("[avbridge] failed to restore position after escalation:", err);
253
+ }
254
+ }
255
+
256
+ // ── Stall supervision ─────────────────────────────────────────────────
257
+
258
+ private attachSupervisor(): void {
259
+ this.clearSupervisor();
260
+ if (this.options.autoEscalate === false) return;
261
+ if (!this.classification?.fallbackChain?.length) return;
262
+
263
+ const strategy = this.session?.strategy;
264
+ if (strategy === "native" || strategy === "remux") {
265
+ // Monitor currentTime progress
266
+ this.lastProgressPosition = this.options.target.currentTime;
267
+ this.lastProgressTime = performance.now();
268
+
269
+ this.stallTimer = setInterval(() => {
270
+ const t = this.options.target;
271
+ if (t.paused || t.ended || t.readyState < 2) {
272
+ this.lastProgressPosition = t.currentTime;
273
+ this.lastProgressTime = performance.now();
274
+ return;
275
+ }
276
+ if (t.currentTime !== this.lastProgressPosition) {
277
+ this.lastProgressPosition = t.currentTime;
278
+ this.lastProgressTime = performance.now();
279
+ return;
280
+ }
281
+ if (performance.now() - this.lastProgressTime > 5000) {
282
+ void this.escalate(
283
+ `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`,
284
+ );
285
+ }
286
+ }, 1000);
287
+
288
+ // Listen for media element errors
289
+ const onError = () => {
290
+ void this.escalate(
291
+ `${strategy} strategy error: ${this.options.target.error?.message ?? "unknown"}`,
292
+ );
293
+ };
294
+ this.options.target.addEventListener("error", onError, { once: true });
295
+ this.errorListener = onError;
296
+ }
297
+ // Hybrid/fallback escalation is handled via onFatalError callback
298
+ }
299
+
300
+ private clearSupervisor(): void {
301
+ if (this.stallTimer) {
302
+ clearInterval(this.stallTimer);
303
+ this.stallTimer = null;
304
+ }
305
+ if (this.errorListener) {
306
+ this.options.target.removeEventListener("error", this.errorListener);
307
+ this.errorListener = null;
308
+ }
309
+ }
310
+
311
+ // ── Public: manual strategy switch ────────────────────────────────────
312
+
313
+ /** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
314
+ async setStrategy(strategy: StrategyName, reason?: string): Promise<void> {
315
+ if (!this.mediaContext) throw new Error("player not ready");
316
+ if (this.session?.strategy === strategy) return;
317
+
318
+ this.switchingPromise = this.switchingPromise.then(() =>
319
+ this.doSetStrategy(strategy, reason),
320
+ );
321
+ await this.switchingPromise;
322
+ }
323
+
324
+ private async doSetStrategy(strategy: StrategyName, reason?: string): Promise<void> {
325
+ const currentTime = this.session?.getCurrentTime() ?? 0;
326
+ const wasPlaying = this.session ? !this.options.target.paused : false;
327
+ const fromStrategy = this.session?.strategy ?? "native";
328
+ const switchReason = reason ?? `manual switch to ${strategy}`;
329
+
330
+ this.emitter.emit("strategychange", {
331
+ from: fromStrategy,
332
+ to: strategy,
333
+ reason: switchReason,
334
+ currentTime,
335
+ });
336
+ this.diag.recordStrategySwitch(strategy, switchReason);
337
+
338
+ this.clearSupervisor();
339
+ if (this.session) {
340
+ try { await this.session.destroy(); } catch { /* ignore */ }
341
+ this.session = null;
342
+ }
343
+
344
+ const plugin = this.registry.findFor(this.mediaContext!, strategy);
345
+ if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
346
+
347
+ this.session = await plugin.execute(this.mediaContext!, this.options.target);
348
+
349
+ this.emitter.emitSticky("strategy", {
350
+ strategy,
351
+ reason: switchReason,
352
+ });
353
+
354
+ this.session.onFatalError?.((fatalReason) => {
355
+ void this.escalate(fatalReason);
356
+ });
357
+ this.attachSupervisor();
358
+
359
+ try {
360
+ await this.session.seek(currentTime);
361
+ if (wasPlaying) await this.session.play();
362
+ } catch (err) {
363
+ console.warn("[avbridge] failed to restore position after strategy switch:", err);
364
+ }
365
+ }
366
+
367
+ // ── Timeupdate loop ───────────────────────────────────────────────────
368
+
369
+ private startTimeupdateLoop(): void {
370
+ this.timeupdateInterval = setInterval(() => {
371
+ const t = this.session?.getCurrentTime() ?? this.options.target.currentTime;
372
+ this.emitter.emit("timeupdate", { currentTime: t });
373
+ }, 250);
374
+ }
375
+
376
+ // ── Public API ────────────────────────────────────────────────────────
377
+
378
+ /** Subscribe to a player event. Returns an unsubscribe function. Sticky events (strategy, ready, tracks) replay for late subscribers. */
379
+ on<K extends PlayerEventName>(event: K, fn: Listener<PlayerEventMap[K]>): () => void {
380
+ return this.emitter.on(event, fn);
381
+ }
382
+
383
+ /** Remove a previously registered event listener. */
384
+ off<K extends PlayerEventName>(event: K, fn: Listener<PlayerEventMap[K]>): void {
385
+ this.emitter.off(event, fn);
386
+ }
387
+
388
+ /** Begin or resume playback. Throws if the player is not ready. */
389
+ async play(): Promise<void> {
390
+ if (!this.session) throw new Error("player not ready");
391
+ await this.session.play();
392
+ }
393
+
394
+ /** Pause playback. No-op if the player is not ready or already paused. */
395
+ pause(): void {
396
+ this.session?.pause();
397
+ }
398
+
399
+ /** Seek to the given time in seconds. Throws if the player is not ready. */
400
+ async seek(time: number): Promise<void> {
401
+ if (!this.session) throw new Error("player not ready");
402
+ await this.session.seek(time);
403
+ }
404
+
405
+ /** Switch the active audio track by track ID. Throws if the player is not ready. */
406
+ async setAudioTrack(id: number): Promise<void> {
407
+ if (!this.session) throw new Error("player not ready");
408
+ await this.session.setAudioTrack(id);
409
+ }
410
+
411
+ /** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
412
+ async setSubtitleTrack(id: number | null): Promise<void> {
413
+ if (!this.session) throw new Error("player not ready");
414
+ await this.session.setSubtitleTrack(id);
415
+ }
416
+
417
+ /** Return a snapshot of current diagnostics: container, codecs, strategy, runtime stats, and strategy history. */
418
+ getDiagnostics(): DiagnosticsSnapshot {
419
+ if (this.session) {
420
+ this.diag.recordRuntime(this.session.getRuntimeStats());
421
+ }
422
+ return this.diag.snapshot();
423
+ }
424
+
425
+ /** Return the total duration in seconds, or `NaN` if unknown. */
426
+ getDuration(): number {
427
+ const fromDiag = this.diag.snapshot().duration;
428
+ if (typeof fromDiag === "number" && Number.isFinite(fromDiag)) return fromDiag;
429
+ const fromVideo = this.options.target.duration;
430
+ return Number.isFinite(fromVideo) ? fromVideo : NaN;
431
+ }
432
+
433
+ /** Return the current playback position in seconds. */
434
+ getCurrentTime(): number {
435
+ return this.session?.getCurrentTime() ?? this.options.target.currentTime ?? 0;
436
+ }
437
+
438
+ /** Tear down the player: stop timers, destroy the active session, remove all event listeners. The player is unusable after this call. */
439
+ async destroy(): Promise<void> {
440
+ if (this.timeupdateInterval) {
441
+ clearInterval(this.timeupdateInterval);
442
+ this.timeupdateInterval = null;
443
+ }
444
+ this.clearSupervisor();
445
+ if (this.session) {
446
+ await this.session.destroy();
447
+ this.session = null;
448
+ }
449
+ this.emitter.removeAll();
450
+ }
451
+ }
452
+
453
+ export async function createPlayer(options: CreatePlayerOptions): Promise<UnifiedPlayer> {
454
+ return UnifiedPlayer.create(options);
455
+ }
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from "../types.js";
2
+ import { createNativeSession } from "../strategies/native.js";
3
+ import { createRemuxSession } from "../strategies/remux/index.js";
4
+ import { createHybridSession } from "../strategies/hybrid/index.js";
5
+ import { createFallbackSession } from "../strategies/fallback/index.js";
6
+ import type { PluginRegistry } from "./registry.js";
7
+
8
+ const nativePlugin: Plugin = {
9
+ name: "native",
10
+ canHandle: () => true,
11
+ execute: (ctx, video) => createNativeSession(ctx, video),
12
+ };
13
+
14
+ const remuxPlugin: Plugin = {
15
+ name: "remux",
16
+ canHandle: () => true,
17
+ execute: (ctx, video) => createRemuxSession(ctx, video),
18
+ };
19
+
20
+ const hybridPlugin: Plugin = {
21
+ name: "hybrid",
22
+ canHandle: () => typeof VideoDecoder !== "undefined",
23
+ execute: (ctx, video) => createHybridSession(ctx, video),
24
+ };
25
+
26
+ const fallbackPlugin: Plugin = {
27
+ name: "fallback",
28
+ canHandle: () => true,
29
+ execute: (ctx, video) => createFallbackSession(ctx, video),
30
+ };
31
+
32
+ export function registerBuiltins(registry: PluginRegistry): void {
33
+ registry.register(nativePlugin);
34
+ registry.register(remuxPlugin);
35
+ registry.register(hybridPlugin);
36
+ registry.register(fallbackPlugin);
37
+ }
@@ -0,0 +1,32 @@
1
+ import type { MediaContext, Plugin, StrategyName } from "../types.js";
2
+
3
+ /**
4
+ * Plugin registry. Built-in strategies are registered as plugins so that
5
+ * user-supplied plugins can preempt them. The registry is consulted twice:
6
+ * once by the player layer to find a plugin matching the picked strategy, and
7
+ * (optionally) by classification to ask plugins what they support.
8
+ */
9
+ export class PluginRegistry {
10
+ private plugins: Plugin[] = [];
11
+
12
+ register(plugin: Plugin, prepend = false): void {
13
+ if (prepend) this.plugins.unshift(plugin);
14
+ else this.plugins.push(plugin);
15
+ }
16
+
17
+ all(): readonly Plugin[] {
18
+ return this.plugins;
19
+ }
20
+
21
+ /**
22
+ * Find the first plugin that claims this context AND its name matches the
23
+ * strategy. Built-in strategy plugins are named exactly `"native"`,
24
+ * `"remux"`, `"fallback"`.
25
+ */
26
+ findFor(context: MediaContext, strategy: StrategyName): Plugin | null {
27
+ for (const p of this.plugins) {
28
+ if (p.name === strategy && p.canHandle(context)) return p;
29
+ }
30
+ return null;
31
+ }
32
+ }