avbridge 2.6.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/player.d.cts CHANGED
@@ -283,6 +283,9 @@ interface AvbridgeVideoElementEventMap {
283
283
  buffered: TimeRanges;
284
284
  }>;
285
285
  loadstart: CustomEvent<Record<string, never>>;
286
+ fitchange: CustomEvent<{
287
+ fit: "contain" | "cover" | "fill";
288
+ }>;
286
289
  }
287
290
 
288
291
  /**
@@ -297,7 +300,7 @@ interface AvbridgeVideoElementEventMap {
297
300
  */
298
301
 
299
302
  declare class AvbridgePlayerElement extends HTMLElement {
300
- static readonly observedAttributes: ("src" | "muted" | "autoplay" | "loop" | "preload" | "poster" | "playsinline" | "crossorigin" | "disableremoteplayback" | "preferstrategy")[];
303
+ static readonly observedAttributes: ("src" | "muted" | "autoplay" | "loop" | "preload" | "poster" | "playsinline" | "crossorigin" | "disableremoteplayback" | "preferstrategy" | "fit")[];
301
304
  private _video;
302
305
  private _playBtn;
303
306
  private _overlayBtn;
@@ -328,6 +331,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
328
331
  private _statsEl;
329
332
  private _statsInterval;
330
333
  private _eventCleanup;
334
+ private _updateToolbarEmpty;
331
335
  constructor();
332
336
  private _template;
333
337
  private _bindEvents;
@@ -357,6 +361,11 @@ declare class AvbridgePlayerElement extends HTMLElement {
357
361
  private _scheduleHide;
358
362
  /** Track whether the last interaction was touch so click handler can skip. */
359
363
  private _lastPointerTypeWasTouch;
364
+ /** True if the event's composed path passes through consumer-slotted toolbar
365
+ * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
366
+ * on the event target won't find the shadow-DOM wrapper — `composedPath()`
367
+ * does. */
368
+ private _isToolbarEvent;
360
369
  private _onContainerClick;
361
370
  private _onContainerDblClick;
362
371
  private _onPointerDown;
@@ -506,14 +515,17 @@ declare class UnifiedPlayer {
506
515
  * 3. Give consumers a `<video>`-compatible primitive they can wrap with
507
516
  * their own UI.
508
517
  *
509
- * **It is not a player UI framework.** The tag name `<avbridge-player>` is
510
- * reserved for a future controls-bearing element. See
518
+ * **It is not a player UI framework.** For YouTube-style chrome (seek
519
+ * bar, play/pause, settings menu, fullscreen, auto-hiding controls) use
520
+ * `<avbridge-player>` — it wraps this element with a full UI. See
511
521
  * `docs/dev/WEB_COMPONENT_SPEC.md` for the full spec, lifecycle invariants,
512
522
  * and edge case list.
513
523
  */
514
524
 
515
525
  /** Strategy preference passed via the `preferstrategy` attribute. */
516
526
  type PreferredStrategy = "auto" | StrategyName;
527
+ /** Fit mode — how the video fills the element's box. Mirrors CSS object-fit. */
528
+ type FitMode = "contain" | "cover" | "fill";
517
529
  /**
518
530
  * `HTMLElement` is a browser-only global. SSR frameworks (Next.js, Astro,
519
531
  * Remix, etc.) commonly import library modules on the server to extract
@@ -586,12 +598,26 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
586
598
  * native fails.
587
599
  */
588
600
  private _preferredStrategy;
601
+ /** Current fit mode. Applied to the inner `<video>` via object-fit, and
602
+ * to the fallback canvas via the `--avbridge-fit` CSS custom property on
603
+ * the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
604
+ private _fit;
605
+ /** The stage wrapper — the element the canvas attaches into, and where
606
+ * the `--avbridge-fit` CSS custom property lives. */
607
+ private _stageEl;
589
608
  /** Set if currentTime was assigned before the player was ready. */
590
609
  private _pendingSeek;
591
610
  /** Set if play() was called before the player was ready. */
592
611
  private _pendingPlay;
593
612
  /** MutationObserver tracking light-DOM `<track>` children. */
594
613
  private _trackObserver;
614
+ /** Document-level fullscreenchange handler — installed while connected so
615
+ * the element can lock/unlock screen orientation to match the video's
616
+ * intrinsic aspect. */
617
+ private _fullscreenChangeHandler;
618
+ /** True if we successfully called screen.orientation.lock() on the last
619
+ * fullscreen entry. Used to know whether to unlock on exit. */
620
+ private _orientationLocked;
595
621
  constructor();
596
622
  connectedCallback(): void;
597
623
  disconnectedCallback(): void;
@@ -635,6 +661,8 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
635
661
  set preload(value: "none" | "metadata" | "auto");
636
662
  get diagnostics(): boolean;
637
663
  set diagnostics(value: boolean);
664
+ get fit(): FitMode;
665
+ set fit(value: FitMode);
638
666
  get preferredStrategy(): PreferredStrategy;
639
667
  set preferredStrategy(value: PreferredStrategy);
640
668
  get currentTime(): number;
@@ -724,6 +752,36 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
724
752
  language?: string;
725
753
  format?: "vtt" | "srt";
726
754
  }): Promise<void>;
755
+ /**
756
+ * Disable the automatic `screen.orientation.lock()` that runs on
757
+ * fullscreen entry. Set when you want to honor the device's native
758
+ * auto-rotate instead of matching the video's intrinsic orientation.
759
+ */
760
+ get noOrientationLock(): boolean;
761
+ set noOrientationLock(value: boolean);
762
+ /** Called whenever `document.fullscreenchange` fires. If this element (or
763
+ * any of its ancestors) is now fullscreen, derive the target orientation
764
+ * from the video's intrinsic size and call `screen.orientation.lock()`.
765
+ * On exit, release the lock we took. iOS Safari rejects `lock()` — we
766
+ * swallow the rejection so nothing breaks on that path. */
767
+ private _onFullscreenChange;
768
+ /** Walk composed-tree ancestors to see if `target` is this element or
769
+ * any ancestor across shadow boundaries. `Node.contains()` can't cross
770
+ * shadow roots, so when `<avbridge-player>` (the fullscreen element)
771
+ * hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
772
+ * returns false. */
773
+ private _isInsideOrEquals;
774
+ /** Derive "landscape" / "portrait" from the intrinsic video dimensions.
775
+ * Returns null when dimensions aren't known yet or the video is square.
776
+ * Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
777
+ * browser sets to the display-aspect-corrected size (so anamorphic
778
+ * content is judged by its display aspect, not pixel aspect). */
779
+ private _desiredOrientation;
780
+ /** Attempt to lock screen orientation. Swallows rejections — iOS Safari
781
+ * doesn't implement `lock()`, and desktop / non-fullscreen contexts will
782
+ * reject too. Records success so we know whether to unlock on exit. */
783
+ private _lockOrientation;
784
+ private _releaseOrientationLock;
727
785
  /** Force a (re-)bootstrap if a source is currently set. */
728
786
  load(): Promise<void>;
729
787
  /**
package/dist/player.d.ts CHANGED
@@ -283,6 +283,9 @@ interface AvbridgeVideoElementEventMap {
283
283
  buffered: TimeRanges;
284
284
  }>;
285
285
  loadstart: CustomEvent<Record<string, never>>;
286
+ fitchange: CustomEvent<{
287
+ fit: "contain" | "cover" | "fill";
288
+ }>;
286
289
  }
287
290
 
288
291
  /**
@@ -297,7 +300,7 @@ interface AvbridgeVideoElementEventMap {
297
300
  */
298
301
 
299
302
  declare class AvbridgePlayerElement extends HTMLElement {
300
- static readonly observedAttributes: ("src" | "muted" | "autoplay" | "loop" | "preload" | "poster" | "playsinline" | "crossorigin" | "disableremoteplayback" | "preferstrategy")[];
303
+ static readonly observedAttributes: ("src" | "muted" | "autoplay" | "loop" | "preload" | "poster" | "playsinline" | "crossorigin" | "disableremoteplayback" | "preferstrategy" | "fit")[];
301
304
  private _video;
302
305
  private _playBtn;
303
306
  private _overlayBtn;
@@ -328,6 +331,7 @@ declare class AvbridgePlayerElement extends HTMLElement {
328
331
  private _statsEl;
329
332
  private _statsInterval;
330
333
  private _eventCleanup;
334
+ private _updateToolbarEmpty;
331
335
  constructor();
332
336
  private _template;
333
337
  private _bindEvents;
@@ -357,6 +361,11 @@ declare class AvbridgePlayerElement extends HTMLElement {
357
361
  private _scheduleHide;
358
362
  /** Track whether the last interaction was touch so click handler can skip. */
359
363
  private _lastPointerTypeWasTouch;
364
+ /** True if the event's composed path passes through consumer-slotted toolbar
365
+ * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
366
+ * on the event target won't find the shadow-DOM wrapper — `composedPath()`
367
+ * does. */
368
+ private _isToolbarEvent;
360
369
  private _onContainerClick;
361
370
  private _onContainerDblClick;
362
371
  private _onPointerDown;
@@ -506,14 +515,17 @@ declare class UnifiedPlayer {
506
515
  * 3. Give consumers a `<video>`-compatible primitive they can wrap with
507
516
  * their own UI.
508
517
  *
509
- * **It is not a player UI framework.** The tag name `<avbridge-player>` is
510
- * reserved for a future controls-bearing element. See
518
+ * **It is not a player UI framework.** For YouTube-style chrome (seek
519
+ * bar, play/pause, settings menu, fullscreen, auto-hiding controls) use
520
+ * `<avbridge-player>` — it wraps this element with a full UI. See
511
521
  * `docs/dev/WEB_COMPONENT_SPEC.md` for the full spec, lifecycle invariants,
512
522
  * and edge case list.
513
523
  */
514
524
 
515
525
  /** Strategy preference passed via the `preferstrategy` attribute. */
516
526
  type PreferredStrategy = "auto" | StrategyName;
527
+ /** Fit mode — how the video fills the element's box. Mirrors CSS object-fit. */
528
+ type FitMode = "contain" | "cover" | "fill";
517
529
  /**
518
530
  * `HTMLElement` is a browser-only global. SSR frameworks (Next.js, Astro,
519
531
  * Remix, etc.) commonly import library modules on the server to extract
@@ -586,12 +598,26 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
586
598
  * native fails.
587
599
  */
588
600
  private _preferredStrategy;
601
+ /** Current fit mode. Applied to the inner `<video>` via object-fit, and
602
+ * to the fallback canvas via the `--avbridge-fit` CSS custom property on
603
+ * the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
604
+ private _fit;
605
+ /** The stage wrapper — the element the canvas attaches into, and where
606
+ * the `--avbridge-fit` CSS custom property lives. */
607
+ private _stageEl;
589
608
  /** Set if currentTime was assigned before the player was ready. */
590
609
  private _pendingSeek;
591
610
  /** Set if play() was called before the player was ready. */
592
611
  private _pendingPlay;
593
612
  /** MutationObserver tracking light-DOM `<track>` children. */
594
613
  private _trackObserver;
614
+ /** Document-level fullscreenchange handler — installed while connected so
615
+ * the element can lock/unlock screen orientation to match the video's
616
+ * intrinsic aspect. */
617
+ private _fullscreenChangeHandler;
618
+ /** True if we successfully called screen.orientation.lock() on the last
619
+ * fullscreen entry. Used to know whether to unlock on exit. */
620
+ private _orientationLocked;
595
621
  constructor();
596
622
  connectedCallback(): void;
597
623
  disconnectedCallback(): void;
@@ -635,6 +661,8 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
635
661
  set preload(value: "none" | "metadata" | "auto");
636
662
  get diagnostics(): boolean;
637
663
  set diagnostics(value: boolean);
664
+ get fit(): FitMode;
665
+ set fit(value: FitMode);
638
666
  get preferredStrategy(): PreferredStrategy;
639
667
  set preferredStrategy(value: PreferredStrategy);
640
668
  get currentTime(): number;
@@ -724,6 +752,36 @@ declare class AvbridgeVideoElement extends HTMLElementCtor {
724
752
  language?: string;
725
753
  format?: "vtt" | "srt";
726
754
  }): Promise<void>;
755
+ /**
756
+ * Disable the automatic `screen.orientation.lock()` that runs on
757
+ * fullscreen entry. Set when you want to honor the device's native
758
+ * auto-rotate instead of matching the video's intrinsic orientation.
759
+ */
760
+ get noOrientationLock(): boolean;
761
+ set noOrientationLock(value: boolean);
762
+ /** Called whenever `document.fullscreenchange` fires. If this element (or
763
+ * any of its ancestors) is now fullscreen, derive the target orientation
764
+ * from the video's intrinsic size and call `screen.orientation.lock()`.
765
+ * On exit, release the lock we took. iOS Safari rejects `lock()` — we
766
+ * swallow the rejection so nothing breaks on that path. */
767
+ private _onFullscreenChange;
768
+ /** Walk composed-tree ancestors to see if `target` is this element or
769
+ * any ancestor across shadow boundaries. `Node.contains()` can't cross
770
+ * shadow roots, so when `<avbridge-player>` (the fullscreen element)
771
+ * hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
772
+ * returns false. */
773
+ private _isInsideOrEquals;
774
+ /** Derive "landscape" / "portrait" from the intrinsic video dimensions.
775
+ * Returns null when dimensions aren't known yet or the video is square.
776
+ * Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
777
+ * browser sets to the display-aspect-corrected size (so anamorphic
778
+ * content is judged by its display aspect, not pixel aspect). */
779
+ private _desiredOrientation;
780
+ /** Attempt to lock screen orientation. Swallows rejections — iOS Safari
781
+ * doesn't implement `lock()`, and desktop / non-fullscreen contexts will
782
+ * reject too. Records success so we know whether to unlock on exit. */
783
+ private _lockOrientation;
784
+ private _releaseOrientationLock;
727
785
  /** Force a (re-)bootstrap if a source is currently set. */
728
786
  load(): Promise<void>;
729
787
  /**
package/dist/player.js CHANGED
@@ -494,6 +494,22 @@ function classifyContext(ctx) {
494
494
  };
495
495
  }
496
496
  if (REMUXABLE_CONTAINERS.has(ctx.container)) {
497
+ const mime = mp4MimeFor(video, audio);
498
+ if (mime && typeof MediaSource !== "undefined" && !mseSupports(mime)) {
499
+ if (webCodecsAvailable()) {
500
+ return {
501
+ class: "HYBRID_CANDIDATE",
502
+ strategy: "hybrid",
503
+ reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime \u2014 routing to WebCodecs hardware decode`,
504
+ fallbackChain: ["fallback"]
505
+ };
506
+ }
507
+ return {
508
+ class: "FALLBACK_REQUIRED",
509
+ strategy: "fallback",
510
+ 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
+ };
512
+ }
497
513
  return {
498
514
  class: "REMUX_CANDIDATE",
499
515
  strategy: "remux",
@@ -1237,7 +1253,7 @@ var VideoRenderer = class {
1237
1253
  this.resolveFirstFrame = resolve;
1238
1254
  });
1239
1255
  this.canvas = document.createElement("canvas");
1240
- this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
1256
+ this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:var(--avbridge-fit, contain);";
1241
1257
  const parent = target.parentElement ?? target.parentNode;
1242
1258
  if (parent && parent instanceof HTMLElement) {
1243
1259
  if (getComputedStyle(parent).position === "static") {
@@ -2580,7 +2596,7 @@ async function createHybridSession(ctx, target, transport) {
2580
2596
 
2581
2597
  // src/strategies/fallback/decoder.ts
2582
2598
  async function startDecoder(opts) {
2583
- const variant = pickLibavVariant(opts.context);
2599
+ const variant = "avbridge";
2584
2600
  const libav = await loadLibav(variant);
2585
2601
  const bridge = await loadBridge2();
2586
2602
  const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
@@ -2636,9 +2652,8 @@ async function startDecoder(opts) {
2636
2652
  videoStream ? `video: ${opts.context.videoTracks[0]?.codec ?? "unknown"}` : null,
2637
2653
  audioStream ? `audio: ${opts.context.audioTracks[0]?.codec ?? "unknown"}` : null
2638
2654
  ].filter(Boolean).join(", ");
2639
- 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.` : "";
2640
2655
  throw new Error(
2641
- `fallback decoder: could not initialize any libav decoders (${codecs}).${hint}`
2656
+ `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.`
2642
2657
  );
2643
2658
  }
2644
2659
  let bsfCtx = null;
@@ -3765,6 +3780,8 @@ var PREFERRED_STRATEGY_VALUES = /* @__PURE__ */ new Set([
3765
3780
  "hybrid",
3766
3781
  "fallback"
3767
3782
  ]);
3783
+ var FIT_VALUES = /* @__PURE__ */ new Set(["contain", "cover", "fill"]);
3784
+ var DEFAULT_FIT = "contain";
3768
3785
  var FORWARDED_VIDEO_EVENTS = [
3769
3786
  "loadstart",
3770
3787
  "loadedmetadata",
@@ -3799,7 +3816,9 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3799
3816
  "crossorigin",
3800
3817
  "disableremoteplayback",
3801
3818
  "diagnostics",
3802
- "preferstrategy"
3819
+ "preferstrategy",
3820
+ "fit",
3821
+ "no-orientation-lock"
3803
3822
  ];
3804
3823
  // ── Internal state ─────────────────────────────────────────────────────
3805
3824
  /** The shadow DOM `<video>` element that strategies render into. */
@@ -3851,23 +3870,38 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3851
3870
  * native fails.
3852
3871
  */
3853
3872
  _preferredStrategy = "auto";
3873
+ /** Current fit mode. Applied to the inner `<video>` via object-fit, and
3874
+ * to the fallback canvas via the `--avbridge-fit` CSS custom property on
3875
+ * the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
3876
+ _fit = DEFAULT_FIT;
3877
+ /** The stage wrapper — the element the canvas attaches into, and where
3878
+ * the `--avbridge-fit` CSS custom property lives. */
3879
+ _stageEl;
3854
3880
  /** Set if currentTime was assigned before the player was ready. */
3855
3881
  _pendingSeek = null;
3856
3882
  /** Set if play() was called before the player was ready. */
3857
3883
  _pendingPlay = false;
3858
3884
  /** MutationObserver tracking light-DOM `<track>` children. */
3859
3885
  _trackObserver = null;
3886
+ /** Document-level fullscreenchange handler — installed while connected so
3887
+ * the element can lock/unlock screen orientation to match the video's
3888
+ * intrinsic aspect. */
3889
+ _fullscreenChangeHandler = null;
3890
+ /** True if we successfully called screen.orientation.lock() on the last
3891
+ * fullscreen entry. Used to know whether to unlock on exit. */
3892
+ _orientationLocked = false;
3860
3893
  // ── Construction & lifecycle ───────────────────────────────────────────
3861
3894
  constructor() {
3862
3895
  super();
3863
3896
  const root = this.attachShadow({ mode: "open" });
3864
3897
  const stage = document.createElement("div");
3865
3898
  stage.setAttribute("part", "stage");
3866
- stage.style.cssText = "position:relative;width:100%;height:100%;display:block;";
3899
+ stage.style.cssText = `position:relative;width:100%;height:100%;display:block;--avbridge-fit:${DEFAULT_FIT};`;
3867
3900
  root.appendChild(stage);
3901
+ this._stageEl = stage;
3868
3902
  this._videoEl = document.createElement("video");
3869
3903
  this._videoEl.setAttribute("part", "video");
3870
- this._videoEl.style.cssText = "width:100%;height:100%;display:block;background:#000;";
3904
+ this._videoEl.style.cssText = `width:100%;height:100%;display:block;background:#000;object-fit:var(--avbridge-fit, ${DEFAULT_FIT});`;
3871
3905
  this._videoEl.playsInline = true;
3872
3906
  stage.appendChild(this._videoEl);
3873
3907
  this._videoEl.addEventListener("progress", () => {
@@ -3888,6 +3922,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3888
3922
  this._trackObserver = new MutationObserver(() => this._syncTextTracks());
3889
3923
  this._trackObserver.observe(this, { childList: true, subtree: false });
3890
3924
  }
3925
+ if (!this._fullscreenChangeHandler) {
3926
+ this._fullscreenChangeHandler = () => this._onFullscreenChange();
3927
+ document.addEventListener("fullscreenchange", this._fullscreenChangeHandler);
3928
+ }
3891
3929
  const source = this._activeSource();
3892
3930
  if (source != null) {
3893
3931
  void this._bootstrap(source);
@@ -3899,6 +3937,11 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3899
3937
  this._trackObserver.disconnect();
3900
3938
  this._trackObserver = null;
3901
3939
  }
3940
+ if (this._fullscreenChangeHandler) {
3941
+ document.removeEventListener("fullscreenchange", this._fullscreenChangeHandler);
3942
+ this._fullscreenChangeHandler = null;
3943
+ }
3944
+ this._releaseOrientationLock();
3902
3945
  this._bootstrapId++;
3903
3946
  void this._teardown();
3904
3947
  }
@@ -3932,6 +3975,14 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
3932
3975
  this._preferredStrategy = "auto";
3933
3976
  }
3934
3977
  break;
3978
+ case "fit": {
3979
+ const next = newValue && FIT_VALUES.has(newValue) ? newValue : DEFAULT_FIT;
3980
+ if (next === this._fit) break;
3981
+ this._fit = next;
3982
+ this._stageEl.style.setProperty("--avbridge-fit", next);
3983
+ this._dispatch("fitchange", { fit: next });
3984
+ break;
3985
+ }
3935
3986
  }
3936
3987
  }
3937
3988
  // ── Source handling ────────────────────────────────────────────────────
@@ -4173,6 +4224,13 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4173
4224
  if (value) this.setAttribute("diagnostics", "");
4174
4225
  else this.removeAttribute("diagnostics");
4175
4226
  }
4227
+ get fit() {
4228
+ return this._fit;
4229
+ }
4230
+ set fit(value) {
4231
+ if (!FIT_VALUES.has(value)) return;
4232
+ this.setAttribute("fit", value);
4233
+ }
4176
4234
  get preferredStrategy() {
4177
4235
  return this._preferredStrategy;
4178
4236
  }
@@ -4346,6 +4404,87 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
4346
4404
  }
4347
4405
  );
4348
4406
  }
4407
+ /**
4408
+ * Disable the automatic `screen.orientation.lock()` that runs on
4409
+ * fullscreen entry. Set when you want to honor the device's native
4410
+ * auto-rotate instead of matching the video's intrinsic orientation.
4411
+ */
4412
+ get noOrientationLock() {
4413
+ return this.hasAttribute("no-orientation-lock");
4414
+ }
4415
+ set noOrientationLock(value) {
4416
+ if (value) this.setAttribute("no-orientation-lock", "");
4417
+ else this.removeAttribute("no-orientation-lock");
4418
+ }
4419
+ // ── Fullscreen orientation lock ────────────────────────────────────────
4420
+ /** Called whenever `document.fullscreenchange` fires. If this element (or
4421
+ * any of its ancestors) is now fullscreen, derive the target orientation
4422
+ * from the video's intrinsic size and call `screen.orientation.lock()`.
4423
+ * On exit, release the lock we took. iOS Safari rejects `lock()` — we
4424
+ * swallow the rejection so nothing breaks on that path. */
4425
+ _onFullscreenChange() {
4426
+ if (this._destroyed) return;
4427
+ const fsEl = document.fullscreenElement;
4428
+ const nowFullscreen = fsEl != null && this._isInsideOrEquals(fsEl);
4429
+ if (nowFullscreen && !this._orientationLocked) {
4430
+ if (this.noOrientationLock) return;
4431
+ const target = this._desiredOrientation();
4432
+ if (!target) return;
4433
+ void this._lockOrientation(target);
4434
+ } else if (!nowFullscreen && this._orientationLocked) {
4435
+ this._releaseOrientationLock();
4436
+ }
4437
+ }
4438
+ /** Walk composed-tree ancestors to see if `target` is this element or
4439
+ * any ancestor across shadow boundaries. `Node.contains()` can't cross
4440
+ * shadow roots, so when `<avbridge-player>` (the fullscreen element)
4441
+ * hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
4442
+ * returns false. */
4443
+ _isInsideOrEquals(target) {
4444
+ let node = this;
4445
+ while (node) {
4446
+ if (node === target) return true;
4447
+ const parent = node.parentNode;
4448
+ if (parent instanceof ShadowRoot) node = parent.host;
4449
+ else node = parent;
4450
+ }
4451
+ return false;
4452
+ }
4453
+ /** Derive "landscape" / "portrait" from the intrinsic video dimensions.
4454
+ * Returns null when dimensions aren't known yet or the video is square.
4455
+ * Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
4456
+ * browser sets to the display-aspect-corrected size (so anamorphic
4457
+ * content is judged by its display aspect, not pixel aspect). */
4458
+ _desiredOrientation() {
4459
+ const w = this._videoEl.videoWidth;
4460
+ const h = this._videoEl.videoHeight;
4461
+ if (!w || !h) return null;
4462
+ if (w === h) return null;
4463
+ return w > h ? "landscape" : "portrait";
4464
+ }
4465
+ /** Attempt to lock screen orientation. Swallows rejections — iOS Safari
4466
+ * doesn't implement `lock()`, and desktop / non-fullscreen contexts will
4467
+ * reject too. Records success so we know whether to unlock on exit. */
4468
+ async _lockOrientation(target) {
4469
+ const so = screen.orientation;
4470
+ if (!so || typeof so.lock !== "function") return;
4471
+ try {
4472
+ await so.lock(target);
4473
+ this._orientationLocked = true;
4474
+ } catch {
4475
+ }
4476
+ }
4477
+ _releaseOrientationLock() {
4478
+ if (!this._orientationLocked) return;
4479
+ this._orientationLocked = false;
4480
+ const so = screen.orientation;
4481
+ if (so && typeof so.unlock === "function") {
4482
+ try {
4483
+ so.unlock();
4484
+ } catch {
4485
+ }
4486
+ }
4487
+ }
4349
4488
  // ── Public methods ─────────────────────────────────────────────────────
4350
4489
  /** Force a (re-)bootstrap if a source is currently set. */
4351
4490
  async load() {
@@ -4605,6 +4744,50 @@ var PLAYER_STYLES = (
4605
4744
 
4606
4745
  :host([data-controls-hidden]) { cursor: none; }
4607
4746
 
4747
+ /* \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
4748
+ Two named slots (top-left, top-right) let consumers place back / title /
4749
+ translate buttons inside the auto-hide chrome. Wrapper has
4750
+ pointer-events:none so empty slots don't block container clicks; each
4751
+ side re-enables pointer-events so real buttons remain interactive. */
4752
+
4753
+ .avp-toolbar-top {
4754
+ position: absolute;
4755
+ top: 0;
4756
+ left: 0;
4757
+ right: 0;
4758
+ z-index: 5;
4759
+ padding: 8px 12px 24px;
4760
+ background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
4761
+ display: flex;
4762
+ align-items: flex-start;
4763
+ justify-content: space-between;
4764
+ gap: 8px;
4765
+ opacity: 1;
4766
+ pointer-events: none;
4767
+ transition: opacity 0.25s;
4768
+ }
4769
+
4770
+ .avp-toolbar-top-left,
4771
+ .avp-toolbar-top-right {
4772
+ display: flex;
4773
+ align-items: center;
4774
+ gap: 8px;
4775
+ pointer-events: auto;
4776
+ }
4777
+
4778
+ .avp-toolbar-top-right { margin-left: auto; }
4779
+
4780
+ /* Hide the gradient band when no consumer has slotted anything \u2014 we
4781
+ toggle data-toolbar-empty from JS via slotchange. */
4782
+ :host([data-toolbar-empty]) .avp-toolbar-top {
4783
+ background: none;
4784
+ }
4785
+
4786
+ :host([data-controls-hidden]) .avp-toolbar-top {
4787
+ opacity: 0;
4788
+ pointer-events: none;
4789
+ }
4790
+
4608
4791
  /* \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 */
4609
4792
 
4610
4793
  .avp-seek {
@@ -4956,7 +5139,8 @@ var PROXY_ATTRIBUTES = [
4956
5139
  "playsinline",
4957
5140
  "crossorigin",
4958
5141
  "disableremoteplayback",
4959
- "preferstrategy"
5142
+ "preferstrategy",
5143
+ "fit"
4960
5144
  ];
4961
5145
  var AvbridgePlayerElement = class extends HTMLElement {
4962
5146
  static observedAttributes = [...PROXY_ATTRIBUTES];
@@ -4994,6 +5178,8 @@ var AvbridgePlayerElement = class extends HTMLElement {
4994
5178
  _statsEl;
4995
5179
  _statsInterval = null;
4996
5180
  _eventCleanup = [];
5181
+ _updateToolbarEmpty = () => {
5182
+ };
4997
5183
  // ── Constructor ────────────────────────────────────────────────────────
4998
5184
  constructor() {
4999
5185
  super();
@@ -5017,12 +5203,23 @@ var AvbridgePlayerElement = class extends HTMLElement {
5017
5203
  this._statsEl = shadow.querySelector(".avp-stats");
5018
5204
  this._rippleLeft = shadow.querySelector(".avp-ripple-left");
5019
5205
  this._rippleRight = shadow.querySelector(".avp-ripple-right");
5206
+ const slots = shadow.querySelectorAll('slot[name="top-left"], slot[name="top-right"]');
5207
+ this._updateToolbarEmpty = () => {
5208
+ const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0);
5209
+ if (hasContent) this.removeAttribute("data-toolbar-empty");
5210
+ else this.setAttribute("data-toolbar-empty", "");
5211
+ };
5212
+ for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty);
5020
5213
  this._bindEvents();
5021
5214
  }
5022
5215
  _template() {
5023
5216
  return `
5024
5217
  <div part="container" class="avp">
5025
5218
  <avbridge-video part="video"></avbridge-video>
5219
+ <div part="toolbar-top" class="avp-toolbar-top">
5220
+ <div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
5221
+ <div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
5222
+ </div>
5026
5223
  <div part="overlay" class="avp-overlay">
5027
5224
  <button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
5028
5225
  <div class="avp-spinner"></div>
@@ -5175,13 +5372,14 @@ var AvbridgePlayerElement = class extends HTMLElement {
5175
5372
  });
5176
5373
  });
5177
5374
  on(this, "keydown", (e) => this._onKeydown(e));
5178
- if (!this.hasAttribute("tabindex")) {
5179
- this.setAttribute("tabindex", "0");
5180
- }
5181
5375
  }
5182
5376
  // ── Lifecycle ──────────────────────────────────────────────────────────
5183
5377
  connectedCallback() {
5184
5378
  this._setState("idle");
5379
+ if (!this.hasAttribute("tabindex")) {
5380
+ this.setAttribute("tabindex", "0");
5381
+ }
5382
+ this._updateToolbarEmpty();
5185
5383
  }
5186
5384
  disconnectedCallback() {
5187
5385
  this._clearTimers();
@@ -5443,8 +5641,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
5443
5641
  // it's treated as a double-click and the single-click action is cancelled.
5444
5642
  /** Track whether the last interaction was touch so click handler can skip. */
5445
5643
  _lastPointerTypeWasTouch = false;
5644
+ /** True if the event's composed path passes through consumer-slotted toolbar
5645
+ * content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
5646
+ * on the event target won't find the shadow-DOM wrapper — `composedPath()`
5647
+ * does. */
5648
+ _isToolbarEvent(e) {
5649
+ for (const node of e.composedPath()) {
5650
+ if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
5651
+ }
5652
+ return false;
5653
+ }
5446
5654
  _onContainerClick(e) {
5447
5655
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5656
+ if (this._isToolbarEvent(e)) return;
5448
5657
  if (this._lastPointerTypeWasTouch) {
5449
5658
  this._lastPointerTypeWasTouch = false;
5450
5659
  return;
@@ -5460,6 +5669,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5460
5669
  }
5461
5670
  _onContainerDblClick(e) {
5462
5671
  if (e.target.closest?.(".avp-controls, .avp-settings")) return;
5672
+ if (this._isToolbarEvent(e)) return;
5463
5673
  if (this._tapTimer) {
5464
5674
  clearTimeout(this._tapTimer);
5465
5675
  this._tapTimer = null;
@@ -5481,6 +5691,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
5481
5691
  if (e.pointerType !== "touch") return;
5482
5692
  this._lastPointerTypeWasTouch = true;
5483
5693
  if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
5694
+ if (this._isToolbarEvent(e)) return;
5484
5695
  const now = Date.now();
5485
5696
  if (now - this._lastTapTime < 300) {
5486
5697
  if (this._tapTimer) {