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.
Files changed (79) hide show
  1. package/CHANGELOG.md +164 -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-KBWQRGHS.js → chunk-3GKM5DFM.js} +119 -8
  14. package/dist/chunk-3GKM5DFM.js.map +1 -0
  15. package/dist/{chunk-ZCUXHW55.cjs → chunk-BYGZN4Z5.cjs} +5 -5
  16. package/dist/{chunk-ZCUXHW55.cjs.map → chunk-BYGZN4Z5.cjs.map} +1 -1
  17. package/dist/{chunk-SMH6IOP2.js → chunk-CL6UEUQF.js} +4 -4
  18. package/dist/{chunk-SMH6IOP2.js.map → chunk-CL6UEUQF.js.map} +1 -1
  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-YX4AGLNF.cjs → chunk-NQULEIA3.cjs} +129 -18
  24. package/dist/chunk-NQULEIA3.cjs.map +1 -0
  25. package/dist/{chunk-Q2VUO52Z.cjs → chunk-OTFS7DC4.cjs} +12 -12
  26. package/dist/{chunk-Q2VUO52Z.cjs.map → chunk-OTFS7DC4.cjs.map} +1 -1
  27. package/dist/element-browser.js +144 -10
  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-DDdNVFDv.d.cts} +24 -2
  44. package/dist/{player-BptSJPfn.d.ts → player-DDdNVFDv.d.ts} +24 -2
  45. package/dist/player.cjs +413 -117
  46. package/dist/player.cjs.map +1 -1
  47. package/dist/player.d.cts +44 -11
  48. package/dist/player.d.ts +44 -11
  49. package/dist/player.js +413 -117
  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 +172 -86
  58. package/src/element/avbridge-video.ts +22 -6
  59. package/src/element/player-styles.ts +149 -34
  60. package/src/index.ts +1 -0
  61. package/src/probe/avi.ts +2 -0
  62. package/src/strategies/fallback/audio-output.ts +29 -4
  63. package/src/strategies/fallback/decoder.ts +30 -0
  64. package/src/strategies/fallback/index.ts +42 -0
  65. package/src/strategies/hybrid/decoder.ts +35 -0
  66. package/src/strategies/hybrid/index.ts +26 -0
  67. package/src/strategies/remux/index.ts +8 -0
  68. package/src/types.ts +31 -0
  69. package/src/util/libav-demux.ts +26 -0
  70. package/dist/avi-2JPBSHGA.js.map +0 -1
  71. package/dist/avi-F6WZJK5T.cjs.map +0 -1
  72. package/dist/avi-NJXAXUXK.js.map +0 -1
  73. package/dist/avi-W6L3BTWU.cjs.map +0 -1
  74. package/dist/chunk-CPZ7PXAM.cjs.map +0 -1
  75. package/dist/chunk-KBWQRGHS.js.map +0 -1
  76. package/dist/chunk-X2K3GIWE.js.map +0 -1
  77. package/dist/chunk-YX4AGLNF.cjs.map +0 -1
  78. package/dist/libav-demux-H2GS46GH.cjs +0 -27
  79. 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
- cursor: pointer;
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
- .avp-toolbar-top-right { margin-left: auto; }
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 menu ────────────────────────────────────────────────────── */
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: 52px;
445
- right: 12px;
446
- background: rgba(28, 28, 28, 0.95);
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
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
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 { display: block; }
524
+ .avp-settings.open {
525
+ transform: translateY(0);
526
+ }
457
527
 
458
- .avp-settings-section {
459
- padding: 8px 0;
460
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
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
- .avp-settings-section:last-child { border-bottom: none; }
537
+ /* ── Accordion sections ──────────────────────────────────────────────── */
464
538
 
465
- .avp-settings-label {
466
- padding: 4px 16px;
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-item {
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
- padding: 8px 16px;
477
- font-size: 13px;
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-item:hover { background: rgba(255, 255, 255, 0.1); }
557
+ .avp-settings-header:hover { background: rgba(255, 255, 255, 0.06); }
483
558
 
484
- .avp-settings-item.active {
485
- color: #3ea6ff;
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-item.active::before {
489
- content: "\\2713";
490
- margin-right: 8px;
491
- font-weight: bold;
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 { transform: translate(-50%, -50%) scale(1); }
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
@@ -36,6 +36,7 @@ export type {
36
36
  HardwareAccelerationHint,
37
37
  FetchFn,
38
38
  TransportConfig,
39
+ SettingsSectionConfig,
39
40
  } from "./types.js";
40
41
 
41
42
  export { classify } from "./classify/index.js";
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
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
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
+ }