avbridge 2.7.0 → 2.8.1

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.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-DGXeCNfD.cjs';
2
- export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-DGXeCNfD.cjs';
1
+ import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-DXEKOky8.cjs';
2
+ export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-DXEKOky8.cjs';
3
3
 
4
4
  /**
5
5
  * Codecs we know `<video>` and MSE support across modern desktop + Android.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-DGXeCNfD.js';
2
- export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-DGXeCNfD.js';
1
+ import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-DXEKOky8.js';
2
+ export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-DXEKOky8.js';
3
3
 
4
4
  /**
5
5
  * Codecs we know `<video>` and MSE support across modern desktop + Android.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mimeForFormat, generateFilename, createOutputFormat } from './chunk-SMH6IOP2.js';
2
2
  export { remux } from './chunk-SMH6IOP2.js';
3
- export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext as classify, createPlayer } from './chunk-OGYHFY6K.js';
3
+ export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext as classify, createPlayer } from './chunk-JSQOBUQB.js';
4
4
  export { srtToVtt } from './chunk-5KVLE6YI.js';
5
5
  import { probe, buildMediabunnySourceFromInput } from './chunk-SR3MPV4D.js';
6
6
  export { probe } from './chunk-SR3MPV4D.js';
@@ -293,6 +293,9 @@ interface AvbridgeVideoElementEventMap {
293
293
  buffered: TimeRanges;
294
294
  }>;
295
295
  loadstart: CustomEvent<Record<string, never>>;
296
+ fitchange: CustomEvent<{
297
+ fit: "contain" | "cover" | "fill";
298
+ }>;
296
299
  }
297
300
  /** Target output format for conversion functions. */
298
301
  type OutputFormat = "mp4" | "webm" | "mkv";
@@ -293,6 +293,9 @@ interface AvbridgeVideoElementEventMap {
293
293
  buffered: TimeRanges;
294
294
  }>;
295
295
  loadstart: CustomEvent<Record<string, never>>;
296
+ fitchange: CustomEvent<{
297
+ fit: "contain" | "cover" | "fill";
298
+ }>;
296
299
  }
297
300
  /** Target output format for conversion functions. */
298
301
  type OutputFormat = "mp4" | "webm" | "mkv";
package/dist/player.cjs CHANGED
@@ -496,6 +496,22 @@ function classifyContext(ctx) {
496
496
  };
497
497
  }
498
498
  if (REMUXABLE_CONTAINERS.has(ctx.container)) {
499
+ const mime = mp4MimeFor(video, audio);
500
+ if (mime && typeof MediaSource !== "undefined" && !mseSupports(mime)) {
501
+ if (webCodecsAvailable()) {
502
+ return {
503
+ class: "HYBRID_CANDIDATE",
504
+ strategy: "hybrid",
505
+ reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime \u2014 routing to WebCodecs hardware decode`,
506
+ fallbackChain: ["fallback"]
507
+ };
508
+ }
509
+ return {
510
+ class: "FALLBACK_REQUIRED",
511
+ strategy: "fallback",
512
+ 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
+ };
514
+ }
499
515
  return {
500
516
  class: "REMUX_CANDIDATE",
501
517
  strategy: "remux",
@@ -1239,7 +1255,7 @@ var VideoRenderer = class {
1239
1255
  this.resolveFirstFrame = resolve;
1240
1256
  });
1241
1257
  this.canvas = document.createElement("canvas");
1242
- this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
1258
+ this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:var(--avbridge-fit, contain);";
1243
1259
  const parent = target.parentElement ?? target.parentNode;
1244
1260
  if (parent && parent instanceof HTMLElement) {
1245
1261
  if (getComputedStyle(parent).position === "static") {
@@ -2582,7 +2598,7 @@ async function createHybridSession(ctx, target, transport) {
2582
2598
 
2583
2599
  // src/strategies/fallback/decoder.ts
2584
2600
  async function startDecoder(opts) {
2585
- const variant = pickLibavVariant(opts.context);
2601
+ const variant = "avbridge";
2586
2602
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
2587
2603
  const bridge = await loadBridge2();
2588
2604
  const { prepareLibavInput } = await import('./libav-http-reader-AZLE7YFS.cjs');
@@ -2638,9 +2654,8 @@ async function startDecoder(opts) {
2638
2654
  videoStream ? `video: ${opts.context.videoTracks[0]?.codec ?? "unknown"}` : null,
2639
2655
  audioStream ? `audio: ${opts.context.audioTracks[0]?.codec ?? "unknown"}` : null
2640
2656
  ].filter(Boolean).join(", ");
2641
- const hint = variant === "webcodecs" ? ` The "${variant}" libav variant does not include software decoders for these codecs. Try the custom "avbridge" variant (scripts/build-libav.sh) for broader codec support, or use a lighter strategy (native, remux, hybrid) instead.` : "";
2642
2657
  throw new Error(
2643
- `fallback decoder: could not initialize any libav decoders (${codecs}).${hint}`
2658
+ `fallback decoder: could not initialize any libav decoders (${codecs}). The "${variant}" libav variant lacks software decoders for these codecs \u2014 rebuild with scripts/build-libav.sh including the missing decoder, or use a lighter strategy (native, remux, hybrid) instead.`
2644
2659
  );
2645
2660
  }
2646
2661
  let bsfCtx = null;
@@ -3767,6 +3782,8 @@ var PREFERRED_STRATEGY_VALUES = /* @__PURE__ */ new Set([
3767
3782
  "hybrid",
3768
3783
  "fallback"
3769
3784
  ]);
3785
+ var FIT_VALUES = /* @__PURE__ */ new Set(["contain", "cover", "fill"]);
3786
+ var DEFAULT_FIT = "contain";
3770
3787
  var FORWARDED_VIDEO_EVENTS = [
3771
3788
  "loadstart",
3772
3789
  "loadedmetadata",
@@ -3801,7 +3818,9 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3801
3818
  "crossorigin",
3802
3819
  "disableremoteplayback",
3803
3820
  "diagnostics",
3804
- "preferstrategy"
3821
+ "preferstrategy",
3822
+ "fit",
3823
+ "no-orientation-lock"
3805
3824
  ];
3806
3825
  // ── Internal state ─────────────────────────────────────────────────────
3807
3826
  /** The shadow DOM `<video>` element that strategies render into. */
@@ -3853,23 +3872,38 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3853
3872
  * native fails.
3854
3873
  */
3855
3874
  _preferredStrategy = "auto";
3875
+ /** Current fit mode. Applied to the inner `<video>` via object-fit, and
3876
+ * to the fallback canvas via the `--avbridge-fit` CSS custom property on
3877
+ * the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
3878
+ _fit = DEFAULT_FIT;
3879
+ /** The stage wrapper — the element the canvas attaches into, and where
3880
+ * the `--avbridge-fit` CSS custom property lives. */
3881
+ _stageEl;
3856
3882
  /** Set if currentTime was assigned before the player was ready. */
3857
3883
  _pendingSeek = null;
3858
3884
  /** Set if play() was called before the player was ready. */
3859
3885
  _pendingPlay = false;
3860
3886
  /** MutationObserver tracking light-DOM `<track>` children. */
3861
3887
  _trackObserver = null;
3888
+ /** Document-level fullscreenchange handler — installed while connected so
3889
+ * the element can lock/unlock screen orientation to match the video's
3890
+ * intrinsic aspect. */
3891
+ _fullscreenChangeHandler = null;
3892
+ /** True if we successfully called screen.orientation.lock() on the last
3893
+ * fullscreen entry. Used to know whether to unlock on exit. */
3894
+ _orientationLocked = false;
3862
3895
  // ── Construction & lifecycle ───────────────────────────────────────────
3863
3896
  constructor() {
3864
3897
  super();
3865
3898
  const root = this.attachShadow({ mode: "open" });
3866
3899
  const stage = document.createElement("div");
3867
3900
  stage.setAttribute("part", "stage");
3868
- stage.style.cssText = "position:relative;width:100%;height:100%;display:block;";
3901
+ stage.style.cssText = `position:relative;width:100%;height:100%;display:block;--avbridge-fit:${DEFAULT_FIT};`;
3869
3902
  root.appendChild(stage);
3903
+ this._stageEl = stage;
3870
3904
  this._videoEl = document.createElement("video");
3871
3905
  this._videoEl.setAttribute("part", "video");
3872
- this._videoEl.style.cssText = "width:100%;height:100%;display:block;background:#000;";
3906
+ this._videoEl.style.cssText = `width:100%;height:100%;display:block;background:#000;object-fit:var(--avbridge-fit, ${DEFAULT_FIT});`;
3873
3907
  this._videoEl.playsInline = true;
3874
3908
  stage.appendChild(this._videoEl);
3875
3909
  this._videoEl.addEventListener("progress", () => {
@@ -3890,6 +3924,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3890
3924
  this._trackObserver = new MutationObserver(() => this._syncTextTracks());
3891
3925
  this._trackObserver.observe(this, { childList: true, subtree: false });
3892
3926
  }
3927
+ if (!this._fullscreenChangeHandler) {
3928
+ this._fullscreenChangeHandler = () => this._onFullscreenChange();
3929
+ document.addEventListener("fullscreenchange", this._fullscreenChangeHandler);
3930
+ }
3893
3931
  const source = this._activeSource();
3894
3932
  if (source != null) {
3895
3933
  void this._bootstrap(source);
@@ -3901,6 +3939,11 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3901
3939
  this._trackObserver.disconnect();
3902
3940
  this._trackObserver = null;
3903
3941
  }
3942
+ if (this._fullscreenChangeHandler) {
3943
+ document.removeEventListener("fullscreenchange", this._fullscreenChangeHandler);
3944
+ this._fullscreenChangeHandler = null;
3945
+ }
3946
+ this._releaseOrientationLock();
3904
3947
  this._bootstrapId++;
3905
3948
  void this._teardown();
3906
3949
  }
@@ -3934,6 +3977,14 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3934
3977
  this._preferredStrategy = "auto";
3935
3978
  }
3936
3979
  break;
3980
+ case "fit": {
3981
+ const next = newValue && FIT_VALUES.has(newValue) ? newValue : DEFAULT_FIT;
3982
+ if (next === this._fit) break;
3983
+ this._fit = next;
3984
+ this._stageEl.style.setProperty("--avbridge-fit", next);
3985
+ this._dispatch("fitchange", { fit: next });
3986
+ break;
3987
+ }
3937
3988
  }
3938
3989
  }
3939
3990
  // ── Source handling ────────────────────────────────────────────────────
@@ -4175,6 +4226,13 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4175
4226
  if (value) this.setAttribute("diagnostics", "");
4176
4227
  else this.removeAttribute("diagnostics");
4177
4228
  }
4229
+ get fit() {
4230
+ return this._fit;
4231
+ }
4232
+ set fit(value) {
4233
+ if (!FIT_VALUES.has(value)) return;
4234
+ this.setAttribute("fit", value);
4235
+ }
4178
4236
  get preferredStrategy() {
4179
4237
  return this._preferredStrategy;
4180
4238
  }
@@ -4348,6 +4406,87 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4348
4406
  }
4349
4407
  );
4350
4408
  }
4409
+ /**
4410
+ * Disable the automatic `screen.orientation.lock()` that runs on
4411
+ * fullscreen entry. Set when you want to honor the device's native
4412
+ * auto-rotate instead of matching the video's intrinsic orientation.
4413
+ */
4414
+ get noOrientationLock() {
4415
+ return this.hasAttribute("no-orientation-lock");
4416
+ }
4417
+ set noOrientationLock(value) {
4418
+ if (value) this.setAttribute("no-orientation-lock", "");
4419
+ else this.removeAttribute("no-orientation-lock");
4420
+ }
4421
+ // ── Fullscreen orientation lock ────────────────────────────────────────
4422
+ /** Called whenever `document.fullscreenchange` fires. If this element (or
4423
+ * any of its ancestors) is now fullscreen, derive the target orientation
4424
+ * from the video's intrinsic size and call `screen.orientation.lock()`.
4425
+ * On exit, release the lock we took. iOS Safari rejects `lock()` — we
4426
+ * swallow the rejection so nothing breaks on that path. */
4427
+ _onFullscreenChange() {
4428
+ if (this._destroyed) return;
4429
+ const fsEl = document.fullscreenElement;
4430
+ const nowFullscreen = fsEl != null && this._isInsideOrEquals(fsEl);
4431
+ if (nowFullscreen && !this._orientationLocked) {
4432
+ if (this.noOrientationLock) return;
4433
+ const target = this._desiredOrientation();
4434
+ if (!target) return;
4435
+ void this._lockOrientation(target);
4436
+ } else if (!nowFullscreen && this._orientationLocked) {
4437
+ this._releaseOrientationLock();
4438
+ }
4439
+ }
4440
+ /** Walk composed-tree ancestors to see if `target` is this element or
4441
+ * any ancestor across shadow boundaries. `Node.contains()` can't cross
4442
+ * shadow roots, so when `<avbridge-player>` (the fullscreen element)
4443
+ * hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
4444
+ * returns false. */
4445
+ _isInsideOrEquals(target) {
4446
+ let node = this;
4447
+ while (node) {
4448
+ if (node === target) return true;
4449
+ const parent = node.parentNode;
4450
+ if (parent instanceof ShadowRoot) node = parent.host;
4451
+ else node = parent;
4452
+ }
4453
+ return false;
4454
+ }
4455
+ /** Derive "landscape" / "portrait" from the intrinsic video dimensions.
4456
+ * Returns null when dimensions aren't known yet or the video is square.
4457
+ * Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
4458
+ * browser sets to the display-aspect-corrected size (so anamorphic
4459
+ * content is judged by its display aspect, not pixel aspect). */
4460
+ _desiredOrientation() {
4461
+ const w = this._videoEl.videoWidth;
4462
+ const h = this._videoEl.videoHeight;
4463
+ if (!w || !h) return null;
4464
+ if (w === h) return null;
4465
+ return w > h ? "landscape" : "portrait";
4466
+ }
4467
+ /** Attempt to lock screen orientation. Swallows rejections — iOS Safari
4468
+ * doesn't implement `lock()`, and desktop / non-fullscreen contexts will
4469
+ * reject too. Records success so we know whether to unlock on exit. */
4470
+ async _lockOrientation(target) {
4471
+ const so = screen.orientation;
4472
+ if (!so || typeof so.lock !== "function") return;
4473
+ try {
4474
+ await so.lock(target);
4475
+ this._orientationLocked = true;
4476
+ } catch {
4477
+ }
4478
+ }
4479
+ _releaseOrientationLock() {
4480
+ if (!this._orientationLocked) return;
4481
+ this._orientationLocked = false;
4482
+ const so = screen.orientation;
4483
+ if (so && typeof so.unlock === "function") {
4484
+ try {
4485
+ so.unlock();
4486
+ } catch {
4487
+ }
4488
+ }
4489
+ }
4351
4490
  // ── Public methods ─────────────────────────────────────────────────────
4352
4491
  /** Force a (re-)bootstrap if a source is currently set. */
4353
4492
  async load() {
@@ -4607,6 +4746,50 @@ var PLAYER_STYLES = (
4607
4746
 
4608
4747
  :host([data-controls-hidden]) { cursor: none; }
4609
4748
 
4749
+ /* \u2500\u2500 Top toolbar (slotted consumer chrome) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4750
+ Two named slots (top-left, top-right) let consumers place back / title /
4751
+ translate buttons inside the auto-hide chrome. Wrapper has
4752
+ pointer-events:none so empty slots don't block container clicks; each
4753
+ side re-enables pointer-events so real buttons remain interactive. */
4754
+
4755
+ .avp-toolbar-top {
4756
+ position: absolute;
4757
+ top: 0;
4758
+ left: 0;
4759
+ right: 0;
4760
+ z-index: 5;
4761
+ padding: 8px 12px 24px;
4762
+ background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
4763
+ display: flex;
4764
+ align-items: flex-start;
4765
+ justify-content: space-between;
4766
+ gap: 8px;
4767
+ opacity: 1;
4768
+ pointer-events: none;
4769
+ transition: opacity 0.25s;
4770
+ }
4771
+
4772
+ .avp-toolbar-top-left,
4773
+ .avp-toolbar-top-right {
4774
+ display: flex;
4775
+ align-items: center;
4776
+ gap: 8px;
4777
+ pointer-events: auto;
4778
+ }
4779
+
4780
+ .avp-toolbar-top-right { margin-left: auto; }
4781
+
4782
+ /* Hide the gradient band when no consumer has slotted anything \u2014 we
4783
+ toggle data-toolbar-empty from JS via slotchange. */
4784
+ :host([data-toolbar-empty]) .avp-toolbar-top {
4785
+ background: none;
4786
+ }
4787
+
4788
+ :host([data-controls-hidden]) .avp-toolbar-top {
4789
+ opacity: 0;
4790
+ pointer-events: none;
4791
+ }
4792
+
4610
4793
  /* \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 */
4611
4794
 
4612
4795
  .avp-seek {
@@ -4958,7 +5141,8 @@ var PROXY_ATTRIBUTES = [
4958
5141
  "playsinline",
4959
5142
  "crossorigin",
4960
5143
  "disableremoteplayback",
4961
- "preferstrategy"
5144
+ "preferstrategy",
5145
+ "fit"
4962
5146
  ];
4963
5147
  var AvbridgePlayerElement = class extends HTMLElement {
4964
5148
  static observedAttributes = [...PROXY_ATTRIBUTES];
@@ -4996,6 +5180,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
4996
5180
  _statsEl;
4997
5181
  _statsInterval = null;
4998
5182
  _eventCleanup = [];
5183
+ _updateToolbarEmpty = () => {
5184
+ };
4999
5185
  // ── Constructor ────────────────────────────────────────────────────────
5000
5186
  constructor() {
5001
5187
  super();
@@ -5019,12 +5205,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5019
5205
  this._statsEl = shadow.querySelector(".avp-stats");
5020
5206
  this._rippleLeft = shadow.querySelector(".avp-ripple-left");
5021
5207
  this._rippleRight = shadow.querySelector(".avp-ripple-right");
5208
+ const slots = shadow.querySelectorAll('slot[name="top-left"], slot[name="top-right"]');
5209
+ this._updateToolbarEmpty = () => {
5210
+ const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0);
5211
+ if (hasContent) this.removeAttribute("data-toolbar-empty");
5212
+ else this.setAttribute("data-toolbar-empty", "");
5213
+ };
5214
+ for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty);
5022
5215
  this._bindEvents();
5023
5216
  }
5024
5217
  _template() {
5025
5218
  return `
5026
5219
  <div part="container" class="avp">
5027
5220
  <avbridge-video part="video"></avbridge-video>
5221
+ <div part="toolbar-top" class="avp-toolbar-top">
5222
+ <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5223
+ <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5224
+ </div>
5028
5225
  <div part="overlay" class="avp-overlay">
5029
5226
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5030
5227
  <div class="avp-spinner"></div>
@@ -5177,13 +5374,14 @@ var AvbridgePlayerElement = class extends HTMLElement {
5177
5374
  });
5178
5375
  });
5179
5376
  on(this, "keydown", (e) => this._onKeydown(e));
5180
- if (!this.hasAttribute("tabindex")) {
5181
- this.setAttribute("tabindex", "0");
5182
- }
5183
5377
  }
5184
5378
  // ── Lifecycle ──────────────────────────────────────────────────────────
5185
5379
  connectedCallback() {
5186
5380
  this._setState("idle");
5381
+ if (!this.hasAttribute("tabindex")) {
5382
+ this.setAttribute("tabindex", "0");
5383
+ }
5384
+ this._updateToolbarEmpty();
5187
5385
  }
5188
5386
  disconnectedCallback() {
5189
5387
  this._clearTimers();
@@ -5445,8 +5643,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
5445
5643
  // it's treated as a double-click and the single-click action is cancelled.
5446
5644
  /** Track whether the last interaction was touch so click handler can skip. */
5447
5645
  _lastPointerTypeWasTouch = false;
5646
+ /** True if the event's composed path passes through consumer-slotted toolbar
5647
+ * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5648
+ * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5649
+ * does. */
5650
+ _isToolbarEvent(e) {
5651
+ for (const node of e.composedPath()) {
5652
+ if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
5653
+ }
5654
+ return false;
5655
+ }
5448
5656
  _onContainerClick(e) {
5449
5657
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5658
+ if (this._isToolbarEvent(e)) return;
5450
5659
  if (this._lastPointerTypeWasTouch) {
5451
5660
  this._lastPointerTypeWasTouch = false;
5452
5661
  return;
@@ -5462,6 +5671,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5462
5671
  }
5463
5672
  _onContainerDblClick(e) {
5464
5673
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5674
+ if (this._isToolbarEvent(e)) return;
5465
5675
  if (this._tapTimer) {
5466
5676
  clearTimeout(this._tapTimer);
5467
5677
  this._tapTimer = null;
@@ -5483,6 +5693,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5483
5693
  if (e.pointerType !== "touch") return;
5484
5694
  this._lastPointerTypeWasTouch = true;
5485
5695
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5696
+ if (this._isToolbarEvent(e)) return;
5486
5697
  const now = Date.now();
5487
5698
  if (now - this._lastTapTime < 300) {
5488
5699
  if (this._tapTimer) {