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