avbridge 2.8.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +165 -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-IUSFLVLJ.cjs → chunk-EY6DZEDT.cjs} +149 -24
  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-JSQOBUQB.js → chunk-SN4WZE24.js} +139 -14
  26. package/dist/chunk-SN4WZE24.js.map +1 -0
  27. package/dist/element-browser.js +164 -16
  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-DXEKOky8.d.cts → player-DEcidWk6.d.cts} +8 -1
  44. package/dist/{player-DXEKOky8.d.ts → player-DEcidWk6.d.ts} +8 -1
  45. package/dist/player.cjs +266 -36
  46. package/dist/player.cjs.map +1 -1
  47. package/dist/player.d.cts +37 -11
  48. package/dist/player.d.ts +37 -11
  49. package/dist/player.js +266 -36
  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 +11 -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/player.ts +96 -8
  61. package/src/probe/avi.ts +2 -0
  62. package/src/strategies/fallback/decoder.ts +30 -0
  63. package/src/strategies/fallback/index.ts +30 -0
  64. package/src/strategies/hybrid/decoder.ts +35 -0
  65. package/src/strategies/hybrid/index.ts +17 -0
  66. package/src/strategies/remux/index.ts +8 -0
  67. package/src/types.ts +6 -0
  68. package/src/util/libav-demux.ts +26 -0
  69. package/dist/avi-2JPBSHGA.js.map +0 -1
  70. package/dist/avi-F6WZJK5T.cjs.map +0 -1
  71. package/dist/avi-NJXAXUXK.js.map +0 -1
  72. package/dist/avi-W6L3BTWU.cjs.map +0 -1
  73. package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
  74. package/dist/chunk-IUSFLVLJ.cjs.map +0 -1
  75. package/dist/chunk-JSQOBUQB.js.map +0 -1
  76. package/dist/chunk-X2K3GIWE.js.map +0 -1
  77. package/dist/libav-demux-H2GS46GH.cjs +0 -27
  78. 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.3",
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",
@@ -4,6 +4,7 @@ import type {
4
4
  Classification,
5
5
  ContainerKind,
6
6
  MediaContext,
7
+ StrategyName,
7
8
  VideoCodec,
8
9
  VideoTrackInfo,
9
10
  } from "../types.js";
@@ -30,6 +31,8 @@ export const FALLBACK_VIDEO_CODECS = new Set<VideoCodec>([
30
31
  "wmv3", "vc1", "mpeg4",
31
32
  "rv10", "rv20", "rv30", "rv40",
32
33
  "mpeg2", "mpeg1", "theora",
34
+ "dv", "hq_hqa",
35
+ "rawvideo", "qtrle", "png", "vp6f",
33
36
  ]);
34
37
  export const FALLBACK_AUDIO_CODECS = new Set<AudioCodec>([
35
38
  "wmav2", "wmapro", "ac3", "eac3",
@@ -218,10 +221,18 @@ export function classifyContext(ctx: MediaContext): Classification {
218
221
  reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable — falling back to WASM decode`,
219
222
  };
220
223
  }
224
+ // Give REMUX_CANDIDATE a fallback chain so the runtime stall / decode
225
+ // supervisors have somewhere to escalate to when MSE lies about codec
226
+ // support (the Firefox HEVC case — audio plays, video never paints).
227
+ // The initial pick is still remux; these only engage on stall.
228
+ const fallbackChain: StrategyName[] = webCodecsAvailable()
229
+ ? ["hybrid", "fallback"]
230
+ : ["fallback"];
221
231
  return {
222
232
  class: "REMUX_CANDIDATE",
223
233
  strategy: "remux",
224
234
  reason: `${ctx.container} container with native-supported codecs — remux to fragmented MP4 for reliable playback`,
235
+ fallbackChain,
225
236
  };
226
237
  }
227
238
 
@@ -167,6 +167,7 @@ export class AvbridgePlayerElement extends HTMLElement {
167
167
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
168
168
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
169
169
  </div>
170
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
170
171
  <div part="overlay" class="avp-overlay">
171
172
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
172
173
  <div class="avp-spinner"></div>
@@ -441,21 +442,25 @@ export class AvbridgePlayerElement extends HTMLElement {
441
442
  this._userSeeking = true;
442
443
  const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
443
444
  seekBar.setPointerCapture(e.pointerId);
445
+ seekBar.setAttribute("data-seeking", "");
444
446
 
445
447
  const initial = this._timeFromSeekPointer(e.clientX);
446
448
  this._seekInput.value = String(initial);
447
449
  this._onSeekInput();
450
+ this._updateSeekTooltip(e.clientX);
448
451
 
449
452
  const onMove = (ev: PointerEvent) => {
450
453
  const t = this._timeFromSeekPointer(ev.clientX);
451
454
  this._seekInput.value = String(t);
452
455
  this._onSeekInput();
456
+ this._updateSeekTooltip(ev.clientX);
453
457
  };
454
458
  const onUp = (ev: PointerEvent) => {
455
459
  const t = this._timeFromSeekPointer(ev.clientX);
456
460
  this._seekInput.value = String(t);
457
461
  this._onSeekCommit();
458
- this._seekInput.focus(); // 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/player.ts CHANGED
@@ -20,6 +20,62 @@ import type {
20
20
  } from "./types.js";
21
21
  import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
22
22
 
23
+ /**
24
+ * Decoded-video-frame counter reader. Prefers the standard
25
+ * `getVideoPlaybackQuality().totalVideoFrames` (all evergreen browsers);
26
+ * falls back to the WebKit-prefixed `webkitDecodedFrameCount` for older
27
+ * Safari. Returns 0 for non-video elements or when nothing exposes the
28
+ * count — the caller treats 0 as "no signal" (constant across samples,
29
+ * which is fine).
30
+ */
31
+ export function readDecodedFrameCount(target: HTMLMediaElement): number {
32
+ if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
33
+ const vq = (target as HTMLVideoElement & { getVideoPlaybackQuality?: () => { totalVideoFrames: number } }).getVideoPlaybackQuality;
34
+ if (typeof vq === "function") {
35
+ try { return vq.call(target).totalVideoFrames; } catch { /* fall through */ }
36
+ }
37
+ const legacy = (target as HTMLVideoElement & { webkitDecodedFrameCount?: number }).webkitDecodedFrameCount;
38
+ return typeof legacy === "number" ? legacy : 0;
39
+ }
40
+
41
+ /**
42
+ * Pure decision function for the stall supervisor. Takes a snapshot of
43
+ * the observable state and returns whether to escalate. Extracted so it
44
+ * can be unit-tested without spinning up a real player / media element.
45
+ *
46
+ * - `time-stall`: `currentTime` hasn't moved for `timeStallThresholdMs`
47
+ * despite the element being in a state where it should be playing.
48
+ * - `silent-video`: the media has a video track, `currentTime` is
49
+ * advancing (audio is playing), but the decoder has produced no new
50
+ * frames for `frameStallThresholdMs`. Catches Firefox-style "MSE
51
+ * reports codec supported but the decoder can't actually decode it".
52
+ */
53
+ export function evaluateDecodeHealth(input: {
54
+ hasVideoTrack: boolean;
55
+ timeAdvanced: boolean;
56
+ framesAdvanced: boolean;
57
+ now: number;
58
+ lastProgressTime: number;
59
+ lastFrameProgressTime: number;
60
+ timeStallThresholdMs?: number;
61
+ frameStallThresholdMs?: number;
62
+ }): { escalate: false } | { escalate: true; kind: "time-stall" | "silent-video" } {
63
+ const timeThreshold = input.timeStallThresholdMs ?? 5000;
64
+ const frameThreshold = input.frameStallThresholdMs ?? 3000;
65
+ if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
66
+ return { escalate: true, kind: "time-stall" };
67
+ }
68
+ if (
69
+ input.hasVideoTrack &&
70
+ input.timeAdvanced &&
71
+ !input.framesAdvanced &&
72
+ input.now - input.lastFrameProgressTime > frameThreshold
73
+ ) {
74
+ return { escalate: true, kind: "silent-video" };
75
+ }
76
+ return { escalate: false };
77
+ }
78
+
23
79
  export class UnifiedPlayer {
24
80
  private emitter = new TypedEmitter<PlayerEventMap>();
25
81
  private session: PlaybackSession | null = null;
@@ -34,6 +90,13 @@ export class UnifiedPlayer {
34
90
  private stallTimer: ReturnType<typeof setInterval> | null = null;
35
91
  private lastProgressTime = 0;
36
92
  private lastProgressPosition = -1;
93
+ /** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
94
+ * (or `webkitDecodedFrameCount` fallback). Used by the silent-video
95
+ * watchdog — catches cases where `currentTime` advances (audio plays)
96
+ * but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
97
+ * via MSE when the decoder actually can't decode HEVC. */
98
+ private lastVideoFrameCount = 0;
99
+ private lastVideoFrameProgressTime = 0;
37
100
  private errorListener: (() => void) | null = null;
38
101
 
39
102
  // Bound so we can removeEventListener in destroy(); without this the
@@ -351,23 +414,48 @@ export class UnifiedPlayer {
351
414
  // Monitor currentTime progress
352
415
  this.lastProgressPosition = this.options.target.currentTime;
353
416
  this.lastProgressTime = performance.now();
417
+ this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
418
+ this.lastVideoFrameProgressTime = performance.now();
419
+
420
+ const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
354
421
 
355
422
  this.stallTimer = setInterval(() => {
356
423
  const t = this.options.target;
424
+ const now = performance.now();
357
425
  if (t.paused || t.ended || t.readyState < 2) {
358
426
  this.lastProgressPosition = t.currentTime;
359
- this.lastProgressTime = performance.now();
427
+ this.lastProgressTime = now;
428
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
429
+ this.lastVideoFrameProgressTime = now;
360
430
  return;
361
431
  }
362
- if (t.currentTime !== this.lastProgressPosition) {
432
+ const timeAdvanced = t.currentTime !== this.lastProgressPosition;
433
+ const frames = readDecodedFrameCount(t);
434
+ const framesAdvanced = frames > this.lastVideoFrameCount;
435
+
436
+ const health = evaluateDecodeHealth({
437
+ hasVideoTrack,
438
+ timeAdvanced,
439
+ framesAdvanced,
440
+ now,
441
+ lastProgressTime: this.lastProgressTime,
442
+ lastFrameProgressTime: this.lastVideoFrameProgressTime,
443
+ });
444
+
445
+ if (timeAdvanced) {
363
446
  this.lastProgressPosition = t.currentTime;
364
- this.lastProgressTime = performance.now();
365
- return;
447
+ this.lastProgressTime = now;
366
448
  }
367
- if (performance.now() - this.lastProgressTime > 5000) {
368
- void this.escalate(
369
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`,
370
- );
449
+ if (framesAdvanced) {
450
+ this.lastVideoFrameCount = frames;
451
+ this.lastVideoFrameProgressTime = now;
452
+ }
453
+
454
+ if (health.escalate) {
455
+ const reason = health.kind === "time-stall"
456
+ ? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
457
+ : `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s — likely a silent codec failure`;
458
+ void this.escalate(reason);
371
459
  }
372
460
  }, 1000);
373
461
 
package/src/probe/avi.ts CHANGED
@@ -181,6 +181,8 @@ function ffmpegToAvbridgeVideo(name: string): VideoCodec {
181
181
  case "rv20": return "rv20";
182
182
  case "rv30": return "rv30";
183
183
  case "rv40": return "rv40";
184
+ case "dvvideo": return "dv"; // DV / DVCPRO (camcorder, MiniDV)
185
+ case "hq_hqa": return "hq_hqa"; // Canopus HQ / HQA (Grass Valley)
184
186
  default: return name as VideoCodec;
185
187
  }
186
188
  }
@@ -32,6 +32,7 @@ import { dbg } from "../../util/debug.js";
32
32
  import {
33
33
  sanitizeFrameTimestamp,
34
34
  libavFrameToInterleavedFloat32,
35
+ packetPtsSec,
35
36
  } from "../../util/libav-demux.js";
36
37
 
37
38
  export interface DecoderHandles {
@@ -46,6 +47,13 @@ export interface DecoderHandles {
46
47
  */
47
48
  setAudioTrack(trackId: number, timeSec: number): Promise<void>;
48
49
  stats(): Record<string, unknown>;
50
+ /**
51
+ * The demuxer's read-ahead frontier in seconds. See
52
+ * `HybridDecoderHandles.bufferedUntilSec` for the full contract —
53
+ * same semantics, same consumer (`<video>.buffered` on canvas
54
+ * strategies).
55
+ */
56
+ bufferedUntilSec(): number;
49
57
  }
50
58
 
51
59
  export interface StartDecoderOptions {
@@ -222,6 +230,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
222
230
 
223
231
  let packetsRead = 0;
224
232
  let videoFramesDecoded = 0;
233
+ let bufferedUntilSec = 0;
225
234
  let audioFramesDecoded = 0;
226
235
 
227
236
  // Decode-rate watchdog. Samples framesDecoded every second and
@@ -278,6 +287,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
278
287
  const videoPackets = videoStream ? packets[videoStream.index] : undefined;
279
288
  const audioPackets = audioStream ? packets[audioStream.index] : undefined;
280
289
 
290
+ // Track demuxer read-ahead for <video>.buffered on this strategy.
291
+ // Peek raw pts before sanitizePacketTimestamp (which would
292
+ // clobber to µs and lose the source-native scale). Monotonic;
293
+ // seeks don't reset.
294
+ if (videoPackets && videoTimeBase) {
295
+ for (const pkt of videoPackets) {
296
+ const sec = packetPtsSec(pkt, videoTimeBase);
297
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
298
+ }
299
+ }
300
+ if (audioPackets && audioTimeBase) {
301
+ for (const pkt of audioPackets) {
302
+ const sec = packetPtsSec(pkt, audioTimeBase);
303
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
304
+ }
305
+ }
306
+
281
307
  // Decode audio BEFORE video. On software-decode-bound content
282
308
  // (rv40/mpeg4/wmv3 @ 720p+) a single video batch can take
283
309
  // 200-400 ms of wall time; if the scheduler hasn't been fed
@@ -617,6 +643,10 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
617
643
  );
618
644
  },
619
645
 
646
+ bufferedUntilSec() {
647
+ return bufferedUntilSec;
648
+ },
649
+
620
650
  stats() {
621
651
  return {
622
652
  decoderType: "libav-wasm",
@@ -152,6 +152,18 @@ export async function createFallbackSession(
152
152
  ? [[0, ctx.duration]]
153
153
  : []),
154
154
  });
155
+ // buffered: demuxer's read-ahead frontier (highest pts pumped from
156
+ // libav). Single [0, end] range — approximation of "how far we've
157
+ // read through the source," the signal the seek-bar buffered
158
+ // indicator wants. Real MSE-style per-range tracking isn't
159
+ // meaningful here since decoded frames are consumed in flight.
160
+ Object.defineProperty(target, "buffered", {
161
+ configurable: true,
162
+ get: () => {
163
+ const end = handles.bufferedUntilSec();
164
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
165
+ },
166
+ });
155
167
 
156
168
  /**
157
169
  * Wait until the decoder has produced enough buffered output to start
@@ -240,6 +252,11 @@ export async function createFallbackSession(
240
252
 
241
253
  async function doSeek(timeSec: number): Promise<void> {
242
254
  const wasPlaying = audio.isPlaying();
255
+ // HTMLMediaElement contract: dispatch `seeking` once the seek
256
+ // operation begins. The inner <video> never fires this itself on
257
+ // canvas strategies (no src), so we dispatch manually to preserve
258
+ // the contract for consumers listening via `<avbridge-video>`.
259
+ target.dispatchEvent(new Event("seeking"));
243
260
  // 1. Stop audio (suspend ctx + capture media time).
244
261
  await audio.pause().catch(() => {});
245
262
  // 2. Tell the decoder to cancel its pump and seek the demuxer.
@@ -256,8 +273,21 @@ export async function createFallbackSession(
256
273
  await waitForBuffer();
257
274
  await audio.start();
258
275
  }
276
+ // HTMLMediaElement contract: dispatch `seeked` after the seek has
277
+ // completed (demuxer + renderer reset + optional buffer refill).
278
+ target.dispatchEvent(new Event("seeked"));
259
279
  }
260
280
 
281
+ // HTMLMediaElement contract: dispatch `loadedmetadata` once the
282
+ // session is ready (duration, dimensions, tracks known via the
283
+ // MediaContext). Dispatched on a microtask so it lands after the
284
+ // session promise resolves and consumers have a chance to attach
285
+ // listeners. The inner <video> never fires this itself here — it
286
+ // has no src.
287
+ queueMicrotask(() => {
288
+ try { target.dispatchEvent(new Event("loadedmetadata")); } catch { /* element torn down */ }
289
+ });
290
+
261
291
  return {
262
292
  strategy: "fallback",
263
293