avbridge 2.8.4 → 2.10.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 +164 -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-KBWQRGHS.js → chunk-3GKM5DFM.js} +119 -8
- package/dist/chunk-3GKM5DFM.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-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-YX4AGLNF.cjs → chunk-NQULEIA3.cjs} +129 -18
- package/dist/chunk-NQULEIA3.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/element-browser.js +144 -10
- 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-DDdNVFDv.d.cts} +24 -2
- package/dist/{player-BptSJPfn.d.ts → player-DDdNVFDv.d.ts} +24 -2
- package/dist/player.cjs +413 -117
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +44 -11
- package/dist/player.d.ts +44 -11
- package/dist/player.js +413 -117
- 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 +172 -86
- package/src/element/avbridge-video.ts +22 -6
- package/src/element/player-styles.ts +149 -34
- package/src/index.ts +1 -0
- package/src/probe/avi.ts +2 -0
- package/src/strategies/fallback/audio-output.ts +29 -4
- package/src/strategies/fallback/decoder.ts +30 -0
- package/src/strategies/fallback/index.ts +42 -0
- package/src/strategies/hybrid/decoder.ts +35 -0
- package/src/strategies/hybrid/index.ts +26 -0
- package/src/strategies/remux/index.ts +8 -0
- package/src/types.ts +31 -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
|
@@ -25,11 +25,17 @@ 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
|
+
-webkit-tap-highlight-color: transparent;
|
|
38
|
+
user-select: none;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
.avp avbridge-video {
|
|
@@ -228,7 +234,14 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
228
234
|
pointer-events: auto;
|
|
229
235
|
}
|
|
230
236
|
|
|
231
|
-
|
|
237
|
+
/* Left slot fills remaining space so slotted text/content can grow.
|
|
238
|
+
min-width: 0 prevents flex children from overflowing the toolbar. */
|
|
239
|
+
.avp-toolbar-top-left {
|
|
240
|
+
flex: 1;
|
|
241
|
+
min-width: 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
|
|
232
245
|
|
|
233
246
|
/* Hide the gradient band when no consumer has slotted anything — we
|
|
234
247
|
toggle data-toolbar-empty from JS via slotchange. */
|
|
@@ -241,6 +254,30 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
241
254
|
pointer-events: none;
|
|
242
255
|
}
|
|
243
256
|
|
|
257
|
+
/* ── Content overlay ─────────────────────────────────────────────────── */
|
|
258
|
+
/* Consumer-provided rich content (tweet cards, media info, annotations).
|
|
259
|
+
Sits above the video, below the play-button overlay and controls in
|
|
260
|
+
z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
|
|
261
|
+
so taps fall through to the video; consumers opt in on their content
|
|
262
|
+
with pointer-events:auto. */
|
|
263
|
+
|
|
264
|
+
.avp-content-overlay {
|
|
265
|
+
position: absolute;
|
|
266
|
+
inset: 0;
|
|
267
|
+
z-index: 1;
|
|
268
|
+
pointer-events: none;
|
|
269
|
+
opacity: 1;
|
|
270
|
+
transition: opacity 0.25s;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.avp-content-overlay ::slotted(*) {
|
|
274
|
+
pointer-events: auto;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
:host([data-controls-hidden]) .avp-content-overlay {
|
|
278
|
+
opacity: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
244
281
|
/* ── Seek bar ─────────────────────────────────────────────────────────── */
|
|
245
282
|
|
|
246
283
|
.avp-seek {
|
|
@@ -327,6 +364,15 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
327
364
|
|
|
328
365
|
.avp-seek:hover .avp-seek-tooltip { display: block; }
|
|
329
366
|
|
|
367
|
+
/* Show tooltip during active drag (touch or mouse). The JS side sets
|
|
368
|
+
data-seeking on .avp-seek while the user is scrubbing. */
|
|
369
|
+
.avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
|
|
370
|
+
|
|
371
|
+
/* Enlarge thumb while scrubbing. */
|
|
372
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
373
|
+
transform: translate(-50%, -50%) scale(1.4);
|
|
374
|
+
}
|
|
375
|
+
|
|
330
376
|
/* ── Bottom row ───────────────────────────────────────────────────────── */
|
|
331
377
|
|
|
332
378
|
.avp-bottom {
|
|
@@ -437,60 +483,114 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
437
483
|
|
|
438
484
|
.avp-spacer { flex: 1; }
|
|
439
485
|
|
|
440
|
-
/* ── Settings
|
|
486
|
+
/* ── Settings bottom sheet ────────────────────────────────────────────── */
|
|
441
487
|
|
|
488
|
+
/* Scrim — semi-transparent overlay behind the sheet, above the video.
|
|
489
|
+
Tapping it dismisses the sheet. */
|
|
490
|
+
.avp-settings-scrim {
|
|
491
|
+
position: absolute;
|
|
492
|
+
inset: 0;
|
|
493
|
+
z-index: 9;
|
|
494
|
+
background: rgba(0, 0, 0, 0.4);
|
|
495
|
+
opacity: 0;
|
|
496
|
+
pointer-events: none;
|
|
497
|
+
transition: opacity 0.2s;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.avp-settings-scrim.open {
|
|
501
|
+
opacity: 1;
|
|
502
|
+
pointer-events: auto;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/* Sheet container — slides up from the bottom. Height is content-driven
|
|
506
|
+
up to a JS-measured max (set on open via style.maxHeight). */
|
|
442
507
|
.avp-settings {
|
|
443
508
|
position: absolute;
|
|
444
|
-
bottom:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
border-radius: 8px;
|
|
448
|
-
min-width: 220px;
|
|
449
|
-
max-height: 300px;
|
|
450
|
-
overflow-y: auto;
|
|
451
|
-
display: none;
|
|
509
|
+
bottom: 0;
|
|
510
|
+
left: 0;
|
|
511
|
+
right: 0;
|
|
452
512
|
z-index: 10;
|
|
453
|
-
|
|
513
|
+
background: rgba(28, 28, 28, 0.97);
|
|
514
|
+
border-radius: 12px 12px 0 0;
|
|
515
|
+
overflow-y: auto;
|
|
516
|
+
overscroll-behavior: contain;
|
|
517
|
+
transform: translateY(100%);
|
|
518
|
+
transition: transform 0.2s ease-out;
|
|
519
|
+
max-height: 70%;
|
|
520
|
+
padding-bottom: 52px;
|
|
521
|
+
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
|
|
454
522
|
}
|
|
455
523
|
|
|
456
|
-
.avp-settings.open {
|
|
524
|
+
.avp-settings.open {
|
|
525
|
+
transform: translateY(0);
|
|
526
|
+
}
|
|
457
527
|
|
|
458
|
-
.
|
|
459
|
-
|
|
460
|
-
|
|
528
|
+
/* Drag handle indicator at top of sheet. */
|
|
529
|
+
.avp-settings-handle {
|
|
530
|
+
width: 36px;
|
|
531
|
+
height: 4px;
|
|
532
|
+
border-radius: 2px;
|
|
533
|
+
background: rgba(255, 255, 255, 0.3);
|
|
534
|
+
margin: 8px auto 4px;
|
|
461
535
|
}
|
|
462
536
|
|
|
463
|
-
|
|
537
|
+
/* ── Accordion sections ──────────────────────────────────────────────── */
|
|
464
538
|
|
|
465
|
-
.avp-settings-
|
|
466
|
-
|
|
467
|
-
font-size: 11px;
|
|
468
|
-
text-transform: uppercase;
|
|
469
|
-
letter-spacing: 0.5px;
|
|
470
|
-
opacity: 0.5;
|
|
539
|
+
.avp-settings-section {
|
|
540
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
471
541
|
}
|
|
472
542
|
|
|
473
|
-
.avp-settings-
|
|
543
|
+
.avp-settings-section:last-child { border-bottom: none; }
|
|
544
|
+
|
|
545
|
+
/* Section header — clickable row showing label + current value. */
|
|
546
|
+
.avp-settings-header {
|
|
547
|
+
position: relative;
|
|
474
548
|
display: flex;
|
|
475
549
|
align-items: center;
|
|
476
|
-
|
|
477
|
-
|
|
550
|
+
justify-content: space-between;
|
|
551
|
+
padding: 12px 16px;
|
|
478
552
|
cursor: pointer;
|
|
553
|
+
font-size: 14px;
|
|
479
554
|
transition: background 0.1s;
|
|
480
555
|
}
|
|
481
556
|
|
|
482
|
-
.avp-settings-
|
|
557
|
+
.avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
|
|
483
558
|
|
|
484
|
-
.avp-settings-
|
|
485
|
-
|
|
559
|
+
.avp-settings-header-label {
|
|
560
|
+
display: flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
gap: 8px;
|
|
563
|
+
font-weight: 500;
|
|
486
564
|
}
|
|
487
565
|
|
|
488
|
-
.avp-settings-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
font-
|
|
566
|
+
.avp-settings-header-value {
|
|
567
|
+
margin-left: auto;
|
|
568
|
+
opacity: 0.6;
|
|
569
|
+
font-size: 13px;
|
|
570
|
+
text-align: right;
|
|
492
571
|
}
|
|
493
572
|
|
|
573
|
+
/* Invisible native <select> layered over the value portion of the row.
|
|
574
|
+
Covers from the value text to the right edge so tapping the value
|
|
575
|
+
opens the OS picker. The label side remains inert. */
|
|
576
|
+
.avp-settings-select {
|
|
577
|
+
position: absolute;
|
|
578
|
+
top: 0;
|
|
579
|
+
right: 0;
|
|
580
|
+
bottom: 0;
|
|
581
|
+
width: 50%;
|
|
582
|
+
opacity: 0;
|
|
583
|
+
cursor: pointer;
|
|
584
|
+
font-size: 16px;
|
|
585
|
+
direction: rtl;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* Toggle-style rows (Stats for Nerds) — no select, just clickable. */
|
|
589
|
+
.avp-settings-toggle {
|
|
590
|
+
cursor: pointer;
|
|
591
|
+
}
|
|
592
|
+
.avp-settings-toggle:hover { background: rgba(255, 255, 255, 0.06); }
|
|
593
|
+
|
|
494
594
|
/* ── Stats for nerds ──────────────────────────────────────────────────── */
|
|
495
595
|
|
|
496
596
|
.avp-stats {
|
|
@@ -518,9 +618,24 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
518
618
|
@media (pointer: coarse) {
|
|
519
619
|
.avp-btn svg { width: 28px; height: 28px; }
|
|
520
620
|
.avp-btn { padding: 8px; }
|
|
621
|
+
|
|
622
|
+
/* Taller touch target on mobile (44px, matching YouTube Mobile)
|
|
623
|
+
while keeping the visual track thin. Negative margin collapses
|
|
624
|
+
the extra space so the controls layout doesn't shift. */
|
|
625
|
+
.avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
|
|
521
626
|
.avp-seek-track { height: 4px; }
|
|
522
627
|
.avp-seek:hover .avp-seek-track { height: 4px; }
|
|
523
|
-
.avp-seek-thumb {
|
|
628
|
+
.avp-seek-thumb {
|
|
629
|
+
transform: translate(-50%, -50%) scale(1);
|
|
630
|
+
width: 16px;
|
|
631
|
+
height: 16px;
|
|
632
|
+
}
|
|
633
|
+
.avp-seek[data-seeking] .avp-seek-thumb {
|
|
634
|
+
transform: translate(-50%, -50%) scale(1.5);
|
|
635
|
+
}
|
|
636
|
+
/* Move tooltip above the taller touch zone. */
|
|
637
|
+
.avp-seek-tooltip { bottom: 32px; }
|
|
638
|
+
|
|
524
639
|
.avp-volume:hover .avp-volume-slider { width: 0; }
|
|
525
640
|
.avp-overlay-btn { width: 56px; height: 56px; }
|
|
526
641
|
.avp-overlay-btn svg { width: 30px; height: 30px; }
|
package/src/index.ts
CHANGED
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
|
}
|
|
@@ -80,6 +80,10 @@ export class AudioOutput implements ClockSource {
|
|
|
80
80
|
private _volume = 1;
|
|
81
81
|
/** User-set muted flag. When true, gain is forced to 0. */
|
|
82
82
|
private _muted = false;
|
|
83
|
+
/** Playback rate. Scales the media clock and each AudioBufferSourceNode's
|
|
84
|
+
* playbackRate so audio pitches up/down accordingly (same as native
|
|
85
|
+
* <video>.playbackRate). Default 1. */
|
|
86
|
+
private _rate = 1;
|
|
83
87
|
|
|
84
88
|
constructor() {
|
|
85
89
|
this.ctx = new AudioContext();
|
|
@@ -107,6 +111,24 @@ export class AudioOutput implements ClockSource {
|
|
|
107
111
|
return this._muted;
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/** Set playback rate. Scales the media clock and pitches audio output
|
|
115
|
+
* (same as native <video>.playbackRate — speed without pitch correction).
|
|
116
|
+
* Rebases the anchor so the clock transition is seamless. */
|
|
117
|
+
setPlaybackRate(rate: number): void {
|
|
118
|
+
if (rate === this._rate) return;
|
|
119
|
+
// Rebase anchor at the current media time before changing rate,
|
|
120
|
+
// so the clock doesn't jump.
|
|
121
|
+
const t = this.now();
|
|
122
|
+
this.mediaTimeOfAnchor = t;
|
|
123
|
+
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
124
|
+
this.wallAnchorMs = performance.now();
|
|
125
|
+
this._rate = rate;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getPlaybackRate(): number {
|
|
129
|
+
return this._rate;
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
private applyGain(): void {
|
|
111
133
|
const target = this._muted ? 0 : this._volume;
|
|
112
134
|
try { this.gain.gain.value = target; } catch { /* ignore */ }
|
|
@@ -127,12 +149,12 @@ export class AudioOutput implements ClockSource {
|
|
|
127
149
|
now(): number {
|
|
128
150
|
if (this.noAudio) {
|
|
129
151
|
if (this.state === "playing") {
|
|
130
|
-
return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000;
|
|
152
|
+
return this.mediaTimeOfAnchor + (performance.now() - this.wallAnchorMs) / 1000 * this._rate;
|
|
131
153
|
}
|
|
132
154
|
return this.mediaTimeOfAnchor;
|
|
133
155
|
}
|
|
134
156
|
if (this.state === "playing") {
|
|
135
|
-
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor);
|
|
157
|
+
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
136
158
|
}
|
|
137
159
|
return this.mediaTimeOfAnchor;
|
|
138
160
|
}
|
|
@@ -200,9 +222,12 @@ export class AudioOutput implements ClockSource {
|
|
|
200
222
|
const node = this.ctx.createBufferSource();
|
|
201
223
|
node.buffer = buffer;
|
|
202
224
|
node.connect(this.gain);
|
|
225
|
+
// Pitch the audio to match the playback rate (same as native <video>).
|
|
226
|
+
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
203
227
|
|
|
204
|
-
// Convert media time → ctx time using the anchor.
|
|
205
|
-
|
|
228
|
+
// Convert media time → ctx time using the anchor + rate. At rate=2,
|
|
229
|
+
// each second of media time occupies 0.5s of ctx time.
|
|
230
|
+
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
206
231
|
|
|
207
232
|
// When the decoder is slower than realtime, `ctxStart` falls into
|
|
208
233
|
// the past (ctx.currentTime has already passed it). Clamping each
|
|
@@ -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",
|
|
@@ -128,6 +128,17 @@ export async function createFallbackSession(
|
|
|
128
128
|
get: () => ctx.duration ?? NaN,
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
|
+
// Playback rate — canvas strategies don't use the real <video>, so the
|
|
132
|
+
// native playbackRate property does nothing. Patch it to drive the
|
|
133
|
+
// AudioOutput clock speed + pitch.
|
|
134
|
+
Object.defineProperty(target, "playbackRate", {
|
|
135
|
+
configurable: true,
|
|
136
|
+
get: () => audio.getPlaybackRate(),
|
|
137
|
+
set: (v: number) => {
|
|
138
|
+
audio.setPlaybackRate(v);
|
|
139
|
+
target.dispatchEvent(new Event("ratechange"));
|
|
140
|
+
},
|
|
141
|
+
});
|
|
131
142
|
// Synthesize HTMLMediaElement parity surfaces that the canvas strategies
|
|
132
143
|
// can't otherwise answer truthfully (the inner <video> has no src, so
|
|
133
144
|
// its own readyState/seekable are zero/empty).
|
|
@@ -152,6 +163,18 @@ export async function createFallbackSession(
|
|
|
152
163
|
? [[0, ctx.duration]]
|
|
153
164
|
: []),
|
|
154
165
|
});
|
|
166
|
+
// buffered: demuxer's read-ahead frontier (highest pts pumped from
|
|
167
|
+
// libav). Single [0, end] range — approximation of "how far we've
|
|
168
|
+
// read through the source," the signal the seek-bar buffered
|
|
169
|
+
// indicator wants. Real MSE-style per-range tracking isn't
|
|
170
|
+
// meaningful here since decoded frames are consumed in flight.
|
|
171
|
+
Object.defineProperty(target, "buffered", {
|
|
172
|
+
configurable: true,
|
|
173
|
+
get: () => {
|
|
174
|
+
const end = handles.bufferedUntilSec();
|
|
175
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
155
178
|
|
|
156
179
|
/**
|
|
157
180
|
* Wait until the decoder has produced enough buffered output to start
|
|
@@ -240,6 +263,11 @@ export async function createFallbackSession(
|
|
|
240
263
|
|
|
241
264
|
async function doSeek(timeSec: number): Promise<void> {
|
|
242
265
|
const wasPlaying = audio.isPlaying();
|
|
266
|
+
// HTMLMediaElement contract: dispatch `seeking` once the seek
|
|
267
|
+
// operation begins. The inner <video> never fires this itself on
|
|
268
|
+
// canvas strategies (no src), so we dispatch manually to preserve
|
|
269
|
+
// the contract for consumers listening via `<avbridge-video>`.
|
|
270
|
+
target.dispatchEvent(new Event("seeking"));
|
|
243
271
|
// 1. Stop audio (suspend ctx + capture media time).
|
|
244
272
|
await audio.pause().catch(() => {});
|
|
245
273
|
// 2. Tell the decoder to cancel its pump and seek the demuxer.
|
|
@@ -256,8 +284,21 @@ export async function createFallbackSession(
|
|
|
256
284
|
await waitForBuffer();
|
|
257
285
|
await audio.start();
|
|
258
286
|
}
|
|
287
|
+
// HTMLMediaElement contract: dispatch `seeked` after the seek has
|
|
288
|
+
// completed (demuxer + renderer reset + optional buffer refill).
|
|
289
|
+
target.dispatchEvent(new Event("seeked"));
|
|
259
290
|
}
|
|
260
291
|
|
|
292
|
+
// HTMLMediaElement contract: dispatch `loadedmetadata` once the
|
|
293
|
+
// session is ready (duration, dimensions, tracks known via the
|
|
294
|
+
// MediaContext). Dispatched on a microtask so it lands after the
|
|
295
|
+
// session promise resolves and consumers have a chance to attach
|
|
296
|
+
// listeners. The inner <video> never fires this itself here — it
|
|
297
|
+
// has no src.
|
|
298
|
+
queueMicrotask(() => {
|
|
299
|
+
try { target.dispatchEvent(new Event("loadedmetadata")); } catch { /* element torn down */ }
|
|
300
|
+
});
|
|
301
|
+
|
|
261
302
|
return {
|
|
262
303
|
strategy: "fallback",
|
|
263
304
|
|
|
@@ -321,6 +362,7 @@ export async function createFallbackSession(
|
|
|
321
362
|
delete (target as unknown as Record<string, unknown>).muted;
|
|
322
363
|
delete (target as unknown as Record<string, unknown>).readyState;
|
|
323
364
|
delete (target as unknown as Record<string, unknown>).seekable;
|
|
365
|
+
delete (target as unknown as Record<string, unknown>).playbackRate;
|
|
324
366
|
} catch { /* ignore */ }
|
|
325
367
|
},
|
|
326
368
|
|
|
@@ -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",
|
|
@@ -84,6 +84,14 @@ export async function createHybridSession(
|
|
|
84
84
|
get: () => ctx.duration ?? NaN,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
+
Object.defineProperty(target, "playbackRate", {
|
|
88
|
+
configurable: true,
|
|
89
|
+
get: () => audio.getPlaybackRate(),
|
|
90
|
+
set: (v: number) => {
|
|
91
|
+
audio.setPlaybackRate(v);
|
|
92
|
+
target.dispatchEvent(new Event("ratechange"));
|
|
93
|
+
},
|
|
94
|
+
});
|
|
87
95
|
// HTMLMediaElement parity surfaces — see fallback/index.ts for rationale.
|
|
88
96
|
Object.defineProperty(target, "readyState", {
|
|
89
97
|
configurable: true,
|
|
@@ -99,6 +107,13 @@ export async function createHybridSession(
|
|
|
99
107
|
? [[0, ctx.duration]]
|
|
100
108
|
: []),
|
|
101
109
|
});
|
|
110
|
+
Object.defineProperty(target, "buffered", {
|
|
111
|
+
configurable: true,
|
|
112
|
+
get: () => {
|
|
113
|
+
const end = handles.bufferedUntilSec();
|
|
114
|
+
return makeTimeRanges(end > 0 ? [[0, end]] : []);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
102
117
|
|
|
103
118
|
async function waitForBuffer(): Promise<void> {
|
|
104
119
|
const start = performance.now();
|
|
@@ -114,6 +129,8 @@ export async function createHybridSession(
|
|
|
114
129
|
|
|
115
130
|
async function doSeek(timeSec: number): Promise<void> {
|
|
116
131
|
const wasPlaying = audio.isPlaying();
|
|
132
|
+
// HTMLMediaElement contract — see fallback/index.ts for the why.
|
|
133
|
+
target.dispatchEvent(new Event("seeking"));
|
|
117
134
|
await audio.pause().catch(() => {});
|
|
118
135
|
await handles.seek(timeSec).catch((err) =>
|
|
119
136
|
console.warn("[avbridge] hybrid decoder seek failed:", err),
|
|
@@ -124,8 +141,16 @@ export async function createHybridSession(
|
|
|
124
141
|
await waitForBuffer();
|
|
125
142
|
await audio.start();
|
|
126
143
|
}
|
|
144
|
+
target.dispatchEvent(new Event("seeked"));
|
|
127
145
|
}
|
|
128
146
|
|
|
147
|
+
// HTMLMediaElement contract: `loadedmetadata` once the session is
|
|
148
|
+
// ready. The inner <video> never fires this itself on the hybrid
|
|
149
|
+
// path — it has no src.
|
|
150
|
+
queueMicrotask(() => {
|
|
151
|
+
try { target.dispatchEvent(new Event("loadedmetadata")); } catch { /* element torn down */ }
|
|
152
|
+
});
|
|
153
|
+
|
|
129
154
|
// Store the fatal error handler so the player can wire escalation
|
|
130
155
|
let fatalErrorHandler: ((reason: string) => void) | null = null;
|
|
131
156
|
handles.onFatalError((reason) => fatalErrorHandler?.(reason));
|
|
@@ -196,6 +221,7 @@ export async function createHybridSession(
|
|
|
196
221
|
delete (target as unknown as Record<string, unknown>).muted;
|
|
197
222
|
delete (target as unknown as Record<string, unknown>).readyState;
|
|
198
223
|
delete (target as unknown as Record<string, unknown>).seekable;
|
|
224
|
+
delete (target as unknown as Record<string, unknown>).playbackRate;
|
|
199
225
|
} catch { /* ignore */ }
|
|
200
226
|
},
|
|
201
227
|
|
|
@@ -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. */
|
|
@@ -459,3 +465,28 @@ export interface ConvertResult {
|
|
|
459
465
|
*/
|
|
460
466
|
notes?: string[];
|
|
461
467
|
}
|
|
468
|
+
|
|
469
|
+
// ── Settings extensibility ──────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Configuration for a custom settings section added to `<avbridge-player>`
|
|
473
|
+
* via {@link addSettingsSection}. Sections render in the bottom-sheet
|
|
474
|
+
* settings panel alongside built-in sections (Speed, Audio, Subtitles,
|
|
475
|
+
* Fit, Stats for Nerds). The player owns rendering — consumers describe
|
|
476
|
+
* data; avbridge renders it in a consistent visual style.
|
|
477
|
+
*/
|
|
478
|
+
export interface SettingsSectionConfig {
|
|
479
|
+
/** Unique id for this section. Used to update/remove later. */
|
|
480
|
+
id: string;
|
|
481
|
+
/** Display label (e.g. "Quality", "Translate"). */
|
|
482
|
+
label: string;
|
|
483
|
+
/** Items to show when the section is expanded. */
|
|
484
|
+
items: Array<{
|
|
485
|
+
id: string;
|
|
486
|
+
label: string;
|
|
487
|
+
/** Mark the currently-selected item. */
|
|
488
|
+
active?: boolean;
|
|
489
|
+
}>;
|
|
490
|
+
/** Called when the user picks an item. */
|
|
491
|
+
onSelect(itemId: string): void;
|
|
492
|
+
}
|