avbridge 2.8.4 → 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 +133 -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-YX4AGLNF.cjs → chunk-EY6DZEDT.cjs} +89 -15
- 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-KBWQRGHS.js → chunk-SN4WZE24.js} +79 -5
- package/dist/chunk-SN4WZE24.js.map +1 -0
- package/dist/element-browser.js +104 -7
- 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-BptSJPfn.d.cts → player-DEcidWk6.d.cts} +1 -1
- package/dist/{player-BptSJPfn.d.ts → player-DEcidWk6.d.ts} +1 -1
- package/dist/player.cjs +187 -23
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +17 -11
- package/dist/player.d.ts +17 -11
- package/dist/player.js +187 -23
- 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 +2 -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/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-KBWQRGHS.js.map +0 -1
- package/dist/chunk-X2K3GIWE.js.map +0 -1
- package/dist/chunk-YX4AGLNF.cjs.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
|
@@ -31,6 +31,8 @@ export const FALLBACK_VIDEO_CODECS = new Set<VideoCodec>([
|
|
|
31
31
|
"wmv3", "vc1", "mpeg4",
|
|
32
32
|
"rv10", "rv20", "rv30", "rv40",
|
|
33
33
|
"mpeg2", "mpeg1", "theora",
|
|
34
|
+
"dv", "hq_hqa",
|
|
35
|
+
"rawvideo", "qtrle", "png", "vp6f",
|
|
34
36
|
]);
|
|
35
37
|
export const FALLBACK_AUDIO_CODECS = new Set<AudioCodec>([
|
|
36
38
|
"wmav2", "wmapro", "ac3", "eac3",
|
|
@@ -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/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
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
sanitizePacketTimestamp,
|
|
25
25
|
sanitizeFrameTimestamp,
|
|
26
26
|
libavFrameToInterleavedFloat32,
|
|
27
|
+
packetPtsSec,
|
|
27
28
|
} from "../../util/libav-demux.js";
|
|
28
29
|
|
|
29
30
|
export interface HybridDecoderHandles {
|
|
@@ -33,6 +34,17 @@ export interface HybridDecoderHandles {
|
|
|
33
34
|
setAudioTrack(trackId: number, timeSec: number): Promise<void>;
|
|
34
35
|
stats(): Record<string, unknown>;
|
|
35
36
|
onFatalError(handler: (reason: string) => void): void;
|
|
37
|
+
/**
|
|
38
|
+
* The demuxer's read-ahead frontier in seconds — the highest pts
|
|
39
|
+
* observed on any packet handed back from `ff_read_frame_multi`.
|
|
40
|
+
* Monotonically non-decreasing: seeks don't reset it, since the
|
|
41
|
+
* frontier represents "how far we've ever demuxed through this
|
|
42
|
+
* source," which matches what a seek-bar buffered indicator should
|
|
43
|
+
* show. Backs `<video>.buffered` on canvas strategies. Returns 0
|
|
44
|
+
* before any valid pts have been seen (some AVI/FLV sources may
|
|
45
|
+
* never reach this — their `buffered` stays empty).
|
|
46
|
+
*/
|
|
47
|
+
bufferedUntilSec(): number;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export interface StartHybridDecoderOptions {
|
|
@@ -211,6 +223,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
211
223
|
let videoFramesDecoded = 0;
|
|
212
224
|
let audioFramesDecoded = 0;
|
|
213
225
|
let videoChunksFed = 0;
|
|
226
|
+
let bufferedUntilSec = 0;
|
|
214
227
|
|
|
215
228
|
let syntheticVideoUs = 0;
|
|
216
229
|
let syntheticAudioUs = 0;
|
|
@@ -239,6 +252,24 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
239
252
|
const videoPackets = videoStream ? packets[videoStream.index] : undefined;
|
|
240
253
|
const audioPackets = audioStream ? packets[audioStream.index] : undefined;
|
|
241
254
|
|
|
255
|
+
// Track how far the demuxer has read through the source — the
|
|
256
|
+
// signal behind `<video>.buffered` on this strategy. Peek at raw
|
|
257
|
+
// packet pts using each stream's native time_base (before the
|
|
258
|
+
// sanitizePacketTimestamp call later in the loop, which
|
|
259
|
+
// overwrites to µs). Monotonic: we never walk it backward.
|
|
260
|
+
if (videoPackets && videoTimeBase) {
|
|
261
|
+
for (const pkt of videoPackets) {
|
|
262
|
+
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
263
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (audioPackets && audioTimeBase) {
|
|
267
|
+
for (const pkt of audioPackets) {
|
|
268
|
+
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
269
|
+
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
242
273
|
// Decode audio BEFORE video. Same rationale as fallback decoder
|
|
243
274
|
// (POSTMORTEMS.md entry 1, fix #2): audio decode via libav's
|
|
244
275
|
// ff_decode_multi is a blocking WASM call that prevents rAF from
|
|
@@ -519,6 +550,10 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
519
550
|
);
|
|
520
551
|
},
|
|
521
552
|
|
|
553
|
+
bufferedUntilSec() {
|
|
554
|
+
return bufferedUntilSec;
|
|
555
|
+
},
|
|
556
|
+
|
|
522
557
|
stats() {
|
|
523
558
|
return {
|
|
524
559
|
decoderType: "webcodecs-hybrid",
|
|
@@ -99,6 +99,13 @@ export async function createHybridSession(
|
|
|
99
99
|
? [[0, ctx.duration]]
|
|
100
100
|
: []),
|
|
101
101
|
});
|
|
102
|
+
Object.defineProperty(target, "buffered", {
|
|
103
|
+
configurable: true,
|
|
104
|
+
get: () => {
|
|
105
|
+
const end = handles.bufferedUntilSec();
|
|
106
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
102
109
|
|
|
103
110
|
async function waitForBuffer(): Promise<void> {
|
|
104
111
|
const start = performance.now();
|
|
@@ -114,6 +121,8 @@ export async function createHybridSession(
|
|
|
114
121
|
|
|
115
122
|
async function doSeek(timeSec: number): Promise<void> {
|
|
116
123
|
const wasPlaying = audio.isPlaying();
|
|
124
|
+
// HTMLMediaElement contract — see fallback/index.ts for the why.
|
|
125
|
+
target.dispatchEvent(new Event("seeking"));
|
|
117
126
|
await audio.pause().catch(() => {});
|
|
118
127
|
await handles.seek(timeSec).catch((err) =>
|
|
119
128
|
console.warn("[avbridge] hybrid decoder seek failed:", err),
|
|
@@ -124,8 +133,16 @@ export async function createHybridSession(
|
|
|
124
133
|
await waitForBuffer();
|
|
125
134
|
await audio.start();
|
|
126
135
|
}
|
|
136
|
+
target.dispatchEvent(new Event("seeked"));
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
// HTMLMediaElement contract: `loadedmetadata` once the session is
|
|
140
|
+
// ready. The inner <video> never fires this itself on the hybrid
|
|
141
|
+
// path — it has no src.
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
try { target.dispatchEvent(new Event("loadedmetadata")); } catch { /* element torn down */ }
|
|
144
|
+
});
|
|
145
|
+
|
|
129
146
|
// Store the fatal error handler so the player can wire escalation
|
|
130
147
|
let fatalErrorHandler: ((reason: string) => void) | null = null;
|
|
131
148
|
handles.onFatalError((reason) => fatalErrorHandler?.(reason));
|
|
@@ -65,6 +65,14 @@ export async function createRemuxSession(
|
|
|
65
65
|
}
|
|
66
66
|
const wasPlaying = !video.paused;
|
|
67
67
|
await pipeline.seek(time, wasPlaying || wantPlay);
|
|
68
|
+
// HTMLMediaElement contract: Firefox + WebKit's MSE doesn't
|
|
69
|
+
// reliably fire `seeked` after a SourceBuffer remove+refill
|
|
70
|
+
// cycle (Chromium does). Dispatch manually so consumers get a
|
|
71
|
+
// consistent signal across browsers. Duplicating a native
|
|
72
|
+
// `seeked` is harmless per spec.
|
|
73
|
+
queueMicrotask(() => {
|
|
74
|
+
try { video.dispatchEvent(new Event("seeked")); } catch { /* ignore */ }
|
|
75
|
+
});
|
|
68
76
|
},
|
|
69
77
|
async setAudioTrack(id) {
|
|
70
78
|
if (!context.audioTracks.some((t) => t.id === id)) {
|
package/src/types.ts
CHANGED
|
@@ -49,6 +49,12 @@ export type VideoCodec =
|
|
|
49
49
|
| "mpeg2"
|
|
50
50
|
| "mpeg1"
|
|
51
51
|
| "theora"
|
|
52
|
+
| "dv" // DV / DVCPRO (camcorder, MiniDV)
|
|
53
|
+
| "hq_hqa" // Canopus HQ / HQA (Grass Valley intermediate)
|
|
54
|
+
| "rawvideo" // uncompressed frames
|
|
55
|
+
| "qtrle" // QuickTime Animation (Apple RLE)
|
|
56
|
+
| "png" // PNG sequence in MOV
|
|
57
|
+
| "vp6f" // VP6 Flash variant
|
|
52
58
|
| (string & {});
|
|
53
59
|
|
|
54
60
|
/** Audio codec families. */
|
package/src/util/libav-demux.ts
CHANGED
|
@@ -248,6 +248,32 @@ export function sanitizePacketTimestamp(
|
|
|
248
248
|
pkt.time_base_den = 1_000_000;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Convert a raw libav packet's pts to seconds using the given stream
|
|
253
|
+
* time_base, or return `null` if the packet lacks a valid pts. Used by
|
|
254
|
+
* the hybrid + fallback strategies to track the demuxer's read-ahead
|
|
255
|
+
* progress (the signal behind `<video>.buffered` on canvas strategies).
|
|
256
|
+
*
|
|
257
|
+
* Separate from `sanitizePacketTimestamp` — sanitization mutates the
|
|
258
|
+
* packet and happens right before decoder feed; this peeks at the
|
|
259
|
+
* timestamp earlier in the pump so we can track buffered extent without
|
|
260
|
+
* perturbing the decode path.
|
|
261
|
+
*/
|
|
262
|
+
export function packetPtsSec(
|
|
263
|
+
pkt: Pick<LibavPacket, "pts" | "ptshi">,
|
|
264
|
+
timeBase: [number, number] | undefined,
|
|
265
|
+
): number | null {
|
|
266
|
+
const lo = pkt.pts ?? 0;
|
|
267
|
+
const hi = pkt.ptshi ?? 0;
|
|
268
|
+
const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
|
|
269
|
+
if (isInvalid) return null;
|
|
270
|
+
const tb = timeBase ?? [1, 1_000_000];
|
|
271
|
+
if (!tb[0] || !tb[1]) return null;
|
|
272
|
+
const pts64 = hi * 0x100000000 + lo;
|
|
273
|
+
const sec = (pts64 * tb[0]) / tb[1];
|
|
274
|
+
return Number.isFinite(sec) ? sec : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
// ─────────────────────────────────────────────────────────────────────────
|
|
252
278
|
// Audio frame → interleaved Float32 (extracted from
|
|
253
279
|
// strategies/hybrid/decoder.ts + strategies/fallback/decoder.ts).
|