avbridge 2.2.0 → 2.3.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.
- package/CHANGELOG.md +125 -1
- package/NOTICE.md +2 -2
- package/README.md +100 -74
- package/THIRD_PARTY_LICENSES.md +2 -2
- package/dist/avi-2JPBSHGA.js +183 -0
- package/dist/avi-2JPBSHGA.js.map +1 -0
- package/dist/avi-F6WZJK5T.cjs +185 -0
- package/dist/avi-F6WZJK5T.cjs.map +1 -0
- package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
- package/dist/avi-NJXAXUXK.js.map +1 -0
- package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
- package/dist/avi-W6L3BTWU.cjs.map +1 -0
- package/dist/{chunk-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
- package/dist/chunk-2PGRFCWB.js.map +1 -0
- package/dist/chunk-5YAWWKA3.js +18 -0
- package/dist/chunk-5YAWWKA3.js.map +1 -0
- package/dist/chunk-6UUT4BEA.cjs +219 -0
- package/dist/chunk-6UUT4BEA.cjs.map +1 -0
- package/dist/{chunk-OE66B34H.cjs → chunk-7RGG6ME7.cjs} +562 -94
- package/dist/chunk-7RGG6ME7.cjs.map +1 -0
- package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
- package/dist/chunk-DCSOQH2N.js.map +1 -0
- package/dist/chunk-F3LQJKXK.cjs +20 -0
- package/dist/chunk-F3LQJKXK.cjs.map +1 -0
- package/dist/chunk-IAYKFGFG.js +200 -0
- package/dist/chunk-IAYKFGFG.js.map +1 -0
- package/dist/chunk-NNVOHKXJ.cjs +204 -0
- package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
- package/dist/{chunk-C5VA5U5O.js → chunk-NV7ILLWH.js} +556 -92
- package/dist/chunk-NV7ILLWH.js.map +1 -0
- package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
- package/dist/chunk-QQXBPW72.js.map +1 -0
- package/dist/chunk-XKPSTC34.cjs +210 -0
- package/dist/chunk-XKPSTC34.cjs.map +1 -0
- package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
- package/dist/chunk-Z33SBWL5.cjs.map +1 -0
- package/dist/element-browser.js +631 -103
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +4 -4
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +3 -3
- package/dist/index.cjs +174 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -4
- package/dist/index.d.ts +48 -4
- package/dist/index.js +93 -12
- package/dist/index.js.map +1 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
- package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
- package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
- package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
- package/dist/{player-DUyvltvy.d.cts → player-B6WB74RD.d.cts} +63 -3
- package/dist/{player-DUyvltvy.d.ts → player-B6WB74RD.d.ts} +63 -3
- package/dist/player.cjs +5500 -0
- package/dist/player.cjs.map +1 -0
- package/dist/player.d.cts +649 -0
- package/dist/player.d.ts +649 -0
- package/dist/player.js +5498 -0
- package/dist/player.js.map +1 -0
- package/dist/source-73CAH6HW.cjs +28 -0
- package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
- package/dist/source-F656KYYV.js +3 -0
- package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
- package/dist/source-QJR3OHTW.js +3 -0
- package/dist/source-QJR3OHTW.js.map +1 -0
- package/dist/source-VB74JQ7Z.cjs +28 -0
- package/dist/source-VB74JQ7Z.cjs.map +1 -0
- package/dist/variant-routing-434STYAB.js +3 -0
- package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
- package/dist/variant-routing-HONNAA6R.cjs +12 -0
- package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
- package/package.json +9 -1
- package/src/classify/rules.ts +27 -5
- package/src/convert/remux.ts +8 -0
- package/src/convert/transcode.ts +41 -8
- package/src/element/avbridge-player.ts +845 -0
- package/src/element/player-icons.ts +25 -0
- package/src/element/player-styles.ts +472 -0
- package/src/errors.ts +47 -0
- package/src/index.ts +23 -0
- package/src/player-element.ts +18 -0
- package/src/player.ts +127 -27
- package/src/plugins/builtin.ts +2 -2
- package/src/probe/avi.ts +4 -0
- package/src/probe/index.ts +40 -10
- package/src/strategies/fallback/audio-output.ts +31 -0
- package/src/strategies/fallback/decoder.ts +83 -2
- package/src/strategies/fallback/index.ts +34 -1
- package/src/strategies/fallback/variant-routing.ts +7 -13
- package/src/strategies/fallback/video-renderer.ts +129 -33
- package/src/strategies/hybrid/decoder.ts +131 -20
- package/src/strategies/hybrid/index.ts +36 -2
- package/src/strategies/remux/index.ts +13 -1
- package/src/strategies/remux/mse.ts +12 -2
- package/src/strategies/remux/pipeline.ts +6 -0
- package/src/subtitles/index.ts +7 -3
- package/src/types.ts +53 -1
- package/src/util/libav-http-reader.ts +5 -1
- package/src/util/source.ts +28 -8
- package/src/util/transport.ts +26 -0
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/dist/avi-6SJLWIWW.cjs.map +0 -1
- package/dist/avi-GCGM7OJI.js.map +0 -1
- package/dist/chunk-C5VA5U5O.js.map +0 -1
- package/dist/chunk-HZLQNKFN.cjs.map +0 -1
- package/dist/chunk-ILKDNBSE.js.map +0 -1
- package/dist/chunk-J5MCMN3S.js +0 -27
- package/dist/chunk-J5MCMN3S.js.map +0 -1
- package/dist/chunk-L4NPOJ36.cjs.map +0 -1
- package/dist/chunk-NZU7W256.cjs +0 -29
- package/dist/chunk-NZU7W256.cjs.map +0 -1
- package/dist/chunk-OE66B34H.cjs.map +0 -1
- package/dist/chunk-WD2ZNQA7.js.map +0 -1
- package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
- package/dist/libav-http-reader-NQJVY273.js +0 -3
- package/dist/source-CN43EI7Z.cjs +0 -28
- package/dist/source-FFZ7TW2B.js +0 -3
- package/dist/variant-routing-GOHB2RZN.cjs +0 -12
- package/dist/variant-routing-JOBWXYKD.js +0 -3
package/src/player.ts
CHANGED
|
@@ -15,8 +15,10 @@ import type {
|
|
|
15
15
|
PlayerEventMap,
|
|
16
16
|
PlayerEventName,
|
|
17
17
|
StrategyName,
|
|
18
|
+
TransportConfig,
|
|
18
19
|
Listener,
|
|
19
20
|
} from "./types.js";
|
|
21
|
+
import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
|
|
20
22
|
|
|
21
23
|
export class UnifiedPlayer {
|
|
22
24
|
private emitter = new TypedEmitter<PlayerEventMap>();
|
|
@@ -34,6 +36,20 @@ export class UnifiedPlayer {
|
|
|
34
36
|
private lastProgressPosition = -1;
|
|
35
37
|
private errorListener: (() => void) | null = null;
|
|
36
38
|
|
|
39
|
+
// Bound so we can removeEventListener in destroy(); without this the
|
|
40
|
+
// listener outlives the player and accumulates on elements that swap
|
|
41
|
+
// source (e.g. <avbridge-video>).
|
|
42
|
+
private endedListener: (() => void) | null = null;
|
|
43
|
+
|
|
44
|
+
// Background tab handling. userIntent is what the user last asked for
|
|
45
|
+
// (play vs pause) — used to decide whether to auto-resume on visibility
|
|
46
|
+
// return. autoPausedForVisibility tracks whether we paused because the
|
|
47
|
+
// tab was hidden, so we don't resume playback the user deliberately
|
|
48
|
+
// paused (e.g. via media keys while hidden).
|
|
49
|
+
private userIntent: "play" | "pause" = "pause";
|
|
50
|
+
private autoPausedForVisibility = false;
|
|
51
|
+
private visibilityListener: (() => void) | null = null;
|
|
52
|
+
|
|
37
53
|
// Serializes escalation / setStrategy calls
|
|
38
54
|
private switchingPromise: Promise<void> = Promise.resolve();
|
|
39
55
|
|
|
@@ -41,13 +57,23 @@ export class UnifiedPlayer {
|
|
|
41
57
|
// Revoked at destroy() so repeated source swaps don't leak.
|
|
42
58
|
private subtitleResources = new SubtitleResourceBag();
|
|
43
59
|
|
|
60
|
+
// Transport config extracted from CreatePlayerOptions. Threaded to probe,
|
|
61
|
+
// subtitle fetches, and strategy session creators. Not stored on MediaContext
|
|
62
|
+
// because it's runtime config, not media analysis.
|
|
63
|
+
private readonly transport: TransportConfig | undefined;
|
|
64
|
+
|
|
44
65
|
/**
|
|
45
66
|
* @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
|
|
46
67
|
*/
|
|
47
68
|
private constructor(
|
|
48
69
|
private readonly options: CreatePlayerOptions,
|
|
49
70
|
private readonly registry: PluginRegistry,
|
|
50
|
-
) {
|
|
71
|
+
) {
|
|
72
|
+
const { requestInit, fetchFn } = options;
|
|
73
|
+
if (requestInit || fetchFn) {
|
|
74
|
+
this.transport = { requestInit, fetchFn };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
51
77
|
|
|
52
78
|
static async create(options: CreatePlayerOptions): Promise<UnifiedPlayer> {
|
|
53
79
|
const registry = new PluginRegistry();
|
|
@@ -69,7 +95,7 @@ export class UnifiedPlayer {
|
|
|
69
95
|
const bootstrapStart = performance.now();
|
|
70
96
|
try {
|
|
71
97
|
dbg.info("bootstrap", "start");
|
|
72
|
-
const ctx = await dbg.timed("probe", "probe", 3000, () => probe(this.options.source));
|
|
98
|
+
const ctx = await dbg.timed("probe", "probe", 3000, () => probe(this.options.source, this.transport));
|
|
73
99
|
dbg.info("probe",
|
|
74
100
|
`container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} ` +
|
|
75
101
|
`audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`,
|
|
@@ -121,12 +147,10 @@ export class UnifiedPlayer {
|
|
|
121
147
|
// Try the primary strategy, falling through the chain on failure
|
|
122
148
|
await this.startSession(decision.strategy, decision.reason);
|
|
123
149
|
|
|
124
|
-
// Apply subtitles for non-canvas strategies.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// load-bearing for playback. (Promoting this to a typed `subtitleerror`
|
|
129
|
-
// event is a nice-to-have follow-up.)
|
|
150
|
+
// Apply subtitles for non-canvas strategies. Per-track failures are
|
|
151
|
+
// caught inside attachSubtitleTracks and logged via console.warn —
|
|
152
|
+
// subtitles are not load-bearing, so a bad sidecar must not break
|
|
153
|
+
// bootstrap.
|
|
130
154
|
if (this.session!.strategy !== "fallback" && this.session!.strategy !== "hybrid") {
|
|
131
155
|
await attachSubtitleTracks(
|
|
132
156
|
this.options.target,
|
|
@@ -136,6 +160,7 @@ export class UnifiedPlayer {
|
|
|
136
160
|
// eslint-disable-next-line no-console
|
|
137
161
|
console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
|
|
138
162
|
},
|
|
163
|
+
this.transport,
|
|
139
164
|
);
|
|
140
165
|
}
|
|
141
166
|
|
|
@@ -146,7 +171,17 @@ export class UnifiedPlayer {
|
|
|
146
171
|
});
|
|
147
172
|
|
|
148
173
|
this.startTimeupdateLoop();
|
|
149
|
-
this.
|
|
174
|
+
this.endedListener = () => this.emitter.emit("ended", undefined);
|
|
175
|
+
this.options.target.addEventListener("ended", this.endedListener);
|
|
176
|
+
|
|
177
|
+
// Auto-pause on background tab (unless explicitly opted out).
|
|
178
|
+
// Chrome throttles rAF and setTimeout in hidden tabs, so playback
|
|
179
|
+
// degrades anyway — better to pause cleanly and resume on return.
|
|
180
|
+
if (this.options.backgroundBehavior !== "continue" && typeof document !== "undefined") {
|
|
181
|
+
this.visibilityListener = () => this.onVisibilityChange();
|
|
182
|
+
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
183
|
+
}
|
|
184
|
+
|
|
150
185
|
this.emitter.emitSticky("ready", undefined);
|
|
151
186
|
const bootstrapElapsed = performance.now() - bootstrapStart;
|
|
152
187
|
dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
|
|
@@ -177,7 +212,7 @@ export class UnifiedPlayer {
|
|
|
177
212
|
}
|
|
178
213
|
|
|
179
214
|
try {
|
|
180
|
-
this.session = await plugin.execute(this.mediaContext!, this.options.target);
|
|
215
|
+
this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
|
|
181
216
|
} catch (err) {
|
|
182
217
|
// Try the fallback chain
|
|
183
218
|
const chain = this.classification?.fallbackChain;
|
|
@@ -271,7 +306,7 @@ export class UnifiedPlayer {
|
|
|
271
306
|
}
|
|
272
307
|
|
|
273
308
|
try {
|
|
274
|
-
this.session = await plugin.execute(this.mediaContext!, this.options.target);
|
|
309
|
+
this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
|
|
275
310
|
} catch (err) {
|
|
276
311
|
const msg = err instanceof Error ? err.message : String(err);
|
|
277
312
|
errors.push(`${nextStrategy}: ${msg}`);
|
|
@@ -298,8 +333,10 @@ export class UnifiedPlayer {
|
|
|
298
333
|
}
|
|
299
334
|
|
|
300
335
|
// Chain exhausted with no working strategy.
|
|
301
|
-
this.emitter.emit("error", new
|
|
302
|
-
|
|
336
|
+
this.emitter.emit("error", new AvbridgeError(
|
|
337
|
+
ERR_ALL_STRATEGIES_EXHAUSTED,
|
|
338
|
+
`All playback strategies failed: ${errors.join("; ")}`,
|
|
339
|
+
"This file may require a codec or container that isn't available in this browser. Try the fallback strategy or check browser codec support.",
|
|
303
340
|
));
|
|
304
341
|
}
|
|
305
342
|
|
|
@@ -362,7 +399,7 @@ export class UnifiedPlayer {
|
|
|
362
399
|
|
|
363
400
|
/** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
|
|
364
401
|
async setStrategy(strategy: StrategyName, reason?: string): Promise<void> {
|
|
365
|
-
if (!this.mediaContext) throw new
|
|
402
|
+
if (!this.mediaContext) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
|
|
366
403
|
if (this.session?.strategy === strategy) return;
|
|
367
404
|
|
|
368
405
|
this.switchingPromise = this.switchingPromise.then(() =>
|
|
@@ -394,7 +431,7 @@ export class UnifiedPlayer {
|
|
|
394
431
|
const plugin = this.registry.findFor(this.mediaContext!, strategy);
|
|
395
432
|
if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
|
|
396
433
|
|
|
397
|
-
this.session = await plugin.execute(this.mediaContext!, this.options.target);
|
|
434
|
+
this.session = await plugin.execute(this.mediaContext!, this.options.target, this.transport);
|
|
398
435
|
|
|
399
436
|
this.emitter.emitSticky("strategy", {
|
|
400
437
|
strategy,
|
|
@@ -437,30 +474,62 @@ export class UnifiedPlayer {
|
|
|
437
474
|
|
|
438
475
|
/** Begin or resume playback. Throws if the player is not ready. */
|
|
439
476
|
async play(): Promise<void> {
|
|
440
|
-
if (!this.session) throw new
|
|
477
|
+
if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
|
|
478
|
+
this.userIntent = "play";
|
|
479
|
+
this.autoPausedForVisibility = false;
|
|
441
480
|
await this.session.play();
|
|
442
481
|
}
|
|
443
482
|
|
|
444
483
|
/** Pause playback. No-op if the player is not ready or already paused. */
|
|
445
484
|
pause(): void {
|
|
485
|
+
this.userIntent = "pause";
|
|
486
|
+
this.autoPausedForVisibility = false;
|
|
446
487
|
this.session?.pause();
|
|
447
488
|
}
|
|
448
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Handle browser tab visibility changes. On hide: pause if the user
|
|
492
|
+
* had been playing. On show: resume if we were the one who paused.
|
|
493
|
+
* Skips when `backgroundBehavior: "continue"` is set (listener isn't
|
|
494
|
+
* installed in that case).
|
|
495
|
+
*/
|
|
496
|
+
private onVisibilityChange(): void {
|
|
497
|
+
if (!this.session) return;
|
|
498
|
+
const action = decideVisibilityAction({
|
|
499
|
+
hidden: document.hidden,
|
|
500
|
+
userIntent: this.userIntent,
|
|
501
|
+
sessionIsPlaying: !this.options.target.paused,
|
|
502
|
+
autoPausedForVisibility: this.autoPausedForVisibility,
|
|
503
|
+
});
|
|
504
|
+
if (action === "pause") {
|
|
505
|
+
this.autoPausedForVisibility = true;
|
|
506
|
+
dbg.info("visibility", "tab hidden — auto-paused");
|
|
507
|
+
this.session.pause();
|
|
508
|
+
} else if (action === "resume") {
|
|
509
|
+
this.autoPausedForVisibility = false;
|
|
510
|
+
dbg.info("visibility", "tab visible — auto-resuming");
|
|
511
|
+
void this.session.play().catch((err) => {
|
|
512
|
+
// eslint-disable-next-line no-console
|
|
513
|
+
console.warn("[avbridge] auto-resume after tab return failed:", err);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
449
518
|
/** Seek to the given time in seconds. Throws if the player is not ready. */
|
|
450
519
|
async seek(time: number): Promise<void> {
|
|
451
|
-
if (!this.session) throw new
|
|
520
|
+
if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
|
|
452
521
|
await this.session.seek(time);
|
|
453
522
|
}
|
|
454
523
|
|
|
455
524
|
/** Switch the active audio track by track ID. Throws if the player is not ready. */
|
|
456
525
|
async setAudioTrack(id: number): Promise<void> {
|
|
457
|
-
if (!this.session) throw new
|
|
526
|
+
if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
|
|
458
527
|
await this.session.setAudioTrack(id);
|
|
459
528
|
}
|
|
460
529
|
|
|
461
530
|
/** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
|
|
462
531
|
async setSubtitleTrack(id: number | null): Promise<void> {
|
|
463
|
-
if (!this.session) throw new
|
|
532
|
+
if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready — wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
|
|
464
533
|
await this.session.setSubtitleTrack(id);
|
|
465
534
|
}
|
|
466
535
|
|
|
@@ -492,6 +561,14 @@ export class UnifiedPlayer {
|
|
|
492
561
|
this.timeupdateInterval = null;
|
|
493
562
|
}
|
|
494
563
|
this.clearSupervisor();
|
|
564
|
+
if (this.endedListener) {
|
|
565
|
+
this.options.target.removeEventListener("ended", this.endedListener);
|
|
566
|
+
this.endedListener = null;
|
|
567
|
+
}
|
|
568
|
+
if (this.visibilityListener) {
|
|
569
|
+
document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
570
|
+
this.visibilityListener = null;
|
|
571
|
+
}
|
|
495
572
|
if (this.session) {
|
|
496
573
|
await this.session.destroy();
|
|
497
574
|
this.session = null;
|
|
@@ -508,14 +585,35 @@ export async function createPlayer(options: CreatePlayerOptions): Promise<Unifie
|
|
|
508
585
|
}
|
|
509
586
|
|
|
510
587
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
588
|
+
* Pure decision function for visibility-change handling. Separated from
|
|
589
|
+
* the class method so it can be unit-tested without a full player
|
|
590
|
+
* instance.
|
|
514
591
|
*
|
|
515
|
-
*
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
592
|
+
* @internal — exported for unit tests; not part of the public API.
|
|
593
|
+
*/
|
|
594
|
+
export function decideVisibilityAction(state: {
|
|
595
|
+
hidden: boolean;
|
|
596
|
+
userIntent: "play" | "pause";
|
|
597
|
+
sessionIsPlaying: boolean;
|
|
598
|
+
autoPausedForVisibility: boolean;
|
|
599
|
+
}): "pause" | "resume" | "noop" {
|
|
600
|
+
if (state.hidden) {
|
|
601
|
+
// Tab hidden: pause if user had been playing and session is active
|
|
602
|
+
if (state.userIntent === "play" && state.sessionIsPlaying) return "pause";
|
|
603
|
+
return "noop";
|
|
604
|
+
}
|
|
605
|
+
// Tab visible: resume only if we're the one who paused
|
|
606
|
+
if (state.autoPausedForVisibility) return "resume";
|
|
607
|
+
return "noop";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Build a synthetic classification for an explicit `initialStrategy` override.
|
|
612
|
+
* The `class` is derived from the chosen strategy so diagnostics and any
|
|
613
|
+
* downstream consumer of `strategyClass` see the real strategy. The fallback
|
|
614
|
+
* chain is inherited from the natural classification but must never contain
|
|
615
|
+
* `initial` itself — otherwise `startSession` would retry the strategy that
|
|
616
|
+
* just failed before escalating.
|
|
519
617
|
*
|
|
520
618
|
* @internal — exported for unit tests; not part of the public API.
|
|
521
619
|
*/
|
|
@@ -525,11 +623,13 @@ export function buildInitialDecision(
|
|
|
525
623
|
): Classification {
|
|
526
624
|
const natural = classify(ctx);
|
|
527
625
|
const cls = strategyToClass(initial, natural);
|
|
626
|
+
const inherited = natural.fallbackChain ?? defaultFallbackChain(initial);
|
|
627
|
+
const fallbackChain = inherited.filter((s) => s !== initial);
|
|
528
628
|
return {
|
|
529
629
|
class: cls,
|
|
530
630
|
strategy: initial,
|
|
531
631
|
reason: `initial strategy "${initial}" requested via options.initialStrategy`,
|
|
532
|
-
fallbackChain
|
|
632
|
+
fallbackChain,
|
|
533
633
|
};
|
|
534
634
|
}
|
|
535
635
|
|
package/src/plugins/builtin.ts
CHANGED
|
@@ -20,13 +20,13 @@ const remuxPlugin: Plugin = {
|
|
|
20
20
|
const hybridPlugin: Plugin = {
|
|
21
21
|
name: "hybrid",
|
|
22
22
|
canHandle: () => typeof VideoDecoder !== "undefined",
|
|
23
|
-
execute: (ctx, video) => createHybridSession(ctx, video),
|
|
23
|
+
execute: (ctx, video, transport) => createHybridSession(ctx, video, transport),
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
const fallbackPlugin: Plugin = {
|
|
27
27
|
name: "fallback",
|
|
28
28
|
canHandle: () => true,
|
|
29
|
-
execute: (ctx, video) => createFallbackSession(ctx, video),
|
|
29
|
+
execute: (ctx, video, transport) => createFallbackSession(ctx, video, transport),
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
export function registerBuiltins(registry: PluginRegistry): void {
|
package/src/probe/avi.ts
CHANGED
|
@@ -205,6 +205,10 @@ function ffmpegToAvbridgeAudio(name: string): AudioCodec {
|
|
|
205
205
|
case "ra_288": return "ra_288";
|
|
206
206
|
case "sipr": return "sipr";
|
|
207
207
|
case "atrac3": return "atrac3";
|
|
208
|
+
case "dca":
|
|
209
|
+
case "dts": return "dts";
|
|
210
|
+
case "truehd":
|
|
211
|
+
case "mlp": return "truehd";
|
|
208
212
|
default: return name as AudioCodec;
|
|
209
213
|
}
|
|
210
214
|
}
|
package/src/probe/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ContainerKind, MediaContext, MediaInput } from "../types.js";
|
|
1
|
+
import type { ContainerKind, MediaContext, MediaInput, TransportConfig } from "../types.js";
|
|
2
2
|
import { normalizeSource, sniffNormalizedSource } from "../util/source.js";
|
|
3
3
|
import { probeWithMediabunny } from "./mediabunny.js";
|
|
4
|
+
import { AvbridgeError, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_LIBAV_NOT_REACHABLE } from "../errors.js";
|
|
4
5
|
|
|
5
6
|
/** Containers mediabunny can demux. Sniff results outside this set go straight to libav. */
|
|
6
7
|
const MEDIABUNNY_CONTAINERS = new Set<ContainerKind>([
|
|
@@ -32,13 +33,33 @@ const MEDIABUNNY_CONTAINERS = new Set<ContainerKind>([
|
|
|
32
33
|
* mediabunny can't read those containers at all, so there's no fast path
|
|
33
34
|
* to try.
|
|
34
35
|
*/
|
|
35
|
-
export async function probe(
|
|
36
|
-
|
|
36
|
+
export async function probe(
|
|
37
|
+
source: MediaInput,
|
|
38
|
+
transport?: TransportConfig,
|
|
39
|
+
): Promise<MediaContext> {
|
|
40
|
+
const normalized = await normalizeSource(source, transport);
|
|
37
41
|
const sniffed = await sniffNormalizedSource(normalized);
|
|
38
42
|
|
|
39
43
|
if (MEDIABUNNY_CONTAINERS.has(sniffed)) {
|
|
40
44
|
try {
|
|
41
|
-
|
|
45
|
+
const result = await probeWithMediabunny(normalized, sniffed);
|
|
46
|
+
// If mediabunny returned unknown codecs, try libav which recognizes
|
|
47
|
+
// a wider range (DTS, TrueHD, etc.). Only escalate if there ARE
|
|
48
|
+
// tracks with unknown codecs — otherwise mediabunny's result is fine.
|
|
49
|
+
const hasUnknownCodec =
|
|
50
|
+
result.videoTracks.some((t) => t.codec === "unknown") ||
|
|
51
|
+
result.audioTracks.some((t) => t.codec === "unknown");
|
|
52
|
+
if (hasUnknownCodec) {
|
|
53
|
+
try {
|
|
54
|
+
const { probeWithLibav } = await import("./avi.js");
|
|
55
|
+
return await probeWithLibav(normalized, sniffed);
|
|
56
|
+
} catch {
|
|
57
|
+
// libav also failed — return mediabunny's result (unknown codecs
|
|
58
|
+
// are better than no result at all)
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
42
63
|
} catch (mediabunnyErr) {
|
|
43
64
|
// mediabunny rejected the file. Before giving up, try libav — it can
|
|
44
65
|
// demux a much wider range of codec combinations in ISOBMFF/MKV/etc.
|
|
@@ -56,8 +77,10 @@ export async function probe(source: MediaInput): Promise<MediaContext> {
|
|
|
56
77
|
} catch (libavErr) {
|
|
57
78
|
const mbMsg = (mediabunnyErr as Error).message || String(mediabunnyErr);
|
|
58
79
|
const lvMsg = libavErr instanceof Error ? libavErr.message : String(libavErr);
|
|
59
|
-
throw new
|
|
60
|
-
|
|
80
|
+
throw new AvbridgeError(
|
|
81
|
+
ERR_PROBE_FAILED,
|
|
82
|
+
`Failed to probe ${sniffed.toUpperCase()} file. mediabunny: ${mbMsg}. libav: ${lvMsg}.`,
|
|
83
|
+
"The file may be corrupt, truncated, or in an unsupported format. Enable AVBRIDGE_DEBUG for detailed logs.",
|
|
61
84
|
);
|
|
62
85
|
}
|
|
63
86
|
}
|
|
@@ -71,10 +94,17 @@ export async function probe(source: MediaInput): Promise<MediaContext> {
|
|
|
71
94
|
const inner = err instanceof Error ? err.message : String(err);
|
|
72
95
|
// eslint-disable-next-line no-console
|
|
73
96
|
console.error("[avbridge] libav probe failed for", sniffed, "file:", err);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
:
|
|
97
|
+
if (sniffed === "unknown") {
|
|
98
|
+
throw new AvbridgeError(
|
|
99
|
+
ERR_PROBE_UNKNOWN_CONTAINER,
|
|
100
|
+
`Unable to probe source: container format could not be identified. libav fallback: ${inner || "(no details)"}`,
|
|
101
|
+
"The file may be corrupt or in a format avbridge doesn't recognize. Check the file plays in VLC or ffprobe.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
throw new AvbridgeError(
|
|
105
|
+
ERR_LIBAV_NOT_REACHABLE,
|
|
106
|
+
`${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no details)"}`,
|
|
107
|
+
"Install @libav.js/variant-webcodecs, or check that AVBRIDGE_LIBAV_BASE points to the correct path.",
|
|
78
108
|
);
|
|
79
109
|
}
|
|
80
110
|
}
|
|
@@ -76,12 +76,42 @@ export class AudioOutput implements ClockSource {
|
|
|
76
76
|
private framesScheduled = 0;
|
|
77
77
|
private destroyed = false;
|
|
78
78
|
|
|
79
|
+
/** User-set volume (0..1). Applied to the gain node. */
|
|
80
|
+
private _volume = 1;
|
|
81
|
+
/** User-set muted flag. When true, gain is forced to 0. */
|
|
82
|
+
private _muted = false;
|
|
83
|
+
|
|
79
84
|
constructor() {
|
|
80
85
|
this.ctx = new AudioContext();
|
|
81
86
|
this.gain = this.ctx.createGain();
|
|
82
87
|
this.gain.connect(this.ctx.destination);
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/** Set volume (0..1). Applied immediately to the gain node. */
|
|
91
|
+
setVolume(v: number): void {
|
|
92
|
+
this._volume = Math.max(0, Math.min(1, v));
|
|
93
|
+
this.applyGain();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getVolume(): number {
|
|
97
|
+
return this._volume;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Set muted. When true, output is silenced regardless of volume. */
|
|
101
|
+
setMuted(m: boolean): void {
|
|
102
|
+
this._muted = m;
|
|
103
|
+
this.applyGain();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getMuted(): boolean {
|
|
107
|
+
return this._muted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private applyGain(): void {
|
|
111
|
+
const target = this._muted ? 0 : this._volume;
|
|
112
|
+
try { this.gain.gain.value = target; } catch { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
/**
|
|
86
116
|
* Switch into wall-clock fallback mode. Called by the decoder when no
|
|
87
117
|
* audio decoder could be initialized for the source. Once set, this
|
|
@@ -287,6 +317,7 @@ export class AudioOutput implements ClockSource {
|
|
|
287
317
|
try { this.gain.disconnect(); } catch { /* ignore */ }
|
|
288
318
|
this.gain = this.ctx.createGain();
|
|
289
319
|
this.gain.connect(this.ctx.destination);
|
|
320
|
+
this.applyGain();
|
|
290
321
|
|
|
291
322
|
this.pendingQueue = [];
|
|
292
323
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
@@ -28,6 +28,7 @@ import { VideoRenderer } from "./video-renderer.js";
|
|
|
28
28
|
import { AudioOutput } from "./audio-output.js";
|
|
29
29
|
import type { MediaContext } from "../../types.js";
|
|
30
30
|
import { pickLibavVariant } from "./variant-routing.js";
|
|
31
|
+
import { dbg } from "../../util/debug.js";
|
|
31
32
|
|
|
32
33
|
export interface DecoderHandles {
|
|
33
34
|
destroy(): Promise<void>;
|
|
@@ -43,6 +44,7 @@ export interface StartDecoderOptions {
|
|
|
43
44
|
context: MediaContext;
|
|
44
45
|
renderer: VideoRenderer;
|
|
45
46
|
audio: AudioOutput;
|
|
47
|
+
transport?: import("../../types.js").TransportConfig;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHandles> {
|
|
@@ -54,7 +56,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
54
56
|
// libav demuxes via Range requests. For Blob sources, it falls back to
|
|
55
57
|
// mkreadaheadfile (in-memory). The returned handle owns cleanup.
|
|
56
58
|
const { prepareLibavInput } = await import("../../util/libav-http-reader.js");
|
|
57
|
-
const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source);
|
|
59
|
+
const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source, opts.transport);
|
|
58
60
|
|
|
59
61
|
// Pre-allocate one AVPacket for ff_read_frame_multi to reuse.
|
|
60
62
|
const readPkt = await libav.av_packet_alloc();
|
|
@@ -126,6 +128,67 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
126
128
|
);
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
// ── Bitstream filter for MPEG-4 Part 2 packed B-frames ───────────────
|
|
132
|
+
// Applied unconditionally for mpeg4 video — the BSF is a no-op when
|
|
133
|
+
// the stream doesn't actually have packed B-frames, so false positives
|
|
134
|
+
// are harmless. Without it, DivX files with packed B-frames produce
|
|
135
|
+
// garbled frame ordering.
|
|
136
|
+
let bsfCtx: number | null = null;
|
|
137
|
+
let bsfPkt: number | null = null;
|
|
138
|
+
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
139
|
+
try {
|
|
140
|
+
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
141
|
+
if (bsfCtx != null && bsfCtx >= 0) {
|
|
142
|
+
const parIn = await libav.AVBSFContext_par_in(bsfCtx);
|
|
143
|
+
await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
|
|
144
|
+
await libav.av_bsf_init(bsfCtx);
|
|
145
|
+
bsfPkt = await libav.av_packet_alloc();
|
|
146
|
+
dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
|
|
147
|
+
} else {
|
|
148
|
+
// eslint-disable-next-line no-console
|
|
149
|
+
console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
|
|
150
|
+
bsfCtx = null;
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// eslint-disable-next-line no-console
|
|
154
|
+
console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
|
|
155
|
+
bsfCtx = null;
|
|
156
|
+
bsfPkt = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Run video packets through the BSF. Returns original packets if no BSF active. */
|
|
161
|
+
async function applyBSF(packets: LibavPacket[]): Promise<LibavPacket[]> {
|
|
162
|
+
if (!bsfCtx || !bsfPkt) return packets;
|
|
163
|
+
const out: LibavPacket[] = [];
|
|
164
|
+
for (const pkt of packets) {
|
|
165
|
+
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
166
|
+
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
167
|
+
if (sendErr < 0) {
|
|
168
|
+
out.push(pkt); // BSF rejected — pass through original
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
while (true) {
|
|
172
|
+
const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
173
|
+
if (recvErr < 0) break; // EAGAIN or EOF
|
|
174
|
+
out.push(await libav.ff_copyout_packet(bsfPkt));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Flush the BSF (on seek or EOF) to drain any internally buffered packets. */
|
|
181
|
+
async function flushBSF(): Promise<void> {
|
|
182
|
+
if (!bsfCtx || !bsfPkt) return;
|
|
183
|
+
try {
|
|
184
|
+
await libav.av_bsf_send_packet(bsfCtx, 0);
|
|
185
|
+
while (true) {
|
|
186
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
187
|
+
if (err < 0) break;
|
|
188
|
+
}
|
|
189
|
+
} catch { /* ignore flush errors */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
129
192
|
// ── Mutable state shared across pump loops ───────────────────────────
|
|
130
193
|
let destroyed = false;
|
|
131
194
|
let pumpToken = 0; // bumped on seek; pump loops bail when token changes
|
|
@@ -202,7 +265,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
202
265
|
}
|
|
203
266
|
if (myToken !== pumpToken || destroyed) return;
|
|
204
267
|
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
205
|
-
await
|
|
268
|
+
const processed = await applyBSF(videoPackets);
|
|
269
|
+
await decodeVideoBatch(processed, myToken);
|
|
206
270
|
}
|
|
207
271
|
|
|
208
272
|
packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
|
|
@@ -382,6 +446,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
382
446
|
destroyed = true;
|
|
383
447
|
pumpToken++;
|
|
384
448
|
try { await pumpRunning; } catch { /* ignore */ }
|
|
449
|
+
try { if (bsfCtx) await libav.av_bsf_free(bsfCtx); } catch { /* ignore */ }
|
|
450
|
+
try { if (bsfPkt) await libav.av_packet_free?.(bsfPkt); } catch { /* ignore */ }
|
|
385
451
|
try { if (videoDec) await libav.ff_free_decoder?.(videoDec.c, videoDec.pkt, videoDec.frame); } catch { /* ignore */ }
|
|
386
452
|
try { if (audioDec) await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
|
|
387
453
|
try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
|
|
@@ -435,6 +501,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
435
501
|
try {
|
|
436
502
|
if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
|
|
437
503
|
} catch { /* ignore */ }
|
|
504
|
+
await flushBSF();
|
|
438
505
|
|
|
439
506
|
// Reset synthetic timestamp counters to the seek target so newly
|
|
440
507
|
// decoded frames start at the right media time.
|
|
@@ -456,6 +523,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
456
523
|
packetsRead,
|
|
457
524
|
videoFramesDecoded,
|
|
458
525
|
audioFramesDecoded,
|
|
526
|
+
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
459
527
|
// Confirmed transport info: once prepareLibavInput returns
|
|
460
528
|
// successfully, we *know* whether the source is http-range (probe
|
|
461
529
|
// succeeded and returned 206) or in-memory blob. Diagnostics hoists
|
|
@@ -740,6 +808,19 @@ interface LibavRuntime {
|
|
|
740
808
|
avformat_close_input_js(ctx: number): Promise<void>;
|
|
741
809
|
/** Sync helper exposed by libav.js: split a JS number into (lo, hi) int64. */
|
|
742
810
|
f64toi64?(val: number): [number, number];
|
|
811
|
+
|
|
812
|
+
// BSF (bitstream filter) methods — used for mpeg4_unpack_bframes
|
|
813
|
+
av_bsf_list_parse_str_js(str: string): Promise<number>;
|
|
814
|
+
AVBSFContext_par_in(ctx: number): Promise<number>;
|
|
815
|
+
avcodec_parameters_copy(dst: number, src: number): Promise<number>;
|
|
816
|
+
av_bsf_init(ctx: number): Promise<number>;
|
|
817
|
+
av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
|
|
818
|
+
av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
|
|
819
|
+
av_bsf_free(ctx: number): Promise<void>;
|
|
820
|
+
|
|
821
|
+
// Packet copy helpers — bridge JS packet objects to/from C-level pointers
|
|
822
|
+
ff_copyin_packet(pktPtr: number, packet: LibavPacket): Promise<void>;
|
|
823
|
+
ff_copyout_packet(pkt: number): Promise<LibavPacket>;
|
|
743
824
|
}
|
|
744
825
|
|
|
745
826
|
interface BridgeModule {
|