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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-
|
|
2
|
-
import './chunk-
|
|
1
|
+
export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-CL6UEUQF.js';
|
|
2
|
+
import './chunk-GYIJU44C.js';
|
|
3
3
|
import './chunk-CPJLFFCC.js';
|
|
4
4
|
import './chunk-LUFA47FP.js';
|
|
5
|
-
import './chunk-
|
|
5
|
+
import './chunk-2NSOOMXW.js';
|
|
6
6
|
import './chunk-DCSOQH2N.js';
|
|
7
7
|
import './chunk-5DMTJVIU.js';
|
|
8
8
|
import './chunk-5YAWWKA3.js';
|
|
9
|
-
//# sourceMappingURL=remux-
|
|
10
|
-
//# sourceMappingURL=remux-
|
|
9
|
+
//# sourceMappingURL=remux-56V7LDAD.js.map
|
|
10
|
+
//# sourceMappingURL=remux-56V7LDAD.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-56V7LDAD.js"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
require('./chunk-
|
|
3
|
+
var chunkOTFS7DC4_cjs = require('./chunk-OTFS7DC4.cjs');
|
|
4
|
+
require('./chunk-BYGZN4Z5.cjs');
|
|
5
5
|
require('./chunk-2IJ66NTD.cjs');
|
|
6
6
|
require('./chunk-QDJLQR53.cjs');
|
|
7
|
-
require('./chunk-
|
|
7
|
+
require('./chunk-L7A3ECI2.cjs');
|
|
8
8
|
require('./chunk-Z33SBWL5.cjs');
|
|
9
9
|
require('./chunk-G4APZMCP.cjs');
|
|
10
10
|
require('./chunk-F3LQJKXK.cjs');
|
|
@@ -13,23 +13,23 @@ require('./chunk-F3LQJKXK.cjs');
|
|
|
13
13
|
|
|
14
14
|
Object.defineProperty(exports, "createOutputFormat", {
|
|
15
15
|
enumerable: true,
|
|
16
|
-
get: function () { return
|
|
16
|
+
get: function () { return chunkOTFS7DC4_cjs.createOutputFormat; }
|
|
17
17
|
});
|
|
18
18
|
Object.defineProperty(exports, "generateFilename", {
|
|
19
19
|
enumerable: true,
|
|
20
|
-
get: function () { return
|
|
20
|
+
get: function () { return chunkOTFS7DC4_cjs.generateFilename; }
|
|
21
21
|
});
|
|
22
22
|
Object.defineProperty(exports, "mimeForFormat", {
|
|
23
23
|
enumerable: true,
|
|
24
|
-
get: function () { return
|
|
24
|
+
get: function () { return chunkOTFS7DC4_cjs.mimeForFormat; }
|
|
25
25
|
});
|
|
26
26
|
Object.defineProperty(exports, "remux", {
|
|
27
27
|
enumerable: true,
|
|
28
|
-
get: function () { return
|
|
28
|
+
get: function () { return chunkOTFS7DC4_cjs.remux; }
|
|
29
29
|
});
|
|
30
30
|
Object.defineProperty(exports, "validateRemuxEligibility", {
|
|
31
31
|
enumerable: true,
|
|
32
|
-
get: function () { return
|
|
32
|
+
get: function () { return chunkOTFS7DC4_cjs.validateRemuxEligibility; }
|
|
33
33
|
});
|
|
34
|
-
//# sourceMappingURL=remux-
|
|
35
|
-
//# sourceMappingURL=remux-
|
|
34
|
+
//# sourceMappingURL=remux-KUS5GIL6.cjs.map
|
|
35
|
+
//# sourceMappingURL=remux-KUS5GIL6.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-KUS5GIL6.cjs"}
|
package/package.json
CHANGED
package/src/classify/rules.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Classification,
|
|
5
5
|
ContainerKind,
|
|
6
6
|
MediaContext,
|
|
7
|
+
StrategyName,
|
|
7
8
|
VideoCodec,
|
|
8
9
|
VideoTrackInfo,
|
|
9
10
|
} from "../types.js";
|
|
@@ -30,6 +31,8 @@ export const FALLBACK_VIDEO_CODECS = new Set<VideoCodec>([
|
|
|
30
31
|
"wmv3", "vc1", "mpeg4",
|
|
31
32
|
"rv10", "rv20", "rv30", "rv40",
|
|
32
33
|
"mpeg2", "mpeg1", "theora",
|
|
34
|
+
"dv", "hq_hqa",
|
|
35
|
+
"rawvideo", "qtrle", "png", "vp6f",
|
|
33
36
|
]);
|
|
34
37
|
export const FALLBACK_AUDIO_CODECS = new Set<AudioCodec>([
|
|
35
38
|
"wmav2", "wmapro", "ac3", "eac3",
|
|
@@ -218,10 +221,18 @@ export function classifyContext(ctx: MediaContext): Classification {
|
|
|
218
221
|
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable — falling back to WASM decode`,
|
|
219
222
|
};
|
|
220
223
|
}
|
|
224
|
+
// Give REMUX_CANDIDATE a fallback chain so the runtime stall / decode
|
|
225
|
+
// supervisors have somewhere to escalate to when MSE lies about codec
|
|
226
|
+
// support (the Firefox HEVC case — audio plays, video never paints).
|
|
227
|
+
// The initial pick is still remux; these only engage on stall.
|
|
228
|
+
const fallbackChain: StrategyName[] = webCodecsAvailable()
|
|
229
|
+
? ["hybrid", "fallback"]
|
|
230
|
+
: ["fallback"];
|
|
221
231
|
return {
|
|
222
232
|
class: "REMUX_CANDIDATE",
|
|
223
233
|
strategy: "remux",
|
|
224
234
|
reason: `${ctx.container} container with native-supported codecs — remux to fragmented MP4 for reliable playback`,
|
|
235
|
+
fallbackChain,
|
|
225
236
|
};
|
|
226
237
|
}
|
|
227
238
|
|
|
@@ -167,6 +167,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
167
167
|
<div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
|
|
168
168
|
<div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
|
|
169
169
|
</div>
|
|
170
|
+
<div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
|
|
170
171
|
<div part="overlay" class="avp-overlay">
|
|
171
172
|
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
172
173
|
<div class="avp-spinner"></div>
|
|
@@ -441,21 +442,25 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
441
442
|
this._userSeeking = true;
|
|
442
443
|
const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
|
|
443
444
|
seekBar.setPointerCapture(e.pointerId);
|
|
445
|
+
seekBar.setAttribute("data-seeking", "");
|
|
444
446
|
|
|
445
447
|
const initial = this._timeFromSeekPointer(e.clientX);
|
|
446
448
|
this._seekInput.value = String(initial);
|
|
447
449
|
this._onSeekInput();
|
|
450
|
+
this._updateSeekTooltip(e.clientX);
|
|
448
451
|
|
|
449
452
|
const onMove = (ev: PointerEvent) => {
|
|
450
453
|
const t = this._timeFromSeekPointer(ev.clientX);
|
|
451
454
|
this._seekInput.value = String(t);
|
|
452
455
|
this._onSeekInput();
|
|
456
|
+
this._updateSeekTooltip(ev.clientX);
|
|
453
457
|
};
|
|
454
458
|
const onUp = (ev: PointerEvent) => {
|
|
455
459
|
const t = this._timeFromSeekPointer(ev.clientX);
|
|
456
460
|
this._seekInput.value = String(t);
|
|
457
461
|
this._onSeekCommit();
|
|
458
|
-
this._seekInput.focus();
|
|
462
|
+
this._seekInput.focus();
|
|
463
|
+
seekBar.removeAttribute("data-seeking");
|
|
459
464
|
seekBar.removeEventListener("pointermove", onMove);
|
|
460
465
|
seekBar.removeEventListener("pointerup", onUp);
|
|
461
466
|
seekBar.removeEventListener("pointercancel", onUp);
|
|
@@ -467,8 +472,12 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
467
472
|
}
|
|
468
473
|
|
|
469
474
|
private _onSeekHover(e: PointerEvent): void {
|
|
475
|
+
this._updateSeekTooltip(e.clientX);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private _updateSeekTooltip(clientX: number): void {
|
|
470
479
|
const rect = this._seekInput.getBoundingClientRect();
|
|
471
|
-
const frac = Math.max(0, Math.min(1, (
|
|
480
|
+
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
472
481
|
const t = frac * (this._video.duration || 0);
|
|
473
482
|
this._seekTooltip.textContent = formatTime(t);
|
|
474
483
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
@@ -727,13 +736,15 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
727
736
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
728
737
|
private _lastPointerTypeWasTouch = false;
|
|
729
738
|
|
|
730
|
-
/** True if the event's composed path passes through consumer-slotted
|
|
731
|
-
* content. Slotted content lives in the
|
|
732
|
-
* on the event target won't
|
|
733
|
-
* does. */
|
|
734
|
-
private
|
|
739
|
+
/** True if the event's composed path passes through consumer-slotted
|
|
740
|
+
* content (toolbar or content-overlay). Slotted content lives in the
|
|
741
|
+
* light DOM so `.closest(".avp-toolbar-top")` on the event target won't
|
|
742
|
+
* find the shadow-DOM wrapper — `composedPath()` does. */
|
|
743
|
+
private _isSlottedContentEvent(e: Event): boolean {
|
|
735
744
|
for (const node of e.composedPath()) {
|
|
736
|
-
if (node instanceof HTMLElement &&
|
|
745
|
+
if (node instanceof HTMLElement &&
|
|
746
|
+
(node.classList.contains("avp-toolbar-top") ||
|
|
747
|
+
node.classList.contains("avp-content-overlay"))) return true;
|
|
737
748
|
}
|
|
738
749
|
return false;
|
|
739
750
|
}
|
|
@@ -741,7 +752,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
741
752
|
private _onContainerClick(e: MouseEvent): void {
|
|
742
753
|
// Ignore clicks on controls
|
|
743
754
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
744
|
-
if (this.
|
|
755
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
745
756
|
|
|
746
757
|
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
747
758
|
// The browser fires a synthetic click after touchend — skip it.
|
|
@@ -760,7 +771,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
760
771
|
|
|
761
772
|
private _onContainerDblClick(e: MouseEvent): void {
|
|
762
773
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
|
|
763
|
-
if (this.
|
|
774
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
764
775
|
// Cancel the pending single-click play/pause
|
|
765
776
|
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
766
777
|
this._toggleFullscreen();
|
|
@@ -786,7 +797,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
786
797
|
|
|
787
798
|
// Ignore touches on controls — buttons have their own handlers
|
|
788
799
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
789
|
-
if (this.
|
|
800
|
+
if (this._isSlottedContentEvent(e)) return;
|
|
790
801
|
|
|
791
802
|
// Double-tap detection
|
|
792
803
|
const now = Date.now();
|
|
@@ -598,10 +598,21 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
598
598
|
}
|
|
599
599
|
|
|
600
600
|
get muted(): boolean {
|
|
601
|
-
|
|
601
|
+
// Read through to the inner <video>'s IDL property — on canvas
|
|
602
|
+
// strategies the property is patched via Object.defineProperty to
|
|
603
|
+
// mirror AudioOutput state, and consumers need the truthful value.
|
|
604
|
+
return this._videoEl.muted;
|
|
602
605
|
}
|
|
603
606
|
|
|
604
607
|
set muted(value: boolean) {
|
|
608
|
+
// Drive the IDL property (fires volumechange per HTML spec) rather
|
|
609
|
+
// than toggling the attribute (which on most browsers is parse-time
|
|
610
|
+
// only and does NOT fire volumechange when toggled runtime). On
|
|
611
|
+
// canvas strategies, the property is patched via Object.defineProperty
|
|
612
|
+
// which also dispatches volumechange; one code path, both worlds.
|
|
613
|
+
this._videoEl.muted = value;
|
|
614
|
+
// Keep the attribute in sync so CSS selectors like [muted] and
|
|
615
|
+
// re-queries via getAttribute reflect current state.
|
|
605
616
|
if (value) this.setAttribute("muted", "");
|
|
606
617
|
else this.removeAttribute("muted");
|
|
607
618
|
}
|
|
@@ -683,11 +694,16 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
683
694
|
|
|
684
695
|
/**
|
|
685
696
|
* Buffered time ranges for the active source. Mirrors the standard
|
|
686
|
-
* `<video>.buffered` `TimeRanges` API.
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
697
|
+
* `<video>.buffered` `TimeRanges` API.
|
|
698
|
+
*
|
|
699
|
+
* - **Native / remux:** pass-through to the real `<video>.buffered`
|
|
700
|
+
* (reflects the browser's SourceBuffer / progressive-download state).
|
|
701
|
+
* - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
|
|
702
|
+
* from the demuxer's read progress — "how far libav has ever pumped
|
|
703
|
+
* packets through." Monotonic; does not shrink on seek. This is an
|
|
704
|
+
* approximation, not MSE-fidelity: decoded frames on canvas strategies
|
|
705
|
+
* are consumed in flight, so we can't report per-range availability
|
|
706
|
+
* the way MSE does. Enough for a seek-bar buffered indicator.
|
|
691
707
|
*/
|
|
692
708
|
get buffered(): TimeRanges {
|
|
693
709
|
return this._videoEl.buffered;
|
|
@@ -25,11 +25,18 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
25
25
|
|
|
26
26
|
/* ── Container ────────────────────────────────────────────────────────── */
|
|
27
27
|
|
|
28
|
+
:host {
|
|
29
|
+
-webkit-tap-highlight-color: transparent;
|
|
30
|
+
outline: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
.avp {
|
|
29
34
|
position: relative;
|
|
30
35
|
width: 100%;
|
|
31
36
|
height: 100%;
|
|
32
37
|
cursor: pointer;
|
|
38
|
+
-webkit-tap-highlight-color: transparent;
|
|
39
|
+
user-select: none;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
.avp avbridge-video {
|
|
@@ -228,7 +235,14 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
228
235
|
pointer-events: auto;
|
|
229
236
|
}
|
|
230
237
|
|
|
231
|
-
|
|
238
|
+
/* Left slot fills remaining space so slotted text/content can grow.
|
|
239
|
+
min-width: 0 prevents flex children from overflowing the toolbar. */
|
|
240
|
+
.avp-toolbar-top-left {
|
|
241
|
+
flex: 1;
|
|
242
|
+
min-width: 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
|
|
232
246
|
|
|
233
247
|
/* Hide the gradient band when no consumer has slotted anything — we
|
|
234
248
|
toggle data-toolbar-empty from JS via slotchange. */
|
|
@@ -241,6 +255,30 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
241
255
|
pointer-events: none;
|
|
242
256
|
}
|
|
243
257
|
|
|
258
|
+
/* ── Content overlay ─────────────────────────────────────────────────── */
|
|
259
|
+
/* Consumer-provided rich content (tweet cards, media info, annotations).
|
|
260
|
+
Sits above the video, below the play-button overlay and controls in
|
|
261
|
+
z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
|
|
262
|
+
so taps fall through to the video; consumers opt in on their content
|
|
263
|
+
with pointer-events:auto. */
|
|
264
|
+
|
|
265
|
+
.avp-content-overlay {
|
|
266
|
+
position: absolute;
|
|
267
|
+
inset: 0;
|
|
268
|
+
z-index: 1;
|
|
269
|
+
pointer-events: none;
|
|
270
|
+
opacity: 1;
|
|
271
|
+
transition: opacity 0.25s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.avp-content-overlay ::slotted(*) {
|
|
275
|
+
pointer-events: auto;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
:host([data-controls-hidden]) .avp-content-overlay {
|
|
279
|
+
opacity: 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
244
282
|
/* ── Seek bar ─────────────────────────────────────────────────────────── */
|
|
245
283
|
|
|
246
284
|
.avp-seek {
|
|
@@ -327,6 +365,15 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
327
365
|
|
|
328
366
|
.avp-seek:hover .avp-seek-tooltip { display: block; }
|
|
329
367
|
|
|
368
|
+
/* Show tooltip during active drag (touch or mouse). The JS side sets
|
|
369
|
+
data-seeking on .avp-seek while the user is scrubbing. */
|
|
370
|
+
.avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
|
|
371
|
+
|
|
372
|
+
/* Enlarge thumb while scrubbing. */
|
|
373
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
374
|
+
transform: translate(-50%, -50%) scale(1.4);
|
|
375
|
+
}
|
|
376
|
+
|
|
330
377
|
/* ── Bottom row ───────────────────────────────────────────────────────── */
|
|
331
378
|
|
|
332
379
|
.avp-bottom {
|
|
@@ -446,7 +493,10 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
446
493
|
background: rgba(28, 28, 28, 0.95);
|
|
447
494
|
border-radius: 8px;
|
|
448
495
|
min-width: 220px;
|
|
449
|
-
|
|
496
|
+
/* Fit within the player: leave room for the controls bar (52px bottom)
|
|
497
|
+
and a small top margin (8px). On tall players this caps at 300px;
|
|
498
|
+
on short players it shrinks to whatever fits. */
|
|
499
|
+
max-height: min(300px, calc(100% - 52px - 8px));
|
|
450
500
|
overflow-y: auto;
|
|
451
501
|
display: none;
|
|
452
502
|
z-index: 10;
|
|
@@ -518,9 +568,24 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
518
568
|
@media (pointer: coarse) {
|
|
519
569
|
.avp-btn svg { width: 28px; height: 28px; }
|
|
520
570
|
.avp-btn { padding: 8px; }
|
|
571
|
+
|
|
572
|
+
/* Taller touch target on mobile (44px, matching YouTube Mobile)
|
|
573
|
+
while keeping the visual track thin. Negative margin collapses
|
|
574
|
+
the extra space so the controls layout doesn't shift. */
|
|
575
|
+
.avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
|
|
521
576
|
.avp-seek-track { height: 4px; }
|
|
522
577
|
.avp-seek:hover .avp-seek-track { height: 4px; }
|
|
523
|
-
.avp-seek-thumb {
|
|
578
|
+
.avp-seek-thumb {
|
|
579
|
+
transform: translate(-50%, -50%) scale(1);
|
|
580
|
+
width: 16px;
|
|
581
|
+
height: 16px;
|
|
582
|
+
}
|
|
583
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
584
|
+
transform: translate(-50%, -50%) scale(1.5);
|
|
585
|
+
}
|
|
586
|
+
/* Move tooltip above the taller touch zone. */
|
|
587
|
+
.avp-seek-tooltip { bottom: 32px; }
|
|
588
|
+
|
|
524
589
|
.avp-volume:hover .avp-volume-slider { width: 0; }
|
|
525
590
|
.avp-overlay-btn { width: 56px; height: 56px; }
|
|
526
591
|
.avp-overlay-btn svg { width: 30px; height: 30px; }
|
package/src/player.ts
CHANGED
|
@@ -20,6 +20,62 @@ import type {
|
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Decoded-video-frame counter reader. Prefers the standard
|
|
25
|
+
* `getVideoPlaybackQuality().totalVideoFrames` (all evergreen browsers);
|
|
26
|
+
* falls back to the WebKit-prefixed `webkitDecodedFrameCount` for older
|
|
27
|
+
* Safari. Returns 0 for non-video elements or when nothing exposes the
|
|
28
|
+
* count — the caller treats 0 as "no signal" (constant across samples,
|
|
29
|
+
* which is fine).
|
|
30
|
+
*/
|
|
31
|
+
export function readDecodedFrameCount(target: HTMLMediaElement): number {
|
|
32
|
+
if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
|
|
33
|
+
const vq = (target as HTMLVideoElement & { getVideoPlaybackQuality?: () => { totalVideoFrames: number } }).getVideoPlaybackQuality;
|
|
34
|
+
if (typeof vq === "function") {
|
|
35
|
+
try { return vq.call(target).totalVideoFrames; } catch { /* fall through */ }
|
|
36
|
+
}
|
|
37
|
+
const legacy = (target as HTMLVideoElement & { webkitDecodedFrameCount?: number }).webkitDecodedFrameCount;
|
|
38
|
+
return typeof legacy === "number" ? legacy : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pure decision function for the stall supervisor. Takes a snapshot of
|
|
43
|
+
* the observable state and returns whether to escalate. Extracted so it
|
|
44
|
+
* can be unit-tested without spinning up a real player / media element.
|
|
45
|
+
*
|
|
46
|
+
* - `time-stall`: `currentTime` hasn't moved for `timeStallThresholdMs`
|
|
47
|
+
* despite the element being in a state where it should be playing.
|
|
48
|
+
* - `silent-video`: the media has a video track, `currentTime` is
|
|
49
|
+
* advancing (audio is playing), but the decoder has produced no new
|
|
50
|
+
* frames for `frameStallThresholdMs`. Catches Firefox-style "MSE
|
|
51
|
+
* reports codec supported but the decoder can't actually decode it".
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateDecodeHealth(input: {
|
|
54
|
+
hasVideoTrack: boolean;
|
|
55
|
+
timeAdvanced: boolean;
|
|
56
|
+
framesAdvanced: boolean;
|
|
57
|
+
now: number;
|
|
58
|
+
lastProgressTime: number;
|
|
59
|
+
lastFrameProgressTime: number;
|
|
60
|
+
timeStallThresholdMs?: number;
|
|
61
|
+
frameStallThresholdMs?: number;
|
|
62
|
+
}): { escalate: false } | { escalate: true; kind: "time-stall" | "silent-video" } {
|
|
63
|
+
const timeThreshold = input.timeStallThresholdMs ?? 5000;
|
|
64
|
+
const frameThreshold = input.frameStallThresholdMs ?? 3000;
|
|
65
|
+
if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
|
|
66
|
+
return { escalate: true, kind: "time-stall" };
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
input.hasVideoTrack &&
|
|
70
|
+
input.timeAdvanced &&
|
|
71
|
+
!input.framesAdvanced &&
|
|
72
|
+
input.now - input.lastFrameProgressTime > frameThreshold
|
|
73
|
+
) {
|
|
74
|
+
return { escalate: true, kind: "silent-video" };
|
|
75
|
+
}
|
|
76
|
+
return { escalate: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
23
79
|
export class UnifiedPlayer {
|
|
24
80
|
private emitter = new TypedEmitter<PlayerEventMap>();
|
|
25
81
|
private session: PlaybackSession | null = null;
|
|
@@ -34,6 +90,13 @@ export class UnifiedPlayer {
|
|
|
34
90
|
private stallTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
91
|
private lastProgressTime = 0;
|
|
36
92
|
private lastProgressPosition = -1;
|
|
93
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
94
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
95
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
96
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
97
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
98
|
+
private lastVideoFrameCount = 0;
|
|
99
|
+
private lastVideoFrameProgressTime = 0;
|
|
37
100
|
private errorListener: (() => void) | null = null;
|
|
38
101
|
|
|
39
102
|
// Bound so we can removeEventListener in destroy(); without this the
|
|
@@ -351,23 +414,48 @@ export class UnifiedPlayer {
|
|
|
351
414
|
// Monitor currentTime progress
|
|
352
415
|
this.lastProgressPosition = this.options.target.currentTime;
|
|
353
416
|
this.lastProgressTime = performance.now();
|
|
417
|
+
this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
|
|
418
|
+
this.lastVideoFrameProgressTime = performance.now();
|
|
419
|
+
|
|
420
|
+
const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
|
|
354
421
|
|
|
355
422
|
this.stallTimer = setInterval(() => {
|
|
356
423
|
const t = this.options.target;
|
|
424
|
+
const now = performance.now();
|
|
357
425
|
if (t.paused || t.ended || t.readyState < 2) {
|
|
358
426
|
this.lastProgressPosition = t.currentTime;
|
|
359
|
-
this.lastProgressTime =
|
|
427
|
+
this.lastProgressTime = now;
|
|
428
|
+
this.lastVideoFrameCount = readDecodedFrameCount(t);
|
|
429
|
+
this.lastVideoFrameProgressTime = now;
|
|
360
430
|
return;
|
|
361
431
|
}
|
|
362
|
-
|
|
432
|
+
const timeAdvanced = t.currentTime !== this.lastProgressPosition;
|
|
433
|
+
const frames = readDecodedFrameCount(t);
|
|
434
|
+
const framesAdvanced = frames > this.lastVideoFrameCount;
|
|
435
|
+
|
|
436
|
+
const health = evaluateDecodeHealth({
|
|
437
|
+
hasVideoTrack,
|
|
438
|
+
timeAdvanced,
|
|
439
|
+
framesAdvanced,
|
|
440
|
+
now,
|
|
441
|
+
lastProgressTime: this.lastProgressTime,
|
|
442
|
+
lastFrameProgressTime: this.lastVideoFrameProgressTime,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
if (timeAdvanced) {
|
|
363
446
|
this.lastProgressPosition = t.currentTime;
|
|
364
|
-
this.lastProgressTime =
|
|
365
|
-
return;
|
|
447
|
+
this.lastProgressTime = now;
|
|
366
448
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
449
|
+
if (framesAdvanced) {
|
|
450
|
+
this.lastVideoFrameCount = frames;
|
|
451
|
+
this.lastVideoFrameProgressTime = now;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (health.escalate) {
|
|
455
|
+
const reason = health.kind === "time-stall"
|
|
456
|
+
? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
|
|
457
|
+
: `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s — likely a silent codec failure`;
|
|
458
|
+
void this.escalate(reason);
|
|
371
459
|
}
|
|
372
460
|
}, 1000);
|
|
373
461
|
|
package/src/probe/avi.ts
CHANGED
|
@@ -181,6 +181,8 @@ function ffmpegToAvbridgeVideo(name: string): VideoCodec {
|
|
|
181
181
|
case "rv20": return "rv20";
|
|
182
182
|
case "rv30": return "rv30";
|
|
183
183
|
case "rv40": return "rv40";
|
|
184
|
+
case "dvvideo": return "dv"; // DV / DVCPRO (camcorder, MiniDV)
|
|
185
|
+
case "hq_hqa": return "hq_hqa"; // Canopus HQ / HQA (Grass Valley)
|
|
184
186
|
default: return name as VideoCodec;
|
|
185
187
|
}
|
|
186
188
|
}
|
|
@@ -32,6 +32,7 @@ import { dbg } from "../../util/debug.js";
|
|
|
32
32
|
import {
|
|
33
33
|
sanitizeFrameTimestamp,
|
|
34
34
|
libavFrameToInterleavedFloat32,
|
|
35
|
+
packetPtsSec,
|
|
35
36
|
} from "../../util/libav-demux.js";
|
|
36
37
|
|
|
37
38
|
export interface DecoderHandles {
|
|
@@ -46,6 +47,13 @@ export interface DecoderHandles {
|
|
|
46
47
|
*/
|
|
47
48
|
setAudioTrack(trackId: number, timeSec: number): Promise<void>;
|
|
48
49
|
stats(): Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* The demuxer's read-ahead frontier in seconds. See
|
|
52
|
+
* `HybridDecoderHandles.bufferedUntilSec` for the full contract —
|
|
53
|
+
* same semantics, same consumer (`<video>.buffered` on canvas
|
|
54
|
+
* strategies).
|
|
55
|
+
*/
|
|
56
|
+
bufferedUntilSec(): number;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export interface StartDecoderOptions {
|
|
@@ -222,6 +230,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
222
230
|
|
|
223
231
|
let packetsRead = 0;
|
|
224
232
|
let videoFramesDecoded = 0;
|
|
233
|
+
let bufferedUntilSec = 0;
|
|
225
234
|
let audioFramesDecoded = 0;
|
|
226
235
|
|
|
227
236
|
// Decode-rate watchdog. Samples framesDecoded every second and
|
|
@@ -278,6 +287,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
278
287
|
const videoPackets = videoStream ? packets[videoStream.index] : undefined;
|
|
279
288
|
const audioPackets = audioStream ? packets[audioStream.index] : undefined;
|
|
280
289
|
|
|
290
|
+
// Track demuxer read-ahead for <video>.buffered on this strategy.
|
|
291
|
+
// Peek raw pts before sanitizePacketTimestamp (which would
|
|
292
|
+
// clobber to µs and lose the source-native scale). Monotonic;
|
|
293
|
+
// seeks don't reset.
|
|
294
|
+
if (videoPackets && videoTimeBase) {
|
|
295
|
+
for (const pkt of videoPackets) {
|
|
296
|
+
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
297
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (audioPackets && audioTimeBase) {
|
|
301
|
+
for (const pkt of audioPackets) {
|
|
302
|
+
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
303
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
// Decode audio BEFORE video. On software-decode-bound content
|
|
282
308
|
// (rv40/mpeg4/wmv3 @ 720p+) a single video batch can take
|
|
283
309
|
// 200-400 ms of wall time; if the scheduler hasn't been fed
|
|
@@ -617,6 +643,10 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
617
643
|
);
|
|
618
644
|
},
|
|
619
645
|
|
|
646
|
+
bufferedUntilSec() {
|
|
647
|
+
return bufferedUntilSec;
|
|
648
|
+
},
|
|
649
|
+
|
|
620
650
|
stats() {
|
|
621
651
|
return {
|
|
622
652
|
decoderType: "libav-wasm",
|
|
@@ -152,6 +152,18 @@ export async function createFallbackSession(
|
|
|
152
152
|
? [[0, ctx.duration]]
|
|
153
153
|
: []),
|
|
154
154
|
});
|
|
155
|
+
// buffered: demuxer's read-ahead frontier (highest pts pumped from
|
|
156
|
+
// libav). Single [0, end] range — approximation of "how far we've
|
|
157
|
+
// read through the source," the signal the seek-bar buffered
|
|
158
|
+
// indicator wants. Real MSE-style per-range tracking isn't
|
|
159
|
+
// meaningful here since decoded frames are consumed in flight.
|
|
160
|
+
Object.defineProperty(target, "buffered", {
|
|
161
|
+
configurable: true,
|
|
162
|
+
get: () => {
|
|
163
|
+
const end = handles.bufferedUntilSec();
|
|
164
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
155
167
|
|
|
156
168
|
/**
|
|
157
169
|
* Wait until the decoder has produced enough buffered output to start
|
|
@@ -240,6 +252,11 @@ export async function createFallbackSession(
|
|
|
240
252
|
|
|
241
253
|
async function doSeek(timeSec: number): Promise<void> {
|
|
242
254
|
const wasPlaying = audio.isPlaying();
|
|
255
|
+
// HTMLMediaElement contract: dispatch `seeking` once the seek
|
|
256
|
+
// operation begins. The inner <video> never fires this itself on
|
|
257
|
+
// canvas strategies (no src), so we dispatch manually to preserve
|
|
258
|
+
// the contract for consumers listening via `<avbridge-video>`.
|
|
259
|
+
target.dispatchEvent(new Event("seeking"));
|
|
243
260
|
// 1. Stop audio (suspend ctx + capture media time).
|
|
244
261
|
await audio.pause().catch(() => {});
|
|
245
262
|
// 2. Tell the decoder to cancel its pump and seek the demuxer.
|
|
@@ -256,8 +273,21 @@ export async function createFallbackSession(
|
|
|
256
273
|
await waitForBuffer();
|
|
257
274
|
await audio.start();
|
|
258
275
|
}
|
|
276
|
+
// HTMLMediaElement contract: dispatch `seeked` after the seek has
|
|
277
|
+
// completed (demuxer + renderer reset + optional buffer refill).
|
|
278
|
+
target.dispatchEvent(new Event("seeked"));
|
|
259
279
|
}
|
|
260
280
|
|
|
281
|
+
// HTMLMediaElement contract: dispatch `loadedmetadata` once the
|
|
282
|
+
// session is ready (duration, dimensions, tracks known via the
|
|
283
|
+
// MediaContext). Dispatched on a microtask so it lands after the
|
|
284
|
+
// session promise resolves and consumers have a chance to attach
|
|
285
|
+
// listeners. The inner <video> never fires this itself here — it
|
|
286
|
+
// has no src.
|
|
287
|
+
queueMicrotask(() => {
|
|
288
|
+
try { target.dispatchEvent(new Event("loadedmetadata")); } catch { /* element torn down */ }
|
|
289
|
+
});
|
|
290
|
+
|
|
261
291
|
return {
|
|
262
292
|
strategy: "fallback",
|
|
263
293
|
|