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.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/avi-M5B4SHRM.cjs +164 -0
- package/dist/avi-M5B4SHRM.cjs.map +1 -0
- package/dist/avi-POCGZ4JX.js +162 -0
- package/dist/avi-POCGZ4JX.js.map +1 -0
- package/dist/chunk-5ISVAODK.js +80 -0
- package/dist/chunk-5ISVAODK.js.map +1 -0
- package/dist/chunk-F7YS2XOA.cjs +2966 -0
- package/dist/chunk-F7YS2XOA.cjs.map +1 -0
- package/dist/chunk-FKM7QBZU.js +2957 -0
- package/dist/chunk-FKM7QBZU.js.map +1 -0
- package/dist/chunk-J5MCMN3S.js +27 -0
- package/dist/chunk-J5MCMN3S.js.map +1 -0
- package/dist/chunk-L4NPOJ36.cjs +180 -0
- package/dist/chunk-L4NPOJ36.cjs.map +1 -0
- package/dist/chunk-NZU7W256.cjs +29 -0
- package/dist/chunk-NZU7W256.cjs.map +1 -0
- package/dist/chunk-PQTZS7OA.js +147 -0
- package/dist/chunk-PQTZS7OA.js.map +1 -0
- package/dist/chunk-WD2ZNQA7.js +177 -0
- package/dist/chunk-WD2ZNQA7.js.map +1 -0
- package/dist/chunk-Y5FYF5KG.cjs +153 -0
- package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
- package/dist/chunk-Z2FJ5TJC.cjs +82 -0
- package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
- package/dist/element.cjs +433 -0
- package/dist/element.cjs.map +1 -0
- package/dist/element.d.cts +158 -0
- package/dist/element.d.ts +158 -0
- package/dist/element.js +431 -0
- package/dist/element.js.map +1 -0
- package/dist/index.cjs +576 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +554 -0
- package/dist/index.js.map +1 -0
- package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
- package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
- package/dist/libav-http-reader-NQJVY273.js +3 -0
- package/dist/libav-http-reader-NQJVY273.js.map +1 -0
- package/dist/libav-import-2JURFHEW.js +8 -0
- package/dist/libav-import-2JURFHEW.js.map +1 -0
- package/dist/libav-import-GST2AMPL.cjs +30 -0
- package/dist/libav-import-GST2AMPL.cjs.map +1 -0
- package/dist/libav-loader-KA2MAWLM.js +3 -0
- package/dist/libav-loader-KA2MAWLM.js.map +1 -0
- package/dist/libav-loader-ZHOERPHW.cjs +12 -0
- package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
- package/dist/player-BBwbCkdL.d.cts +365 -0
- package/dist/player-BBwbCkdL.d.ts +365 -0
- package/dist/source-SC6ZEQYR.cjs +28 -0
- package/dist/source-SC6ZEQYR.cjs.map +1 -0
- package/dist/source-ZFS4H7J3.js +3 -0
- package/dist/source-ZFS4H7J3.js.map +1 -0
- package/dist/variant-routing-GOHB2RZN.cjs +12 -0
- package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
- package/dist/variant-routing-JOBWXYKD.js +3 -0
- package/dist/variant-routing-JOBWXYKD.js.map +1 -0
- package/package.json +95 -0
- package/src/classify/index.ts +1 -0
- package/src/classify/rules.ts +214 -0
- package/src/convert/index.ts +2 -0
- package/src/convert/remux.ts +522 -0
- package/src/convert/transcode.ts +329 -0
- package/src/diagnostics.ts +99 -0
- package/src/element/avbridge-player.ts +576 -0
- package/src/element.ts +19 -0
- package/src/events.ts +71 -0
- package/src/index.ts +42 -0
- package/src/libav-stubs.d.ts +24 -0
- package/src/player.ts +455 -0
- package/src/plugins/builtin.ts +37 -0
- package/src/plugins/registry.ts +32 -0
- package/src/probe/avi.ts +242 -0
- package/src/probe/index.ts +59 -0
- package/src/probe/mediabunny.ts +194 -0
- package/src/strategies/fallback/audio-output.ts +293 -0
- package/src/strategies/fallback/clock.ts +7 -0
- package/src/strategies/fallback/decoder.ts +660 -0
- package/src/strategies/fallback/index.ts +170 -0
- package/src/strategies/fallback/libav-import.ts +27 -0
- package/src/strategies/fallback/libav-loader.ts +190 -0
- package/src/strategies/fallback/variant-routing.ts +43 -0
- package/src/strategies/fallback/video-renderer.ts +216 -0
- package/src/strategies/hybrid/decoder.ts +641 -0
- package/src/strategies/hybrid/index.ts +139 -0
- package/src/strategies/native.ts +107 -0
- package/src/strategies/remux/annexb.ts +112 -0
- package/src/strategies/remux/index.ts +79 -0
- package/src/strategies/remux/mse.ts +234 -0
- package/src/strategies/remux/pipeline.ts +254 -0
- package/src/subtitles/index.ts +91 -0
- package/src/subtitles/render.ts +62 -0
- package/src/subtitles/srt.ts +62 -0
- package/src/subtitles/vtt.ts +5 -0
- package/src/types-shim.d.ts +3 -0
- package/src/types.ts +360 -0
- package/src/util/codec-strings.ts +86 -0
- package/src/util/libav-http-reader.ts +315 -0
- 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
|
+
}
|