movi-player 0.3.4 → 0.3.5

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.
@@ -75,6 +75,10 @@ export class MoviElement extends HTMLElement {
75
75
  // Blurred-backdrop element behind the sharp-art canvas — CSS filter:blur()
76
76
  // (cross-browser Gaussian, unlike canvas ctx.filter).
77
77
  _coverArtBgEl = null;
78
+ // A small data URL of the embedded album art, generated once per source, so
79
+ // the CSS-blurred backdrop can point at it too (background-image needs a URL;
80
+ // the embedded art arrives as an ImageBitmap with none).
81
+ _coverArtBgUrl = "";
78
82
  // True once cover-art extraction has settled for the current source (bitmap
79
83
  // arrived OR extraction failed). Until then, if the source has an art track,
80
84
  // we hold the audio-strip layout off so the player doesn't flash strip→cover.
@@ -3432,7 +3436,7 @@ export class MoviElement extends HTMLElement {
3432
3436
  if (this.player && !this._pipWindow) {
3433
3437
  const deg = this.player.rotateVideo();
3434
3438
  this.showOSD(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>`, `${deg}°`);
3435
- const statusEl = this.shadowRoot?.querySelector(".movi-rotate-status");
3439
+ const statusEl = this.contextMenuRoot().querySelector(".movi-rotate-status");
3436
3440
  if (statusEl)
3437
3441
  statusEl.textContent = `${deg}°`;
3438
3442
  this.syncThumbnailRotation(deg);
@@ -3852,7 +3856,16 @@ export class MoviElement extends HTMLElement {
3852
3856
  // useless. Switch to position:fixed in viewport coordinates so
3853
3857
  // the menu can spill into the empty page area below the strip.
3854
3858
  const isStripMode = this.classList.contains("movi-audio-strip");
3855
- if (isStripMode) {
3859
+ // In fullscreen the player host IS the fullscreen element (the browser's
3860
+ // top layer), so the body-level portal renders OUTSIDE it and the menu
3861
+ // never appears. There are also no page clipping ancestors to escape here
3862
+ // (the host fills the screen). So keep the menu in the shadow root, lift
3863
+ // the host's paint clip so it isn't chopped at the host edge, and position
3864
+ // it with fixed viewport coordinates — the fullscreen host box == viewport.
3865
+ const inFullscreen = this.isFullscreenActive();
3866
+ if (isStripMode || inFullscreen) {
3867
+ if (inFullscreen)
3868
+ this.classList.add("movi-menu-overflow");
3856
3869
  contextMenu.style.position = "fixed";
3857
3870
  contextMenu.style.maxHeight = `${Math.max(180, window.innerHeight - 40)}px`;
3858
3871
  contextMenu.style.display = "block";
@@ -4339,7 +4352,7 @@ export class MoviElement extends HTMLElement {
4339
4352
  else if (action === "rotate-video") {
4340
4353
  if (this.player && !this._pipWindow) {
4341
4354
  const deg = this.player.rotateVideo();
4342
- const statusEl = shadowRoot.querySelector(".movi-rotate-status");
4355
+ const statusEl = this.contextMenuRoot().querySelector(".movi-rotate-status");
4343
4356
  if (statusEl)
4344
4357
  statusEl.textContent = `${deg}°`;
4345
4358
  this.syncThumbnailRotation(deg);
@@ -12014,7 +12027,7 @@ export class MoviElement extends HTMLElement {
12014
12027
  border: 1px solid rgba(255, 255, 255, 0.12);
12015
12028
  border-radius: 16px;
12016
12029
  padding: 8px 4px;
12017
- min-width: 160px;
12030
+ min-width: 230px;
12018
12031
  visibility: hidden;
12019
12032
  opacity: 0;
12020
12033
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
@@ -12044,7 +12057,7 @@ export class MoviElement extends HTMLElement {
12044
12057
  border: 1px solid rgba(255, 255, 255, 0.12);
12045
12058
  border-radius: 16px;
12046
12059
  padding: 8px 4px;
12047
- min-width: 160px;
12060
+ min-width: 230px;
12048
12061
  visibility: hidden;
12049
12062
  opacity: 0;
12050
12063
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
@@ -14366,6 +14379,28 @@ export class MoviElement extends HTMLElement {
14366
14379
  };
14367
14380
  img.src = url;
14368
14381
  }
14382
+ /** Downscale an album-art bitmap to a tiny JPEG data URL for the blurred
14383
+ * backdrop. The backdrop is blurred to 40px, so ~96px is indistinguishable
14384
+ * from full-res while keeping the URL small and the draw cheap. */
14385
+ makeCoverArtBgUrl(bitmap) {
14386
+ try {
14387
+ const max = 96;
14388
+ const ar = bitmap.width / bitmap.height;
14389
+ const w = Math.max(1, ar >= 1 ? max : Math.round(max * ar));
14390
+ const h = Math.max(1, ar >= 1 ? Math.round(max / ar) : max);
14391
+ const c = document.createElement("canvas");
14392
+ c.width = w;
14393
+ c.height = h;
14394
+ const cx = c.getContext("2d");
14395
+ if (!cx)
14396
+ return "";
14397
+ cx.drawImage(bitmap, 0, 0, w, h);
14398
+ return c.toDataURL("image/jpeg", 0.7);
14399
+ }
14400
+ catch {
14401
+ return "";
14402
+ }
14403
+ }
14369
14404
  updateCoverArtOverlay() {
14370
14405
  const overlay = this.coverArtOverlay;
14371
14406
  const canvas = this.coverArtCanvas;
@@ -14497,11 +14532,12 @@ export class MoviElement extends HTMLElement {
14497
14532
  ctx.scale(dpr, dpr);
14498
14533
  // Blurred backdrop is now a CSS-blurred DOM element BEHIND this canvas — a
14499
14534
  // real Gaussian in every browser (canvas ctx.filter "blur()" is unsupported
14500
- // in Safari < 17 and looked bad faked). Point it at the poster URL; embedded
14501
- // art (an ImageBitmap, currently disabled) has no URL, so it falls back to
14502
- // the overlay's plain dark background. The canvas paints only the sharp art.
14535
+ // in Safari < 17 and looked bad faked). Point it at the poster URL, or at a
14536
+ // baked-down data URL of the embedded album art (an ImageBitmap has no URL
14537
+ // of its own), so both cover-art sources get the same blurred surround. The
14538
+ // canvas paints only the sharp art.
14503
14539
  if (this._coverArtBgEl) {
14504
- const bgUrl = this.coverArtBitmap ? "" : this._posterCoverUrl;
14540
+ const bgUrl = this.coverArtBitmap ? this._coverArtBgUrl : this._posterCoverUrl;
14505
14541
  this._coverArtBgEl.style.backgroundImage = bgUrl
14506
14542
  ? `url("${bgUrl.replace(/"/g, '\\"')}")`
14507
14543
  : "none";
@@ -15495,6 +15531,10 @@ export class MoviElement extends HTMLElement {
15495
15531
  // and reuses one on subsequent loads. Our reference can be dropped
15496
15532
  // safely; the player will close() when it stomps its own slot.
15497
15533
  this.coverArtBitmap = bitmap;
15534
+ // Bake a tiny data URL from the art so the CSS-blurred backdrop can use
15535
+ // it (the poster path already had a URL; embedded art didn't). Generated
15536
+ // once here, not on every resize.
15537
+ this._coverArtBgUrl = bitmap ? this.makeCoverArtBgUrl(bitmap) : "";
15498
15538
  // Extraction has settled (success or failure) — release the strip-layout
15499
15539
  // hold so a failed extraction falls back to the strip cleanly.
15500
15540
  this._coverArtResolved = true;
@@ -15528,6 +15568,7 @@ export class MoviElement extends HTMLElement {
15528
15568
  // owned by MoviPlayer; we just clear our own pointer and hide the
15529
15569
  // overlay so the new load doesn't briefly show last track's art.
15530
15570
  this.coverArtBitmap = null;
15571
+ this._coverArtBgUrl = "";
15531
15572
  this._coverArtResolved = false;
15532
15573
  // Drop the poster-as-album-art bitmap too — we own it, so close to free it.
15533
15574
  if (this._posterCoverBitmap) {
@@ -17441,16 +17482,31 @@ export class MoviElement extends HTMLElement {
17441
17482
  }
17442
17483
  this.updateLoopUI();
17443
17484
  }
17485
+ /**
17486
+ * The root the context menu currently lives in. On desktop the menu portals
17487
+ * to a body-level shadow root while open, so its items are NOT in this.shadowRoot
17488
+ * at that moment — a toggle-state updater querying the shadow root would find
17489
+ * nothing and silently no-op (the reason menu toggles didn't reflect their new
17490
+ * state after a click). Query menu items through this so they're found whether
17491
+ * the menu is portaled or still home in the shadow root.
17492
+ */
17493
+ contextMenuRoot() {
17494
+ if (this._menuPortalRoot?.querySelector(".movi-context-menu")) {
17495
+ return this._menuPortalRoot;
17496
+ }
17497
+ return this.shadowRoot;
17498
+ }
17444
17499
  updateLoopUI() {
17445
17500
  const shadowRoot = this.shadowRoot;
17446
17501
  if (!shadowRoot)
17447
17502
  return;
17503
+ const menuRoot = this.contextMenuRoot();
17448
17504
  const loopBtn = shadowRoot.querySelector(".movi-loop-btn");
17449
- const loopMenuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="loop-toggle"]');
17450
- const loopStatus = shadowRoot.querySelector(".movi-loop-status");
17505
+ const loopMenuItem = menuRoot.querySelector('.movi-context-menu-item[data-action="loop-toggle"]');
17506
+ const loopStatus = menuRoot.querySelector(".movi-loop-status");
17451
17507
  // Context menu icons
17452
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-loop-outline");
17453
- const ctxFilled = shadowRoot.querySelector(".movi-context-menu-loop-filled");
17508
+ const ctxOutline = menuRoot.querySelector(".movi-context-menu-loop-outline");
17509
+ const ctxFilled = menuRoot.querySelector(".movi-context-menu-loop-filled");
17454
17510
  if (this._loop) {
17455
17511
  loopBtn?.classList.add("active");
17456
17512
  loopMenuItem?.classList.add("movi-context-menu-active");
@@ -17476,10 +17532,11 @@ export class MoviElement extends HTMLElement {
17476
17532
  const shadowRoot = this.shadowRoot;
17477
17533
  if (!shadowRoot)
17478
17534
  return;
17479
- const menuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="ambient-toggle"]');
17480
- const status = shadowRoot.querySelector(".movi-ambient-status");
17481
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-ambient-outline");
17482
- const ctxFilled = shadowRoot.querySelector(".movi-context-menu-ambient-filled");
17535
+ const menuRoot = this.contextMenuRoot();
17536
+ const menuItem = menuRoot.querySelector('.movi-context-menu-item[data-action="ambient-toggle"]');
17537
+ const status = menuRoot.querySelector(".movi-ambient-status");
17538
+ const ctxOutline = menuRoot.querySelector(".movi-context-menu-ambient-outline");
17539
+ const ctxFilled = menuRoot.querySelector(".movi-context-menu-ambient-filled");
17483
17540
  if (this._ambientMode) {
17484
17541
  menuItem?.classList.add("movi-context-menu-active");
17485
17542
  if (status)
@@ -17503,12 +17560,13 @@ export class MoviElement extends HTMLElement {
17503
17560
  if (!shadowRoot)
17504
17561
  return;
17505
17562
  const isEnabled = this.player?.getStableAudio() ?? true;
17563
+ const menuRoot = this.contextMenuRoot();
17506
17564
  const stableBtn = shadowRoot.querySelector(".movi-stable-audio-btn");
17507
- const stableMenuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="stable-audio-toggle"]');
17508
- const stableStatus = shadowRoot.querySelector(".movi-stable-audio-status");
17565
+ const stableMenuItem = menuRoot.querySelector('.movi-context-menu-item[data-action="stable-audio-toggle"]');
17566
+ const stableStatus = menuRoot.querySelector(".movi-stable-audio-status");
17509
17567
  // Context menu icons
17510
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-stable-outline");
17511
- const ctxFilled = shadowRoot.querySelector(".movi-context-menu-stable-filled");
17568
+ const ctxOutline = menuRoot.querySelector(".movi-context-menu-stable-outline");
17569
+ const ctxFilled = menuRoot.querySelector(".movi-context-menu-stable-filled");
17512
17570
  if (isEnabled) {
17513
17571
  stableBtn?.classList.add("active");
17514
17572
  stableMenuItem?.classList.add("movi-context-menu-active");
@@ -18977,9 +19035,10 @@ export class MoviElement extends HTMLElement {
18977
19035
  return !!this.player?.isHDRSupported?.();
18978
19036
  }
18979
19037
  updateHDRUI() {
19038
+ const menuRoot = this.contextMenuRoot();
18980
19039
  const hdrBtn = this.shadowRoot?.querySelector(".movi-hdr-btn");
18981
- const hdrStatus = this.shadowRoot?.querySelector(".movi-hdr-status");
18982
- const hdrMenuItem = this.shadowRoot?.querySelector('.movi-context-menu-item[data-action="hdr-toggle"]');
19040
+ const hdrStatus = menuRoot.querySelector(".movi-hdr-status");
19041
+ const hdrMenuItem = menuRoot.querySelector('.movi-context-menu-item[data-action="hdr-toggle"]');
18983
19042
  if (this._hdr) {
18984
19043
  hdrBtn?.classList.add("movi-hdr-active");
18985
19044
  hdrMenuItem?.classList.add("movi-context-menu-active");