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
package/dist/player.d.cts CHANGED
@@ -15,7 +15,7 @@ type MediaInput = File | Blob | string | URL | ArrayBuffer | Uint8Array;
15
15
  /** Container format families we know about. */
16
16
  type ContainerKind = "mp4" | "mov" | "mkv" | "webm" | "avi" | "asf" | "flv" | "rm" | "ogg" | "wav" | "mp3" | "flac" | "adts" | "mpegts" | "unknown";
17
17
  /** Video codec families. Strings, not enums, so plugins can extend. */
18
- type VideoCodec = "h264" | "h265" | "vp8" | "vp9" | "av1" | "mpeg4" | "wmv3" | "vc1" | "rv10" | "rv20" | "rv30" | "rv40" | "mpeg2" | "mpeg1" | "theora" | (string & {});
18
+ type VideoCodec = "h264" | "h265" | "vp8" | "vp9" | "av1" | "mpeg4" | "wmv3" | "vc1" | "rv10" | "rv20" | "rv30" | "rv40" | "mpeg2" | "mpeg1" | "theora" | "dv" | "hq_hqa" | "rawvideo" | "qtrle" | "png" | "vp6f" | (string & {});
19
19
  /** Audio codec families. */
20
20
  type AudioCodec = "aac" | "mp3" | "opus" | "vorbis" | "flac" | "pcm" | "ac3" | "eac3" | "wmav2" | "wmapro" | "alac" | "cook" | "ra_144" | "ra_288" | "sipr" | "atrac3" | "dts" | "truehd" | (string & {});
21
21
  interface VideoTrackInfo {
@@ -347,6 +347,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
347
347
  private _timeFromSeekPointer;
348
348
  private _onSeekPointerDown;
349
349
  private _onSeekHover;
350
+ private _updateSeekTooltip;
350
351
  private _updateSeekVisuals;
351
352
  private _updateTime;
352
353
  private _toggleMute;
@@ -375,11 +376,11 @@ declare class AvbridgePlayerElement extends HTMLElement {
375
376
  private _scheduleHide;
376
377
  /** Track whether the last interaction was touch so click handler can skip. */
377
378
  private _lastPointerTypeWasTouch;
378
- /** True if the event's composed path passes through consumer-slotted toolbar
379
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
380
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
381
- * does. */
382
- private _isToolbarEvent;
379
+ /** True if the event's composed path passes through consumer-slotted
380
+ * content (toolbar or content-overlay). Slotted content lives in the
381
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
382
+ * find the shadow-DOM wrapper — `composedPath()` does. */
383
+ private _isSlottedContentEvent;
383
384
  private _onContainerClick;
384
385
  private _onContainerDblClick;
385
386
  private _onPointerDown;
@@ -694,11 +695,16 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
694
695
  get readyState(): number;
695
696
  /**
696
697
  * Buffered time ranges for the active source. Mirrors the standard
697
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
698
- * this reflects the underlying SourceBuffer / progressive download state.
699
- * For the hybrid and fallback (canvas-rendered) strategies it currently
700
- * returns an empty TimeRanges; a future release will synthesize a coarse
701
- * range from the decoder's read position.
698
+ * `<video>.buffered` `TimeRanges` API.
699
+ *
700
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
701
+ * (reflects the browser's SourceBuffer / progressive-download state).
702
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
703
+ * from the demuxer's read progress — "how far libav has ever pumped
704
+ * packets through." Monotonic; does not shrink on seek. This is an
705
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
706
+ * are consumed in flight, so we can't report per-range availability
707
+ * the way MSE does. Enough for a seek-bar buffered indicator.
702
708
  */
703
709
  get buffered(): TimeRanges;
704
710
  get poster(): string;
package/dist/player.d.ts CHANGED
@@ -15,7 +15,7 @@ type MediaInput = File | Blob | string | URL | ArrayBuffer | Uint8Array;
15
15
  /** Container format families we know about. */
16
16
  type ContainerKind = "mp4" | "mov" | "mkv" | "webm" | "avi" | "asf" | "flv" | "rm" | "ogg" | "wav" | "mp3" | "flac" | "adts" | "mpegts" | "unknown";
17
17
  /** Video codec families. Strings, not enums, so plugins can extend. */
18
- type VideoCodec = "h264" | "h265" | "vp8" | "vp9" | "av1" | "mpeg4" | "wmv3" | "vc1" | "rv10" | "rv20" | "rv30" | "rv40" | "mpeg2" | "mpeg1" | "theora" | (string & {});
18
+ type VideoCodec = "h264" | "h265" | "vp8" | "vp9" | "av1" | "mpeg4" | "wmv3" | "vc1" | "rv10" | "rv20" | "rv30" | "rv40" | "mpeg2" | "mpeg1" | "theora" | "dv" | "hq_hqa" | "rawvideo" | "qtrle" | "png" | "vp6f" | (string & {});
19
19
  /** Audio codec families. */
20
20
  type AudioCodec = "aac" | "mp3" | "opus" | "vorbis" | "flac" | "pcm" | "ac3" | "eac3" | "wmav2" | "wmapro" | "alac" | "cook" | "ra_144" | "ra_288" | "sipr" | "atrac3" | "dts" | "truehd" | (string & {});
21
21
  interface VideoTrackInfo {
@@ -347,6 +347,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
347
347
  private _timeFromSeekPointer;
348
348
  private _onSeekPointerDown;
349
349
  private _onSeekHover;
350
+ private _updateSeekTooltip;
350
351
  private _updateSeekVisuals;
351
352
  private _updateTime;
352
353
  private _toggleMute;
@@ -375,11 +376,11 @@ declare class AvbridgePlayerElement extends HTMLElement {
375
376
  private _scheduleHide;
376
377
  /** Track whether the last interaction was touch so click handler can skip. */
377
378
  private _lastPointerTypeWasTouch;
378
- /** True if the event's composed path passes through consumer-slotted toolbar
379
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
380
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
381
- * does. */
382
- private _isToolbarEvent;
379
+ /** True if the event's composed path passes through consumer-slotted
380
+ * content (toolbar or content-overlay). Slotted content lives in the
381
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
382
+ * find the shadow-DOM wrapper — `composedPath()` does. */
383
+ private _isSlottedContentEvent;
383
384
  private _onContainerClick;
384
385
  private _onContainerDblClick;
385
386
  private _onPointerDown;
@@ -694,11 +695,16 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
694
695
  get readyState(): number;
695
696
  /**
696
697
  * Buffered time ranges for the active source. Mirrors the standard
697
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
698
- * this reflects the underlying SourceBuffer / progressive download state.
699
- * For the hybrid and fallback (canvas-rendered) strategies it currently
700
- * returns an empty TimeRanges; a future release will synthesize a coarse
701
- * range from the decoder's read position.
698
+ * `<video>.buffered` `TimeRanges` API.
699
+ *
700
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
701
+ * (reflects the browser's SourceBuffer / progressive-download state).
702
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
703
+ * from the demuxer's read progress — "how far libav has ever pumped
704
+ * packets through." Monotonic; does not shrink on seek. This is an
705
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
706
+ * are consumed in flight, so we can't report per-range availability
707
+ * the way MSE does. Enough for a seek-bar buffered indicator.
702
708
  */
703
709
  get buffered(): TimeRanges;
704
710
  get poster(): string;
package/dist/player.js CHANGED
@@ -237,7 +237,7 @@ async function probe(source, transport) {
237
237
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
238
238
  if (hasUnknownCodec) {
239
239
  try {
240
- const { probeWithLibav } = await import('./avi-2JPBSHGA.js');
240
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
241
241
  return await probeWithLibav(normalized, sniffed);
242
242
  } catch {
243
243
  return result;
@@ -250,7 +250,7 @@ async function probe(source, transport) {
250
250
  mediabunnyErr.message
251
251
  );
252
252
  try {
253
- const { probeWithLibav } = await import('./avi-2JPBSHGA.js');
253
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
254
254
  return await probeWithLibav(normalized, sniffed);
255
255
  } catch (libavErr) {
256
256
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -264,7 +264,7 @@ async function probe(source, transport) {
264
264
  }
265
265
  }
266
266
  try {
267
- const { probeWithLibav } = await import('./avi-2JPBSHGA.js');
267
+ const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
268
268
  return await probeWithLibav(normalized, sniffed);
269
269
  } catch (err) {
270
270
  const inner = err instanceof Error ? err.message : String(err);
@@ -369,7 +369,13 @@ var FALLBACK_VIDEO_CODECS = /* @__PURE__ */ new Set([
369
369
  "rv40",
370
370
  "mpeg2",
371
371
  "mpeg1",
372
- "theora"
372
+ "theora",
373
+ "dv",
374
+ "hq_hqa",
375
+ "rawvideo",
376
+ "qtrle",
377
+ "png",
378
+ "vp6f"
373
379
  ]);
374
380
  var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
375
381
  "wmav2",
@@ -1205,6 +1211,12 @@ async function createRemuxSession(context, video) {
1205
1211
  }
1206
1212
  const wasPlaying = !video.paused;
1207
1213
  await pipeline.seek(time, wasPlaying || wantPlay);
1214
+ queueMicrotask(() => {
1215
+ try {
1216
+ video.dispatchEvent(new Event("seeked"));
1217
+ } catch {
1218
+ }
1219
+ });
1208
1220
  },
1209
1221
  async setAudioTrack(id) {
1210
1222
  if (!context.audioTracks.some((t) => t.id === id)) {
@@ -1841,6 +1853,17 @@ function sanitizePacketTimestamp(pkt, nextUs, fallbackTimeBase) {
1841
1853
  pkt.time_base_num = 1;
1842
1854
  pkt.time_base_den = 1e6;
1843
1855
  }
1856
+ function packetPtsSec(pkt, timeBase) {
1857
+ const lo = pkt.pts ?? 0;
1858
+ const hi = pkt.ptshi ?? 0;
1859
+ const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
1860
+ if (isInvalid) return null;
1861
+ const tb = timeBase ?? [1, 1e6];
1862
+ if (!tb[0] || !tb[1]) return null;
1863
+ const pts64 = hi * 4294967296 + lo;
1864
+ const sec = pts64 * tb[0] / tb[1];
1865
+ return Number.isFinite(sec) ? sec : null;
1866
+ }
1844
1867
  var AV_SAMPLE_FMT_U8 = 0;
1845
1868
  var AV_SAMPLE_FMT_S16 = 1;
1846
1869
  var AV_SAMPLE_FMT_S32 = 2;
@@ -2101,6 +2124,7 @@ async function startHybridDecoder(opts) {
2101
2124
  let videoFramesDecoded = 0;
2102
2125
  let audioFramesDecoded = 0;
2103
2126
  let videoChunksFed = 0;
2127
+ let bufferedUntilSec = 0;
2104
2128
  let syntheticVideoUs = 0;
2105
2129
  let syntheticAudioUs = 0;
2106
2130
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -2121,6 +2145,18 @@ async function startHybridDecoder(opts) {
2121
2145
  if (myToken !== pumpToken || destroyed) return;
2122
2146
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2123
2147
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2148
+ if (videoPackets && videoTimeBase) {
2149
+ for (const pkt of videoPackets) {
2150
+ const sec = packetPtsSec(pkt, videoTimeBase);
2151
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2152
+ }
2153
+ }
2154
+ if (audioPackets && audioTimeBase) {
2155
+ for (const pkt of audioPackets) {
2156
+ const sec = packetPtsSec(pkt, audioTimeBase);
2157
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2158
+ }
2159
+ }
2124
2160
  if (audioDec && audioPackets && audioPackets.length > 0) {
2125
2161
  await decodeAudioBatch(audioPackets, myToken);
2126
2162
  }
@@ -2377,6 +2413,9 @@ async function startHybridDecoder(opts) {
2377
2413
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2378
2414
  );
2379
2415
  },
2416
+ bufferedUntilSec() {
2417
+ return bufferedUntilSec;
2418
+ },
2380
2419
  stats() {
2381
2420
  return {
2382
2421
  decoderType: "webcodecs-hybrid",
@@ -2504,6 +2543,13 @@ async function createHybridSession(ctx, target, transport) {
2504
2543
  configurable: true,
2505
2544
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
2506
2545
  });
2546
+ Object.defineProperty(target, "buffered", {
2547
+ configurable: true,
2548
+ get: () => {
2549
+ const end = handles.bufferedUntilSec();
2550
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
2551
+ }
2552
+ });
2507
2553
  async function waitForBuffer() {
2508
2554
  const start = performance.now();
2509
2555
  while (true) {
@@ -2517,6 +2563,7 @@ async function createHybridSession(ctx, target, transport) {
2517
2563
  }
2518
2564
  async function doSeek(timeSec) {
2519
2565
  const wasPlaying = audio.isPlaying();
2566
+ target.dispatchEvent(new Event("seeking"));
2520
2567
  await audio.pause().catch(() => {
2521
2568
  });
2522
2569
  await handles.seek(timeSec).catch(
@@ -2528,7 +2575,14 @@ async function createHybridSession(ctx, target, transport) {
2528
2575
  await waitForBuffer();
2529
2576
  await audio.start();
2530
2577
  }
2578
+ target.dispatchEvent(new Event("seeked"));
2531
2579
  }
2580
+ queueMicrotask(() => {
2581
+ try {
2582
+ target.dispatchEvent(new Event("loadedmetadata"));
2583
+ } catch {
2584
+ }
2585
+ });
2532
2586
  let fatalErrorHandler = null;
2533
2587
  handles.onFatalError((reason) => fatalErrorHandler?.(reason));
2534
2588
  return {
@@ -2713,6 +2767,7 @@ async function startDecoder(opts) {
2713
2767
  let pumpRunning = null;
2714
2768
  let packetsRead = 0;
2715
2769
  let videoFramesDecoded = 0;
2770
+ let bufferedUntilSec = 0;
2716
2771
  let audioFramesDecoded = 0;
2717
2772
  let watchdogFirstFrameMs = 0;
2718
2773
  let watchdogSlowSinceMs = 0;
@@ -2738,6 +2793,18 @@ async function startDecoder(opts) {
2738
2793
  if (myToken !== pumpToken || destroyed) return;
2739
2794
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2740
2795
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2796
+ if (videoPackets && videoTimeBase) {
2797
+ for (const pkt of videoPackets) {
2798
+ const sec = packetPtsSec(pkt, videoTimeBase);
2799
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2800
+ }
2801
+ }
2802
+ if (audioPackets && audioTimeBase) {
2803
+ for (const pkt of audioPackets) {
2804
+ const sec = packetPtsSec(pkt, audioTimeBase);
2805
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2806
+ }
2807
+ }
2741
2808
  if (audioDec && audioPackets && audioPackets.length > 0) {
2742
2809
  await decodeAudioBatch(audioPackets, myToken);
2743
2810
  }
@@ -3019,6 +3086,9 @@ async function startDecoder(opts) {
3019
3086
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3020
3087
  );
3021
3088
  },
3089
+ bufferedUntilSec() {
3090
+ return bufferedUntilSec;
3091
+ },
3022
3092
  stats() {
3023
3093
  return {
3024
3094
  decoderType: "libav-wasm",
@@ -3118,6 +3188,13 @@ async function createFallbackSession(ctx, target, transport) {
3118
3188
  configurable: true,
3119
3189
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
3120
3190
  });
3191
+ Object.defineProperty(target, "buffered", {
3192
+ configurable: true,
3193
+ get: () => {
3194
+ const end = handles.bufferedUntilSec();
3195
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
3196
+ }
3197
+ });
3121
3198
  async function waitForBuffer() {
3122
3199
  const start = performance.now();
3123
3200
  let firstFrameAtMs = 0;
@@ -3157,6 +3234,7 @@ async function createFallbackSession(ctx, target, transport) {
3157
3234
  }
3158
3235
  async function doSeek(timeSec) {
3159
3236
  const wasPlaying = audio.isPlaying();
3237
+ target.dispatchEvent(new Event("seeking"));
3160
3238
  await audio.pause().catch(() => {
3161
3239
  });
3162
3240
  await handles.seek(timeSec).catch(
@@ -3168,7 +3246,14 @@ async function createFallbackSession(ctx, target, transport) {
3168
3246
  await waitForBuffer();
3169
3247
  await audio.start();
3170
3248
  }
3249
+ target.dispatchEvent(new Event("seeked"));
3171
3250
  }
3251
+ queueMicrotask(() => {
3252
+ try {
3253
+ target.dispatchEvent(new Event("loadedmetadata"));
3254
+ } catch {
3255
+ }
3256
+ });
3172
3257
  return {
3173
3258
  strategy: "fallback",
3174
3259
  async play() {
@@ -4248,9 +4333,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4248
4333
  else this.removeAttribute("autoplay");
4249
4334
  }
4250
4335
  get muted() {
4251
- return this.hasAttribute("muted");
4336
+ return this._videoEl.muted;
4252
4337
  }
4253
4338
  set muted(value) {
4339
+ this._videoEl.muted = value;
4254
4340
  if (value) this.setAttribute("muted", "");
4255
4341
  else this.removeAttribute("muted");
4256
4342
  }
@@ -4315,11 +4401,16 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4315
4401
  }
4316
4402
  /**
4317
4403
  * Buffered time ranges for the active source. Mirrors the standard
4318
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
4319
- * this reflects the underlying SourceBuffer / progressive download state.
4320
- * For the hybrid and fallback (canvas-rendered) strategies it currently
4321
- * returns an empty TimeRanges; a future release will synthesize a coarse
4322
- * range from the decoder's read position.
4404
+ * `<video>.buffered` `TimeRanges` API.
4405
+ *
4406
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
4407
+ * (reflects the browser's SourceBuffer / progressive-download state).
4408
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
4409
+ * from the demuxer's read progress — "how far libav has ever pumped
4410
+ * packets through." Monotonic; does not shrink on seek. This is an
4411
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
4412
+ * are consumed in flight, so we can't report per-range availability
4413
+ * the way MSE does. Enough for a seek-bar buffered indicator.
4323
4414
  */
4324
4415
  get buffered() {
4325
4416
  return this._videoEl.buffered;
@@ -4623,11 +4714,18 @@ var PLAYER_STYLES = (
4623
4714
 
4624
4715
  /* \u2500\u2500 Container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4625
4716
 
4717
+ :host {
4718
+ -webkit-tap-highlight-color: transparent;
4719
+ outline: none;
4720
+ }
4721
+
4626
4722
  .avp {
4627
4723
  position: relative;
4628
4724
  width: 100%;
4629
4725
  height: 100%;
4630
4726
  cursor: pointer;
4727
+ -webkit-tap-highlight-color: transparent;
4728
+ user-select: none;
4631
4729
  }
4632
4730
 
4633
4731
  .avp avbridge-video {
@@ -4826,7 +4924,14 @@ var PLAYER_STYLES = (
4826
4924
  pointer-events: auto;
4827
4925
  }
4828
4926
 
4829
- .avp-toolbar-top-right { margin-left: auto; }
4927
+ /* Left slot fills remaining space so slotted text/content can grow.
4928
+ min-width: 0 prevents flex children from overflowing the toolbar. */
4929
+ .avp-toolbar-top-left {
4930
+ flex: 1;
4931
+ min-width: 0;
4932
+ }
4933
+
4934
+ .avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
4830
4935
 
4831
4936
  /* Hide the gradient band when no consumer has slotted anything \u2014 we
4832
4937
  toggle data-toolbar-empty from JS via slotchange. */
@@ -4839,6 +4944,30 @@ var PLAYER_STYLES = (
4839
4944
  pointer-events: none;
4840
4945
  }
4841
4946
 
4947
+ /* \u2500\u2500 Content overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4948
+ /* Consumer-provided rich content (tweet cards, media info, annotations).
4949
+ Sits above the video, below the play-button overlay and controls in
4950
+ z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
4951
+ so taps fall through to the video; consumers opt in on their content
4952
+ with pointer-events:auto. */
4953
+
4954
+ .avp-content-overlay {
4955
+ position: absolute;
4956
+ inset: 0;
4957
+ z-index: 1;
4958
+ pointer-events: none;
4959
+ opacity: 1;
4960
+ transition: opacity 0.25s;
4961
+ }
4962
+
4963
+ .avp-content-overlay ::slotted(*) {
4964
+ pointer-events: auto;
4965
+ }
4966
+
4967
+ :host([data-controls-hidden]) .avp-content-overlay {
4968
+ opacity: 0;
4969
+ }
4970
+
4842
4971
  /* \u2500\u2500 Seek bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4843
4972
 
4844
4973
  .avp-seek {
@@ -4925,6 +5054,15 @@ var PLAYER_STYLES = (
4925
5054
 
4926
5055
  .avp-seek:hover .avp-seek-tooltip { display: block; }
4927
5056
 
5057
+ /* Show tooltip during active drag (touch or mouse). The JS side sets
5058
+ data-seeking on .avp-seek while the user is scrubbing. */
5059
+ .avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
5060
+
5061
+ /* Enlarge thumb while scrubbing. */
5062
+ .avp-seek[data-seeking] .avp-seek-thumb {
5063
+ transform: translate(-50%, -50%) scale(1.4);
5064
+ }
5065
+
4928
5066
  /* \u2500\u2500 Bottom row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4929
5067
 
4930
5068
  .avp-bottom {
@@ -5044,7 +5182,10 @@ var PLAYER_STYLES = (
5044
5182
  background: rgba(28, 28, 28, 0.95);
5045
5183
  border-radius: 8px;
5046
5184
  min-width: 220px;
5047
- max-height: 300px;
5185
+ /* Fit within the player: leave room for the controls bar (52px bottom)
5186
+ and a small top margin (8px). On tall players this caps at 300px;
5187
+ on short players it shrinks to whatever fits. */
5188
+ max-height: min(300px, calc(100% - 52px - 8px));
5048
5189
  overflow-y: auto;
5049
5190
  display: none;
5050
5191
  z-index: 10;
@@ -5116,9 +5257,24 @@ var PLAYER_STYLES = (
5116
5257
  @media (pointer: coarse) {
5117
5258
  .avp-btn svg { width: 28px; height: 28px; }
5118
5259
  .avp-btn { padding: 8px; }
5260
+
5261
+ /* Taller touch target on mobile (44px, matching YouTube Mobile)
5262
+ while keeping the visual track thin. Negative margin collapses
5263
+ the extra space so the controls layout doesn't shift. */
5264
+ .avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
5119
5265
  .avp-seek-track { height: 4px; }
5120
5266
  .avp-seek:hover .avp-seek-track { height: 4px; }
5121
- .avp-seek-thumb { transform: translate(-50%, -50%) scale(1); }
5267
+ .avp-seek-thumb {
5268
+ transform: translate(-50%, -50%) scale(1);
5269
+ width: 16px;
5270
+ height: 16px;
5271
+ }
5272
+ .avp-seek[data-seeking] .avp-seek-thumb {
5273
+ transform: translate(-50%, -50%) scale(1.5);
5274
+ }
5275
+ /* Move tooltip above the taller touch zone. */
5276
+ .avp-seek-tooltip { bottom: 32px; }
5277
+
5122
5278
  .avp-volume:hover .avp-volume-slider { width: 0; }
5123
5279
  .avp-overlay-btn { width: 56px; height: 56px; }
5124
5280
  .avp-overlay-btn svg { width: 30px; height: 30px; }
@@ -5276,6 +5432,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5276
5432
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5277
5433
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5278
5434
  </div>
5435
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
5279
5436
  <div part="overlay" class="avp-overlay">
5280
5437
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5281
5438
  <div class="avp-spinner"></div>
@@ -5491,19 +5648,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5491
5648
  this._userSeeking = true;
5492
5649
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5493
5650
  seekBar.setPointerCapture(e.pointerId);
5651
+ seekBar.setAttribute("data-seeking", "");
5494
5652
  const initial = this._timeFromSeekPointer(e.clientX);
5495
5653
  this._seekInput.value = String(initial);
5496
5654
  this._onSeekInput();
5655
+ this._updateSeekTooltip(e.clientX);
5497
5656
  const onMove = (ev) => {
5498
5657
  const t = this._timeFromSeekPointer(ev.clientX);
5499
5658
  this._seekInput.value = String(t);
5500
5659
  this._onSeekInput();
5660
+ this._updateSeekTooltip(ev.clientX);
5501
5661
  };
5502
5662
  const onUp = (ev) => {
5503
5663
  const t = this._timeFromSeekPointer(ev.clientX);
5504
5664
  this._seekInput.value = String(t);
5505
5665
  this._onSeekCommit();
5506
5666
  this._seekInput.focus();
5667
+ seekBar.removeAttribute("data-seeking");
5507
5668
  seekBar.removeEventListener("pointermove", onMove);
5508
5669
  seekBar.removeEventListener("pointerup", onUp);
5509
5670
  seekBar.removeEventListener("pointercancel", onUp);
@@ -5517,8 +5678,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
5517
5678
  seekBar.addEventListener("pointercancel", onUp);
5518
5679
  }
5519
5680
  _onSeekHover(e) {
5681
+ this._updateSeekTooltip(e.clientX);
5682
+ }
5683
+ _updateSeekTooltip(clientX) {
5520
5684
  const rect = this._seekInput.getBoundingClientRect();
5521
- const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
5685
+ const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5522
5686
  const t = frac * (this._video.duration || 0);
5523
5687
  this._seekTooltip.textContent = formatTime(t);
5524
5688
  this._seekTooltip.style.left = `${frac * 100}%`;
@@ -5738,19 +5902,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
5738
5902
  // it's treated as a double-click and the single-click action is cancelled.
5739
5903
  /** Track whether the last interaction was touch so click handler can skip. */
5740
5904
  _lastPointerTypeWasTouch = false;
5741
- /** True if the event's composed path passes through consumer-slotted toolbar
5742
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5743
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5744
- * does. */
5745
- _isToolbarEvent(e) {
5905
+ /** True if the event's composed path passes through consumer-slotted
5906
+ * content (toolbar or content-overlay). Slotted content lives in the
5907
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
5908
+ * find the shadow-DOM wrapper — `composedPath()` does. */
5909
+ _isSlottedContentEvent(e) {
5746
5910
  for (const node of e.composedPath()) {
5747
- if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
5911
+ if (node instanceof HTMLElement && (node.classList.contains("avp-toolbar-top") || node.classList.contains("avp-content-overlay"))) return true;
5748
5912
  }
5749
5913
  return false;
5750
5914
  }
5751
5915
  _onContainerClick(e) {
5752
5916
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5753
- if (this._isToolbarEvent(e)) return;
5917
+ if (this._isSlottedContentEvent(e)) return;
5754
5918
  if (this._lastPointerTypeWasTouch) {
5755
5919
  this._lastPointerTypeWasTouch = false;
5756
5920
  return;
@@ -5766,7 +5930,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5766
5930
  }
5767
5931
  _onContainerDblClick(e) {
5768
5932
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5769
- if (this._isToolbarEvent(e)) return;
5933
+ if (this._isSlottedContentEvent(e)) return;
5770
5934
  if (this._tapTimer) {
5771
5935
  clearTimeout(this._tapTimer);
5772
5936
  this._tapTimer = null;
@@ -5788,7 +5952,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5788
5952
  if (e.pointerType !== "touch") return;
5789
5953
  this._lastPointerTypeWasTouch = true;
5790
5954
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5791
- if (this._isToolbarEvent(e)) return;
5955
+ if (this._isSlottedContentEvent(e)) return;
5792
5956
  const now = Date.now();
5793
5957
  if (now - this._lastTapTime < 300) {
5794
5958
  if (this._tapTimer) {