avbridge 2.8.3 → 2.9.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 +165 -0
- package/README.md +74 -1
- package/dist/{avi-F6WZJK5T.cjs → avi-2ILLBNPQ.cjs} +8 -2
- package/dist/avi-2ILLBNPQ.cjs.map +1 -0
- package/dist/{avi-W6L3BTWU.cjs → avi-B5CQYB7L.cjs} +8 -2
- package/dist/avi-B5CQYB7L.cjs.map +1 -0
- package/dist/{avi-2JPBSHGA.js → avi-JXU4GQL2.js} +8 -2
- package/dist/avi-JXU4GQL2.js.map +1 -0
- package/dist/{avi-NJXAXUXK.js → avi-RWWPN2PR.js} +8 -2
- package/dist/avi-RWWPN2PR.js.map +1 -0
- package/dist/{chunk-X2K3GIWE.js → chunk-2NSOOMXW.js} +14 -3
- package/dist/chunk-2NSOOMXW.js.map +1 -0
- package/dist/{chunk-ZCUXHW55.cjs → chunk-BYGZN4Z5.cjs} +5 -5
- package/dist/{chunk-ZCUXHW55.cjs.map → chunk-BYGZN4Z5.cjs.map} +1 -1
- package/dist/{chunk-SMH6IOP2.js → chunk-CL6UEUQF.js} +4 -4
- package/dist/{chunk-SMH6IOP2.js.map → chunk-CL6UEUQF.js.map} +1 -1
- package/dist/{chunk-IUSFLVLJ.cjs → chunk-EY6DZEDT.cjs} +149 -24
- package/dist/chunk-EY6DZEDT.cjs.map +1 -0
- package/dist/{chunk-SR3MPV4D.js → chunk-GYIJU44C.js} +5 -5
- package/dist/{chunk-SR3MPV4D.js.map → chunk-GYIJU44C.js.map} +1 -1
- package/dist/{chunk-CPZ7PXAM.cjs → chunk-L7A3ECI2.cjs} +14 -2
- package/dist/chunk-L7A3ECI2.cjs.map +1 -0
- package/dist/{chunk-Q2VUO52Z.cjs → chunk-OTFS7DC4.cjs} +12 -12
- package/dist/{chunk-Q2VUO52Z.cjs.map → chunk-OTFS7DC4.cjs.map} +1 -1
- package/dist/{chunk-JSQOBUQB.js → chunk-SN4WZE24.js} +139 -14
- package/dist/chunk-SN4WZE24.js.map +1 -0
- package/dist/element-browser.js +164 -16
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +16 -10
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +11 -6
- package/dist/element.d.ts +11 -6
- package/dist/element.js +15 -9
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +20 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/libav-demux-3N5Y3VQA.cjs +31 -0
- package/dist/{libav-demux-H2GS46GH.cjs.map → libav-demux-3N5Y3VQA.cjs.map} +1 -1
- package/dist/libav-demux-JXD4OTLM.js +6 -0
- package/dist/{libav-demux-OWZ4T2YW.js.map → libav-demux-JXD4OTLM.js.map} +1 -1
- package/dist/{player-DXEKOky8.d.cts → player-DEcidWk6.d.cts} +8 -1
- package/dist/{player-DXEKOky8.d.ts → player-DEcidWk6.d.ts} +8 -1
- package/dist/player.cjs +266 -36
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +37 -11
- package/dist/player.d.ts +37 -11
- package/dist/player.js +266 -36
- package/dist/player.js.map +1 -1
- package/dist/{remux-WBYIZBBX.js → remux-56V7LDAD.js} +5 -5
- package/dist/{remux-WBYIZBBX.js.map → remux-56V7LDAD.js.map} +1 -1
- package/dist/{remux-OBSMIENG.cjs → remux-KUS5GIL6.cjs} +10 -10
- package/dist/{remux-OBSMIENG.cjs.map → remux-KUS5GIL6.cjs.map} +1 -1
- package/package.json +1 -1
- package/src/classify/rules.ts +11 -0
- package/src/element/avbridge-player.ts +22 -11
- package/src/element/avbridge-video.ts +22 -6
- package/src/element/player-styles.ts +68 -3
- package/src/player.ts +96 -8
- package/src/probe/avi.ts +2 -0
- package/src/strategies/fallback/decoder.ts +30 -0
- package/src/strategies/fallback/index.ts +30 -0
- package/src/strategies/hybrid/decoder.ts +35 -0
- package/src/strategies/hybrid/index.ts +17 -0
- package/src/strategies/remux/index.ts +8 -0
- package/src/types.ts +6 -0
- package/src/util/libav-demux.ts +26 -0
- package/dist/avi-2JPBSHGA.js.map +0 -1
- package/dist/avi-F6WZJK5T.cjs.map +0 -1
- package/dist/avi-NJXAXUXK.js.map +0 -1
- package/dist/avi-W6L3BTWU.cjs.map +0 -1
- package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
- package/dist/chunk-IUSFLVLJ.cjs.map +0 -1
- package/dist/chunk-JSQOBUQB.js.map +0 -1
- package/dist/chunk-X2K3GIWE.js.map +0 -1
- package/dist/libav-demux-H2GS46GH.cjs +0 -27
- package/dist/libav-demux-OWZ4T2YW.js +0 -6
package/dist/player.js
CHANGED
|
@@ -237,7 +237,7 @@ async function probe(source, transport) {
|
|
|
237
237
|
const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
|
|
238
238
|
if (hasUnknownCodec) {
|
|
239
239
|
try {
|
|
240
|
-
const { probeWithLibav } = await import('./avi-
|
|
240
|
+
const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
|
|
241
241
|
return await probeWithLibav(normalized, sniffed);
|
|
242
242
|
} catch {
|
|
243
243
|
return result;
|
|
@@ -250,7 +250,7 @@ async function probe(source, transport) {
|
|
|
250
250
|
mediabunnyErr.message
|
|
251
251
|
);
|
|
252
252
|
try {
|
|
253
|
-
const { probeWithLibav } = await import('./avi-
|
|
253
|
+
const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
|
|
254
254
|
return await probeWithLibav(normalized, sniffed);
|
|
255
255
|
} catch (libavErr) {
|
|
256
256
|
const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
|
|
@@ -264,7 +264,7 @@ async function probe(source, transport) {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
try {
|
|
267
|
-
const { probeWithLibav } = await import('./avi-
|
|
267
|
+
const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
|
|
268
268
|
return await probeWithLibav(normalized, sniffed);
|
|
269
269
|
} catch (err) {
|
|
270
270
|
const inner = err instanceof Error ? err.message : String(err);
|
|
@@ -369,7 +369,13 @@ var FALLBACK_VIDEO_CODECS = /* @__PURE__ */ new Set([
|
|
|
369
369
|
"rv40",
|
|
370
370
|
"mpeg2",
|
|
371
371
|
"mpeg1",
|
|
372
|
-
"theora"
|
|
372
|
+
"theora",
|
|
373
|
+
"dv",
|
|
374
|
+
"hq_hqa",
|
|
375
|
+
"rawvideo",
|
|
376
|
+
"qtrle",
|
|
377
|
+
"png",
|
|
378
|
+
"vp6f"
|
|
373
379
|
]);
|
|
374
380
|
var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
|
|
375
381
|
"wmav2",
|
|
@@ -510,10 +516,12 @@ function classifyContext(ctx) {
|
|
|
510
516
|
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable \u2014 falling back to WASM decode`
|
|
511
517
|
};
|
|
512
518
|
}
|
|
519
|
+
const fallbackChain = webCodecsAvailable() ? ["hybrid", "fallback"] : ["fallback"];
|
|
513
520
|
return {
|
|
514
521
|
class: "REMUX_CANDIDATE",
|
|
515
522
|
strategy: "remux",
|
|
516
|
-
reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback
|
|
523
|
+
reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`,
|
|
524
|
+
fallbackChain
|
|
517
525
|
};
|
|
518
526
|
}
|
|
519
527
|
if (webCodecsAvailable()) {
|
|
@@ -1203,6 +1211,12 @@ async function createRemuxSession(context, video) {
|
|
|
1203
1211
|
}
|
|
1204
1212
|
const wasPlaying = !video.paused;
|
|
1205
1213
|
await pipeline.seek(time, wasPlaying || wantPlay);
|
|
1214
|
+
queueMicrotask(() => {
|
|
1215
|
+
try {
|
|
1216
|
+
video.dispatchEvent(new Event("seeked"));
|
|
1217
|
+
} catch {
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1206
1220
|
},
|
|
1207
1221
|
async setAudioTrack(id) {
|
|
1208
1222
|
if (!context.audioTracks.some((t) => t.id === id)) {
|
|
@@ -1839,6 +1853,17 @@ function sanitizePacketTimestamp(pkt, nextUs, fallbackTimeBase) {
|
|
|
1839
1853
|
pkt.time_base_num = 1;
|
|
1840
1854
|
pkt.time_base_den = 1e6;
|
|
1841
1855
|
}
|
|
1856
|
+
function packetPtsSec(pkt, timeBase) {
|
|
1857
|
+
const lo = pkt.pts ?? 0;
|
|
1858
|
+
const hi = pkt.ptshi ?? 0;
|
|
1859
|
+
const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
|
|
1860
|
+
if (isInvalid) return null;
|
|
1861
|
+
const tb = timeBase ?? [1, 1e6];
|
|
1862
|
+
if (!tb[0] || !tb[1]) return null;
|
|
1863
|
+
const pts64 = hi * 4294967296 + lo;
|
|
1864
|
+
const sec = pts64 * tb[0] / tb[1];
|
|
1865
|
+
return Number.isFinite(sec) ? sec : null;
|
|
1866
|
+
}
|
|
1842
1867
|
var AV_SAMPLE_FMT_U8 = 0;
|
|
1843
1868
|
var AV_SAMPLE_FMT_S16 = 1;
|
|
1844
1869
|
var AV_SAMPLE_FMT_S32 = 2;
|
|
@@ -2099,6 +2124,7 @@ async function startHybridDecoder(opts) {
|
|
|
2099
2124
|
let videoFramesDecoded = 0;
|
|
2100
2125
|
let audioFramesDecoded = 0;
|
|
2101
2126
|
let videoChunksFed = 0;
|
|
2127
|
+
let bufferedUntilSec = 0;
|
|
2102
2128
|
let syntheticVideoUs = 0;
|
|
2103
2129
|
let syntheticAudioUs = 0;
|
|
2104
2130
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
@@ -2119,6 +2145,18 @@ async function startHybridDecoder(opts) {
|
|
|
2119
2145
|
if (myToken !== pumpToken || destroyed) return;
|
|
2120
2146
|
const videoPackets = videoStream ? packets[videoStream.index] : void 0;
|
|
2121
2147
|
const audioPackets = audioStream ? packets[audioStream.index] : void 0;
|
|
2148
|
+
if (videoPackets && videoTimeBase) {
|
|
2149
|
+
for (const pkt of videoPackets) {
|
|
2150
|
+
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
2151
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
if (audioPackets && audioTimeBase) {
|
|
2155
|
+
for (const pkt of audioPackets) {
|
|
2156
|
+
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
2157
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2122
2160
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2123
2161
|
await decodeAudioBatch(audioPackets, myToken);
|
|
2124
2162
|
}
|
|
@@ -2375,6 +2413,9 @@ async function startHybridDecoder(opts) {
|
|
|
2375
2413
|
(err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
|
|
2376
2414
|
);
|
|
2377
2415
|
},
|
|
2416
|
+
bufferedUntilSec() {
|
|
2417
|
+
return bufferedUntilSec;
|
|
2418
|
+
},
|
|
2378
2419
|
stats() {
|
|
2379
2420
|
return {
|
|
2380
2421
|
decoderType: "webcodecs-hybrid",
|
|
@@ -2502,6 +2543,13 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2502
2543
|
configurable: true,
|
|
2503
2544
|
get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
|
|
2504
2545
|
});
|
|
2546
|
+
Object.defineProperty(target, "buffered", {
|
|
2547
|
+
configurable: true,
|
|
2548
|
+
get: () => {
|
|
2549
|
+
const end = handles.bufferedUntilSec();
|
|
2550
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
2551
|
+
}
|
|
2552
|
+
});
|
|
2505
2553
|
async function waitForBuffer() {
|
|
2506
2554
|
const start = performance.now();
|
|
2507
2555
|
while (true) {
|
|
@@ -2515,6 +2563,7 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2515
2563
|
}
|
|
2516
2564
|
async function doSeek(timeSec) {
|
|
2517
2565
|
const wasPlaying = audio.isPlaying();
|
|
2566
|
+
target.dispatchEvent(new Event("seeking"));
|
|
2518
2567
|
await audio.pause().catch(() => {
|
|
2519
2568
|
});
|
|
2520
2569
|
await handles.seek(timeSec).catch(
|
|
@@ -2526,7 +2575,14 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2526
2575
|
await waitForBuffer();
|
|
2527
2576
|
await audio.start();
|
|
2528
2577
|
}
|
|
2578
|
+
target.dispatchEvent(new Event("seeked"));
|
|
2529
2579
|
}
|
|
2580
|
+
queueMicrotask(() => {
|
|
2581
|
+
try {
|
|
2582
|
+
target.dispatchEvent(new Event("loadedmetadata"));
|
|
2583
|
+
} catch {
|
|
2584
|
+
}
|
|
2585
|
+
});
|
|
2530
2586
|
let fatalErrorHandler = null;
|
|
2531
2587
|
handles.onFatalError((reason) => fatalErrorHandler?.(reason));
|
|
2532
2588
|
return {
|
|
@@ -2711,6 +2767,7 @@ async function startDecoder(opts) {
|
|
|
2711
2767
|
let pumpRunning = null;
|
|
2712
2768
|
let packetsRead = 0;
|
|
2713
2769
|
let videoFramesDecoded = 0;
|
|
2770
|
+
let bufferedUntilSec = 0;
|
|
2714
2771
|
let audioFramesDecoded = 0;
|
|
2715
2772
|
let watchdogFirstFrameMs = 0;
|
|
2716
2773
|
let watchdogSlowSinceMs = 0;
|
|
@@ -2736,6 +2793,18 @@ async function startDecoder(opts) {
|
|
|
2736
2793
|
if (myToken !== pumpToken || destroyed) return;
|
|
2737
2794
|
const videoPackets = videoStream ? packets[videoStream.index] : void 0;
|
|
2738
2795
|
const audioPackets = audioStream ? packets[audioStream.index] : void 0;
|
|
2796
|
+
if (videoPackets && videoTimeBase) {
|
|
2797
|
+
for (const pkt of videoPackets) {
|
|
2798
|
+
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
2799
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
if (audioPackets && audioTimeBase) {
|
|
2803
|
+
for (const pkt of audioPackets) {
|
|
2804
|
+
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
2805
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2739
2808
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2740
2809
|
await decodeAudioBatch(audioPackets, myToken);
|
|
2741
2810
|
}
|
|
@@ -3017,6 +3086,9 @@ async function startDecoder(opts) {
|
|
|
3017
3086
|
(err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
|
|
3018
3087
|
);
|
|
3019
3088
|
},
|
|
3089
|
+
bufferedUntilSec() {
|
|
3090
|
+
return bufferedUntilSec;
|
|
3091
|
+
},
|
|
3020
3092
|
stats() {
|
|
3021
3093
|
return {
|
|
3022
3094
|
decoderType: "libav-wasm",
|
|
@@ -3116,6 +3188,13 @@ async function createFallbackSession(ctx, target, transport) {
|
|
|
3116
3188
|
configurable: true,
|
|
3117
3189
|
get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
|
|
3118
3190
|
});
|
|
3191
|
+
Object.defineProperty(target, "buffered", {
|
|
3192
|
+
configurable: true,
|
|
3193
|
+
get: () => {
|
|
3194
|
+
const end = handles.bufferedUntilSec();
|
|
3195
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
3196
|
+
}
|
|
3197
|
+
});
|
|
3119
3198
|
async function waitForBuffer() {
|
|
3120
3199
|
const start = performance.now();
|
|
3121
3200
|
let firstFrameAtMs = 0;
|
|
@@ -3155,6 +3234,7 @@ async function createFallbackSession(ctx, target, transport) {
|
|
|
3155
3234
|
}
|
|
3156
3235
|
async function doSeek(timeSec) {
|
|
3157
3236
|
const wasPlaying = audio.isPlaying();
|
|
3237
|
+
target.dispatchEvent(new Event("seeking"));
|
|
3158
3238
|
await audio.pause().catch(() => {
|
|
3159
3239
|
});
|
|
3160
3240
|
await handles.seek(timeSec).catch(
|
|
@@ -3166,7 +3246,14 @@ async function createFallbackSession(ctx, target, transport) {
|
|
|
3166
3246
|
await waitForBuffer();
|
|
3167
3247
|
await audio.start();
|
|
3168
3248
|
}
|
|
3249
|
+
target.dispatchEvent(new Event("seeked"));
|
|
3169
3250
|
}
|
|
3251
|
+
queueMicrotask(() => {
|
|
3252
|
+
try {
|
|
3253
|
+
target.dispatchEvent(new Event("loadedmetadata"));
|
|
3254
|
+
} catch {
|
|
3255
|
+
}
|
|
3256
|
+
});
|
|
3170
3257
|
return {
|
|
3171
3258
|
strategy: "fallback",
|
|
3172
3259
|
async play() {
|
|
@@ -3258,6 +3345,29 @@ function registerBuiltins(registry) {
|
|
|
3258
3345
|
}
|
|
3259
3346
|
|
|
3260
3347
|
// src/player.ts
|
|
3348
|
+
function readDecodedFrameCount(target) {
|
|
3349
|
+
if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
|
|
3350
|
+
const vq = target.getVideoPlaybackQuality;
|
|
3351
|
+
if (typeof vq === "function") {
|
|
3352
|
+
try {
|
|
3353
|
+
return vq.call(target).totalVideoFrames;
|
|
3354
|
+
} catch {
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
const legacy = target.webkitDecodedFrameCount;
|
|
3358
|
+
return typeof legacy === "number" ? legacy : 0;
|
|
3359
|
+
}
|
|
3360
|
+
function evaluateDecodeHealth(input) {
|
|
3361
|
+
const timeThreshold = input.timeStallThresholdMs ?? 5e3;
|
|
3362
|
+
const frameThreshold = input.frameStallThresholdMs ?? 3e3;
|
|
3363
|
+
if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
|
|
3364
|
+
return { escalate: true, kind: "time-stall" };
|
|
3365
|
+
}
|
|
3366
|
+
if (input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold) {
|
|
3367
|
+
return { escalate: true, kind: "silent-video" };
|
|
3368
|
+
}
|
|
3369
|
+
return { escalate: false };
|
|
3370
|
+
}
|
|
3261
3371
|
var UnifiedPlayer = class _UnifiedPlayer {
|
|
3262
3372
|
/**
|
|
3263
3373
|
* @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
|
|
@@ -3283,6 +3393,13 @@ var UnifiedPlayer = class _UnifiedPlayer {
|
|
|
3283
3393
|
stallTimer = null;
|
|
3284
3394
|
lastProgressTime = 0;
|
|
3285
3395
|
lastProgressPosition = -1;
|
|
3396
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
3397
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
3398
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
3399
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
3400
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
3401
|
+
lastVideoFrameCount = 0;
|
|
3402
|
+
lastVideoFrameProgressTime = 0;
|
|
3286
3403
|
errorListener = null;
|
|
3287
3404
|
// Bound so we can removeEventListener in destroy(); without this the
|
|
3288
3405
|
// listener outlives the player and accumulates on elements that swap
|
|
@@ -3527,22 +3644,41 @@ var UnifiedPlayer = class _UnifiedPlayer {
|
|
|
3527
3644
|
if (strategy === "native" || strategy === "remux") {
|
|
3528
3645
|
this.lastProgressPosition = this.options.target.currentTime;
|
|
3529
3646
|
this.lastProgressTime = performance.now();
|
|
3647
|
+
this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
|
|
3648
|
+
this.lastVideoFrameProgressTime = performance.now();
|
|
3649
|
+
const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
|
|
3530
3650
|
this.stallTimer = setInterval(() => {
|
|
3531
3651
|
const t = this.options.target;
|
|
3652
|
+
const now = performance.now();
|
|
3532
3653
|
if (t.paused || t.ended || t.readyState < 2) {
|
|
3533
3654
|
this.lastProgressPosition = t.currentTime;
|
|
3534
|
-
this.lastProgressTime =
|
|
3655
|
+
this.lastProgressTime = now;
|
|
3656
|
+
this.lastVideoFrameCount = readDecodedFrameCount(t);
|
|
3657
|
+
this.lastVideoFrameProgressTime = now;
|
|
3535
3658
|
return;
|
|
3536
3659
|
}
|
|
3537
|
-
|
|
3660
|
+
const timeAdvanced = t.currentTime !== this.lastProgressPosition;
|
|
3661
|
+
const frames = readDecodedFrameCount(t);
|
|
3662
|
+
const framesAdvanced = frames > this.lastVideoFrameCount;
|
|
3663
|
+
const health = evaluateDecodeHealth({
|
|
3664
|
+
hasVideoTrack,
|
|
3665
|
+
timeAdvanced,
|
|
3666
|
+
framesAdvanced,
|
|
3667
|
+
now,
|
|
3668
|
+
lastProgressTime: this.lastProgressTime,
|
|
3669
|
+
lastFrameProgressTime: this.lastVideoFrameProgressTime
|
|
3670
|
+
});
|
|
3671
|
+
if (timeAdvanced) {
|
|
3538
3672
|
this.lastProgressPosition = t.currentTime;
|
|
3539
|
-
this.lastProgressTime =
|
|
3540
|
-
return;
|
|
3673
|
+
this.lastProgressTime = now;
|
|
3541
3674
|
}
|
|
3542
|
-
if (
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3675
|
+
if (framesAdvanced) {
|
|
3676
|
+
this.lastVideoFrameCount = frames;
|
|
3677
|
+
this.lastVideoFrameProgressTime = now;
|
|
3678
|
+
}
|
|
3679
|
+
if (health.escalate) {
|
|
3680
|
+
const reason = health.kind === "time-stall" ? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s` : `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s \u2014 likely a silent codec failure`;
|
|
3681
|
+
void this.escalate(reason);
|
|
3546
3682
|
}
|
|
3547
3683
|
}, 1e3);
|
|
3548
3684
|
const onError = () => {
|
|
@@ -4197,9 +4333,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
4197
4333
|
else this.removeAttribute("autoplay");
|
|
4198
4334
|
}
|
|
4199
4335
|
get muted() {
|
|
4200
|
-
return this.
|
|
4336
|
+
return this._videoEl.muted;
|
|
4201
4337
|
}
|
|
4202
4338
|
set muted(value) {
|
|
4339
|
+
this._videoEl.muted = value;
|
|
4203
4340
|
if (value) this.setAttribute("muted", "");
|
|
4204
4341
|
else this.removeAttribute("muted");
|
|
4205
4342
|
}
|
|
@@ -4264,11 +4401,16 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
4264
4401
|
}
|
|
4265
4402
|
/**
|
|
4266
4403
|
* Buffered time ranges for the active source. Mirrors the standard
|
|
4267
|
-
* `<video>.buffered` `TimeRanges` API.
|
|
4268
|
-
*
|
|
4269
|
-
*
|
|
4270
|
-
*
|
|
4271
|
-
*
|
|
4404
|
+
* `<video>.buffered` `TimeRanges` API.
|
|
4405
|
+
*
|
|
4406
|
+
* - **Native / remux:** pass-through to the real `<video>.buffered`
|
|
4407
|
+
* (reflects the browser's SourceBuffer / progressive-download state).
|
|
4408
|
+
* - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
|
|
4409
|
+
* from the demuxer's read progress — "how far libav has ever pumped
|
|
4410
|
+
* packets through." Monotonic; does not shrink on seek. This is an
|
|
4411
|
+
* approximation, not MSE-fidelity: decoded frames on canvas strategies
|
|
4412
|
+
* are consumed in flight, so we can't report per-range availability
|
|
4413
|
+
* the way MSE does. Enough for a seek-bar buffered indicator.
|
|
4272
4414
|
*/
|
|
4273
4415
|
get buffered() {
|
|
4274
4416
|
return this._videoEl.buffered;
|
|
@@ -4572,11 +4714,18 @@ var PLAYER_STYLES = (
|
|
|
4572
4714
|
|
|
4573
4715
|
/* \u2500\u2500 Container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
4574
4716
|
|
|
4717
|
+
:host {
|
|
4718
|
+
-webkit-tap-highlight-color: transparent;
|
|
4719
|
+
outline: none;
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4575
4722
|
.avp {
|
|
4576
4723
|
position: relative;
|
|
4577
4724
|
width: 100%;
|
|
4578
4725
|
height: 100%;
|
|
4579
4726
|
cursor: pointer;
|
|
4727
|
+
-webkit-tap-highlight-color: transparent;
|
|
4728
|
+
user-select: none;
|
|
4580
4729
|
}
|
|
4581
4730
|
|
|
4582
4731
|
.avp avbridge-video {
|
|
@@ -4775,7 +4924,14 @@ var PLAYER_STYLES = (
|
|
|
4775
4924
|
pointer-events: auto;
|
|
4776
4925
|
}
|
|
4777
4926
|
|
|
4778
|
-
|
|
4927
|
+
/* Left slot fills remaining space so slotted text/content can grow.
|
|
4928
|
+
min-width: 0 prevents flex children from overflowing the toolbar. */
|
|
4929
|
+
.avp-toolbar-top-left {
|
|
4930
|
+
flex: 1;
|
|
4931
|
+
min-width: 0;
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
.avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
|
|
4779
4935
|
|
|
4780
4936
|
/* Hide the gradient band when no consumer has slotted anything \u2014 we
|
|
4781
4937
|
toggle data-toolbar-empty from JS via slotchange. */
|
|
@@ -4788,6 +4944,30 @@ var PLAYER_STYLES = (
|
|
|
4788
4944
|
pointer-events: none;
|
|
4789
4945
|
}
|
|
4790
4946
|
|
|
4947
|
+
/* \u2500\u2500 Content overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
4948
|
+
/* Consumer-provided rich content (tweet cards, media info, annotations).
|
|
4949
|
+
Sits above the video, below the play-button overlay and controls in
|
|
4950
|
+
z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
|
|
4951
|
+
so taps fall through to the video; consumers opt in on their content
|
|
4952
|
+
with pointer-events:auto. */
|
|
4953
|
+
|
|
4954
|
+
.avp-content-overlay {
|
|
4955
|
+
position: absolute;
|
|
4956
|
+
inset: 0;
|
|
4957
|
+
z-index: 1;
|
|
4958
|
+
pointer-events: none;
|
|
4959
|
+
opacity: 1;
|
|
4960
|
+
transition: opacity 0.25s;
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
.avp-content-overlay ::slotted(*) {
|
|
4964
|
+
pointer-events: auto;
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4967
|
+
:host([data-controls-hidden]) .avp-content-overlay {
|
|
4968
|
+
opacity: 0;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4791
4971
|
/* \u2500\u2500 Seek bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
4792
4972
|
|
|
4793
4973
|
.avp-seek {
|
|
@@ -4874,6 +5054,15 @@ var PLAYER_STYLES = (
|
|
|
4874
5054
|
|
|
4875
5055
|
.avp-seek:hover .avp-seek-tooltip { display: block; }
|
|
4876
5056
|
|
|
5057
|
+
/* Show tooltip during active drag (touch or mouse). The JS side sets
|
|
5058
|
+
data-seeking on .avp-seek while the user is scrubbing. */
|
|
5059
|
+
.avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
|
|
5060
|
+
|
|
5061
|
+
/* Enlarge thumb while scrubbing. */
|
|
5062
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
5063
|
+
transform: translate(-50%, -50%) scale(1.4);
|
|
5064
|
+
}
|
|
5065
|
+
|
|
4877
5066
|
/* \u2500\u2500 Bottom row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
4878
5067
|
|
|
4879
5068
|
.avp-bottom {
|
|
@@ -4993,7 +5182,10 @@ var PLAYER_STYLES = (
|
|
|
4993
5182
|
background: rgba(28, 28, 28, 0.95);
|
|
4994
5183
|
border-radius: 8px;
|
|
4995
5184
|
min-width: 220px;
|
|
4996
|
-
|
|
5185
|
+
/* Fit within the player: leave room for the controls bar (52px bottom)
|
|
5186
|
+
and a small top margin (8px). On tall players this caps at 300px;
|
|
5187
|
+
on short players it shrinks to whatever fits. */
|
|
5188
|
+
max-height: min(300px, calc(100% - 52px - 8px));
|
|
4997
5189
|
overflow-y: auto;
|
|
4998
5190
|
display: none;
|
|
4999
5191
|
z-index: 10;
|
|
@@ -5065,9 +5257,24 @@ var PLAYER_STYLES = (
|
|
|
5065
5257
|
@media (pointer: coarse) {
|
|
5066
5258
|
.avp-btn svg { width: 28px; height: 28px; }
|
|
5067
5259
|
.avp-btn { padding: 8px; }
|
|
5260
|
+
|
|
5261
|
+
/* Taller touch target on mobile (44px, matching YouTube Mobile)
|
|
5262
|
+
while keeping the visual track thin. Negative margin collapses
|
|
5263
|
+
the extra space so the controls layout doesn't shift. */
|
|
5264
|
+
.avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
|
|
5068
5265
|
.avp-seek-track { height: 4px; }
|
|
5069
5266
|
.avp-seek:hover .avp-seek-track { height: 4px; }
|
|
5070
|
-
.avp-seek-thumb {
|
|
5267
|
+
.avp-seek-thumb {
|
|
5268
|
+
transform: translate(-50%, -50%) scale(1);
|
|
5269
|
+
width: 16px;
|
|
5270
|
+
height: 16px;
|
|
5271
|
+
}
|
|
5272
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
5273
|
+
transform: translate(-50%, -50%) scale(1.5);
|
|
5274
|
+
}
|
|
5275
|
+
/* Move tooltip above the taller touch zone. */
|
|
5276
|
+
.avp-seek-tooltip { bottom: 32px; }
|
|
5277
|
+
|
|
5071
5278
|
.avp-volume:hover .avp-volume-slider { width: 0; }
|
|
5072
5279
|
.avp-overlay-btn { width: 56px; height: 56px; }
|
|
5073
5280
|
.avp-overlay-btn svg { width: 30px; height: 30px; }
|
|
@@ -5225,6 +5432,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5225
5432
|
<div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
|
|
5226
5433
|
<div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
|
|
5227
5434
|
</div>
|
|
5435
|
+
<div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
|
|
5228
5436
|
<div part="overlay" class="avp-overlay">
|
|
5229
5437
|
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
5230
5438
|
<div class="avp-spinner"></div>
|
|
@@ -5440,19 +5648,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5440
5648
|
this._userSeeking = true;
|
|
5441
5649
|
const seekBar = this.shadowRoot.querySelector(".avp-seek");
|
|
5442
5650
|
seekBar.setPointerCapture(e.pointerId);
|
|
5651
|
+
seekBar.setAttribute("data-seeking", "");
|
|
5443
5652
|
const initial = this._timeFromSeekPointer(e.clientX);
|
|
5444
5653
|
this._seekInput.value = String(initial);
|
|
5445
5654
|
this._onSeekInput();
|
|
5655
|
+
this._updateSeekTooltip(e.clientX);
|
|
5446
5656
|
const onMove = (ev) => {
|
|
5447
5657
|
const t = this._timeFromSeekPointer(ev.clientX);
|
|
5448
5658
|
this._seekInput.value = String(t);
|
|
5449
5659
|
this._onSeekInput();
|
|
5660
|
+
this._updateSeekTooltip(ev.clientX);
|
|
5450
5661
|
};
|
|
5451
5662
|
const onUp = (ev) => {
|
|
5452
5663
|
const t = this._timeFromSeekPointer(ev.clientX);
|
|
5453
5664
|
this._seekInput.value = String(t);
|
|
5454
5665
|
this._onSeekCommit();
|
|
5455
5666
|
this._seekInput.focus();
|
|
5667
|
+
seekBar.removeAttribute("data-seeking");
|
|
5456
5668
|
seekBar.removeEventListener("pointermove", onMove);
|
|
5457
5669
|
seekBar.removeEventListener("pointerup", onUp);
|
|
5458
5670
|
seekBar.removeEventListener("pointercancel", onUp);
|
|
@@ -5466,8 +5678,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5466
5678
|
seekBar.addEventListener("pointercancel", onUp);
|
|
5467
5679
|
}
|
|
5468
5680
|
_onSeekHover(e) {
|
|
5681
|
+
this._updateSeekTooltip(e.clientX);
|
|
5682
|
+
}
|
|
5683
|
+
_updateSeekTooltip(clientX) {
|
|
5469
5684
|
const rect = this._seekInput.getBoundingClientRect();
|
|
5470
|
-
const frac = Math.max(0, Math.min(1, (
|
|
5685
|
+
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
5471
5686
|
const t = frac * (this._video.duration || 0);
|
|
5472
5687
|
this._seekTooltip.textContent = formatTime(t);
|
|
5473
5688
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
@@ -5646,12 +5861,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5646
5861
|
this._fullscreenBtn.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
|
|
5647
5862
|
}
|
|
5648
5863
|
// ── Controls: auto-hide ────────────────────────────────────────────────
|
|
5649
|
-
|
|
5864
|
+
/**
|
|
5865
|
+
* Reveal the auto-hiding chrome (top toolbar + bottom controls) and
|
|
5866
|
+
* re-start the auto-hide timer. Call this from app-level code to
|
|
5867
|
+
* briefly surface the player UI — e.g. to confirm "you just swiped to
|
|
5868
|
+
* this video" in a carousel, or to flash the title on focus change.
|
|
5869
|
+
*
|
|
5870
|
+
* @param durationMs How long the chrome stays visible before fading.
|
|
5871
|
+
* Defaults to the player's normal 3 s auto-hide.
|
|
5872
|
+
* Pointer movement or any other interaction resets
|
|
5873
|
+
* the timer, so a user hovering during the flash
|
|
5874
|
+
* sees no flicker.
|
|
5875
|
+
*/
|
|
5876
|
+
showControls(durationMs) {
|
|
5650
5877
|
this.removeAttribute("data-controls-hidden");
|
|
5651
5878
|
this._toolbarTop.setAttribute("data-visible", "true");
|
|
5652
|
-
this._scheduleHide();
|
|
5879
|
+
this._scheduleHide(durationMs);
|
|
5880
|
+
}
|
|
5881
|
+
_showControls() {
|
|
5882
|
+
this.showControls();
|
|
5653
5883
|
}
|
|
5654
|
-
_scheduleHide() {
|
|
5884
|
+
_scheduleHide(durationMs = CONTROLS_HIDE_MS) {
|
|
5655
5885
|
if (this._controlsTimer) clearTimeout(this._controlsTimer);
|
|
5656
5886
|
if (this._state !== "playing" && this._state !== "buffering") return;
|
|
5657
5887
|
if (this._settingsOpen) return;
|
|
@@ -5660,7 +5890,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5660
5890
|
this.setAttribute("data-controls-hidden", "");
|
|
5661
5891
|
this._toolbarTop.setAttribute("data-visible", "false");
|
|
5662
5892
|
}
|
|
5663
|
-
},
|
|
5893
|
+
}, durationMs);
|
|
5664
5894
|
}
|
|
5665
5895
|
// Strategy is visible in Stats for Nerds, no badge in controls bar.
|
|
5666
5896
|
// ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
|
|
@@ -5672,19 +5902,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5672
5902
|
// it's treated as a double-click and the single-click action is cancelled.
|
|
5673
5903
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
5674
5904
|
_lastPointerTypeWasTouch = false;
|
|
5675
|
-
/** True if the event's composed path passes through consumer-slotted
|
|
5676
|
-
* content. Slotted content lives in the
|
|
5677
|
-
* on the event target won't
|
|
5678
|
-
* does. */
|
|
5679
|
-
|
|
5905
|
+
/** True if the event's composed path passes through consumer-slotted
|
|
5906
|
+
* content (toolbar or content-overlay). Slotted content lives in the
|
|
5907
|
+
* light DOM so `.closest(".avp-toolbar-top")` on the event target won't
|
|
5908
|
+
* find the shadow-DOM wrapper — `composedPath()` does. */
|
|
5909
|
+
_isSlottedContentEvent(e) {
|
|
5680
5910
|
for (const node of e.composedPath()) {
|
|
5681
|
-
if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
|
|
5911
|
+
if (node instanceof HTMLElement && (node.classList.contains("avp-toolbar-top") || node.classList.contains("avp-content-overlay"))) return true;
|
|
5682
5912
|
}
|
|
5683
5913
|
return false;
|
|
5684
5914
|
}
|
|
5685
5915
|
_onContainerClick(e) {
|
|
5686
5916
|
if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
5687
|
-
if (this.
|
|
5917
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
5688
5918
|
if (this._lastPointerTypeWasTouch) {
|
|
5689
5919
|
this._lastPointerTypeWasTouch = false;
|
|
5690
5920
|
return;
|
|
@@ -5700,7 +5930,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5700
5930
|
}
|
|
5701
5931
|
_onContainerDblClick(e) {
|
|
5702
5932
|
if (e.target.closest?.(".avp-controls, .avp-settings")) return;
|
|
5703
|
-
if (this.
|
|
5933
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
5704
5934
|
if (this._tapTimer) {
|
|
5705
5935
|
clearTimeout(this._tapTimer);
|
|
5706
5936
|
this._tapTimer = null;
|
|
@@ -5722,7 +5952,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5722
5952
|
if (e.pointerType !== "touch") return;
|
|
5723
5953
|
this._lastPointerTypeWasTouch = true;
|
|
5724
5954
|
if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
5725
|
-
if (this.
|
|
5955
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
5726
5956
|
const now = Date.now();
|
|
5727
5957
|
if (now - this._lastTapTime < 300) {
|
|
5728
5958
|
if (this._tapTimer) {
|