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
package/dist/player.cjs CHANGED
@@ -239,7 +239,7 @@ async function probe(source, transport) {
239
239
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
240
240
  if (hasUnknownCodec) {
241
241
  try {
242
- const { probeWithLibav } = await import('./avi-F6WZJK5T.cjs');
242
+ const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
243
243
  return await probeWithLibav(normalized, sniffed);
244
244
  } catch {
245
245
  return result;
@@ -252,7 +252,7 @@ async function probe(source, transport) {
252
252
  mediabunnyErr.message
253
253
  );
254
254
  try {
255
- const { probeWithLibav } = await import('./avi-F6WZJK5T.cjs');
255
+ const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
256
256
  return await probeWithLibav(normalized, sniffed);
257
257
  } catch (libavErr) {
258
258
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -266,7 +266,7 @@ async function probe(source, transport) {
266
266
  }
267
267
  }
268
268
  try {
269
- const { probeWithLibav } = await import('./avi-F6WZJK5T.cjs');
269
+ const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
270
270
  return await probeWithLibav(normalized, sniffed);
271
271
  } catch (err) {
272
272
  const inner = err instanceof Error ? err.message : String(err);
@@ -371,7 +371,13 @@ var FALLBACK_VIDEO_CODECS = /* @__PURE__ */ new Set([
371
371
  "rv40",
372
372
  "mpeg2",
373
373
  "mpeg1",
374
- "theora"
374
+ "theora",
375
+ "dv",
376
+ "hq_hqa",
377
+ "rawvideo",
378
+ "qtrle",
379
+ "png",
380
+ "vp6f"
375
381
  ]);
376
382
  var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
377
383
  "wmav2",
@@ -512,10 +518,12 @@ function classifyContext(ctx) {
512
518
  reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable \u2014 falling back to WASM decode`
513
519
  };
514
520
  }
521
+ const fallbackChain = webCodecsAvailable() ? ["hybrid", "fallback"] : ["fallback"];
515
522
  return {
516
523
  class: "REMUX_CANDIDATE",
517
524
  strategy: "remux",
518
- reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`
525
+ reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`,
526
+ fallbackChain
519
527
  };
520
528
  }
521
529
  if (webCodecsAvailable()) {
@@ -1205,6 +1213,12 @@ async function createRemuxSession(context, video) {
1205
1213
  }
1206
1214
  const wasPlaying = !video.paused;
1207
1215
  await pipeline.seek(time, wasPlaying || wantPlay);
1216
+ queueMicrotask(() => {
1217
+ try {
1218
+ video.dispatchEvent(new Event("seeked"));
1219
+ } catch {
1220
+ }
1221
+ });
1208
1222
  },
1209
1223
  async setAudioTrack(id) {
1210
1224
  if (!context.audioTracks.some((t) => t.id === id)) {
@@ -1841,6 +1855,17 @@ function sanitizePacketTimestamp(pkt, nextUs, fallbackTimeBase) {
1841
1855
  pkt.time_base_num = 1;
1842
1856
  pkt.time_base_den = 1e6;
1843
1857
  }
1858
+ function packetPtsSec(pkt, timeBase) {
1859
+ const lo = pkt.pts ?? 0;
1860
+ const hi = pkt.ptshi ?? 0;
1861
+ const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
1862
+ if (isInvalid) return null;
1863
+ const tb = timeBase ?? [1, 1e6];
1864
+ if (!tb[0] || !tb[1]) return null;
1865
+ const pts64 = hi * 4294967296 + lo;
1866
+ const sec = pts64 * tb[0] / tb[1];
1867
+ return Number.isFinite(sec) ? sec : null;
1868
+ }
1844
1869
  var AV_SAMPLE_FMT_U8 = 0;
1845
1870
  var AV_SAMPLE_FMT_S16 = 1;
1846
1871
  var AV_SAMPLE_FMT_S32 = 2;
@@ -2101,6 +2126,7 @@ async function startHybridDecoder(opts) {
2101
2126
  let videoFramesDecoded = 0;
2102
2127
  let audioFramesDecoded = 0;
2103
2128
  let videoChunksFed = 0;
2129
+ let bufferedUntilSec = 0;
2104
2130
  let syntheticVideoUs = 0;
2105
2131
  let syntheticAudioUs = 0;
2106
2132
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -2121,6 +2147,18 @@ async function startHybridDecoder(opts) {
2121
2147
  if (myToken !== pumpToken || destroyed) return;
2122
2148
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2123
2149
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2150
+ if (videoPackets && videoTimeBase) {
2151
+ for (const pkt of videoPackets) {
2152
+ const sec = packetPtsSec(pkt, videoTimeBase);
2153
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2154
+ }
2155
+ }
2156
+ if (audioPackets && audioTimeBase) {
2157
+ for (const pkt of audioPackets) {
2158
+ const sec = packetPtsSec(pkt, audioTimeBase);
2159
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2160
+ }
2161
+ }
2124
2162
  if (audioDec && audioPackets && audioPackets.length > 0) {
2125
2163
  await decodeAudioBatch(audioPackets, myToken);
2126
2164
  }
@@ -2377,6 +2415,9 @@ async function startHybridDecoder(opts) {
2377
2415
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2378
2416
  );
2379
2417
  },
2418
+ bufferedUntilSec() {
2419
+ return bufferedUntilSec;
2420
+ },
2380
2421
  stats() {
2381
2422
  return {
2382
2423
  decoderType: "webcodecs-hybrid",
@@ -2504,6 +2545,13 @@ async function createHybridSession(ctx, target, transport) {
2504
2545
  configurable: true,
2505
2546
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
2506
2547
  });
2548
+ Object.defineProperty(target, "buffered", {
2549
+ configurable: true,
2550
+ get: () => {
2551
+ const end = handles.bufferedUntilSec();
2552
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
2553
+ }
2554
+ });
2507
2555
  async function waitForBuffer() {
2508
2556
  const start = performance.now();
2509
2557
  while (true) {
@@ -2517,6 +2565,7 @@ async function createHybridSession(ctx, target, transport) {
2517
2565
  }
2518
2566
  async function doSeek(timeSec) {
2519
2567
  const wasPlaying = audio.isPlaying();
2568
+ target.dispatchEvent(new Event("seeking"));
2520
2569
  await audio.pause().catch(() => {
2521
2570
  });
2522
2571
  await handles.seek(timeSec).catch(
@@ -2528,7 +2577,14 @@ async function createHybridSession(ctx, target, transport) {
2528
2577
  await waitForBuffer();
2529
2578
  await audio.start();
2530
2579
  }
2580
+ target.dispatchEvent(new Event("seeked"));
2531
2581
  }
2582
+ queueMicrotask(() => {
2583
+ try {
2584
+ target.dispatchEvent(new Event("loadedmetadata"));
2585
+ } catch {
2586
+ }
2587
+ });
2532
2588
  let fatalErrorHandler = null;
2533
2589
  handles.onFatalError((reason) => fatalErrorHandler?.(reason));
2534
2590
  return {
@@ -2713,6 +2769,7 @@ async function startDecoder(opts) {
2713
2769
  let pumpRunning = null;
2714
2770
  let packetsRead = 0;
2715
2771
  let videoFramesDecoded = 0;
2772
+ let bufferedUntilSec = 0;
2716
2773
  let audioFramesDecoded = 0;
2717
2774
  let watchdogFirstFrameMs = 0;
2718
2775
  let watchdogSlowSinceMs = 0;
@@ -2738,6 +2795,18 @@ async function startDecoder(opts) {
2738
2795
  if (myToken !== pumpToken || destroyed) return;
2739
2796
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2740
2797
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2798
+ if (videoPackets && videoTimeBase) {
2799
+ for (const pkt of videoPackets) {
2800
+ const sec = packetPtsSec(pkt, videoTimeBase);
2801
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2802
+ }
2803
+ }
2804
+ if (audioPackets && audioTimeBase) {
2805
+ for (const pkt of audioPackets) {
2806
+ const sec = packetPtsSec(pkt, audioTimeBase);
2807
+ if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2808
+ }
2809
+ }
2741
2810
  if (audioDec && audioPackets && audioPackets.length > 0) {
2742
2811
  await decodeAudioBatch(audioPackets, myToken);
2743
2812
  }
@@ -3019,6 +3088,9 @@ async function startDecoder(opts) {
3019
3088
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3020
3089
  );
3021
3090
  },
3091
+ bufferedUntilSec() {
3092
+ return bufferedUntilSec;
3093
+ },
3022
3094
  stats() {
3023
3095
  return {
3024
3096
  decoderType: "libav-wasm",
@@ -3118,6 +3190,13 @@ async function createFallbackSession(ctx, target, transport) {
3118
3190
  configurable: true,
3119
3191
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
3120
3192
  });
3193
+ Object.defineProperty(target, "buffered", {
3194
+ configurable: true,
3195
+ get: () => {
3196
+ const end = handles.bufferedUntilSec();
3197
+ return makeTimeRanges(end > 0 ? [[0, end]] : []);
3198
+ }
3199
+ });
3121
3200
  async function waitForBuffer() {
3122
3201
  const start = performance.now();
3123
3202
  let firstFrameAtMs = 0;
@@ -3157,6 +3236,7 @@ async function createFallbackSession(ctx, target, transport) {
3157
3236
  }
3158
3237
  async function doSeek(timeSec) {
3159
3238
  const wasPlaying = audio.isPlaying();
3239
+ target.dispatchEvent(new Event("seeking"));
3160
3240
  await audio.pause().catch(() => {
3161
3241
  });
3162
3242
  await handles.seek(timeSec).catch(
@@ -3168,7 +3248,14 @@ async function createFallbackSession(ctx, target, transport) {
3168
3248
  await waitForBuffer();
3169
3249
  await audio.start();
3170
3250
  }
3251
+ target.dispatchEvent(new Event("seeked"));
3171
3252
  }
3253
+ queueMicrotask(() => {
3254
+ try {
3255
+ target.dispatchEvent(new Event("loadedmetadata"));
3256
+ } catch {
3257
+ }
3258
+ });
3172
3259
  return {
3173
3260
  strategy: "fallback",
3174
3261
  async play() {
@@ -3260,6 +3347,29 @@ function registerBuiltins(registry) {
3260
3347
  }
3261
3348
 
3262
3349
  // src/player.ts
3350
+ function readDecodedFrameCount(target) {
3351
+ if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
3352
+ const vq = target.getVideoPlaybackQuality;
3353
+ if (typeof vq === "function") {
3354
+ try {
3355
+ return vq.call(target).totalVideoFrames;
3356
+ } catch {
3357
+ }
3358
+ }
3359
+ const legacy = target.webkitDecodedFrameCount;
3360
+ return typeof legacy === "number" ? legacy : 0;
3361
+ }
3362
+ function evaluateDecodeHealth(input) {
3363
+ const timeThreshold = input.timeStallThresholdMs ?? 5e3;
3364
+ const frameThreshold = input.frameStallThresholdMs ?? 3e3;
3365
+ if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
3366
+ return { escalate: true, kind: "time-stall" };
3367
+ }
3368
+ if (input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold) {
3369
+ return { escalate: true, kind: "silent-video" };
3370
+ }
3371
+ return { escalate: false };
3372
+ }
3263
3373
  var UnifiedPlayer = class _UnifiedPlayer {
3264
3374
  /**
3265
3375
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
@@ -3285,6 +3395,13 @@ var UnifiedPlayer = class _UnifiedPlayer {
3285
3395
  stallTimer = null;
3286
3396
  lastProgressTime = 0;
3287
3397
  lastProgressPosition = -1;
3398
+ /** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
3399
+ * (or `webkitDecodedFrameCount` fallback). Used by the silent-video
3400
+ * watchdog — catches cases where `currentTime` advances (audio plays)
3401
+ * but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
3402
+ * via MSE when the decoder actually can't decode HEVC. */
3403
+ lastVideoFrameCount = 0;
3404
+ lastVideoFrameProgressTime = 0;
3288
3405
  errorListener = null;
3289
3406
  // Bound so we can removeEventListener in destroy(); without this the
3290
3407
  // listener outlives the player and accumulates on elements that swap
@@ -3529,22 +3646,41 @@ var UnifiedPlayer = class _UnifiedPlayer {
3529
3646
  if (strategy === "native" || strategy === "remux") {
3530
3647
  this.lastProgressPosition = this.options.target.currentTime;
3531
3648
  this.lastProgressTime = performance.now();
3649
+ this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
3650
+ this.lastVideoFrameProgressTime = performance.now();
3651
+ const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
3532
3652
  this.stallTimer = setInterval(() => {
3533
3653
  const t = this.options.target;
3654
+ const now = performance.now();
3534
3655
  if (t.paused || t.ended || t.readyState < 2) {
3535
3656
  this.lastProgressPosition = t.currentTime;
3536
- this.lastProgressTime = performance.now();
3657
+ this.lastProgressTime = now;
3658
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
3659
+ this.lastVideoFrameProgressTime = now;
3537
3660
  return;
3538
3661
  }
3539
- if (t.currentTime !== this.lastProgressPosition) {
3662
+ const timeAdvanced = t.currentTime !== this.lastProgressPosition;
3663
+ const frames = readDecodedFrameCount(t);
3664
+ const framesAdvanced = frames > this.lastVideoFrameCount;
3665
+ const health = evaluateDecodeHealth({
3666
+ hasVideoTrack,
3667
+ timeAdvanced,
3668
+ framesAdvanced,
3669
+ now,
3670
+ lastProgressTime: this.lastProgressTime,
3671
+ lastFrameProgressTime: this.lastVideoFrameProgressTime
3672
+ });
3673
+ if (timeAdvanced) {
3540
3674
  this.lastProgressPosition = t.currentTime;
3541
- this.lastProgressTime = performance.now();
3542
- return;
3675
+ this.lastProgressTime = now;
3543
3676
  }
3544
- if (performance.now() - this.lastProgressTime > 5e3) {
3545
- void this.escalate(
3546
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
3547
- );
3677
+ if (framesAdvanced) {
3678
+ this.lastVideoFrameCount = frames;
3679
+ this.lastVideoFrameProgressTime = now;
3680
+ }
3681
+ if (health.escalate) {
3682
+ const reason = health.kind === "time-stall" ? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s` : `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s \u2014 likely a silent codec failure`;
3683
+ void this.escalate(reason);
3548
3684
  }
3549
3685
  }, 1e3);
3550
3686
  const onError = () => {
@@ -4199,9 +4335,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4199
4335
  else this.removeAttribute("autoplay");
4200
4336
  }
4201
4337
  get muted() {
4202
- return this.hasAttribute("muted");
4338
+ return this._videoEl.muted;
4203
4339
  }
4204
4340
  set muted(value) {
4341
+ this._videoEl.muted = value;
4205
4342
  if (value) this.setAttribute("muted", "");
4206
4343
  else this.removeAttribute("muted");
4207
4344
  }
@@ -4266,11 +4403,16 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4266
4403
  }
4267
4404
  /**
4268
4405
  * Buffered time ranges for the active source. Mirrors the standard
4269
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
4270
- * this reflects the underlying SourceBuffer / progressive download state.
4271
- * For the hybrid and fallback (canvas-rendered) strategies it currently
4272
- * returns an empty TimeRanges; a future release will synthesize a coarse
4273
- * range from the decoder's read position.
4406
+ * `<video>.buffered` `TimeRanges` API.
4407
+ *
4408
+ * - **Native / remux:** pass-through to the real `<video>.buffered`
4409
+ * (reflects the browser's SourceBuffer / progressive-download state).
4410
+ * - **Hybrid / fallback:** a single `[0, frontier]` range synthesized
4411
+ * from the demuxer's read progress — "how far libav has ever pumped
4412
+ * packets through." Monotonic; does not shrink on seek. This is an
4413
+ * approximation, not MSE-fidelity: decoded frames on canvas strategies
4414
+ * are consumed in flight, so we can't report per-range availability
4415
+ * the way MSE does. Enough for a seek-bar buffered indicator.
4274
4416
  */
4275
4417
  get buffered() {
4276
4418
  return this._videoEl.buffered;
@@ -4574,11 +4716,18 @@ var PLAYER_STYLES = (
4574
4716
 
4575
4717
  /* \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 */
4576
4718
 
4719
+ :host {
4720
+ -webkit-tap-highlight-color: transparent;
4721
+ outline: none;
4722
+ }
4723
+
4577
4724
  .avp {
4578
4725
  position: relative;
4579
4726
  width: 100%;
4580
4727
  height: 100%;
4581
4728
  cursor: pointer;
4729
+ -webkit-tap-highlight-color: transparent;
4730
+ user-select: none;
4582
4731
  }
4583
4732
 
4584
4733
  .avp avbridge-video {
@@ -4777,7 +4926,14 @@ var PLAYER_STYLES = (
4777
4926
  pointer-events: auto;
4778
4927
  }
4779
4928
 
4780
- .avp-toolbar-top-right { margin-left: auto; }
4929
+ /* Left slot fills remaining space so slotted text/content can grow.
4930
+ min-width: 0 prevents flex children from overflowing the toolbar. */
4931
+ .avp-toolbar-top-left {
4932
+ flex: 1;
4933
+ min-width: 0;
4934
+ }
4935
+
4936
+ .avp-toolbar-top-right { margin-left: auto; flex-shrink: 0; }
4781
4937
 
4782
4938
  /* Hide the gradient band when no consumer has slotted anything \u2014 we
4783
4939
  toggle data-toolbar-empty from JS via slotchange. */
@@ -4790,6 +4946,30 @@ var PLAYER_STYLES = (
4790
4946
  pointer-events: none;
4791
4947
  }
4792
4948
 
4949
+ /* \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 */
4950
+ /* Consumer-provided rich content (tweet cards, media info, annotations).
4951
+ Sits above the video, below the play-button overlay and controls in
4952
+ z-order. Auto-hides with the chrome. The wrapper is pointer-events:none
4953
+ so taps fall through to the video; consumers opt in on their content
4954
+ with pointer-events:auto. */
4955
+
4956
+ .avp-content-overlay {
4957
+ position: absolute;
4958
+ inset: 0;
4959
+ z-index: 1;
4960
+ pointer-events: none;
4961
+ opacity: 1;
4962
+ transition: opacity 0.25s;
4963
+ }
4964
+
4965
+ .avp-content-overlay ::slotted(*) {
4966
+ pointer-events: auto;
4967
+ }
4968
+
4969
+ :host([data-controls-hidden]) .avp-content-overlay {
4970
+ opacity: 0;
4971
+ }
4972
+
4793
4973
  /* \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 */
4794
4974
 
4795
4975
  .avp-seek {
@@ -4876,6 +5056,15 @@ var PLAYER_STYLES = (
4876
5056
 
4877
5057
  .avp-seek:hover .avp-seek-tooltip { display: block; }
4878
5058
 
5059
+ /* Show tooltip during active drag (touch or mouse). The JS side sets
5060
+ data-seeking on .avp-seek while the user is scrubbing. */
5061
+ .avp-seek[data-seeking] .avp-seek-tooltip { display: block; }
5062
+
5063
+ /* Enlarge thumb while scrubbing. */
5064
+ .avp-seek[data-seeking] .avp-seek-thumb {
5065
+ transform: translate(-50%, -50%) scale(1.4);
5066
+ }
5067
+
4879
5068
  /* \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 */
4880
5069
 
4881
5070
  .avp-bottom {
@@ -4995,7 +5184,10 @@ var PLAYER_STYLES = (
4995
5184
  background: rgba(28, 28, 28, 0.95);
4996
5185
  border-radius: 8px;
4997
5186
  min-width: 220px;
4998
- max-height: 300px;
5187
+ /* Fit within the player: leave room for the controls bar (52px bottom)
5188
+ and a small top margin (8px). On tall players this caps at 300px;
5189
+ on short players it shrinks to whatever fits. */
5190
+ max-height: min(300px, calc(100% - 52px - 8px));
4999
5191
  overflow-y: auto;
5000
5192
  display: none;
5001
5193
  z-index: 10;
@@ -5067,9 +5259,24 @@ var PLAYER_STYLES = (
5067
5259
  @media (pointer: coarse) {
5068
5260
  .avp-btn svg { width: 28px; height: 28px; }
5069
5261
  .avp-btn { padding: 8px; }
5262
+
5263
+ /* Taller touch target on mobile (44px, matching YouTube Mobile)
5264
+ while keeping the visual track thin. Negative margin collapses
5265
+ the extra space so the controls layout doesn't shift. */
5266
+ .avp-seek { height: 44px; margin-top: -12px; margin-bottom: -12px; }
5070
5267
  .avp-seek-track { height: 4px; }
5071
5268
  .avp-seek:hover .avp-seek-track { height: 4px; }
5072
- .avp-seek-thumb { transform: translate(-50%, -50%) scale(1); }
5269
+ .avp-seek-thumb {
5270
+ transform: translate(-50%, -50%) scale(1);
5271
+ width: 16px;
5272
+ height: 16px;
5273
+ }
5274
+ .avp-seek[data-seeking] .avp-seek-thumb {
5275
+ transform: translate(-50%, -50%) scale(1.5);
5276
+ }
5277
+ /* Move tooltip above the taller touch zone. */
5278
+ .avp-seek-tooltip { bottom: 32px; }
5279
+
5073
5280
  .avp-volume:hover .avp-volume-slider { width: 0; }
5074
5281
  .avp-overlay-btn { width: 56px; height: 56px; }
5075
5282
  .avp-overlay-btn svg { width: 30px; height: 30px; }
@@ -5227,6 +5434,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5227
5434
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5228
5435
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5229
5436
  </div>
5437
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
5230
5438
  <div part="overlay" class="avp-overlay">
5231
5439
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5232
5440
  <div class="avp-spinner"></div>
@@ -5442,19 +5650,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5442
5650
  this._userSeeking = true;
5443
5651
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5444
5652
  seekBar.setPointerCapture(e.pointerId);
5653
+ seekBar.setAttribute("data-seeking", "");
5445
5654
  const initial = this._timeFromSeekPointer(e.clientX);
5446
5655
  this._seekInput.value = String(initial);
5447
5656
  this._onSeekInput();
5657
+ this._updateSeekTooltip(e.clientX);
5448
5658
  const onMove = (ev) => {
5449
5659
  const t = this._timeFromSeekPointer(ev.clientX);
5450
5660
  this._seekInput.value = String(t);
5451
5661
  this._onSeekInput();
5662
+ this._updateSeekTooltip(ev.clientX);
5452
5663
  };
5453
5664
  const onUp = (ev) => {
5454
5665
  const t = this._timeFromSeekPointer(ev.clientX);
5455
5666
  this._seekInput.value = String(t);
5456
5667
  this._onSeekCommit();
5457
5668
  this._seekInput.focus();
5669
+ seekBar.removeAttribute("data-seeking");
5458
5670
  seekBar.removeEventListener("pointermove", onMove);
5459
5671
  seekBar.removeEventListener("pointerup", onUp);
5460
5672
  seekBar.removeEventListener("pointercancel", onUp);
@@ -5468,8 +5680,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
5468
5680
  seekBar.addEventListener("pointercancel", onUp);
5469
5681
  }
5470
5682
  _onSeekHover(e) {
5683
+ this._updateSeekTooltip(e.clientX);
5684
+ }
5685
+ _updateSeekTooltip(clientX) {
5471
5686
  const rect = this._seekInput.getBoundingClientRect();
5472
- const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
5687
+ const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5473
5688
  const t = frac * (this._video.duration || 0);
5474
5689
  this._seekTooltip.textContent = formatTime(t);
5475
5690
  this._seekTooltip.style.left = `${frac * 100}%`;
@@ -5648,12 +5863,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
5648
5863
  this._fullscreenBtn.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
5649
5864
  }
5650
5865
  // ── Controls: auto-hide ────────────────────────────────────────────────
5651
- _showControls() {
5866
+ /**
5867
+ * Reveal the auto-hiding chrome (top toolbar + bottom controls) and
5868
+ * re-start the auto-hide timer. Call this from app-level code to
5869
+ * briefly surface the player UI — e.g. to confirm "you just swiped to
5870
+ * this video" in a carousel, or to flash the title on focus change.
5871
+ *
5872
+ * @param durationMs How long the chrome stays visible before fading.
5873
+ * Defaults to the player's normal 3 s auto-hide.
5874
+ * Pointer movement or any other interaction resets
5875
+ * the timer, so a user hovering during the flash
5876
+ * sees no flicker.
5877
+ */
5878
+ showControls(durationMs) {
5652
5879
  this.removeAttribute("data-controls-hidden");
5653
5880
  this._toolbarTop.setAttribute("data-visible", "true");
5654
- this._scheduleHide();
5881
+ this._scheduleHide(durationMs);
5882
+ }
5883
+ _showControls() {
5884
+ this.showControls();
5655
5885
  }
5656
- _scheduleHide() {
5886
+ _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
5657
5887
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
5658
5888
  if (this._state !== "playing" && this._state !== "buffering") return;
5659
5889
  if (this._settingsOpen) return;
@@ -5662,7 +5892,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5662
5892
  this.setAttribute("data-controls-hidden", "");
5663
5893
  this._toolbarTop.setAttribute("data-visible", "false");
5664
5894
  }
5665
- }, CONTROLS_HIDE_MS);
5895
+ }, durationMs);
5666
5896
  }
5667
5897
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
5668
5898
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -5674,19 +5904,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
5674
5904
  // it's treated as a double-click and the single-click action is cancelled.
5675
5905
  /** Track whether the last interaction was touch so click handler can skip. */
5676
5906
  _lastPointerTypeWasTouch = false;
5677
- /** True if the event's composed path passes through consumer-slotted toolbar
5678
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5679
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5680
- * does. */
5681
- _isToolbarEvent(e) {
5907
+ /** True if the event's composed path passes through consumer-slotted
5908
+ * content (toolbar or content-overlay). Slotted content lives in the
5909
+ * light DOM so `.closest(".avp-toolbar-top")` on the event target won't
5910
+ * find the shadow-DOM wrapper — `composedPath()` does. */
5911
+ _isSlottedContentEvent(e) {
5682
5912
  for (const node of e.composedPath()) {
5683
- if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
5913
+ if (node instanceof HTMLElement && (node.classList.contains("avp-toolbar-top") || node.classList.contains("avp-content-overlay"))) return true;
5684
5914
  }
5685
5915
  return false;
5686
5916
  }
5687
5917
  _onContainerClick(e) {
5688
5918
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5689
- if (this._isToolbarEvent(e)) return;
5919
+ if (this._isSlottedContentEvent(e)) return;
5690
5920
  if (this._lastPointerTypeWasTouch) {
5691
5921
  this._lastPointerTypeWasTouch = false;
5692
5922
  return;
@@ -5702,7 +5932,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5702
5932
  }
5703
5933
  _onContainerDblClick(e) {
5704
5934
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5705
- if (this._isToolbarEvent(e)) return;
5935
+ if (this._isSlottedContentEvent(e)) return;
5706
5936
  if (this._tapTimer) {
5707
5937
  clearTimeout(this._tapTimer);
5708
5938
  this._tapTimer = null;
@@ -5724,7 +5954,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5724
5954
  if (e.pointerType !== "touch") return;
5725
5955
  this._lastPointerTypeWasTouch = true;
5726
5956
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5727
- if (this._isToolbarEvent(e)) return;
5957
+ if (this._isSlottedContentEvent(e)) return;
5728
5958
  const now = Date.now();
5729
5959
  if (now - this._lastTapTime < 300) {
5730
5960
  if (this._tapTimer) {