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.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",
@@ -510,10 +516,12 @@ function classifyContext(ctx) {
510
516
  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`
511
517
  };
512
518
  }
519
+ const fallbackChain = webCodecsAvailable() ? ["hybrid", "fallback"] : ["fallback"];
513
520
  return {
514
521
  class: "REMUX_CANDIDATE",
515
522
  strategy: "remux",
516
- reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`
523
+ reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`,
524
+ fallbackChain
517
525
  };
518
526
  }
519
527
  if (webCodecsAvailable()) {
@@ -1203,6 +1211,12 @@ async function createRemuxSession(context, video) {
1203
1211
  }
1204
1212
  const wasPlaying = !video.paused;
1205
1213
  await pipeline.seek(time, wasPlaying || wantPlay);
1214
+ queueMicrotask(() => {
1215
+ try {
1216
+ video.dispatchEvent(new Event("seeked"));
1217
+ } catch {
1218
+ }
1219
+ });
1206
1220
  },
1207
1221
  async setAudioTrack(id) {
1208
1222
  if (!context.audioTracks.some((t) => t.id === id)) {
@@ -1839,6 +1853,17 @@ function sanitizePacketTimestamp(pkt, nextUs, fallbackTimeBase) {
1839
1853
  pkt.time_base_num = 1;
1840
1854
  pkt.time_base_den = 1e6;
1841
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
+ }
1842
1867
  var AV_SAMPLE_FMT_U8 = 0;
1843
1868
  var AV_SAMPLE_FMT_S16 = 1;
1844
1869
  var AV_SAMPLE_FMT_S32 = 2;
@@ -2099,6 +2124,7 @@ async function startHybridDecoder(opts) {
2099
2124
  let videoFramesDecoded = 0;
2100
2125
  let audioFramesDecoded = 0;
2101
2126
  let videoChunksFed = 0;
2127
+ let bufferedUntilSec = 0;
2102
2128
  let syntheticVideoUs = 0;
2103
2129
  let syntheticAudioUs = 0;
2104
2130
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -2119,6 +2145,18 @@ async function startHybridDecoder(opts) {
2119
2145
  if (myToken !== pumpToken || destroyed) return;
2120
2146
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2121
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
+ }
2122
2160
  if (audioDec && audioPackets && audioPackets.length > 0) {
2123
2161
  await decodeAudioBatch(audioPackets, myToken);
2124
2162
  }
@@ -2375,6 +2413,9 @@ async function startHybridDecoder(opts) {
2375
2413
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2376
2414
  );
2377
2415
  },
2416
+ bufferedUntilSec() {
2417
+ return bufferedUntilSec;
2418
+ },
2378
2419
  stats() {
2379
2420
  return {
2380
2421
  decoderType: "webcodecs-hybrid",
@@ -2502,6 +2543,13 @@ async function createHybridSession(ctx, target, transport) {
2502
2543
  configurable: true,
2503
2544
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
2504
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
+ });
2505
2553
  async function waitForBuffer() {
2506
2554
  const start = performance.now();
2507
2555
  while (true) {
@@ -2515,6 +2563,7 @@ async function createHybridSession(ctx, target, transport) {
2515
2563
  }
2516
2564
  async function doSeek(timeSec) {
2517
2565
  const wasPlaying = audio.isPlaying();
2566
+ target.dispatchEvent(new Event("seeking"));
2518
2567
  await audio.pause().catch(() => {
2519
2568
  });
2520
2569
  await handles.seek(timeSec).catch(
@@ -2526,7 +2575,14 @@ async function createHybridSession(ctx, target, transport) {
2526
2575
  await waitForBuffer();
2527
2576
  await audio.start();
2528
2577
  }
2578
+ target.dispatchEvent(new Event("seeked"));
2529
2579
  }
2580
+ queueMicrotask(() => {
2581
+ try {
2582
+ target.dispatchEvent(new Event("loadedmetadata"));
2583
+ } catch {
2584
+ }
2585
+ });
2530
2586
  let fatalErrorHandler = null;
2531
2587
  handles.onFatalError((reason) => fatalErrorHandler?.(reason));
2532
2588
  return {
@@ -2711,6 +2767,7 @@ async function startDecoder(opts) {
2711
2767
  let pumpRunning = null;
2712
2768
  let packetsRead = 0;
2713
2769
  let videoFramesDecoded = 0;
2770
+ let bufferedUntilSec = 0;
2714
2771
  let audioFramesDecoded = 0;
2715
2772
  let watchdogFirstFrameMs = 0;
2716
2773
  let watchdogSlowSinceMs = 0;
@@ -2736,6 +2793,18 @@ async function startDecoder(opts) {
2736
2793
  if (myToken !== pumpToken || destroyed) return;
2737
2794
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2738
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
+ }
2739
2808
  if (audioDec && audioPackets && audioPackets.length > 0) {
2740
2809
  await decodeAudioBatch(audioPackets, myToken);
2741
2810
  }
@@ -3017,6 +3086,9 @@ async function startDecoder(opts) {
3017
3086
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3018
3087
  );
3019
3088
  },
3089
+ bufferedUntilSec() {
3090
+ return bufferedUntilSec;
3091
+ },
3020
3092
  stats() {
3021
3093
  return {
3022
3094
  decoderType: "libav-wasm",
@@ -3116,6 +3188,13 @@ async function createFallbackSession(ctx, target, transport) {
3116
3188
  configurable: true,
3117
3189
  get: () => makeTimeRanges(ctx.duration && Number.isFinite(ctx.duration) && ctx.duration > 0 ? [[0, ctx.duration]] : [])
3118
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
+ });
3119
3198
  async function waitForBuffer() {
3120
3199
  const start = performance.now();
3121
3200
  let firstFrameAtMs = 0;
@@ -3155,6 +3234,7 @@ async function createFallbackSession(ctx, target, transport) {
3155
3234
  }
3156
3235
  async function doSeek(timeSec) {
3157
3236
  const wasPlaying = audio.isPlaying();
3237
+ target.dispatchEvent(new Event("seeking"));
3158
3238
  await audio.pause().catch(() => {
3159
3239
  });
3160
3240
  await handles.seek(timeSec).catch(
@@ -3166,7 +3246,14 @@ async function createFallbackSession(ctx, target, transport) {
3166
3246
  await waitForBuffer();
3167
3247
  await audio.start();
3168
3248
  }
3249
+ target.dispatchEvent(new Event("seeked"));
3169
3250
  }
3251
+ queueMicrotask(() => {
3252
+ try {
3253
+ target.dispatchEvent(new Event("loadedmetadata"));
3254
+ } catch {
3255
+ }
3256
+ });
3170
3257
  return {
3171
3258
  strategy: "fallback",
3172
3259
  async play() {
@@ -3258,6 +3345,29 @@ function registerBuiltins(registry) {
3258
3345
  }
3259
3346
 
3260
3347
  // src/player.ts
3348
+ function readDecodedFrameCount(target) {
3349
+ if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
3350
+ const vq = target.getVideoPlaybackQuality;
3351
+ if (typeof vq === "function") {
3352
+ try {
3353
+ return vq.call(target).totalVideoFrames;
3354
+ } catch {
3355
+ }
3356
+ }
3357
+ const legacy = target.webkitDecodedFrameCount;
3358
+ return typeof legacy === "number" ? legacy : 0;
3359
+ }
3360
+ function evaluateDecodeHealth(input) {
3361
+ const timeThreshold = input.timeStallThresholdMs ?? 5e3;
3362
+ const frameThreshold = input.frameStallThresholdMs ?? 3e3;
3363
+ if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
3364
+ return { escalate: true, kind: "time-stall" };
3365
+ }
3366
+ if (input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold) {
3367
+ return { escalate: true, kind: "silent-video" };
3368
+ }
3369
+ return { escalate: false };
3370
+ }
3261
3371
  var UnifiedPlayer = class _UnifiedPlayer {
3262
3372
  /**
3263
3373
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
@@ -3283,6 +3393,13 @@ var UnifiedPlayer = class _UnifiedPlayer {
3283
3393
  stallTimer = null;
3284
3394
  lastProgressTime = 0;
3285
3395
  lastProgressPosition = -1;
3396
+ /** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
3397
+ * (or `webkitDecodedFrameCount` fallback). Used by the silent-video
3398
+ * watchdog — catches cases where `currentTime` advances (audio plays)
3399
+ * but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
3400
+ * via MSE when the decoder actually can't decode HEVC. */
3401
+ lastVideoFrameCount = 0;
3402
+ lastVideoFrameProgressTime = 0;
3286
3403
  errorListener = null;
3287
3404
  // Bound so we can removeEventListener in destroy(); without this the
3288
3405
  // listener outlives the player and accumulates on elements that swap
@@ -3527,22 +3644,41 @@ var UnifiedPlayer = class _UnifiedPlayer {
3527
3644
  if (strategy === "native" || strategy === "remux") {
3528
3645
  this.lastProgressPosition = this.options.target.currentTime;
3529
3646
  this.lastProgressTime = performance.now();
3647
+ this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
3648
+ this.lastVideoFrameProgressTime = performance.now();
3649
+ const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
3530
3650
  this.stallTimer = setInterval(() => {
3531
3651
  const t = this.options.target;
3652
+ const now = performance.now();
3532
3653
  if (t.paused || t.ended || t.readyState < 2) {
3533
3654
  this.lastProgressPosition = t.currentTime;
3534
- this.lastProgressTime = performance.now();
3655
+ this.lastProgressTime = now;
3656
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
3657
+ this.lastVideoFrameProgressTime = now;
3535
3658
  return;
3536
3659
  }
3537
- if (t.currentTime !== this.lastProgressPosition) {
3660
+ const timeAdvanced = t.currentTime !== this.lastProgressPosition;
3661
+ const frames = readDecodedFrameCount(t);
3662
+ const framesAdvanced = frames > this.lastVideoFrameCount;
3663
+ const health = evaluateDecodeHealth({
3664
+ hasVideoTrack,
3665
+ timeAdvanced,
3666
+ framesAdvanced,
3667
+ now,
3668
+ lastProgressTime: this.lastProgressTime,
3669
+ lastFrameProgressTime: this.lastVideoFrameProgressTime
3670
+ });
3671
+ if (timeAdvanced) {
3538
3672
  this.lastProgressPosition = t.currentTime;
3539
- this.lastProgressTime = performance.now();
3540
- return;
3673
+ this.lastProgressTime = now;
3541
3674
  }
3542
- if (performance.now() - this.lastProgressTime > 5e3) {
3543
- void this.escalate(
3544
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
3545
- );
3675
+ if (framesAdvanced) {
3676
+ this.lastVideoFrameCount = frames;
3677
+ this.lastVideoFrameProgressTime = now;
3678
+ }
3679
+ if (health.escalate) {
3680
+ 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`;
3681
+ void this.escalate(reason);
3546
3682
  }
3547
3683
  }, 1e3);
3548
3684
  const onError = () => {
@@ -4197,9 +4333,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4197
4333
  else this.removeAttribute("autoplay");
4198
4334
  }
4199
4335
  get muted() {
4200
- return this.hasAttribute("muted");
4336
+ return this._videoEl.muted;
4201
4337
  }
4202
4338
  set muted(value) {
4339
+ this._videoEl.muted = value;
4203
4340
  if (value) this.setAttribute("muted", "");
4204
4341
  else this.removeAttribute("muted");
4205
4342
  }
@@ -4264,11 +4401,16 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4264
4401
  }
4265
4402
  /**
4266
4403
  * Buffered time ranges for the active source. Mirrors the standard
4267
- * `<video>.buffered` `TimeRanges` API. For the native and remux strategies
4268
- * this reflects the underlying SourceBuffer / progressive download state.
4269
- * For the hybrid and fallback (canvas-rendered) strategies it currently
4270
- * returns an empty TimeRanges; a future release will synthesize a coarse
4271
- * 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.
4272
4414
  */
4273
4415
  get buffered() {
4274
4416
  return this._videoEl.buffered;
@@ -4572,11 +4714,18 @@ var PLAYER_STYLES = (
4572
4714
 
4573
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 */
4574
4716
 
4717
+ :host {
4718
+ -webkit-tap-highlight-color: transparent;
4719
+ outline: none;
4720
+ }
4721
+
4575
4722
  .avp {
4576
4723
  position: relative;
4577
4724
  width: 100%;
4578
4725
  height: 100%;
4579
4726
  cursor: pointer;
4727
+ -webkit-tap-highlight-color: transparent;
4728
+ user-select: none;
4580
4729
  }
4581
4730
 
4582
4731
  .avp avbridge-video {
@@ -4775,7 +4924,14 @@ var PLAYER_STYLES = (
4775
4924
  pointer-events: auto;
4776
4925
  }
4777
4926
 
4778
- .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; }
4779
4935
 
4780
4936
  /* Hide the gradient band when no consumer has slotted anything \u2014 we
4781
4937
  toggle data-toolbar-empty from JS via slotchange. */
@@ -4788,6 +4944,30 @@ var PLAYER_STYLES = (
4788
4944
  pointer-events: none;
4789
4945
  }
4790
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
+
4791
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 */
4792
4972
 
4793
4973
  .avp-seek {
@@ -4874,6 +5054,15 @@ var PLAYER_STYLES = (
4874
5054
 
4875
5055
  .avp-seek:hover .avp-seek-tooltip { display: block; }
4876
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
+
4877
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 */
4878
5067
 
4879
5068
  .avp-bottom {
@@ -4993,7 +5182,10 @@ var PLAYER_STYLES = (
4993
5182
  background: rgba(28, 28, 28, 0.95);
4994
5183
  border-radius: 8px;
4995
5184
  min-width: 220px;
4996
- 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));
4997
5189
  overflow-y: auto;
4998
5190
  display: none;
4999
5191
  z-index: 10;
@@ -5065,9 +5257,24 @@ var PLAYER_STYLES = (
5065
5257
  @media (pointer: coarse) {
5066
5258
  .avp-btn svg { width: 28px; height: 28px; }
5067
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; }
5068
5265
  .avp-seek-track { height: 4px; }
5069
5266
  .avp-seek:hover .avp-seek-track { height: 4px; }
5070
- .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
+
5071
5278
  .avp-volume:hover .avp-volume-slider { width: 0; }
5072
5279
  .avp-overlay-btn { width: 56px; height: 56px; }
5073
5280
  .avp-overlay-btn svg { width: 30px; height: 30px; }
@@ -5225,6 +5432,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5225
5432
  <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5226
5433
  <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5227
5434
  </div>
5435
+ <div part="content-overlay" class="avp-content-overlay"><slot name="content-overlay"></slot></div>
5228
5436
  <div part="overlay" class="avp-overlay">
5229
5437
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5230
5438
  <div class="avp-spinner"></div>
@@ -5440,19 +5648,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5440
5648
  this._userSeeking = true;
5441
5649
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5442
5650
  seekBar.setPointerCapture(e.pointerId);
5651
+ seekBar.setAttribute("data-seeking", "");
5443
5652
  const initial = this._timeFromSeekPointer(e.clientX);
5444
5653
  this._seekInput.value = String(initial);
5445
5654
  this._onSeekInput();
5655
+ this._updateSeekTooltip(e.clientX);
5446
5656
  const onMove = (ev) => {
5447
5657
  const t = this._timeFromSeekPointer(ev.clientX);
5448
5658
  this._seekInput.value = String(t);
5449
5659
  this._onSeekInput();
5660
+ this._updateSeekTooltip(ev.clientX);
5450
5661
  };
5451
5662
  const onUp = (ev) => {
5452
5663
  const t = this._timeFromSeekPointer(ev.clientX);
5453
5664
  this._seekInput.value = String(t);
5454
5665
  this._onSeekCommit();
5455
5666
  this._seekInput.focus();
5667
+ seekBar.removeAttribute("data-seeking");
5456
5668
  seekBar.removeEventListener("pointermove", onMove);
5457
5669
  seekBar.removeEventListener("pointerup", onUp);
5458
5670
  seekBar.removeEventListener("pointercancel", onUp);
@@ -5466,8 +5678,11 @@ var AvbridgePlayerElement = class extends HTMLElement {
5466
5678
  seekBar.addEventListener("pointercancel", onUp);
5467
5679
  }
5468
5680
  _onSeekHover(e) {
5681
+ this._updateSeekTooltip(e.clientX);
5682
+ }
5683
+ _updateSeekTooltip(clientX) {
5469
5684
  const rect = this._seekInput.getBoundingClientRect();
5470
- 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));
5471
5686
  const t = frac * (this._video.duration || 0);
5472
5687
  this._seekTooltip.textContent = formatTime(t);
5473
5688
  this._seekTooltip.style.left = `${frac * 100}%`;
@@ -5646,12 +5861,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
5646
5861
  this._fullscreenBtn.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
5647
5862
  }
5648
5863
  // ── Controls: auto-hide ────────────────────────────────────────────────
5649
- _showControls() {
5864
+ /**
5865
+ * Reveal the auto-hiding chrome (top toolbar + bottom controls) and
5866
+ * re-start the auto-hide timer. Call this from app-level code to
5867
+ * briefly surface the player UI — e.g. to confirm "you just swiped to
5868
+ * this video" in a carousel, or to flash the title on focus change.
5869
+ *
5870
+ * @param durationMs How long the chrome stays visible before fading.
5871
+ * Defaults to the player's normal 3 s auto-hide.
5872
+ * Pointer movement or any other interaction resets
5873
+ * the timer, so a user hovering during the flash
5874
+ * sees no flicker.
5875
+ */
5876
+ showControls(durationMs) {
5650
5877
  this.removeAttribute("data-controls-hidden");
5651
5878
  this._toolbarTop.setAttribute("data-visible", "true");
5652
- this._scheduleHide();
5879
+ this._scheduleHide(durationMs);
5880
+ }
5881
+ _showControls() {
5882
+ this.showControls();
5653
5883
  }
5654
- _scheduleHide() {
5884
+ _scheduleHide(durationMs = CONTROLS_HIDE_MS) {
5655
5885
  if (this._controlsTimer) clearTimeout(this._controlsTimer);
5656
5886
  if (this._state !== "playing" && this._state !== "buffering") return;
5657
5887
  if (this._settingsOpen) return;
@@ -5660,7 +5890,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5660
5890
  this.setAttribute("data-controls-hidden", "");
5661
5891
  this._toolbarTop.setAttribute("data-visible", "false");
5662
5892
  }
5663
- }, CONTROLS_HIDE_MS);
5893
+ }, durationMs);
5664
5894
  }
5665
5895
  // Strategy is visible in Stats for Nerds, no badge in controls bar.
5666
5896
  // ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
@@ -5672,19 +5902,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
5672
5902
  // it's treated as a double-click and the single-click action is cancelled.
5673
5903
  /** Track whether the last interaction was touch so click handler can skip. */
5674
5904
  _lastPointerTypeWasTouch = false;
5675
- /** True if the event's composed path passes through consumer-slotted toolbar
5676
- * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5677
- * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5678
- * does. */
5679
- _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) {
5680
5910
  for (const node of e.composedPath()) {
5681
- 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;
5682
5912
  }
5683
5913
  return false;
5684
5914
  }
5685
5915
  _onContainerClick(e) {
5686
5916
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5687
- if (this._isToolbarEvent(e)) return;
5917
+ if (this._isSlottedContentEvent(e)) return;
5688
5918
  if (this._lastPointerTypeWasTouch) {
5689
5919
  this._lastPointerTypeWasTouch = false;
5690
5920
  return;
@@ -5700,7 +5930,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5700
5930
  }
5701
5931
  _onContainerDblClick(e) {
5702
5932
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5703
- if (this._isToolbarEvent(e)) return;
5933
+ if (this._isSlottedContentEvent(e)) return;
5704
5934
  if (this._tapTimer) {
5705
5935
  clearTimeout(this._tapTimer);
5706
5936
  this._tapTimer = null;
@@ -5722,7 +5952,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5722
5952
  if (e.pointerType !== "touch") return;
5723
5953
  this._lastPointerTypeWasTouch = true;
5724
5954
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5725
- if (this._isToolbarEvent(e)) return;
5955
+ if (this._isSlottedContentEvent(e)) return;
5726
5956
  const now = Date.now();
5727
5957
  if (now - this._lastTapTime < 300) {
5728
5958
  if (this._tapTimer) {