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.
Files changed (77) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +74 -1
  3. package/dist/{avi-F6WZJK5T.cjs → avi-2ILLBNPQ.cjs} +8 -2
  4. package/dist/avi-2ILLBNPQ.cjs.map +1 -0
  5. package/dist/{avi-W6L3BTWU.cjs → avi-B5CQYB7L.cjs} +8 -2
  6. package/dist/avi-B5CQYB7L.cjs.map +1 -0
  7. package/dist/{avi-2JPBSHGA.js → avi-JXU4GQL2.js} +8 -2
  8. package/dist/avi-JXU4GQL2.js.map +1 -0
  9. package/dist/{avi-NJXAXUXK.js → avi-RWWPN2PR.js} +8 -2
  10. package/dist/avi-RWWPN2PR.js.map +1 -0
  11. package/dist/{chunk-X2K3GIWE.js → chunk-2NSOOMXW.js} +14 -3
  12. package/dist/chunk-2NSOOMXW.js.map +1 -0
  13. package/dist/{chunk-ZCUXHW55.cjs → chunk-BYGZN4Z5.cjs} +5 -5
  14. package/dist/{chunk-ZCUXHW55.cjs.map → chunk-BYGZN4Z5.cjs.map} +1 -1
  15. package/dist/{chunk-SMH6IOP2.js → chunk-CL6UEUQF.js} +4 -4
  16. package/dist/{chunk-SMH6IOP2.js.map → chunk-CL6UEUQF.js.map} +1 -1
  17. package/dist/{chunk-YX4AGLNF.cjs → chunk-EY6DZEDT.cjs} +89 -15
  18. package/dist/chunk-EY6DZEDT.cjs.map +1 -0
  19. package/dist/{chunk-SR3MPV4D.js → chunk-GYIJU44C.js} +5 -5
  20. package/dist/{chunk-SR3MPV4D.js.map → chunk-GYIJU44C.js.map} +1 -1
  21. package/dist/{chunk-CPZ7PXAM.cjs → chunk-L7A3ECI2.cjs} +14 -2
  22. package/dist/chunk-L7A3ECI2.cjs.map +1 -0
  23. package/dist/{chunk-Q2VUO52Z.cjs → chunk-OTFS7DC4.cjs} +12 -12
  24. package/dist/{chunk-Q2VUO52Z.cjs.map → chunk-OTFS7DC4.cjs.map} +1 -1
  25. package/dist/{chunk-KBWQRGHS.js → chunk-SN4WZE24.js} +79 -5
  26. package/dist/chunk-SN4WZE24.js.map +1 -0
  27. package/dist/element-browser.js +104 -7
  28. package/dist/element-browser.js.map +1 -1
  29. package/dist/element.cjs +16 -10
  30. package/dist/element.cjs.map +1 -1
  31. package/dist/element.d.cts +11 -6
  32. package/dist/element.d.ts +11 -6
  33. package/dist/element.js +15 -9
  34. package/dist/element.js.map +1 -1
  35. package/dist/index.cjs +20 -20
  36. package/dist/index.d.cts +2 -2
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.js +8 -8
  39. package/dist/libav-demux-3N5Y3VQA.cjs +31 -0
  40. package/dist/{libav-demux-H2GS46GH.cjs.map → libav-demux-3N5Y3VQA.cjs.map} +1 -1
  41. package/dist/libav-demux-JXD4OTLM.js +6 -0
  42. package/dist/{libav-demux-OWZ4T2YW.js.map → libav-demux-JXD4OTLM.js.map} +1 -1
  43. package/dist/{player-BptSJPfn.d.cts → player-DEcidWk6.d.cts} +1 -1
  44. package/dist/{player-BptSJPfn.d.ts → player-DEcidWk6.d.ts} +1 -1
  45. package/dist/player.cjs +187 -23
  46. package/dist/player.cjs.map +1 -1
  47. package/dist/player.d.cts +17 -11
  48. package/dist/player.d.ts +17 -11
  49. package/dist/player.js +187 -23
  50. package/dist/player.js.map +1 -1
  51. package/dist/{remux-WBYIZBBX.js → remux-56V7LDAD.js} +5 -5
  52. package/dist/{remux-WBYIZBBX.js.map → remux-56V7LDAD.js.map} +1 -1
  53. package/dist/{remux-OBSMIENG.cjs → remux-KUS5GIL6.cjs} +10 -10
  54. package/dist/{remux-OBSMIENG.cjs.map → remux-KUS5GIL6.cjs.map} +1 -1
  55. package/package.json +1 -1
  56. package/src/classify/rules.ts +2 -0
  57. package/src/element/avbridge-player.ts +22 -11
  58. package/src/element/avbridge-video.ts +22 -6
  59. package/src/element/player-styles.ts +68 -3
  60. package/src/probe/avi.ts +2 -0
  61. package/src/strategies/fallback/decoder.ts +30 -0
  62. package/src/strategies/fallback/index.ts +30 -0
  63. package/src/strategies/hybrid/decoder.ts +35 -0
  64. package/src/strategies/hybrid/index.ts +17 -0
  65. package/src/strategies/remux/index.ts +8 -0
  66. package/src/types.ts +6 -0
  67. package/src/util/libav-demux.ts +26 -0
  68. package/dist/avi-2JPBSHGA.js.map +0 -1
  69. package/dist/avi-F6WZJK5T.cjs.map +0 -1
  70. package/dist/avi-NJXAXUXK.js.map +0 -1
  71. package/dist/avi-W6L3BTWU.cjs.map +0 -1
  72. package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
  73. package/dist/chunk-KBWQRGHS.js.map +0 -1
  74. package/dist/chunk-X2K3GIWE.js.map +0 -1
  75. package/dist/chunk-YX4AGLNF.cjs.map +0 -1
  76. package/dist/libav-demux-H2GS46GH.cjs +0 -27
  77. package/dist/libav-demux-OWZ4T2YW.js +0 -6
@@ -1,10 +1,10 @@
1
- export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-SMH6IOP2.js';
2
- import './chunk-SR3MPV4D.js';
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-X2K3GIWE.js';
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-WBYIZBBX.js.map
10
- //# sourceMappingURL=remux-WBYIZBBX.js.map
9
+ //# sourceMappingURL=remux-56V7LDAD.js.map
10
+ //# sourceMappingURL=remux-56V7LDAD.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-WBYIZBBX.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-56V7LDAD.js"}
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- var chunkQ2VUO52Z_cjs = require('./chunk-Q2VUO52Z.cjs');
4
- require('./chunk-ZCUXHW55.cjs');
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-CPZ7PXAM.cjs');
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 chunkQ2VUO52Z_cjs.createOutputFormat; }
16
+ get: function () { return chunkOTFS7DC4_cjs.createOutputFormat; }
17
17
  });
18
18
  Object.defineProperty(exports, "generateFilename", {
19
19
  enumerable: true,
20
- get: function () { return chunkQ2VUO52Z_cjs.generateFilename; }
20
+ get: function () { return chunkOTFS7DC4_cjs.generateFilename; }
21
21
  });
22
22
  Object.defineProperty(exports, "mimeForFormat", {
23
23
  enumerable: true,
24
- get: function () { return chunkQ2VUO52Z_cjs.mimeForFormat; }
24
+ get: function () { return chunkOTFS7DC4_cjs.mimeForFormat; }
25
25
  });
26
26
  Object.defineProperty(exports, "remux", {
27
27
  enumerable: true,
28
- get: function () { return chunkQ2VUO52Z_cjs.remux; }
28
+ get: function () { return chunkOTFS7DC4_cjs.remux; }
29
29
  });
30
30
  Object.defineProperty(exports, "validateRemuxEligibility", {
31
31
  enumerable: true,
32
- get: function () { return chunkQ2VUO52Z_cjs.validateRemuxEligibility; }
32
+ get: function () { return chunkOTFS7DC4_cjs.validateRemuxEligibility; }
33
33
  });
34
- //# sourceMappingURL=remux-OBSMIENG.cjs.map
35
- //# sourceMappingURL=remux-OBSMIENG.cjs.map
34
+ //# sourceMappingURL=remux-KUS5GIL6.cjs.map
35
+ //# sourceMappingURL=remux-KUS5GIL6.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-OBSMIENG.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-KUS5GIL6.cjs"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.8.4",
3
+ "version": "2.9.0",
4
4
  "description": "Play and convert arbitrary video files in the browser. Native, remux, hybrid, fallback, and transcode — one API.",
5
5
  "license": "MIT",
6
6
  "author": "Keishi Hattori",
@@ -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(); // keep keyboard nav responsive
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, (e.clientX - rect.left) / rect.width));
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 toolbar
731
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
732
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
733
- * does. */
734
- private _isToolbarEvent(e: Event): boolean {
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 && node.classList.contains("avp-toolbar-top")) return true;
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._isToolbarEvent(e)) return;
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._isToolbarEvent(e)) return;
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._isToolbarEvent(e)) return;
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
- return this.hasAttribute("muted");
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. For the native and remux strategies
687
- * this reflects the underlying SourceBuffer / progressive download state.
688
- * For the hybrid and fallback (canvas-rendered) strategies it currently
689
- * returns an empty TimeRanges; a future release will synthesize a coarse
690
- * range from the decoder's read position.
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
- .avp-toolbar-top-right { margin-left: auto; }
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
- max-height: 300px;
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 { transform: translate(-50%, -50%) scale(1); }
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. */
@@ -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).