movi-player 0.3.3 → 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.
Files changed (66) hide show
  1. package/README.md +23 -12
  2. package/dist/core/MoviPlayer.d.ts +65 -1
  3. package/dist/core/MoviPlayer.d.ts.map +1 -1
  4. package/dist/core/MoviPlayer.js +245 -10
  5. package/dist/core/MoviPlayer.js.map +1 -1
  6. package/dist/decode/AudioDecoder.d.ts +4 -0
  7. package/dist/decode/AudioDecoder.d.ts.map +1 -1
  8. package/dist/decode/AudioDecoder.js +14 -0
  9. package/dist/decode/AudioDecoder.js.map +1 -1
  10. package/dist/decode/SoftwareAudioDecoder.d.ts +12 -0
  11. package/dist/decode/SoftwareAudioDecoder.d.ts.map +1 -1
  12. package/dist/decode/SoftwareAudioDecoder.js +91 -2
  13. package/dist/decode/SoftwareAudioDecoder.js.map +1 -1
  14. package/dist/decode/VideoDecoder.d.ts.map +1 -1
  15. package/dist/decode/VideoDecoder.js +7 -0
  16. package/dist/decode/VideoDecoder.js.map +1 -1
  17. package/dist/demuxer.cjs +1863 -1788
  18. package/dist/demuxer.js +1722 -1649
  19. package/dist/element.cjs +2987 -1933
  20. package/dist/element.d.ts +48 -0
  21. package/dist/element.d.ts.map +1 -1
  22. package/dist/element.js +8008 -6950
  23. package/dist/element.js.map +1 -1
  24. package/dist/index.cjs +2987 -1933
  25. package/dist/index.js +8008 -6950
  26. package/dist/player.cjs +3102 -2970
  27. package/dist/player.js +1812 -1680
  28. package/dist/render/AudioRenderer.d.ts +30 -0
  29. package/dist/render/AudioRenderer.d.ts.map +1 -1
  30. package/dist/render/AudioRenderer.js +127 -44
  31. package/dist/render/AudioRenderer.js.map +1 -1
  32. package/dist/render/CanvasRenderer.d.ts +31 -0
  33. package/dist/render/CanvasRenderer.d.ts.map +1 -1
  34. package/dist/render/CanvasRenderer.js +81 -25
  35. package/dist/render/CanvasRenderer.js.map +1 -1
  36. package/dist/render/DASHPlayerWrapper.d.ts.map +1 -1
  37. package/dist/render/DASHPlayerWrapper.js +3 -1
  38. package/dist/render/DASHPlayerWrapper.js.map +1 -1
  39. package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
  40. package/dist/render/HLSPlayerWrapper.js +3 -1
  41. package/dist/render/HLSPlayerWrapper.js.map +1 -1
  42. package/dist/render/MoviElement.d.ts +237 -0
  43. package/dist/render/MoviElement.d.ts.map +1 -1
  44. package/dist/render/MoviElement.js +2154 -232
  45. package/dist/render/MoviElement.js.map +1 -1
  46. package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -1
  47. package/dist/render/ShakaPlayerWrapper.js +3 -1
  48. package/dist/render/ShakaPlayerWrapper.js.map +1 -1
  49. package/dist/source/HttpSource.d.ts +3 -0
  50. package/dist/source/HttpSource.d.ts.map +1 -1
  51. package/dist/source/HttpSource.js +81 -22
  52. package/dist/source/HttpSource.js.map +1 -1
  53. package/dist/utils/QoE.d.ts +126 -0
  54. package/dist/utils/QoE.d.ts.map +1 -0
  55. package/dist/utils/QoE.js +192 -0
  56. package/dist/utils/QoE.js.map +1 -0
  57. package/dist/utils/ThumbnailRenderer.d.ts +25 -0
  58. package/dist/utils/ThumbnailRenderer.d.ts.map +1 -1
  59. package/dist/utils/ThumbnailRenderer.js +195 -2
  60. package/dist/utils/ThumbnailRenderer.js.map +1 -1
  61. package/dist/wasm/bindings.d.ts +8 -0
  62. package/dist/wasm/bindings.d.ts.map +1 -1
  63. package/dist/wasm/bindings.js +31 -7
  64. package/dist/wasm/bindings.js.map +1 -1
  65. package/dist/wasm/movi.js +0 -0
  66. package/package.json +3 -2
@@ -12,6 +12,7 @@
12
12
  import { MoviPlayer } from "../core/MoviPlayer";
13
13
  import { Logger, LogLevel } from "../utils/Logger";
14
14
  import { SettingsStorage } from "../utils/SettingsStorage";
15
+ import { QoECollector, beaconSink } from "../utils/QoE";
15
16
  const TAG = "MoviElement";
16
17
  // OSD icon constants — shared across keyboard, button, and context menu handlers
17
18
  const OSD = {
@@ -34,6 +35,16 @@ export class MoviElement extends HTMLElement {
34
35
  canvas;
35
36
  video;
36
37
  subtitleOverlay;
38
+ // Off-screen aria-live region that mirrors the current caption text so screen
39
+ // readers announce each cue (the visual overlay is canvas-aligned + holds
40
+ // image-based PGS/DVB cues, so it isn't a reliable SR source on its own).
41
+ _captionLive = null;
42
+ _captionObserver = null;
43
+ _lastCaptionText = "";
44
+ // QoE analytics: collects startup/rebuffer/error/decode metrics and fans them
45
+ // to sinks (and a `movi-qoe` DOM event). Heartbeats while playing.
46
+ _qoe = new QoECollector();
47
+ _qoeHeartbeat = null;
37
48
  player = null;
38
49
  isLoading = false;
39
50
  _isUnsupported = false;
@@ -64,10 +75,18 @@ export class MoviElement extends HTMLElement {
64
75
  // Blurred-backdrop element behind the sharp-art canvas — CSS filter:blur()
65
76
  // (cross-browser Gaussian, unlike canvas ctx.filter).
66
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 = "";
67
82
  // True once cover-art extraction has settled for the current source (bitmap
68
83
  // arrived OR extraction failed). Until then, if the source has an art track,
69
84
  // we hold the audio-strip layout off so the player doesn't flash strip→cover.
70
85
  _coverArtResolved = false;
86
+ // The last audio-strip value we dispatched to the embedding page. Survives
87
+ // load() (unlike the movi-audio-strip class) so a strip↔non-strip switch is
88
+ // detected even across a source change. null = never told the page yet.
89
+ _lastStripDispatched = null;
71
90
  controlsTimeout = null;
72
91
  isOverControls = false;
73
92
  isSeeking = false;
@@ -92,7 +111,37 @@ export class MoviElement extends HTMLElement {
92
111
  _seekChainTarget = null;
93
112
  _contextMenuVisible = false;
94
113
  _contextMenuJustClosed = false;
114
+ // Body-level portal for the desktop right-click menu so it can escape any
115
+ // overflow:hidden / clipping ancestor of the player and feel native. The menu
116
+ // element is moved here on open and back to the shadow root on close.
117
+ _menuPortalHost = null;
118
+ _menuPortalRoot = null;
119
+ _menuHome = null;
95
120
  lastTouchTime = 0;
121
+ // Press-and-hold-to-2x (touch): a long-press on the video speeds playback to
122
+ // 2x while held and reverts on release (YouTube-style). Since long-press now
123
+ // drives this gesture, the context menu opens from the gear button instead —
124
+ // _openMenuViaGear lets the gear's synthetic contextmenu through the
125
+ // touch-long-press gate in preventDefaultContextMenu.
126
+ _holdSpeedTimer = null;
127
+ _holdSpeedActive = false;
128
+ _rateBeforeHold = 1;
129
+ _openMenuViaGear = false;
130
+ // Media Session (OS lock-screen / notification / hardware-key controls).
131
+ // Action handlers are registered once per element (idempotent overwrite is
132
+ // cheap but the flag avoids re-binding on every source load); metadata,
133
+ // playbackState and positionState are refreshed as the player changes.
134
+ _mediaSessionReady = false;
135
+ // Last position pushed to the OS scrubber, to throttle setPositionState to
136
+ // ~1s granularity (timeUpdate can fire far more often). A jump > 1s (seek)
137
+ // also trips the update so the lock-screen scrubber snaps immediately.
138
+ _mediaSessionLastPos = -1;
139
+ // Data-URL snapshot of the decoded poster/first frame, captured off the
140
+ // canvas for a source with NO explicit `poster` attribute — the still frame
141
+ // such a source paints on canvas (via the postertime / frame-0 seek) never
142
+ // becomes a URL otherwise, so the OS lock screen would have no thumbnail.
143
+ // Reset on dispose.
144
+ _mediaSessionArtworkUrl = null;
96
145
  // Nerd Stats
97
146
  _nerdStatsVisible = false;
98
147
  _currentManualRotation = 0; // Track for thumbnail margin re-apply
@@ -102,6 +151,15 @@ export class MoviElement extends HTMLElement {
102
151
  _timelineNextIndex = 0;
103
152
  nerdStatsInterval = null;
104
153
  networkSpeedHistory = []; // speed samples for graph
154
+ // Stutter hint: on a heavy source the decoder can't keep up above 1x, so the
155
+ // video drops frames (audio stays smooth). Detect a sustained low presented-
156
+ // fps while playing >1x and nudge the user toward 1x once, with a long
157
+ // cooldown so it never nags.
158
+ _stutterInterval = null;
159
+ _stutterLastPresented = 0;
160
+ _stutterSeconds = 0;
161
+ _stutterCooldown = false;
162
+ _stutterCooldownTimer = null;
105
163
  static GRAPH_MAX_SAMPLES = 60; // 30 seconds of data (500ms interval)
106
164
  // Internal state
107
165
  _src = null;
@@ -209,7 +267,9 @@ export class MoviElement extends HTMLElement {
209
267
  // Guards the sw-attr-change callback from auto-reloading during dispose().
210
268
  _suppressSwReload = false;
211
269
  _fps = 0; // Custom frame rate (0 = auto from video)
212
- _gesturefs = false; // Gestures only in fullscreen if true
270
+ // @deprecated alias for `playsinline` — gestures-only-in-fullscreen is now
271
+ // driven by playsinline; kept so existing `gesturefs` markup keeps working.
272
+ _gesturefs = false;
213
273
  _noHotkeys = false; // Disable keyboard shortcuts if true
214
274
  _startAt = 0; // Start time in seconds
215
275
  _fastSeek = false; // Enable skip controls (buttons, keys, gestures) if true
@@ -235,6 +295,7 @@ export class MoviElement extends HTMLElement {
235
295
  _vrLastX = 0; // Last pointer/touch X for incremental pan
236
296
  _vrLastY = 0;
237
297
  _vrPinchDist = 0; // Two-finger pinch baseline (touch zoom)
298
+ _aspectPinchDist = 0; // Two-finger pinch baseline (fullscreen aspect-fit)
238
299
  _vrSuppressClick = false; // Swallow the click that ends a drag
239
300
  _vrPadDragging = false; // On-screen joystick is being dragged
240
301
  _encrypted = false; // Encrypted source mode
@@ -490,10 +551,29 @@ export class MoviElement extends HTMLElement {
490
551
  this.coverArtCanvas.style.display = "block";
491
552
  this.coverArtOverlay.appendChild(this.coverArtCanvas);
492
553
  shadowRoot.appendChild(this.coverArtOverlay);
493
- // Create subtitle overlay element
554
+ // Create subtitle overlay element. Decorative for screen readers — the
555
+ // live region below is their source, so the overlay is aria-hidden.
494
556
  this.subtitleOverlay = document.createElement("div");
495
557
  this.subtitleOverlay.className = "movi-subtitle-overlay";
558
+ this.subtitleOverlay.setAttribute("aria-hidden", "true");
496
559
  shadowRoot.appendChild(this.subtitleOverlay);
560
+ // Off-screen aria-live region: announce each caption to screen readers.
561
+ this._captionLive = document.createElement("div");
562
+ this._captionLive.className = "movi-caption-live";
563
+ this._captionLive.setAttribute("aria-live", "polite");
564
+ this._captionLive.setAttribute("aria-atomic", "true");
565
+ this._captionLive.setAttribute("role", "status");
566
+ shadowRoot.appendChild(this._captionLive);
567
+ // Mirror the visible cue's text into the live region whenever it changes.
568
+ this._captionObserver = new MutationObserver(() => this.mirrorCaption());
569
+ this._captionObserver.observe(this.subtitleOverlay, {
570
+ childList: true,
571
+ characterData: true,
572
+ subtree: true,
573
+ });
574
+ // QoE: mirror every analytics event out as a `movi-qoe` DOM event so
575
+ // embedders can forward it without registering a JS sink.
576
+ this._qoe.addSink((e) => this.dispatchEvent(new CustomEvent("movi-qoe", { detail: e, bubbles: true, composed: true })));
497
577
  // Click on the live caption text → open the transcript browser
498
578
  // (scrolled to the current cue). The overlay itself stays
499
579
  // pointer-events:none so clicks elsewhere keep falling through to
@@ -600,6 +680,12 @@ export class MoviElement extends HTMLElement {
600
680
  <div class="movi-osd-text"></div>
601
681
  `;
602
682
  shadowRoot.appendChild(osdContainer);
683
+ // Persistent "2×" pill shown while a press-and-hold speeds playback up.
684
+ const holdSpeed = document.createElement("div");
685
+ holdSpeed.className = "movi-hold-speed";
686
+ holdSpeed.style.display = "none";
687
+ holdSpeed.innerHTML = `<span>2×</span><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 5l8 7-8 7V5zm9 0l8 7-8 7V5z"/></svg>`;
688
+ shadowRoot.appendChild(holdSpeed);
603
689
  // Create context menu FIRST (before setupContextMenu is called)
604
690
  this.createContextMenu(shadowRoot);
605
691
  // Create controls UI (this will call setupContextMenu)
@@ -870,7 +956,7 @@ export class MoviElement extends HTMLElement {
870
956
  container.innerHTML = `
871
957
  <div class="movi-controls-bar" style="position: relative;">
872
958
  <div class="movi-progress-container">
873
- <div class="movi-progress-bar">
959
+ <div class="movi-progress-bar" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuenow="0" aria-valuetext="0:00">
874
960
  <div class="movi-progress-buffer"></div>
875
961
  <div class="movi-progress-filled"></div>
876
962
  <div class="movi-chapter-markers"></div>
@@ -928,7 +1014,7 @@ export class MoviElement extends HTMLElement {
928
1014
  </svg>
929
1015
  </button>
930
1016
  <div class="movi-volume-slider-container">
931
- <input type="range" class="movi-volume-slider" min="0" max="1" step="0.01" value="1" aria-label="Volume">
1017
+ <input type="range" class="movi-volume-slider" min="0" max="2" step="0.01" value="1" aria-label="Volume" aria-valuemin="0" aria-valuemax="200" aria-valuenow="100" aria-valuetext="Volume 100%">
932
1018
  </div>
933
1019
  </div>
934
1020
 
@@ -1087,8 +1173,14 @@ export class MoviElement extends HTMLElement {
1087
1173
  <path d="M17 2l4 4-4 4"></path><path d="M3 11v-1a4 4 0 0 1 4-4h14"></path><path d="M7 22l-4-4 4-4"></path><path d="M21 13v1a4 4 0 0 1-4 4H3"></path>
1088
1174
  </svg>
1089
1175
  </button>
1176
+
1177
+ <button class="movi-btn movi-pip-btn" aria-label="Picture in Picture" style="display:none">
1178
+ <svg class="movi-icon-pip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1179
+ <rect x="2" y="3" width="20" height="14" rx="2"/><rect x="12" y="9" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/>
1180
+ </svg>
1181
+ </button>
1090
1182
  </div>
1091
-
1183
+
1092
1184
  <button class="movi-btn movi-more-btn" aria-label="More Settings">
1093
1185
  <svg class="movi-icon-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1094
1186
  <polyline points="15 18 9 12 15 6"></polyline>
@@ -1097,12 +1189,7 @@ export class MoviElement extends HTMLElement {
1097
1189
  <polyline points="9 18 15 12 9 6"></polyline>
1098
1190
  </svg>
1099
1191
  </button>
1100
-
1101
- <button class="movi-btn movi-pip-btn" aria-label="Picture in Picture" style="display:none">
1102
- <svg class="movi-icon-pip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1103
- <rect x="2" y="3" width="20" height="14" rx="2"/><rect x="12" y="9" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/>
1104
- </svg>
1105
- </button>
1192
+
1106
1193
  <button class="movi-btn movi-fullscreen-btn" aria-label="Fullscreen">
1107
1194
  <svg class="movi-icon-fullscreen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1108
1195
  <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
@@ -1177,6 +1264,29 @@ export class MoviElement extends HTMLElement {
1177
1264
  titleBar.style.display = "none";
1178
1265
  titleBar.innerHTML = `<span class="movi-title-text"></span>`;
1179
1266
  shadowRoot.appendChild(titleBar);
1267
+ // Gear button (top-right) → opens the context menu. This is how touch users
1268
+ // reach it now that long-press drives hold-to-2x; on desktop it's a
1269
+ // discoverable alternative to right-click. Shown with the chrome (its
1270
+ // movi-gear-visible class is toggled in show/hideControls).
1271
+ const gearBtn = document.createElement("button");
1272
+ gearBtn.className = "movi-btn movi-gear-btn";
1273
+ gearBtn.setAttribute("aria-label", "Settings");
1274
+ gearBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`;
1275
+ gearBtn.addEventListener("click", (e) => {
1276
+ e.stopPropagation();
1277
+ // Open the context menu via a synthetic contextmenu event; the flag lets
1278
+ // it through the touch-long-press gate in preventDefaultContextMenu.
1279
+ this._openMenuViaGear = true;
1280
+ const rect = this.getBoundingClientRect();
1281
+ this.dispatchEvent(new MouseEvent("contextmenu", {
1282
+ bubbles: true,
1283
+ composed: true,
1284
+ cancelable: true,
1285
+ clientX: rect.right - 44,
1286
+ clientY: rect.top + 44,
1287
+ }));
1288
+ });
1289
+ shadowRoot.appendChild(gearBtn);
1180
1290
  // Create Nerd Stats overlay
1181
1291
  const nerdStats = document.createElement("div");
1182
1292
  nerdStats.className = "movi-nerd-stats";
@@ -1213,6 +1323,13 @@ export class MoviElement extends HTMLElement {
1213
1323
  this._timelineCancelled = true;
1214
1324
  timelinePanel.style.display = "none";
1215
1325
  this.focus();
1326
+ // Only re-arm the auto-hide countdown if the bar is currently showing —
1327
+ // that restarts the timer the open panel had suspended so a stuck bar
1328
+ // fades during playback. Never surface a hidden bar here: showControls()
1329
+ // on a hidden bar would fade it in and straight back out (a "flash").
1330
+ if (!this.controlsContainer?.classList.contains("movi-controls-hidden")) {
1331
+ this.showControls();
1332
+ }
1216
1333
  });
1217
1334
  // Create Resume Dialog
1218
1335
  const resumeDialog = document.createElement("div");
@@ -1254,6 +1371,17 @@ export class MoviElement extends HTMLElement {
1254
1371
  this.clearResumePosition();
1255
1372
  this.focus();
1256
1373
  });
1374
+ // Move the selection ring to whichever button the pointer is over, so it
1375
+ // tracks the mouse the same way arrow keys move it — hovering Cancel puts
1376
+ // the ring on Cancel instead of leaving it stuck on Resume.
1377
+ const resumeYes = resumeDialog.querySelector(".movi-resume-yes");
1378
+ const resumeNo = resumeDialog.querySelector(".movi-resume-no");
1379
+ const focusResumeBtn = (btn) => {
1380
+ resumeYes?.classList.toggle("movi-resume-focused", btn === resumeYes);
1381
+ resumeNo?.classList.toggle("movi-resume-focused", btn === resumeNo);
1382
+ };
1383
+ resumeYes?.addEventListener("pointerenter", () => focusResumeBtn(resumeYes));
1384
+ resumeNo?.addEventListener("pointerenter", () => focusResumeBtn(resumeNo));
1257
1385
  // Create Keyboard Shortcuts Panel
1258
1386
  const shortcutsPanel = document.createElement("div");
1259
1387
  shortcutsPanel.className = "movi-shortcuts-panel";
@@ -1438,7 +1566,12 @@ export class MoviElement extends HTMLElement {
1438
1566
  try {
1439
1567
  if (!this.player)
1440
1568
  return;
1441
- const blob = await this.player.getPreviewFrame?.(timeToFetch);
1569
+ // 360°: pass the live viewing angle so the preview reprojects the
1570
+ // equirect frame to what the user currently sees (2D → undefined).
1571
+ const vrView = this._vr360
1572
+ ? this.player.getVR360View?.()
1573
+ : undefined;
1574
+ const blob = await this.player.getPreviewFrame?.(timeToFetch, vrView);
1442
1575
  // Update UI if we got a blob
1443
1576
  if (blob && thumbnailImg) {
1444
1577
  if (lastPreviewUrl)
@@ -1450,8 +1583,13 @@ export class MoviElement extends HTMLElement {
1450
1583
  if (thumbnailPlaceholder)
1451
1584
  thumbnailPlaceholder.style.display = "none";
1452
1585
  thumbnailImg.style.display = "block";
1453
- // Re-apply rotation transform + margin on each preview load
1454
- this.applyThumbnailRotation(thumbnailImg);
1586
+ // Re-apply rotation transform + margin on each preview load. Skip for
1587
+ // 360° previews — those are already reprojected to the upright view,
1588
+ // so a CSS rotate would double-transform them.
1589
+ if (!this._vr360)
1590
+ this.applyThumbnailRotation(thumbnailImg);
1591
+ else
1592
+ thumbnailImg.style.transform = "";
1455
1593
  }
1456
1594
  }
1457
1595
  catch (e) {
@@ -1976,20 +2114,40 @@ export class MoviElement extends HTMLElement {
1976
2114
  e.stopPropagation(); // Prevent triggering overlay click
1977
2115
  this.toggleFullscreen();
1978
2116
  });
1979
- // Picture-in-Picture
2117
+ // In an iframe, fullscreen / Document PiP need to be delegated via the
2118
+ // iframe's `allow` attribute. Without it the Permissions Policy blocks them
2119
+ // and the buttons would be dead controls — so hide them. Scoped to the
2120
+ // iframe case only: at top level `fullscreenEnabled` can legitimately be
2121
+ // false (e.g. iOS Safari, which has no element-fullscreen) yet still has a
2122
+ // working fallback, so we must not hide there.
2123
+ const inIframe = this.isEmbeddedIframe();
2124
+ // Picture-in-Picture — needs the Document PiP API and, in an iframe,
2125
+ // allow="picture-in-picture" (which `pictureInPictureEnabled` reflects, since
2126
+ // Document PiP shares that policy).
1980
2127
  const pipBtn = shadowRoot.querySelector(".movi-pip-btn");
1981
- // Show PiP button + context menu item only if Document PiP API is available
1982
- if ("documentPictureInPicture" in window) {
1983
- if (pipBtn)
1984
- pipBtn.style.display = "";
1985
- const pipCtxItem = shadowRoot.querySelector(".movi-context-menu-pip");
1986
- if (pipCtxItem)
1987
- pipCtxItem.style.display = "";
1988
- }
2128
+ const pipCtxItem = shadowRoot.querySelector(".movi-context-menu-pip");
2129
+ const pipAvailable = "documentPictureInPicture" in window &&
2130
+ (!inIframe || document.pictureInPictureEnabled !== false);
2131
+ if (pipBtn)
2132
+ pipBtn.style.display = pipAvailable ? "" : "none";
2133
+ if (pipCtxItem)
2134
+ pipCtxItem.style.display = pipAvailable ? "" : "none";
1989
2135
  pipBtn?.addEventListener("click", (e) => {
1990
2136
  e.stopPropagation();
1991
2137
  this.togglePiP();
1992
2138
  });
2139
+ // Fullscreen — hide the button + menu item inside an iframe that wasn't
2140
+ // granted allow="fullscreen" (document.fullscreenEnabled is false there). A
2141
+ // host that drives its own fullscreen via the `movi-fullscreen-request`
2142
+ // event can still trigger it through the F shortcut / its own chrome.
2143
+ if (inIframe && !document.fullscreenEnabled) {
2144
+ const fsBtn = shadowRoot.querySelector(".movi-fullscreen-btn");
2145
+ if (fsBtn)
2146
+ fsBtn.style.display = "none";
2147
+ const fsCtxItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="fullscreen"]');
2148
+ if (fsCtxItem)
2149
+ fsCtxItem.style.display = "none";
2150
+ }
1993
2151
  // More Settings (Mobile Horizontal Expansion)
1994
2152
  const moreBtn = shadowRoot.querySelector(".movi-more-btn");
1995
2153
  const controlsRight = shadowRoot.querySelector(".movi-controls-right");
@@ -2022,6 +2180,10 @@ export class MoviElement extends HTMLElement {
2022
2180
  // Click on video to play/pause (only on canvas/video area, not controls)
2023
2181
  // Handle clicks on both overlay and canvas
2024
2182
  const handleVideoClick = (e) => {
2183
+ // A bare player (no controls attr) is a non-interactive display surface —
2184
+ // a click on it must not toggle play/pause.
2185
+ if (!this._controls)
2186
+ return;
2025
2187
  // A 360° look-around drag ends with a synthetic click — swallow it so the
2026
2188
  // drag doesn't also toggle play/pause. A pure click (no drag) still does.
2027
2189
  if (this._vrSuppressClick) {
@@ -2116,7 +2278,7 @@ export class MoviElement extends HTMLElement {
2116
2278
  this.setupKeyboardShortcuts();
2117
2279
  // Setup gestures
2118
2280
  this.setupGestures(shadowRoot);
2119
- // Setup 360° look-around (mouse drag + wheel zoom)
2281
+ // Setup 360° look-around (mouse/touch drag; two-finger pinch to zoom)
2120
2282
  this.setupVRControls(shadowRoot);
2121
2283
  // Setup context menu
2122
2284
  this.setupContextMenu(shadowRoot);
@@ -2124,6 +2286,7 @@ export class MoviElement extends HTMLElement {
2124
2286
  document.addEventListener("fullscreenchange", () => {
2125
2287
  const isFullscreen = !!document.fullscreenElement;
2126
2288
  this.applyFullscreenUiState(isFullscreen);
2289
+ this.applyFullscreenOrientation(isFullscreen);
2127
2290
  requestAnimationFrame(() => {
2128
2291
  requestAnimationFrame(() => {
2129
2292
  this.updateCanvasSize();
@@ -2309,6 +2472,41 @@ export class MoviElement extends HTMLElement {
2309
2472
  this.isSeeking = false;
2310
2473
  }
2311
2474
  }
2475
+ /** Begin press-and-hold fast playback (2x). No-op unless actively playing, so
2476
+ * a long-press while paused doesn't silently change the saved rate. */
2477
+ startHoldSpeed() {
2478
+ this._holdSpeedTimer = null;
2479
+ if (this._holdSpeedActive || !this.player)
2480
+ return;
2481
+ if (this.player.getState() !== "playing")
2482
+ return;
2483
+ this._holdSpeedActive = true;
2484
+ this.gesturePerformed = true; // suppress the touchend tap (show/hide chrome)
2485
+ this._rateBeforeHold = this._playbackRate || 1;
2486
+ this.player.setPlaybackRate(2);
2487
+ this._playbackRate = 2;
2488
+ this.updateMediaSessionPosition();
2489
+ this.resetStutterHint();
2490
+ const pill = this.shadowRoot?.querySelector(".movi-hold-speed");
2491
+ if (pill)
2492
+ pill.style.display = "flex";
2493
+ }
2494
+ /** End press-and-hold fast playback, restoring the pre-hold rate. */
2495
+ stopHoldSpeed() {
2496
+ if (this._holdSpeedTimer !== null) {
2497
+ clearTimeout(this._holdSpeedTimer);
2498
+ this._holdSpeedTimer = null;
2499
+ }
2500
+ if (!this._holdSpeedActive)
2501
+ return;
2502
+ this._holdSpeedActive = false;
2503
+ this.player?.setPlaybackRate(this._rateBeforeHold);
2504
+ this._playbackRate = this._rateBeforeHold;
2505
+ this.updateMediaSessionPosition();
2506
+ const pill = this.shadowRoot?.querySelector(".movi-hold-speed");
2507
+ if (pill)
2508
+ pill.style.display = "none";
2509
+ }
2312
2510
  setupGestures(shadowRoot) {
2313
2511
  const overlay = shadowRoot.querySelector(".movi-controls-overlay");
2314
2512
  const canvas = this.canvas;
@@ -2450,9 +2648,78 @@ export class MoviElement extends HTMLElement {
2450
2648
  initialSeekTime = this.currentTime;
2451
2649
  isVerticalGesture = false;
2452
2650
  isHorizontalGesture = false;
2651
+ // Arm press-and-hold-to-2x. A move (scrub/swipe/pan) or a release
2652
+ // before the threshold cancels it (touchmove/touchend below). VR
2653
+ // owns its own touch, so skip there.
2654
+ if (this._holdSpeedTimer !== null) {
2655
+ clearTimeout(this._holdSpeedTimer);
2656
+ this._holdSpeedTimer = null;
2657
+ }
2658
+ if (!this._vr360 && !isEdgeStart) {
2659
+ // 600ms, not 400 — a shorter threshold fired 2x on ordinary
2660
+ // taps/brief holds. This matches the ~500-600ms long-press feel
2661
+ // users expect on iOS/Android.
2662
+ this._holdSpeedTimer = window.setTimeout(() => this.startHoldSpeed(), 600);
2663
+ }
2664
+ }
2665
+ else if (e.touches.length >= 2) {
2666
+ // A second finger down means a pinch (aspect-fit / 360° zoom), never
2667
+ // a press-and-hold-to-2x — cancel the arm the first finger set, and
2668
+ // drop out of 2x if it already engaged, so the two don't conflict.
2669
+ if (this._holdSpeedTimer !== null) {
2670
+ clearTimeout(this._holdSpeedTimer);
2671
+ this._holdSpeedTimer = null;
2672
+ }
2673
+ if (this._holdSpeedActive)
2674
+ this.stopHoldSpeed();
2453
2675
  }
2454
2676
  }, { passive: true });
2455
2677
  target.addEventListener("touchmove", (e) => {
2678
+ // Two-finger pinch in fullscreen → switch aspect fit, YouTube-style:
2679
+ // spread out zooms to fill (cover / crop to edges), pinch in returns
2680
+ // to fit (contain). Handled before the edge-swipe guard so a pinch
2681
+ // that happens to begin near a screen edge still registers, and gated
2682
+ // to fullscreen so it never fights the page's own pinch-zoom inline.
2683
+ // (360° mode has its own two-finger pinch below, so exclude it here.)
2684
+ if (!this._vr360 &&
2685
+ e.touches.length === 2 &&
2686
+ this.isFullscreenActive()) {
2687
+ const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
2688
+ if (this._aspectPinchDist === 0) {
2689
+ this._aspectPinchDist = dist;
2690
+ }
2691
+ else {
2692
+ const ratio = dist / this._aspectPinchDist;
2693
+ const current = this._objectFit === "control"
2694
+ ? this._currentFit
2695
+ : this._objectFit;
2696
+ let next = null;
2697
+ if (ratio > 1.2 && current !== "cover")
2698
+ next = "cover";
2699
+ else if (ratio < 0.83 && current !== "contain")
2700
+ next = "contain";
2701
+ if (next) {
2702
+ if (this._objectFit === "control")
2703
+ this._currentFit = next;
2704
+ else
2705
+ this._objectFit = next;
2706
+ this.updateFitMode();
2707
+ const labels = {
2708
+ contain: "Fit",
2709
+ cover: "Fill",
2710
+ };
2711
+ const osdSvg = MoviElement.ASPECT_ICONS[next] ||
2712
+ MoviElement.ASPECT_ICONS.contain;
2713
+ this.showOSD(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${osdSvg}</svg>`, labels[next]);
2714
+ // Re-baseline so the opposite pinch can toggle straight back.
2715
+ this._aspectPinchDist = dist;
2716
+ }
2717
+ }
2718
+ this.gesturePerformed = true;
2719
+ if (e.cancelable)
2720
+ e.preventDefault();
2721
+ return;
2722
+ }
2456
2723
  if (isEdgeStart)
2457
2724
  return;
2458
2725
  // 360° mode owns touch: one finger looks around, two fingers pinch
@@ -2484,9 +2751,23 @@ export class MoviElement extends HTMLElement {
2484
2751
  const touch = e.touches[0];
2485
2752
  const deltaX = touch.clientX - this.touchStartX;
2486
2753
  const deltaY = touch.clientY - this.touchStartY;
2487
- // Determine gesture type early
2488
- // If gesturefs is enabled, ONLY allow gestures if in fullscreen
2489
- if (this._gesturefs && !document.fullscreenElement) {
2754
+ // Any real finger travel means the press wasn't a stationary hold —
2755
+ // cancel the pending hold-to-2x so a page scroll / swipe / pan
2756
+ // doesn't trip it. Done here (before the gesturefs gate and the
2757
+ // gesture-type classification) so it fires regardless of side,
2758
+ // fullscreen, or gesture config — a scroll must always win.
2759
+ if (this._holdSpeedTimer !== null &&
2760
+ Math.hypot(deltaX, deltaY) > 10) {
2761
+ clearTimeout(this._holdSpeedTimer);
2762
+ this._holdSpeedTimer = null;
2763
+ }
2764
+ // Determine gesture type early.
2765
+ // An inline player (`playsinline`) shares the page's scroll, so its
2766
+ // touch gestures (swipe-seek / volume) must not fight it — restrict
2767
+ // them to fullscreen. `gesturefs` is the deprecated alias for the
2768
+ // same behaviour, still honoured.
2769
+ if ((this._playsinline || this._gesturefs) &&
2770
+ !document.fullscreenElement) {
2490
2771
  return;
2491
2772
  }
2492
2773
  if (!isVerticalGesture &&
@@ -2500,6 +2781,12 @@ export class MoviElement extends HTMLElement {
2500
2781
  isHorizontalGesture = true;
2501
2782
  this.gesturePerformed = true;
2502
2783
  }
2784
+ // A real move means this isn't a stationary press — cancel the
2785
+ // pending hold-to-2x so scrub/swipe/volume gestures win.
2786
+ if (this.gesturePerformed && this._holdSpeedTimer !== null) {
2787
+ clearTimeout(this._holdSpeedTimer);
2788
+ this._holdSpeedTimer = null;
2789
+ }
2503
2790
  }
2504
2791
  if (isVerticalGesture) {
2505
2792
  const rect = this.getBoundingClientRect();
@@ -2509,7 +2796,7 @@ export class MoviElement extends HTMLElement {
2509
2796
  if (e.cancelable)
2510
2797
  e.preventDefault();
2511
2798
  const volumeChange = -deltaY / 200;
2512
- const newVolume = Math.max(0, Math.min(1, initialVolume + volumeChange));
2799
+ const newVolume = Math.max(0, Math.min(this.getMaxVolume(), initialVolume + volumeChange));
2513
2800
  this.volume = newVolume;
2514
2801
  }
2515
2802
  }
@@ -2538,6 +2825,16 @@ export class MoviElement extends HTMLElement {
2538
2825
  }
2539
2826
  }, { passive: false });
2540
2827
  target.addEventListener("touchend", (e) => {
2828
+ // A lifted finger ends any pinch — clear the aspect-fit baseline so
2829
+ // the next two-finger gesture re-measures from scratch.
2830
+ this._aspectPinchDist = 0;
2831
+ // Release ends press-and-hold-to-2x (and cancels a pending arm). When
2832
+ // it was active this restores the pre-hold rate; gesturePerformed was
2833
+ // set on activation so the tap show/hide-chrome branch below is skipped.
2834
+ const wasHolding = this._holdSpeedActive;
2835
+ this.stopHoldSpeed();
2836
+ if (wasHolding)
2837
+ return;
2541
2838
  if (e.changedTouches.length === 1) {
2542
2839
  const touch = e.changedTouches[0];
2543
2840
  const endTime = Date.now();
@@ -2551,17 +2848,18 @@ export class MoviElement extends HTMLElement {
2551
2848
  isVerticalGesture &&
2552
2849
  startXPercent <= 0.5) {
2553
2850
  if (deltaY < -60) {
2554
- // Swipe UP -> Enter Fullscreen (skip for any audio source)
2555
- if (!document.fullscreenElement && !this.classList.contains("movi-audio-mode")) {
2556
- this.requestFullscreen().catch((err) => Logger.error(TAG, "Error entering fullscreen", err));
2851
+ // Swipe UP -> Enter Fullscreen (skip for any audio source).
2852
+ // Route through toggleFullscreen so the iOS pseudo-fullscreen
2853
+ // fallback and the host-fullscreen event apply here too.
2854
+ if (!this.isFullscreenActive() &&
2855
+ !this.classList.contains("movi-audio-mode")) {
2856
+ this.toggleFullscreen();
2557
2857
  }
2558
2858
  }
2559
2859
  else if (deltaY > 60) {
2560
2860
  // Swipe DOWN -> Exit Fullscreen
2561
- if (document.fullscreenElement) {
2562
- document
2563
- .exitFullscreen()
2564
- .catch((err) => Logger.error(TAG, "Error exiting fullscreen", err));
2861
+ if (this.isFullscreenActive()) {
2862
+ this.toggleFullscreen();
2565
2863
  }
2566
2864
  }
2567
2865
  }
@@ -2593,6 +2891,11 @@ export class MoviElement extends HTMLElement {
2593
2891
  });
2594
2892
  // Mouse double click for fullscreen / fast seek
2595
2893
  const handleDoubleClick = (e) => {
2894
+ // Bare player (no controls attr): no double-click-to-fullscreen. The
2895
+ // host's pointer-events:none is defeated by the many explicit
2896
+ // pointer-events:auto layers, so guard the handler directly.
2897
+ if (!this._controls)
2898
+ return;
2596
2899
  // Cancel any pending single click
2597
2900
  if (this.clickTimer) {
2598
2901
  clearTimeout(this.clickTimer);
@@ -2670,12 +2973,6 @@ export class MoviElement extends HTMLElement {
2670
2973
  };
2671
2974
  target.addEventListener("pointerup", endDrag);
2672
2975
  target.addEventListener("pointercancel", endDrag);
2673
- target.addEventListener("wheel", (e) => {
2674
- if (!this._vr360)
2675
- return;
2676
- e.preventDefault();
2677
- this.player?.zoomVR360(e.deltaY);
2678
- }, { passive: false });
2679
2976
  });
2680
2977
  // ── On-screen pan joystick (the YouTube-style compass, desktop only) ──
2681
2978
  const pad = shadowRoot.querySelector(".movi-vr360-pad");
@@ -2896,6 +3193,18 @@ export class MoviElement extends HTMLElement {
2896
3193
  void this.renderVRPosterIfNeeded();
2897
3194
  }
2898
3195
  setupKeyboardShortcuts() {
3196
+ // Keyboard shortcuts require the host to hold focus. handleVideoClick grabs
3197
+ // it on a canvas/video click, but in audio-strip mode the whole surface is
3198
+ // the control bar, so that click never fires and the hotkeys look dead.
3199
+ // Take focus on any pointerdown on the player (except real form fields) so
3200
+ // shortcuts work regardless of what part of the chrome was clicked.
3201
+ this.addEventListener("pointerdown", (e) => {
3202
+ const t = e.composedPath()[0];
3203
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))
3204
+ return;
3205
+ if (document.activeElement !== this)
3206
+ this.focus({ preventScroll: true });
3207
+ }, true);
2899
3208
  this.addEventListener("keydown", (e) => {
2900
3209
  // Check if keyboard controls are disabled
2901
3210
  if (this._noHotkeys)
@@ -3070,7 +3379,7 @@ export class MoviElement extends HTMLElement {
3070
3379
  // Up Arrow: Increase volume
3071
3380
  e.preventDefault();
3072
3381
  if (this.player && this.player.hasAudibleSource()) {
3073
- this.volume = Math.min(1, this.volume + 0.1);
3382
+ this.volume = Math.min(this.getMaxVolume(), this.volume + 0.1);
3074
3383
  }
3075
3384
  break;
3076
3385
  case "ArrowDown":
@@ -3127,7 +3436,7 @@ export class MoviElement extends HTMLElement {
3127
3436
  if (this.player && !this._pipWindow) {
3128
3437
  const deg = this.player.rotateVideo();
3129
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}°`);
3130
- const statusEl = this.shadowRoot?.querySelector(".movi-rotate-status");
3439
+ const statusEl = this.contextMenuRoot().querySelector(".movi-rotate-status");
3131
3440
  if (statusEl)
3132
3441
  statusEl.textContent = `${deg}°`;
3133
3442
  this.syncThumbnailRotation(deg);
@@ -3349,6 +3658,24 @@ export class MoviElement extends HTMLElement {
3349
3658
  this.currentTime = 0;
3350
3659
  this.showControls();
3351
3660
  break;
3661
+ case "1":
3662
+ case "2":
3663
+ case "3":
3664
+ case "4":
3665
+ case "5":
3666
+ case "6":
3667
+ case "7":
3668
+ case "8":
3669
+ case "9": {
3670
+ // 1-9: Seek to 10%–90% of the video (YouTube-style).
3671
+ e.preventDefault();
3672
+ const d = this.duration;
3673
+ if (Number.isFinite(d) && d > 0) {
3674
+ this.currentTime = d * (parseInt(e.key, 10) / 10);
3675
+ this.showControls();
3676
+ }
3677
+ break;
3678
+ }
3352
3679
  case "Home":
3353
3680
  // Home: Seek to start
3354
3681
  e.preventDefault();
@@ -3456,6 +3783,15 @@ export class MoviElement extends HTMLElement {
3456
3783
  e.preventDefault();
3457
3784
  e.stopPropagation();
3458
3785
  e.stopImmediatePropagation();
3786
+ // On touch, a long-press now drives hold-to-2x, so it must NOT open the
3787
+ // context menu — that opens from the gear button, which sets
3788
+ // _openMenuViaGear before dispatching this event. (Desktop right-click is
3789
+ // unaffected: pointer is fine, so this gate is skipped.)
3790
+ const isCoarse = window.matchMedia("(pointer: coarse)").matches;
3791
+ if (isCoarse && !this._openMenuViaGear) {
3792
+ return false;
3793
+ }
3794
+ this._openMenuViaGear = false;
3459
3795
  // Don't show on controls
3460
3796
  const target = e.target;
3461
3797
  const shadowTarget = e.composedPath
@@ -3492,8 +3828,6 @@ export class MoviElement extends HTMLElement {
3492
3828
  // Re-enumerate output devices each open (cheap) so a just-plugged
3493
3829
  // headset shows up; the async result re-renders the submenu in place.
3494
3830
  this.refreshAudioOutputs();
3495
- // Show custom context menu
3496
- const rect = this.getBoundingClientRect();
3497
3831
  // Touch-only: narrow desktop windows still get the hover-based menu,
3498
3832
  // because slide-panel submenus require tap-to-open semantics.
3499
3833
  const isTouchDevice = window.matchMedia("(pointer: coarse)").matches;
@@ -3522,7 +3856,16 @@ export class MoviElement extends HTMLElement {
3522
3856
  // useless. Switch to position:fixed in viewport coordinates so
3523
3857
  // the menu can spill into the empty page area below the strip.
3524
3858
  const isStripMode = this.classList.contains("movi-audio-strip");
3525
- 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");
3526
3869
  contextMenu.style.position = "fixed";
3527
3870
  contextMenu.style.maxHeight = `${Math.max(180, window.innerHeight - 40)}px`;
3528
3871
  contextMenu.style.display = "block";
@@ -3542,59 +3885,39 @@ export class MoviElement extends HTMLElement {
3542
3885
  fy = 10;
3543
3886
  contextMenu.style.left = `${fx}px`;
3544
3887
  contextMenu.style.top = `${fy}px`;
3888
+ this.clampMenuToViewport(contextMenu);
3545
3889
  // skip the host-relative absolute-position path below
3546
3890
  requestAnimationFrame(() => contextMenu.classList.add("visible"));
3547
3891
  this._contextMenuVisible = true;
3548
3892
  return;
3549
3893
  }
3550
- // Reset any leftover fixed-position state from a previous strip-
3551
- // mode invocation (track switch from audio to video).
3552
- contextMenu.style.position = "";
3553
- let x = e.clientX - rect.left;
3554
- let y = e.clientY - rect.top;
3555
- Logger.debug(TAG, "[ContextMenu] Initial position", {
3556
- x,
3557
- y,
3558
- rectWidth: rect.width,
3559
- rectHeight: rect.height,
3560
- });
3561
- // Clamp menu height to the player so it scrolls when too tall
3562
- const maxMenuHeight = Math.max(120, rect.height - 20);
3563
- contextMenu.style.maxHeight = `${maxMenuHeight}px`;
3894
+ // Portal the menu to a body-level shadow root and switch to fixed
3895
+ // positioning so it escapes EVERY clipping ancestor of the player (a
3896
+ // page wrapper's overflow:hidden, body{overflow-x:hidden}, etc.) and
3897
+ // spills freely into the page like a native context menu. Lifting the
3898
+ // player's own clip isn't enough — the ancestors still chop it. Once
3899
+ // portaled + fixed, positions are plain viewport coordinates.
3900
+ this.portalContextMenu(contextMenu);
3901
+ contextMenu.style.position = "fixed";
3902
+ let x = e.clientX;
3903
+ let y = e.clientY;
3904
+ // Cap height to the VIEWPORT so a tall menu extends downward instead of
3905
+ // scrolling inside a tiny box.
3906
+ contextMenu.style.maxHeight = `${Math.max(180, window.innerHeight - 40)}px`;
3564
3907
  // Temporarily show menu to get its dimensions
3565
3908
  contextMenu.style.display = "block";
3566
3909
  contextMenu.style.visibility = "hidden";
3567
3910
  const menuWidth = contextMenu.offsetWidth;
3568
3911
  const menuHeight = contextMenu.offsetHeight;
3569
- Logger.debug(TAG, "[ContextMenu] Menu dimensions", {
3570
- menuWidth,
3571
- menuHeight,
3572
- });
3573
3912
  contextMenu.style.visibility = "visible";
3574
- // Adjust horizontal position if menu would overflow
3575
- if (x + menuWidth > rect.width) {
3576
- x = rect.width - menuWidth - 10;
3577
- Logger.debug(TAG, "[ContextMenu] Adjusted x to prevent overflow", {
3578
- x,
3579
- });
3580
- }
3581
- if (x < 10) {
3582
- x = 10;
3583
- Logger.debug(TAG, "[ContextMenu] Adjusted x to minimum", { x });
3584
- }
3585
- // Adjust vertical position if menu would overflow
3586
- if (y + menuHeight > rect.height) {
3587
- y = rect.height - menuHeight - 10;
3588
- Logger.debug(TAG, "[ContextMenu] Adjusted y to prevent overflow", {
3589
- y,
3590
- });
3591
- }
3592
- if (y < 10) {
3593
- y = 10;
3594
- Logger.debug(TAG, "[ContextMenu] Adjusted y to minimum", { y });
3595
- }
3913
+ // Flip to the left of / above the cursor near the viewport edge.
3914
+ if (x + menuWidth > window.innerWidth - 10)
3915
+ x -= menuWidth;
3916
+ if (y + menuHeight > window.innerHeight - 10)
3917
+ y -= menuHeight;
3596
3918
  contextMenu.style.left = `${x}px`;
3597
3919
  contextMenu.style.top = `${y}px`;
3920
+ this.clampMenuToViewport(contextMenu);
3598
3921
  }
3599
3922
  // Delay adding visible class slightly to ensure transition works
3600
3923
  requestAnimationFrame(() => {
@@ -3614,6 +3937,9 @@ export class MoviElement extends HTMLElement {
3614
3937
  // Helper to hide context menu
3615
3938
  const hideContextMenu = () => {
3616
3939
  contextMenu.classList.remove("visible");
3940
+ // Restore the host's paint/overflow clip (lifted while the desktop menu
3941
+ // was open so it could spill outside the player).
3942
+ this.classList.remove("movi-menu-overflow");
3617
3943
  this._contextMenuVisible = false;
3618
3944
  // Set just-closed flag to prevent play/pause toggle
3619
3945
  this._contextMenuJustClosed = true;
@@ -3638,6 +3964,11 @@ export class MoviElement extends HTMLElement {
3638
3964
  }
3639
3965
  else {
3640
3966
  contextMenu.style.display = "none";
3967
+ // Return the menu from the body portal to the shadow root and clear its
3968
+ // fixed positioning so the next open (incl. strip/touch modes) starts
3969
+ // clean and the submenu query below finds it back home.
3970
+ contextMenu.style.position = "";
3971
+ this.unportalContextMenu(contextMenu);
3641
3972
  }
3642
3973
  // Hide all submenus
3643
3974
  shadowRoot
@@ -3651,6 +3982,73 @@ export class MoviElement extends HTMLElement {
3651
3982
  if (!isTouchDevice)
3652
3983
  this.showControls();
3653
3984
  };
3985
+ // Touch: drag the mobile menu drawer to the right to dismiss it. The drawer
3986
+ // slides in from the right edge (translateX(100%) -> 0), so dragging it back
3987
+ // toward the right is the natural close gesture. Only the full-height drawer
3988
+ // participates (the audio-strip variant is an opacity-fade, not a slide),
3989
+ // and vertical/leftward moves are handed back to native scrolling / taps so
3990
+ // menu buttons and scrolling still work. The drawer's CSS transform/
3991
+ // transition carry `!important`, so inline overrides must use setProperty
3992
+ // with the "important" priority to win.
3993
+ const SWIPE_SLOP = 8; // px of travel before we lock an axis
3994
+ const SWIPE_CLOSE_RATIO = 0.35; // fraction of drawer width that dismisses
3995
+ let swStartX = 0;
3996
+ let swStartY = 0;
3997
+ let swDecided = false; // axis for this gesture chosen
3998
+ let swDragging = false; // horizontal drag-to-close has taken over
3999
+ const drawerActive = () => contextMenu.classList.contains("movi-context-menu-mobile") &&
4000
+ contextMenu.classList.contains("visible") &&
4001
+ !this.classList.contains("movi-audio-strip");
4002
+ contextMenu.addEventListener("touchstart", (e) => {
4003
+ if (!drawerActive() || e.touches.length !== 1)
4004
+ return;
4005
+ swStartX = e.touches[0].clientX;
4006
+ swStartY = e.touches[0].clientY;
4007
+ swDecided = false;
4008
+ swDragging = false;
4009
+ }, { passive: true });
4010
+ contextMenu.addEventListener("touchmove", (e) => {
4011
+ if (!drawerActive() || e.touches.length !== 1)
4012
+ return;
4013
+ const dx = e.touches[0].clientX - swStartX;
4014
+ const dy = e.touches[0].clientY - swStartY;
4015
+ if (!swDecided) {
4016
+ if (Math.abs(dx) < SWIPE_SLOP && Math.abs(dy) < SWIPE_SLOP)
4017
+ return;
4018
+ swDecided = true;
4019
+ // Own the gesture only when it's a clear rightward horizontal swipe;
4020
+ // otherwise leave it to the menu's own vertical scroll.
4021
+ swDragging = Math.abs(dx) > Math.abs(dy) && dx > 0;
4022
+ if (swDragging)
4023
+ contextMenu.style.setProperty("transition", "none", "important");
4024
+ }
4025
+ if (!swDragging)
4026
+ return;
4027
+ e.preventDefault(); // we own this gesture now — no scroll/tap
4028
+ const offset = Math.max(0, dx); // rightward only
4029
+ contextMenu.style.setProperty("transform", `translateX(${offset}px)`, "important");
4030
+ }, { passive: false });
4031
+ const endSwipe = (e) => {
4032
+ if (!swDragging) {
4033
+ swDecided = false;
4034
+ return;
4035
+ }
4036
+ swDragging = false;
4037
+ swDecided = false;
4038
+ const dx = (e.changedTouches[0]?.clientX ?? swStartX) - swStartX;
4039
+ const width = contextMenu.offsetWidth || 1;
4040
+ // Restore the CSS 0.4s slide and commit the dragged position as its
4041
+ // start, then hand the transform back to CSS so it animates to the
4042
+ // resting/closed state instead of jumping.
4043
+ contextMenu.style.removeProperty("transition");
4044
+ void contextMenu.offsetWidth; // reflow: lock in the current translateX
4045
+ if (dx > width * SWIPE_CLOSE_RATIO) {
4046
+ hideContextMenu(); // drops .visible -> CSS target translateX(100%)
4047
+ }
4048
+ contextMenu.style.removeProperty("transform");
4049
+ };
4050
+ contextMenu.addEventListener("touchend", endSwipe, { passive: true });
4051
+ contextMenu.addEventListener("touchcancel", endSwipe, { passive: true });
3654
4052
  // Add event listeners with capture phase and passive: false to allow preventDefault
3655
4053
  // Use capture phase to intercept before it reaches other handlers
3656
4054
  // Add to multiple elements to ensure we catch it everywhere
@@ -3797,6 +4195,7 @@ export class MoviElement extends HTMLElement {
3797
4195
  else if (action === "fit") {
3798
4196
  const submenu = shadowRoot.querySelector('.movi-context-menu-submenu[data-submenu="fit"]');
3799
4197
  if (submenu) {
4198
+ this.syncFitSubmenuActive(submenu); // touch: reflect current fit
3800
4199
  contextMenu.scrollTop = 0;
3801
4200
  submenu.classList.add("movi-context-menu-submenu-visible");
3802
4201
  }
@@ -3905,12 +4304,12 @@ export class MoviElement extends HTMLElement {
3905
4304
  this._playbackRate = playbackSpeed;
3906
4305
  this.setAttribute("playbackrate", playbackSpeed.toString());
3907
4306
  }
3908
- // Update active state
3909
- contextMenu
3910
- .querySelectorAll(".movi-context-menu-item[data-speed]")
3911
- .forEach((el) => {
3912
- el.classList.remove("movi-context-menu-active");
3913
- });
4307
+ // Update active state (query the item's own submenu — the speed submenu
4308
+ // is a moved-out sibling of contextMenu, so contextMenu wouldn't find
4309
+ // these items and the active class would accumulate; see the fit case).
4310
+ item.parentElement
4311
+ ?.querySelectorAll(".movi-context-menu-item[data-speed]")
4312
+ .forEach((el) => el.classList.remove("movi-context-menu-active"));
3914
4313
  item.classList.add("movi-context-menu-active");
3915
4314
  this.showOSD(OSD.speed, `Speed ${playbackSpeed}x`);
3916
4315
  hideContextMenu();
@@ -3925,16 +4324,24 @@ export class MoviElement extends HTMLElement {
3925
4324
  }
3926
4325
  this.updateFitMode();
3927
4326
  this.updateAspectRatioIcon();
3928
- contextMenu.querySelectorAll(".movi-context-menu-item[data-fit]").forEach((el) => {
3929
- el.classList.remove("movi-context-menu-active");
3930
- });
4327
+ // Query within the item's own submenu, NOT contextMenu: the fit submenu
4328
+ // is moved out to be a sibling of contextMenu (so it escapes the menu's
4329
+ // overflow), so contextMenu.querySelectorAll finds none of these items —
4330
+ // the active class was never cleared and every tapped option stayed
4331
+ // highlighted (most visible on touch, where the submenu lingers open).
4332
+ item.parentElement
4333
+ ?.querySelectorAll(".movi-context-menu-item[data-fit]")
4334
+ .forEach((el) => el.classList.remove("movi-context-menu-active"));
3931
4335
  item.classList.add("movi-context-menu-active");
3932
4336
  // Update the new context menu status too
3933
4337
  const aspectStatus = shadowRoot.querySelector(".movi-aspect-status");
4338
+ const fitLabels = { contain: "Fit", cover: "Fill", fill: "Stretch", zoom: "Zoom" };
3934
4339
  if (aspectStatus) {
3935
- const labels = { contain: "Fit", cover: "Fill", fill: "Stretch", zoom: "Zoom" };
3936
- aspectStatus.textContent = labels[fitMode] || fitMode;
4340
+ aspectStatus.textContent = fitLabels[fitMode] || fitMode;
3937
4341
  }
4342
+ // Surface the same Fit/Fill/… OSD the aspect button and keyboard show —
4343
+ // changing the fit from the menu was silently skipping it.
4344
+ this.showOSD(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${MoviElement.ASPECT_ICONS[fitMode] || MoviElement.ASPECT_ICONS.contain}</svg>`, fitLabels[fitMode] || fitMode);
3938
4345
  hideContextMenu();
3939
4346
  }
3940
4347
  else if (action === "hdr-toggle") {
@@ -3945,7 +4352,7 @@ export class MoviElement extends HTMLElement {
3945
4352
  else if (action === "rotate-video") {
3946
4353
  if (this.player && !this._pipWindow) {
3947
4354
  const deg = this.player.rotateVideo();
3948
- const statusEl = shadowRoot.querySelector(".movi-rotate-status");
4355
+ const statusEl = this.contextMenuRoot().querySelector(".movi-rotate-status");
3949
4356
  if (statusEl)
3950
4357
  statusEl.textContent = `${deg}°`;
3951
4358
  this.syncThumbnailRotation(deg);
@@ -4178,7 +4585,7 @@ export class MoviElement extends HTMLElement {
4178
4585
  }
4179
4586
  // Update HDR visibility/state in context menu
4180
4587
  this.updateHDRVisibility();
4181
- // Update active state for Fit mode
4588
+ // Update active state for Fit mode.
4182
4589
  const currentActiveFit = this._objectFit === "control" ? this._currentFit : this._objectFit;
4183
4590
  const fitItems = contextMenu.querySelectorAll(".movi-context-menu-item[data-fit]");
4184
4591
  fitItems.forEach((item) => {
@@ -4196,6 +4603,20 @@ export class MoviElement extends HTMLElement {
4196
4603
  rotateItem.classList.toggle("movi-context-menu-disabled", !!this._pipWindow);
4197
4604
  }
4198
4605
  }
4606
+ /**
4607
+ * Highlight the current fit as the active row in the aspect-ratio submenu, so
4608
+ * it reflects the live fit however it was last changed — context menu, the
4609
+ * bottom-controls aspect button, or the keyboard shortcut. Called each time
4610
+ * the fit submenu is shown: on hover (desktop, via showSubmenu) and on tap
4611
+ * (touch, via the action==="fit" branch). Kept OUT of updateContextMenuContent
4612
+ * (which runs on the main menu's open) to avoid touch-open side effects.
4613
+ */
4614
+ syncFitSubmenuActive(submenu) {
4615
+ const currentFit = this._objectFit === "control" ? this._currentFit : this._objectFit;
4616
+ submenu
4617
+ .querySelectorAll(".movi-context-menu-item[data-fit]")
4618
+ .forEach((el) => el.classList.toggle("movi-context-menu-active", el.dataset.fit === currentFit));
4619
+ }
4199
4620
  setupSubmenuHover(item, submenu) {
4200
4621
  // Check if listeners are already attached (using a data attribute)
4201
4622
  if (item.dataset.hoverSetup === "true") {
@@ -4210,38 +4631,56 @@ export class MoviElement extends HTMLElement {
4210
4631
  clearTimeout(hideTimeout);
4211
4632
  hideTimeout = null;
4212
4633
  }
4213
- // Submenu lives as a sibling of the context menu inside shadowRoot.
4214
- // It's position:absolute, so coordinates are relative to :host (player).
4215
- const contextMenu = this.shadowRoot?.querySelector(".movi-context-menu");
4634
+ // The menu (and its submenu panels) may be in the body portal (desktop)
4635
+ // rather than the shadow root find it wherever it lives.
4636
+ const contextMenu = (this._menuPortalRoot?.querySelector(".movi-context-menu") ||
4637
+ this.shadowRoot?.querySelector(".movi-context-menu"));
4216
4638
  if (contextMenu) {
4639
+ // Use VIEWPORT coordinates when the menu itself is viewport-positioned:
4640
+ // the desktop menu is portaled (absolute inside the fixed portal host at
4641
+ // 0,0), and the audio-strip menu is fixed (strip mode drops the host's
4642
+ // container-type, so a fixed submenu is viewport-relative too and lines
4643
+ // up with it — the tiny 56px strip's bounds must NOT confine it).
4644
+ // Otherwise the submenu is :host-relative and confined to the player.
4645
+ const portaled = contextMenu.getRootNode() === this._menuPortalRoot;
4646
+ const strip = this.classList.contains("movi-audio-strip");
4647
+ const useViewport = portaled || strip;
4648
+ // Strip: pin the submenu to the viewport like the strip menu.
4649
+ submenu.style.position = strip ? "fixed" : "";
4217
4650
  const itemRect = item.getBoundingClientRect();
4218
4651
  const menuRect = contextMenu.getBoundingClientRect();
4219
4652
  const playerRect = this.getBoundingClientRect();
4220
4653
  const submenuWidth = submenu.offsetWidth || 160;
4221
4654
  const gap = 4;
4222
4655
  const padding = 10;
4223
- const spaceOnRight = playerRect.right - menuRect.right;
4224
- const spaceOnLeft = menuRect.left - playerRect.left;
4656
+ const originLeft = useViewport ? 0 : playerRect.left;
4657
+ const originTop = useViewport ? 0 : playerRect.top;
4658
+ const boundsLeft = useViewport ? 0 : playerRect.left;
4659
+ const boundsRight = useViewport ? window.innerWidth : playerRect.right;
4660
+ const boundsBottom = useViewport ? window.innerHeight : playerRect.bottom;
4661
+ const spaceOnRight = boundsRight - menuRect.right;
4662
+ const spaceOnLeft = menuRect.left - boundsLeft;
4225
4663
  submenu.style.right = "auto";
4226
4664
  submenu.style.marginLeft = "0";
4227
4665
  submenu.style.marginRight = "0";
4228
- // Convert viewport coords :host-relative
4666
+ // Positions are relative to the submenu's containing block (viewport
4667
+ // origin when portaled, else :host), so subtract the origin.
4229
4668
  if (spaceOnRight >= submenuWidth + padding) {
4230
4669
  // 1. RIGHT (Preferred)
4231
- submenu.style.left = `${menuRect.right + gap - playerRect.left}px`;
4670
+ submenu.style.left = `${menuRect.right + gap - originLeft}px`;
4232
4671
  submenu.style.transform = "translateX(-8px)";
4233
4672
  }
4234
4673
  else if (spaceOnLeft >= submenuWidth + padding) {
4235
4674
  // 2. LEFT
4236
- submenu.style.left = `${menuRect.left - submenuWidth - gap - playerRect.left}px`;
4675
+ submenu.style.left = `${menuRect.left - submenuWidth - gap - originLeft}px`;
4237
4676
  submenu.style.transform = "translateX(8px)";
4238
4677
  }
4239
4678
  else {
4240
4679
  // 3. OVERLAP (tight space)
4241
- submenu.style.left = `${menuRect.left + 20 - playerRect.left}px`;
4680
+ submenu.style.left = `${menuRect.left + 20 - originLeft}px`;
4242
4681
  submenu.style.transform = "translateY(10px)";
4243
4682
  }
4244
- let topPx = itemRect.top - playerRect.top;
4683
+ let topPx = itemRect.top - originTop;
4245
4684
  submenu.style.top = `${topPx}px`;
4246
4685
  // Measure submenu height (force layout if hidden)
4247
4686
  const wasClassVisible = submenu.classList.contains("movi-context-menu-submenu-visible");
@@ -4254,15 +4693,16 @@ export class MoviElement extends HTMLElement {
4254
4693
  submenu.style.display = "";
4255
4694
  submenu.style.visibility = "";
4256
4695
  }
4257
- // Clamp to player bounds (player-relative)
4258
- const playerHeight = playerRect.height;
4259
- if (topPx + submenuHeight > playerHeight - padding) {
4260
- topPx = playerHeight - padding - submenuHeight;
4261
- if (topPx < padding)
4262
- topPx = padding;
4696
+ // Clamp to the bounds (viewport when portaled, else player).
4697
+ const maxTop = boundsBottom - originTop - padding - submenuHeight;
4698
+ if (topPx > maxTop) {
4699
+ topPx = Math.max(padding, maxTop);
4263
4700
  submenu.style.top = `${topPx}px`;
4264
4701
  }
4265
4702
  }
4703
+ // Desktop hover-open of the aspect submenu: reflect the current fit.
4704
+ if (submenu.dataset.submenu === "fit")
4705
+ this.syncFitSubmenuActive(submenu);
4266
4706
  submenu.classList.add("movi-context-menu-submenu-visible");
4267
4707
  };
4268
4708
  const hideSubmenu = () => {
@@ -4321,7 +4761,7 @@ export class MoviElement extends HTMLElement {
4321
4761
  // requestFullscreen is blocked, or embedded apps that drive their own
4322
4762
  // fullscreen layout). Hosts call event.preventDefault() and then drive
4323
4763
  // setHostFullscreen() themselves to keep the UI in sync.
4324
- const currentlyActive = !!document.fullscreenElement || this._hostFullscreen;
4764
+ const currentlyActive = this.isFullscreenActive();
4325
4765
  const requestEvent = new CustomEvent("movi-fullscreen-request", {
4326
4766
  cancelable: true,
4327
4767
  bubbles: true,
@@ -4332,6 +4772,13 @@ export class MoviElement extends HTMLElement {
4332
4772
  if (requestEvent.defaultPrevented) {
4333
4773
  return;
4334
4774
  }
4775
+ // iOS Safari (and any engine without element-fullscreen): the Fullscreen
4776
+ // API only works on <video>, and we render to a canvas — so fall back to a
4777
+ // CSS viewport-fill "pseudo fullscreen" instead of throwing on requestFullscreen.
4778
+ if (!this.nativeFullscreenSupported()) {
4779
+ this.setPseudoFullscreen(!this._pseudoFullscreen);
4780
+ return;
4781
+ }
4335
4782
  try {
4336
4783
  if (!document.fullscreenElement) {
4337
4784
  await this.requestFullscreen();
@@ -4462,7 +4909,7 @@ export class MoviElement extends HTMLElement {
4462
4909
  }
4463
4910
  .pip-progress-bar:hover { height: 5px; }
4464
4911
  .pip-progress-fill {
4465
- height: 100%; background: #8B5CF6; border-radius: 2px;
4912
+ height: 100%; background: var(--movi-primary, #8B5CF6); border-radius: 2px;
4466
4913
  width: 0%; pointer-events: none;
4467
4914
  }
4468
4915
  .pip-time { font: 500 10px/1 -apple-system, sans-serif; color: rgba(255,255,255,0.7); white-space: nowrap; }
@@ -4470,7 +4917,7 @@ export class MoviElement extends HTMLElement {
4470
4917
  .pip-btn-row { display: flex; align-items: center; justify-content: center; gap: 16px; position: relative; }
4471
4918
  .pip-btn {
4472
4919
  background: none; border: none; cursor: pointer; padding: 4px;
4473
- color: #fff; opacity: 0.85; display: flex; align-items: center; justify-content: center;
4920
+ color: var(--movi-chrome-fg, #fff); opacity: 0.85; display: flex; align-items: center; justify-content: center;
4474
4921
  }
4475
4922
  .pip-btn:hover { opacity: 1; }
4476
4923
  .pip-btn svg { width: 20px; height: 20px; }
@@ -4483,6 +4930,9 @@ export class MoviElement extends HTMLElement {
4483
4930
  pipWindow.document.head.appendChild(style);
4484
4931
  // Move canvas to PiP window
4485
4932
  pipWindow.document.body.appendChild(this.canvas);
4933
+ // Clear any leftover cursor:none (from a hidden-controls state before
4934
+ // entering PiP) so the pointer is visible in the PiP window immediately.
4935
+ this.canvas.style.cursor = "default";
4486
4936
  Logger.info(TAG, `PiP: canvas moved to PiP window, isConnected=${this.canvas.isConnected}`);
4487
4937
  // Build PiP controls
4488
4938
  const controls = pipWindow.document.createElement("div");
@@ -4652,12 +5102,94 @@ export class MoviElement extends HTMLElement {
4652
5102
  catch (error) {
4653
5103
  Logger.error(TAG, "Failed to open PiP", error);
4654
5104
  this._pipWindow = null;
5105
+ // The Document PiP API exists but requestWindow was blocked — e.g. a
5106
+ // sandboxed iframe without allow-popups, or a Permissions-Policy the
5107
+ // upfront check couldn't see. That's a static property of the embedding
5108
+ // context, not a one-off, so retire the (dead) control rather than let it
5109
+ // fail again on every click.
5110
+ const pipBtn = this.shadowRoot?.querySelector(".movi-pip-btn");
5111
+ if (pipBtn)
5112
+ pipBtn.style.display = "none";
5113
+ const pipCtxItem = this.shadowRoot?.querySelector(".movi-context-menu-pip");
5114
+ if (pipCtxItem)
5115
+ pipCtxItem.style.display = "none";
4655
5116
  }
4656
5117
  }
4657
5118
  /** Tracks host-driven fullscreen so toggleFullscreen() and the
4658
5119
  * movi-fullscreen-request event can reflect it correctly even when
4659
5120
  * document.fullscreenElement is null. */
4660
5121
  _hostFullscreen = false;
5122
+ /** CSS "fill the viewport" fallback used where the element Fullscreen API
5123
+ * isn't available (iOS Safari only exposes it for <video>, and we render to
5124
+ * a canvas). Not real fullscreen — no chrome hiding — but the player takes
5125
+ * over the visible viewport. */
5126
+ _pseudoFullscreen = false;
5127
+ _prevBodyOverflow = "";
5128
+ /** True when the player is fullscreen by any route: native element FS, a
5129
+ * host-driven FS, or the iOS pseudo-fullscreen fallback. */
5130
+ isFullscreenActive() {
5131
+ return (document.fullscreenElement === this ||
5132
+ this._hostFullscreen ||
5133
+ this._pseudoFullscreen);
5134
+ }
5135
+ /** Whether the browser exposes the element Fullscreen API for this element
5136
+ * (false on iOS Safari, which only fullscreens <video>). */
5137
+ nativeFullscreenSupported() {
5138
+ return (typeof this.requestFullscreen === "function" && !!document.fullscreenEnabled);
5139
+ }
5140
+ /** Re-evaluate on device rotation / viewport change while pseudo-fullscreen. */
5141
+ _onPseudoFsResize = () => {
5142
+ this.updatePseudoFsRotation();
5143
+ this.updateCanvasSize();
5144
+ };
5145
+ /**
5146
+ * Forced-landscape for the pseudo-fullscreen fallback: iOS won't rotate the
5147
+ * screen for us, so a landscape video on a portrait screen is turned 90° via
5148
+ * CSS (the .movi-pseudo-fs-rotate class) to fill it — matching what Chrome /
5149
+ * Android do with an orientation lock. Once the device itself is landscape
5150
+ * (window wider than tall) the rotation is dropped.
5151
+ */
5152
+ updatePseudoFsRotation() {
5153
+ if (!this._pseudoFullscreen) {
5154
+ this.classList.remove("movi-pseudo-fs-rotate");
5155
+ return;
5156
+ }
5157
+ const track = this.player?.trackManager?.getActiveVideoTrack?.();
5158
+ const rawW = track?.width ?? 0;
5159
+ const rawH = track?.height ?? 0;
5160
+ const rot = this.player?.getVideoRotation?.() ?? track?.rotation ?? 0;
5161
+ const swap = Math.abs(rot) % 180 !== 0;
5162
+ const vidW = swap ? rawH : rawW;
5163
+ const vidH = swap ? rawW : rawH;
5164
+ const videoLandscape = vidW > vidH && vidW > 0;
5165
+ const windowPortrait = window.innerHeight > window.innerWidth;
5166
+ this.classList.toggle("movi-pseudo-fs-rotate", videoLandscape && windowPortrait);
5167
+ }
5168
+ /** Enter/leave the CSS viewport-fill fallback. */
5169
+ setPseudoFullscreen(on) {
5170
+ if (this._pseudoFullscreen === on)
5171
+ return;
5172
+ this._pseudoFullscreen = on;
5173
+ this.classList.toggle("movi-pseudo-fullscreen", on);
5174
+ if (on) {
5175
+ // Lock the page scroll behind the overlay while active.
5176
+ this._prevBodyOverflow = document.body.style.overflow;
5177
+ document.body.style.overflow = "hidden";
5178
+ this.updatePseudoFsRotation();
5179
+ window.addEventListener("resize", this._onPseudoFsResize);
5180
+ window.addEventListener("orientationchange", this._onPseudoFsResize);
5181
+ }
5182
+ else {
5183
+ this.classList.remove("movi-pseudo-fs-rotate");
5184
+ window.removeEventListener("resize", this._onPseudoFsResize);
5185
+ window.removeEventListener("orientationchange", this._onPseudoFsResize);
5186
+ document.body.style.overflow = this._prevBodyOverflow;
5187
+ }
5188
+ this.applyFullscreenUiState(on);
5189
+ this.applyFullscreenOrientation(on);
5190
+ // Repaint the canvas at the new (viewport) size.
5191
+ this.updateCanvasSize();
5192
+ }
4661
5193
  updateFullscreenIcon(isFullscreen) {
4662
5194
  const fullscreenIcon = this.shadowRoot?.querySelector(".movi-icon-fullscreen");
4663
5195
  const fullscreenExitIcon = this.shadowRoot?.querySelector(".movi-icon-fullscreen-exit");
@@ -4675,6 +5207,47 @@ export class MoviElement extends HTMLElement {
4675
5207
  if (label)
4676
5208
  label.textContent = isFullscreen ? "Exit Fullscreen" : "Fullscreen";
4677
5209
  }
5210
+ /**
5211
+ * On phones/tablets, rotate to match the video when entering fullscreen and
5212
+ * release the lock on exit. Wide clips lock landscape, tall clips portrait;
5213
+ * defaults to landscape (the common case) if dimensions aren't known. No-op on
5214
+ * desktop, audio-only, or where the Screen Orientation lock API is missing
5215
+ * (e.g. iOS Safari, which rejects programmatic lock — caught and ignored).
5216
+ */
5217
+ applyFullscreenOrientation(isFullscreen) {
5218
+ const so = screen.orientation;
5219
+ if (!so || typeof so.lock !== "function")
5220
+ return;
5221
+ if (!window.matchMedia("(pointer: coarse)").matches)
5222
+ return;
5223
+ if (this.classList.contains("movi-audio-mode"))
5224
+ return;
5225
+ if (isFullscreen) {
5226
+ const track = this.player?.trackManager?.getActiveVideoTrack?.();
5227
+ const rawW = track?.width ?? 0;
5228
+ const rawH = track?.height ?? 0;
5229
+ // Portrait clips shot on phones are usually stored as landscape frames
5230
+ // plus a 90°/270° rotation flag, so the raw width/height read landscape.
5231
+ // Use the effective display rotation (metadata + manual) to swap them,
5232
+ // otherwise a tall video would wrongly lock landscape on entry.
5233
+ const rot = this.player?.getVideoRotation?.() ?? track?.rotation ?? 0;
5234
+ const rotated = Math.abs(rot) % 180 !== 0;
5235
+ const w = rotated ? rawH : rawW;
5236
+ const h = rotated ? rawW : rawH;
5237
+ const target = h > w && h > 0 ? "portrait" : "landscape";
5238
+ Promise.resolve(so.lock(target)).catch(() => {
5239
+ /* unsupported / user-denied — leave orientation as-is */
5240
+ });
5241
+ }
5242
+ else {
5243
+ try {
5244
+ so.unlock?.();
5245
+ }
5246
+ catch {
5247
+ /* ignore */
5248
+ }
5249
+ }
5250
+ }
4678
5251
  applyFullscreenUiState(isFullscreen) {
4679
5252
  this.updateFullscreenIcon(isFullscreen);
4680
5253
  this.updateFullscreenContextMenu(isFullscreen);
@@ -4853,8 +5426,14 @@ export class MoviElement extends HTMLElement {
4853
5426
  }
4854
5427
  this.updateAudioTrackMenu();
4855
5428
  const menu = this.shadowRoot?.querySelector(".movi-audio-track-menu");
4856
- if (menu)
4857
- menu.style.display = "none";
5429
+ // Close via setBottomMenuOpen, NOT display:none directly. Setting
5430
+ // display:none leaves the `is-open` class on the menu, so
5431
+ // isBottomMenuOpen()/isAnyMenuOpen() stay true FOREVER — every later
5432
+ // showControls() then no-ops and the controls auto-hide never
5433
+ // re-arms again (not just for audio, but for any subsequent menu or
5434
+ // playback). setBottomMenuOpen(false) removes is-open and re-arms
5435
+ // the auto-hide once the exit transition finishes.
5436
+ this.setBottomMenuOpen(menu, false);
4858
5437
  }
4859
5438
  });
4860
5439
  });
@@ -5251,6 +5830,107 @@ export class MoviElement extends HTMLElement {
5251
5830
  getMaxAllowedRate() {
5252
5831
  return 2;
5253
5832
  }
5833
+ /** Volume ceiling: 200% normally (boost via AudioContext gain), but 100% when
5834
+ * audio plays through a native element (adaptive stream / native split-source)
5835
+ * which can't boost — so the UI doesn't promise a boost that won't happen. */
5836
+ getMaxVolume() {
5837
+ return this.player?.usesNativeAudio?.() ? 1 : 2;
5838
+ }
5839
+ /** Re-apply the volume ceiling to the slider + current volume when the audio
5840
+ * path changes (source load, audio-track switch between muxed and native). */
5841
+ updateVolumeCap() {
5842
+ const max = this.getMaxVolume();
5843
+ const slider = this.shadowRoot?.querySelector(".movi-volume-slider");
5844
+ if (slider) {
5845
+ slider.max = String(max);
5846
+ slider.setAttribute("aria-valuemax", String(max * 100));
5847
+ }
5848
+ // Clamp a boosted volume back down when switching to a native (no-boost) path.
5849
+ if (this._volume > max)
5850
+ this.volume = max;
5851
+ else
5852
+ this.updateVolume();
5853
+ }
5854
+ /**
5855
+ * Start sampling render health once per second to detect sustained stutter
5856
+ * (decoder can't keep up above 1x on a heavy source → dropped video frames,
5857
+ * smooth audio). Runs only during active playback; cheap (reads a couple of
5858
+ * numbers). Idempotent.
5859
+ */
5860
+ startStutterMonitor() {
5861
+ if (this._stutterInterval !== null)
5862
+ return;
5863
+ const h = this.player?.getRenderHealth?.();
5864
+ this._stutterLastPresented = h ? h.framesPresented : 0;
5865
+ this._stutterSeconds = 0;
5866
+ this._stutterInterval = window.setInterval(() => this.sampleStutter(), 1000);
5867
+ }
5868
+ stopStutterMonitor() {
5869
+ if (this._stutterInterval !== null) {
5870
+ clearInterval(this._stutterInterval);
5871
+ this._stutterInterval = null;
5872
+ }
5873
+ this._stutterSeconds = 0;
5874
+ }
5875
+ /**
5876
+ * Clear the stutter-hint cooldown so the "play at 1x" warning can fire again.
5877
+ * Called on every playback-rate change — each new speed the user picks gets a
5878
+ * fresh chance to warn if it stutters (instead of showing only once per
5879
+ * source). The 3-second sustained requirement still prevents instant spam.
5880
+ */
5881
+ resetStutterHint() {
5882
+ this._stutterSeconds = 0;
5883
+ this._stutterCooldown = false;
5884
+ if (this._stutterCooldownTimer !== null) {
5885
+ clearTimeout(this._stutterCooldownTimer);
5886
+ this._stutterCooldownTimer = null;
5887
+ }
5888
+ }
5889
+ /** One stutter sample: compare presented FPS to the smooth-playback baseline. */
5890
+ sampleStutter() {
5891
+ // Only judge during genuine playback — buffering/seeking legitimately
5892
+ // present few/no frames and would false-positive.
5893
+ if (this.player?.getState?.() !== "playing") {
5894
+ this._stutterSeconds = 0;
5895
+ return;
5896
+ }
5897
+ const h = this.player?.getRenderHealth?.();
5898
+ if (!h)
5899
+ return;
5900
+ const presented = h.framesPresented - this._stutterLastPresented;
5901
+ this._stutterLastPresented = h.framesPresented;
5902
+ // framesPresented resets to 0 on seek → negative delta; skip that sample.
5903
+ if (presented < 0) {
5904
+ this._stutterSeconds = 0;
5905
+ return;
5906
+ }
5907
+ // Only meaningful above 1x — at ≤1x there's nothing slower to suggest.
5908
+ // Smooth playback presents ~min(60, sourceFps × rate) distinct frames/s
5909
+ // (display-capped); sustained < 60% of that means the decoder is dropping a
5910
+ // lot of frames. Profile-agnostic: works for any heavy source (4K/8K,
5911
+ // high-bitrate, software decode) that can't keep up above 1x.
5912
+ const expected = Math.min(60, h.sourceFps * this._playbackRate);
5913
+ if (this._playbackRate > 1 && presented < expected * 0.6) {
5914
+ this._stutterSeconds++;
5915
+ }
5916
+ else {
5917
+ this._stutterSeconds = 0;
5918
+ }
5919
+ if (this._stutterSeconds >= 3 && !this._stutterCooldown) {
5920
+ this._stutterSeconds = 0;
5921
+ this._stutterCooldown = true;
5922
+ // Cooldown so it doesn't nag while stuttering at the SAME rate; a rate
5923
+ // change (resetStutterHint) lifts it early so each new speed can warn.
5924
+ if (this._stutterCooldownTimer !== null) {
5925
+ clearTimeout(this._stutterCooldownTimer);
5926
+ }
5927
+ this._stutterCooldownTimer = window.setTimeout(() => {
5928
+ this._stutterCooldown = false;
5929
+ this._stutterCooldownTimer = null;
5930
+ }, 45000);
5931
+ this.showOSD(OSD.speed, "Play at 1x for smoother playback");
5932
+ }
5933
+ }
5254
5934
  /**
5255
5935
  * Show On-Screen Display (OSD) notification
5256
5936
  */
@@ -6516,6 +7196,12 @@ export class MoviElement extends HTMLElement {
6516
7196
  titleBar.classList.add("movi-title-visible");
6517
7197
  }
6518
7198
  }
7199
+ // Reveal the settings gear with the rest of the chrome — but only once a
7200
+ // source is set (nothing to configure in the empty "No Video" state).
7201
+ const gearEl = this.shadowRoot?.querySelector(".movi-gear-btn");
7202
+ if (gearEl) {
7203
+ gearEl.classList.toggle("movi-gear-visible", this.hasMediaSource());
7204
+ }
6519
7205
  // Clear existing timeout
6520
7206
  if (this.controlsTimeout) {
6521
7207
  clearTimeout(this.controlsTimeout);
@@ -6618,6 +7304,42 @@ export class MoviElement extends HTMLElement {
6618
7304
  // fixed with the button's bounding rect — viewport coordinates
6619
7305
  // can't be clipped by ancestor overflow.
6620
7306
  this.applyStripFixedMenuPosition(el);
7307
+ // Non-strip: keep the upward-opening dropdown INSIDE the player and scroll
7308
+ // it internally, rather than letting it spill into the page above — on a
7309
+ // short embedded player that overlapped unrelated page content and hid the
7310
+ // first rows. The menu's bottom edge is anchored just above its button;
7311
+ // cap its height to the room between the player's OWN top and that bottom.
7312
+ // This is player-relative (subtract the host's top), NOT viewport-relative,
7313
+ // so it stays contained wherever the player sits on a scrolled page.
7314
+ // scrollTop 0 keeps the first row (0.25x / Audio 1 / first subtitle)
7315
+ // visible; the rest is reachable by scrolling. The mobile menu CSS sets
7316
+ // max-height with !important, so the inline override must also carry it.
7317
+ if (!this.classList.contains("movi-audio-strip")) {
7318
+ // Cap the upward-opening menu to the room between the player's top and
7319
+ // the controls bar, measured from STABLE elements — the bar and the
7320
+ // host — NOT the menu's own rect, which on a very short player is
7321
+ // unreliable before .is-open (it sits at natural height, so the cap
7322
+ // wouldn't bite and the first rows would clip above the player instead
7323
+ // of scrolling). The menu's containing block is the bar and it anchors
7324
+ // ~15px above the bar's top; leave ~8px breathing room -> subtract ~23.
7325
+ // scrollTop 0 keeps the first row (0.25x / Audio 1 / first subtitle)
7326
+ // reachable at the top. The mobile menu CSS sets max-height !important,
7327
+ // so the inline override must carry it too.
7328
+ const hostTop = this.getBoundingClientRect().top;
7329
+ const bar = this.shadowRoot?.querySelector(".movi-controls-bar");
7330
+ const barTop = bar
7331
+ ? bar.getBoundingClientRect().top
7332
+ : el.getBoundingClientRect().bottom;
7333
+ // Floor kept low (not ~96) on purpose: on a very short player the room
7334
+ // above the bar can be < 96px, and a floor larger than the room would
7335
+ // push the menu's top back out above the player and clip the first row.
7336
+ el.style.setProperty("max-height", `${Math.max(32, barTop - hostTop - 23)}px`, "important");
7337
+ el.scrollTop = 0;
7338
+ }
7339
+ // The dropdowns live inside the controls container's stacking context
7340
+ // (z-index 10), so the top-right gear (z-index 30) would paint on top of a
7341
+ // menu that opens up over it. Hide the gear while any dropdown is open.
7342
+ this.classList.add("movi-bottom-menu-open");
6621
7343
  void el.getBoundingClientRect(); // flush layout so transition starts
6622
7344
  el.classList.add("is-open");
6623
7345
  return;
@@ -6635,18 +7357,23 @@ export class MoviElement extends HTMLElement {
6635
7357
  if (el.classList.contains("is-open"))
6636
7358
  return;
6637
7359
  el.style.display = "none";
6638
- // Reset the strip-mode fixed positioning so a subsequent open in
6639
- // non-strip mode doesn't inherit stale inline coords.
7360
+ // Reset the strip-mode fixed positioning + the viewport max-height cap so
7361
+ // a subsequent open (in a different mode / at a different scroll spot)
7362
+ // doesn't inherit stale inline values.
6640
7363
  el.style.position = "";
6641
7364
  el.style.top = "";
6642
7365
  el.style.left = "";
6643
7366
  el.style.right = "";
6644
7367
  el.style.bottom = "";
7368
+ el.style.removeProperty("max-height");
6645
7369
  // Menu fully closed — if nothing else is open, re-arm the auto-hide
6646
7370
  // timer. Covers toggling a popup shut from its own button (no
6647
7371
  // closeAllBottomMenus / mouseleave fires on touch), which otherwise
6648
7372
  // left the bar pinned open on touch devices.
6649
7373
  if (!this.isAnyMenuOpen()) {
7374
+ // Restore the host clip + gear once every dropdown is closed.
7375
+ this.classList.remove("movi-menu-overflow");
7376
+ this.classList.remove("movi-bottom-menu-open");
6650
7377
  this.showControls();
6651
7378
  }
6652
7379
  };
@@ -6813,6 +7540,38 @@ export class MoviElement extends HTMLElement {
6813
7540
  typeof AudioContext.prototype
6814
7541
  .setSinkId === "function");
6815
7542
  }
7543
+ /** Running inside a (possibly cross-origin) iframe. */
7544
+ isEmbeddedIframe() {
7545
+ try {
7546
+ return window.self !== window.top;
7547
+ }
7548
+ catch {
7549
+ // Cross-origin access to window.top can throw in some sandboxes — if we
7550
+ // can't even read it, we're definitely framed.
7551
+ return true;
7552
+ }
7553
+ }
7554
+ /**
7555
+ * Whether a Permissions-Policy feature (e.g. "fullscreen",
7556
+ * "picture-in-picture", "speaker-selection") is allowed in this document.
7557
+ * At the top level features are allowed by default; inside an iframe they
7558
+ * must be delegated via the `allow` attribute. Uses the (Chromium) feature
7559
+ * policy API when present and defaults to allowed when it isn't — the
7560
+ * features we gate this way are all Chromium-only, so a missing API means a
7561
+ * browser that wouldn't expose the capability regardless.
7562
+ */
7563
+ featureAllowed(feature) {
7564
+ const fp = document.featurePolicy;
7565
+ if (fp && typeof fp.allowsFeature === "function") {
7566
+ try {
7567
+ return fp.allowsFeature(feature);
7568
+ }
7569
+ catch {
7570
+ return true;
7571
+ }
7572
+ }
7573
+ return true;
7574
+ }
6816
7575
  /**
6817
7576
  * List the available audio output devices. Labels may be empty until the
6818
7577
  * page has been granted audio-device access (the desktop app has it; a bare
@@ -6945,8 +7704,15 @@ export class MoviElement extends HTMLElement {
6945
7704
  // opening the submenu triggers the Meet-style prompt and the list fills
6946
7705
  // in. The desktop app already has permission, so `real` is populated and
6947
7706
  // no prompt is needed.
6948
- const canUnlock = !!navigator.mediaDevices?.getUserMedia;
6949
- if (!MoviElement.audioSinkSupported() || (real.length < 1 && !canUnlock)) {
7707
+ // In an iframe, routing to a device needs allow="speaker-selection", and
7708
+ // the getUserMedia unlock needs allow="microphone". Without those the menu
7709
+ // would be a dead control (setSinkId / getUserMedia throw NotAllowedError),
7710
+ // so gate on the Permissions Policy.
7711
+ const speakerAllowed = this.featureAllowed("speaker-selection");
7712
+ const canUnlock = !!navigator.mediaDevices?.getUserMedia && this.featureAllowed("microphone");
7713
+ if (!MoviElement.audioSinkSupported() ||
7714
+ !speakerAllowed ||
7715
+ (real.length < 1 && !canUnlock)) {
6950
7716
  divider.style.display = "none";
6951
7717
  item.style.display = "none";
6952
7718
  submenu.style.display = "none";
@@ -7023,8 +7789,12 @@ export class MoviElement extends HTMLElement {
7023
7789
  // (and canvas/video, which sit underneath the overlay) via
7024
7790
  // inline style makes the change take effect immediately.
7025
7791
  this.style.cursor = "none";
7792
+ // In document-PiP the canvas is MOVED into the PiP window (out of the
7793
+ // shadow tree, so the cursor-hiding CSS can't reach it) — this inline
7794
+ // style still travels with the element and would hide the pointer over
7795
+ // the whole PiP video area above its own controls. Keep it visible there.
7026
7796
  if (this.canvas)
7027
- this.canvas.style.cursor = "none";
7797
+ this.canvas.style.cursor = this._pipWindow ? "default" : "none";
7028
7798
  if (this.video)
7029
7799
  this.video.style.cursor = "none";
7030
7800
  // The center play/pause button stays visible with pointer-events:auto
@@ -7079,6 +7849,14 @@ export class MoviElement extends HTMLElement {
7079
7849
  titleBar.classList.remove("movi-title-visible");
7080
7850
  }
7081
7851
  }
7852
+ // Hide the settings gear with the rest of the chrome — EXCEPT in audio
7853
+ // strip mode, where the bar is the whole (always-visible) player, so its
7854
+ // gear must stay put rather than auto-hiding with the transient controls.
7855
+ if (!this.classList.contains("movi-audio-strip")) {
7856
+ this.shadowRoot
7857
+ ?.querySelector(".movi-gear-btn")
7858
+ ?.classList.remove("movi-gear-visible");
7859
+ }
7082
7860
  }
7083
7861
  updateControlsVisibility() {
7084
7862
  const container = this.controlsContainer;
@@ -7147,6 +7925,25 @@ export class MoviElement extends HTMLElement {
7147
7925
  requestAnimationFrame(updateUI);
7148
7926
  }
7149
7927
  addStyles(shadowRoot) {
7928
+ // Load the webfont in its OWN sheet, appended a frame AFTER the main styles.
7929
+ // WebKit/Safari treats a shadow-DOM <style> whose first rule is a remote
7930
+ // @import as "pending" until that font finishes downloading — so on the
7931
+ // first (uncached) load the ENTIRE stylesheet is withheld and the shadow
7932
+ // content paints UNSTYLED for ~1s: the VR pad, gear, etc. flash in at their
7933
+ // default (static, opacity 1) positions before snapping to their real
7934
+ // hidden state. Isolating the @import — and adding it only after the first
7935
+ // paint — guarantees the layout/visibility rules below apply immediately;
7936
+ // Inter just swaps in late (font-display: swap). Chromium never had this
7937
+ // bug (it applies the other rules right away regardless of the @import).
7938
+ requestAnimationFrame(() => {
7939
+ if (shadowRoot.querySelector("style[data-movi-font]"))
7940
+ return;
7941
+ const fontStyle = document.createElement("style");
7942
+ fontStyle.setAttribute("data-movi-font", "");
7943
+ fontStyle.textContent =
7944
+ "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');";
7945
+ shadowRoot.appendChild(fontStyle);
7946
+ });
7150
7947
  const style = document.createElement("style");
7151
7948
  style.textContent = `
7152
7949
  /* ========================================
@@ -7154,9 +7951,6 @@ export class MoviElement extends HTMLElement {
7154
7951
  Rich, Elegant & Responsive Design
7155
7952
  ======================================== */
7156
7953
 
7157
- /* Import premium fonts */
7158
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
7159
-
7160
7954
  /* Global rules to remove all outlines and focus rings */
7161
7955
  * {
7162
7956
  outline: none !important;
@@ -7239,6 +8033,10 @@ export class MoviElement extends HTMLElement {
7239
8033
 
7240
8034
  /* Text Colors */
7241
8035
  --movi-controls-color: #FFFFFF;
8036
+ /* Foreground for chrome that is ALWAYS dark (bottom bar, OSD capsule,
8037
+ dark pills) — stays white in both themes, unlike --movi-controls-color
8038
+ which flips to dark for menus on light surfaces. Public theming hook. */
8039
+ --movi-chrome-fg: #ffffff;
7242
8040
  --movi-text-secondary: rgba(255, 255, 255, 0.7);
7243
8041
  --movi-text-tertiary: rgba(255, 255, 255, 0.5);
7244
8042
 
@@ -7297,6 +8095,56 @@ export class MoviElement extends HTMLElement {
7297
8095
  -moz-osx-font-smoothing: grayscale;
7298
8096
  }
7299
8097
 
8098
+ /* While a desktop context menu is open, let it spill past the player's
8099
+ own box the way strip mode does: lift the host's paint containment and
8100
+ overflow clip. container-type stays, so the responsive @container
8101
+ queries keep firing and the menu is still positioned relative to the
8102
+ host — it just isn't clipped to the player edges anymore. Transient:
8103
+ the class is removed the moment the menu closes. */
8104
+ :host(.movi-menu-overflow) {
8105
+ overflow: visible !important;
8106
+ contain: layout style !important;
8107
+ }
8108
+
8109
+ /* iOS pseudo-fullscreen: iOS Safari only fullscreens <video>, and we
8110
+ render to a canvas, so the element Fullscreen API is unavailable. Fill
8111
+ the whole screen via CSS instead. Sized in LARGE viewport units
8112
+ (100lvw/100lvh, 100vw/100vh fallback) so the box spans the entire
8113
+ screen — behind Safari's toolbars — instead of just the shrunken
8114
+ visible area (dvh/inset left page content peeking at the edges). */
8115
+ :host(.movi-pseudo-fullscreen) {
8116
+ position: fixed !important;
8117
+ top: 0 !important;
8118
+ left: 0 !important;
8119
+ right: auto !important;
8120
+ bottom: auto !important;
8121
+ width: 100vw !important;
8122
+ width: 100lvw !important;
8123
+ height: 100vh !important;
8124
+ height: 100lvh !important;
8125
+ max-width: none !important;
8126
+ max-height: none !important;
8127
+ margin: 0 !important;
8128
+ border-radius: 0 !important;
8129
+ z-index: 2147483647 !important;
8130
+ background: #000 !important;
8131
+ }
8132
+ /* Forced-landscape: a landscape video on a portrait screen (where iOS
8133
+ won't rotate for us) is turned 90° via CSS so it fills the screen the
8134
+ way Chrome/Android's orientation lock does. Swapped large-viewport
8135
+ dimensions so, after the rotate, the box maps onto the full screen;
8136
+ centred so the rotate pivots about the middle. */
8137
+ :host(.movi-pseudo-fs-rotate) {
8138
+ width: 100vh !important;
8139
+ width: 100lvh !important;
8140
+ height: 100vw !important;
8141
+ height: 100lvw !important;
8142
+ top: 50% !important;
8143
+ left: 50% !important;
8144
+ transform: translate(-50%, -50%) rotate(90deg) !important;
8145
+ transform-origin: center center !important;
8146
+ }
8147
+
7300
8148
  /* The :host{display:block} above is an author rule, so it beats the
7301
8149
  UA stylesheet's [hidden]{display:none}. Without this, the standard
7302
8150
  hidden attribute does nothing and the player stays visible. Honour
@@ -7494,6 +8342,14 @@ export class MoviElement extends HTMLElement {
7494
8342
  background: color-mix(in srgb, var(--movi-primary) 0.15) !important;
7495
8343
  }
7496
8344
 
8345
+ /* The active item's info badge hard-codes color:var(--movi-controls-color)
8346
+ (white) for the dark theme; in light theme that leaves the selected
8347
+ track's codec text white-on-light (unreadable). Match the item text. */
8348
+ :host([theme="light"]) .movi-audio-track-item.movi-audio-track-active .movi-audio-track-info,
8349
+ :host([theme="light"]) .movi-subtitle-track-item.movi-subtitle-track-active .movi-subtitle-track-info {
8350
+ color: #11142d !important;
8351
+ }
8352
+
7497
8353
  :host([theme="light"]) .movi-quality-item:hover {
7498
8354
  background: rgba(0, 0, 0, 0.05) !important;
7499
8355
  }
@@ -7601,6 +8457,25 @@ export class MoviElement extends HTMLElement {
7601
8457
  display: none !important;
7602
8458
  }
7603
8459
 
8460
+ /* Compact players: when the storyboard timeline (T) is open, hide the
8461
+ controls bar so the thumbnail strip gets the whole area, and drop the
8462
+ panel into the freed space. Keyed off the panel's inline display:flex
8463
+ (same signal as the seek-thumbnail rule above), so every close path
8464
+ restores the bar automatically. Desktop keeps the bar visible. */
8465
+ @container movi-host (max-width: 720px) {
8466
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-controls-container {
8467
+ opacity: 0 !important;
8468
+ pointer-events: none !important;
8469
+ transform: translateY(10px) !important;
8470
+ }
8471
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-controls-bar {
8472
+ pointer-events: none !important;
8473
+ }
8474
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-timeline-panel {
8475
+ bottom: 12px !important;
8476
+ }
8477
+ }
8478
+
7604
8479
  /* Force enable pointer events on all interactive controls */
7605
8480
  .movi-controls-container.movi-controls-visible .movi-controls-bar,
7606
8481
  .movi-controls-container.movi-controls-visible .movi-controls-bar *,
@@ -7653,7 +8528,7 @@ export class MoviElement extends HTMLElement {
7653
8528
  gap: 8px;
7654
8529
  padding: 8px 14px;
7655
8530
  background: rgba(0, 0, 0, 0.75);
7656
- color: #fff;
8531
+ color: var(--movi-chrome-fg, #fff);
7657
8532
  font-size: 13px;
7658
8533
  font-weight: 600;
7659
8534
  border: 1px solid rgba(255, 255, 255, 0.15);
@@ -7686,7 +8561,9 @@ export class MoviElement extends HTMLElement {
7686
8561
  top: 0;
7687
8562
  left: 0;
7688
8563
  right: 0;
7689
- padding: 16px 20px;
8564
+ /* Extra right padding keeps the title text from running under the
8565
+ settings gear pinned in the top-right corner. */
8566
+ padding: 16px 62px 16px 20px;
7690
8567
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
7691
8568
  z-index: 5;
7692
8569
  opacity: 0;
@@ -7700,6 +8577,89 @@ export class MoviElement extends HTMLElement {
7700
8577
  transform: translateY(0);
7701
8578
  }
7702
8579
 
8580
+ /* Settings gear (top-right) — opens the context menu. Vertically centred
8581
+ on the title text (title padding-top 16px + ~half the ~20px line) and
8582
+ inset to match; the title bar reserves right padding so the title
8583
+ doesn't run under it. */
8584
+ .movi-gear-btn {
8585
+ position: absolute;
8586
+ top: 9px;
8587
+ right: 14px;
8588
+ z-index: 30;
8589
+ width: 38px;
8590
+ height: 38px;
8591
+ padding: 8px;
8592
+ display: flex;
8593
+ align-items: center;
8594
+ justify-content: center;
8595
+ border: none;
8596
+ cursor: pointer;
8597
+ color: var(--movi-chrome-fg, #fff);
8598
+ background: rgba(0, 0, 0, 0.35);
8599
+ border-radius: 50%;
8600
+ opacity: 0;
8601
+ visibility: hidden;
8602
+ transform: translateY(-4px);
8603
+ transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease;
8604
+ pointer-events: none;
8605
+ }
8606
+ .movi-gear-btn.movi-gear-visible {
8607
+ opacity: 1;
8608
+ visibility: visible;
8609
+ transform: translateY(0);
8610
+ pointer-events: auto;
8611
+ }
8612
+ /* Hide the gear while a bottom dropdown OR the storyboard timeline is
8613
+ open — it lives in a higher stacking context than either and would
8614
+ otherwise paint over them. */
8615
+ :host(.movi-bottom-menu-open) .movi-gear-btn,
8616
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-gear-btn {
8617
+ opacity: 0 !important;
8618
+ visibility: hidden !important;
8619
+ pointer-events: none !important;
8620
+ }
8621
+ .movi-gear-btn:hover {
8622
+ background: rgba(0, 0, 0, 0.55);
8623
+ }
8624
+ .movi-gear-btn svg {
8625
+ width: 22px;
8626
+ height: 22px;
8627
+ }
8628
+ /* The gear is a TOUCH affordance — it only exists because touch has no
8629
+ right-click and the long-press context menu is gated. On non-touch
8630
+ (mouse/hover) devices, right-clicking the video opens the same menu,
8631
+ so the gear is redundant chrome; hide it there. (Mirrors the VR pad,
8632
+ which does the inverse — hidden on touch, shown on desktop.) */
8633
+ @media (hover: hover) and (pointer: fine) {
8634
+ .movi-gear-btn {
8635
+ display: none !important;
8636
+ }
8637
+ }
8638
+
8639
+ /* Press-and-hold-to-2x pill (top-centre, shown only while held). */
8640
+ .movi-hold-speed {
8641
+ position: absolute;
8642
+ top: 16px;
8643
+ left: 50%;
8644
+ transform: translateX(-50%);
8645
+ z-index: 40;
8646
+ display: flex;
8647
+ align-items: center;
8648
+ gap: 6px;
8649
+ padding: 6px 14px;
8650
+ background: rgba(0, 0, 0, 0.6);
8651
+ color: var(--movi-chrome-fg, #fff);
8652
+ border-radius: 999px;
8653
+ font-size: 14px;
8654
+ font-weight: 700;
8655
+ letter-spacing: 0.02em;
8656
+ pointer-events: none;
8657
+ }
8658
+ .movi-hold-speed svg {
8659
+ width: 14px;
8660
+ height: 14px;
8661
+ }
8662
+
7703
8663
  .movi-title-text {
7704
8664
  color: var(--movi-controls-color);
7705
8665
  font-size: clamp(16px, 4vw, 20px);
@@ -7845,7 +8805,7 @@ export class MoviElement extends HTMLElement {
7845
8805
  touch-action: none;
7846
8806
  }
7847
8807
  /* Opt-in via the vrpad attribute. Without it the pad never shows —
7848
- drag-to-look + wheel-zoom already cover desktop navigation; the pad is
8808
+ drag-to-look already covers desktop navigation; the pad is
7849
8809
  for those who want a YouTube-style continuous-pan compass. */
7850
8810
  :host(.movi-vr360-active[vrpad]) .movi-vr360-pad {
7851
8811
  opacity: 1;
@@ -8176,6 +9136,19 @@ export class MoviElement extends HTMLElement {
8176
9136
  rgba(255, 255, 255, 0.2) 100%
8177
9137
  );
8178
9138
  border-radius: 100px;
9139
+ }
9140
+ /* Boosting above 100% (VLC-style): amber fill flags the boost zone, and
9141
+ a tick at the 50%-of-track unity point marks where "normal" sits. */
9142
+ .movi-volume-slider.movi-volume-boosted {
9143
+ background: linear-gradient(
9144
+ to right,
9145
+ var(--movi-primary) 0%,
9146
+ var(--movi-primary) 50%,
9147
+ var(--movi-volume-boost, #f5a623) 50%,
9148
+ var(--movi-volume-boost, #f5a623) var(--movi-volume-pct, 100%),
9149
+ rgba(255, 255, 255, 0.2) var(--movi-volume-pct, 100%),
9150
+ rgba(255, 255, 255, 0.2) 100%
9151
+ );
8179
9152
  outline: none !important;
8180
9153
  pointer-events: auto !important;
8181
9154
  cursor: pointer;
@@ -8220,7 +9193,7 @@ export class MoviElement extends HTMLElement {
8220
9193
  appearance: none;
8221
9194
  width: 14px;
8222
9195
  height: 14px;
8223
- background: #fff;
9196
+ background: var(--movi-chrome-fg, #fff);
8224
9197
  border-radius: 50%;
8225
9198
  cursor: pointer;
8226
9199
  margin-top: -5px;
@@ -8245,7 +9218,7 @@ export class MoviElement extends HTMLElement {
8245
9218
  .movi-volume-slider::-moz-range-thumb {
8246
9219
  width: 14px;
8247
9220
  height: 14px;
8248
- background: #fff;
9221
+ background: var(--movi-chrome-fg, #fff);
8249
9222
  border-radius: 50%;
8250
9223
  cursor: pointer;
8251
9224
  border: none !important;
@@ -8633,7 +9606,7 @@ export class MoviElement extends HTMLElement {
8633
9606
  rgba(255,255,255,0.04) 0% 25%,
8634
9607
  rgba(255,255,255,0.02) 0% 50%
8635
9608
  ),
8636
- #1a1a1a;
9609
+ var(--movi-surface, #1a1a1a);
8637
9610
  background-size: auto, 16px 16px, auto;
8638
9611
  border: 1px solid rgba(255, 255, 255, 0.08);
8639
9612
  }
@@ -8718,7 +9691,7 @@ export class MoviElement extends HTMLElement {
8718
9691
  height: 18px;
8719
9692
  border-radius: 50%;
8720
9693
  background: var(--movi-primary);
8721
- border: 3px solid #FFFFFF;
9694
+ border: 3px solid var(--movi-chrome-fg, #FFFFFF);
8722
9695
  margin-top: -6px;
8723
9696
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
8724
9697
  cursor: pointer;
@@ -8742,7 +9715,7 @@ export class MoviElement extends HTMLElement {
8742
9715
  height: 18px;
8743
9716
  border-radius: 50%;
8744
9717
  background: var(--movi-primary);
8745
- border: 3px solid #FFFFFF;
9718
+ border: 3px solid var(--movi-chrome-fg, #FFFFFF);
8746
9719
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
8747
9720
  cursor: pointer;
8748
9721
  }
@@ -9100,7 +10073,7 @@ export class MoviElement extends HTMLElement {
9100
10073
  font-size: 11px;
9101
10074
  font-weight: 800;
9102
10075
  letter-spacing: 0.08em;
9103
- color: #fff;
10076
+ color: var(--movi-chrome-fg, #fff);
9104
10077
  line-height: 1;
9105
10078
  text-transform: uppercase;
9106
10079
  }
@@ -9137,7 +10110,10 @@ export class MoviElement extends HTMLElement {
9137
10110
  border: 1px solid var(--movi-glass-border);
9138
10111
  border-radius: 12px;
9139
10112
  min-width: 140px;
9140
- max-height: 280px;
10113
+ /* Tall enough for all 8 speeds (0.25x…2x) without scrolling on a normal
10114
+ player; capped to 70vh so a short player still fits — its top rows no
10115
+ longer clip because opening lifts the host clip (movi-menu-overflow). */
10116
+ max-height: min(70vh, 380px);
9141
10117
  overflow-y: auto;
9142
10118
  box-shadow: var(--movi-shadow-lg);
9143
10119
  z-index: 1000;
@@ -9270,7 +10246,7 @@ export class MoviElement extends HTMLElement {
9270
10246
  }
9271
10247
 
9272
10248
  .movi-nerd-stats-close:hover {
9273
- color: #fff;
10249
+ color: var(--movi-chrome-fg, #fff);
9274
10250
  }
9275
10251
 
9276
10252
  .movi-nerd-stats-body {
@@ -9457,7 +10433,7 @@ export class MoviElement extends HTMLElement {
9457
10433
  .movi-timeline-generate-btn {
9458
10434
  background: var(--movi-primary);
9459
10435
  border: none;
9460
- color: #fff;
10436
+ color: var(--movi-chrome-fg, #fff);
9461
10437
  font-size: 11px;
9462
10438
  font-weight: 600;
9463
10439
  padding: 5px 12px;
@@ -9488,12 +10464,12 @@ export class MoviElement extends HTMLElement {
9488
10464
  }
9489
10465
 
9490
10466
  .movi-timeline-close:hover {
9491
- color: #fff;
10467
+ color: var(--movi-chrome-fg, #fff);
9492
10468
  }
9493
10469
 
9494
10470
  .movi-timeline-strip {
9495
10471
  display: flex;
9496
- gap: 4px;
10472
+ gap: 8px;
9497
10473
  padding: 10px 14px;
9498
10474
  overflow-x: auto;
9499
10475
  overflow-y: hidden;
@@ -9527,7 +10503,10 @@ export class MoviElement extends HTMLElement {
9527
10503
  .movi-timeline-item:hover,
9528
10504
  .movi-timeline-item.movi-timeline-selected {
9529
10505
  border-color: var(--movi-primary);
9530
- transform: scale(1.05);
10506
+ /* Keep the pop small enough that an active + adjacent-hovered item
10507
+ don't grow into each other across the 8px gap (scale doesn't
10508
+ reflow neighbours, so an over-large scale visually overlaps). */
10509
+ transform: scale(1.03);
9531
10510
  }
9532
10511
 
9533
10512
  .movi-timeline-item img {
@@ -9554,7 +10533,7 @@ export class MoviElement extends HTMLElement {
9554
10533
  text-align: center;
9555
10534
  font-size: 10px;
9556
10535
  font-weight: 600;
9557
- color: #fff;
10536
+ color: var(--movi-chrome-fg, #fff);
9558
10537
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
9559
10538
  padding: 12px 4px 4px;
9560
10539
  font-variant-numeric: tabular-nums;
@@ -9579,7 +10558,7 @@ export class MoviElement extends HTMLElement {
9579
10558
  .movi-timeline-chapter-title {
9580
10559
  font-size: 10px;
9581
10560
  font-weight: 600;
9582
- color: #fff;
10561
+ color: var(--movi-chrome-fg, #fff);
9583
10562
  white-space: nowrap;
9584
10563
  overflow: hidden;
9585
10564
  text-overflow: ellipsis;
@@ -9661,7 +10640,7 @@ export class MoviElement extends HTMLElement {
9661
10640
  }
9662
10641
 
9663
10642
  .movi-shortcuts-close:hover {
9664
- color: #fff;
10643
+ color: var(--movi-chrome-fg, #fff);
9665
10644
  }
9666
10645
 
9667
10646
  .movi-shortcuts-body {
@@ -9751,7 +10730,7 @@ export class MoviElement extends HTMLElement {
9751
10730
  all: unset;
9752
10731
  flex: 1;
9753
10732
  font-size: 13px;
9754
- color: #fff;
10733
+ color: var(--movi-chrome-fg, #fff);
9755
10734
  font-family: inherit;
9756
10735
  }
9757
10736
  .movi-cues-search::placeholder { color: rgba(255, 255, 255, 0.4); }
@@ -9766,7 +10745,7 @@ export class MoviElement extends HTMLElement {
9766
10745
  padding: 0 6px;
9767
10746
  transition: color 0.15s;
9768
10747
  }
9769
- .movi-cues-close:hover { color: #fff; }
10748
+ .movi-cues-close:hover { color: var(--movi-chrome-fg, #fff); }
9770
10749
  .movi-cues-meta {
9771
10750
  padding: 6px 18px;
9772
10751
  font-size: 11px;
@@ -9934,15 +10913,28 @@ export class MoviElement extends HTMLElement {
9934
10913
  transform: scale(0.97);
9935
10914
  }
9936
10915
 
9937
- .movi-resume-btn.movi-resume-focused {
9938
- outline: 2px solid var(--movi-primary);
9939
- outline-offset: 2px;
9940
- transform: scale(1.05);
10916
+ /* The selection ring is a KEYBOARD affordance — the arrow keys move it
10917
+ between Resume/Cancel. Touch has no arrow-key nav (you just tap), so
10918
+ the auto-focused ring is meaningless noise there; only draw it on
10919
+ mouse/hover devices. */
10920
+ @media (hover: hover) and (pointer: fine) {
10921
+ .movi-resume-btn.movi-resume-focused {
10922
+ /* This stylesheet globally nukes outline (the universal reset at
10923
+ line ~8515 sets outline none with !important) and box-shadow on
10924
+ :focus, so the selection ring MUST be a box-shadow WITH !important
10925
+ to survive — a plain outline never renders. White ring (NOT
10926
+ --movi-primary — that's the Resume button's own fill) + dark halo
10927
+ reads on both the primary and grey buttons. The two-class selector
10928
+ out-specifies the reset among !important rules. */
10929
+ box-shadow: 0 0 0 3px #fff, 0 0 0 6px rgba(0, 0, 0, 0.5) !important;
10930
+ transform: scale(1.06);
10931
+ transition: transform 0.1s, box-shadow 0.1s;
10932
+ }
9941
10933
  }
9942
10934
 
9943
10935
  .movi-resume-yes {
9944
10936
  background: var(--movi-primary);
9945
- color: #fff;
10937
+ color: var(--movi-chrome-fg, #fff);
9946
10938
  }
9947
10939
 
9948
10940
  .movi-resume-no {
@@ -9967,6 +10959,31 @@ export class MoviElement extends HTMLElement {
9967
10959
  }
9968
10960
  }
9969
10961
 
10962
+ /* Very small players (≤400px): the horizontal pill (text + two buttons)
10963
+ is wider than the player and, being right-anchored + nowrap, clipped
10964
+ its left edge ("...ume from"). Span the player instead and stack the
10965
+ buttons under the text. left/right (not transform) so the slide-up
10966
+ animation's translateY isn't overridden. */
10967
+ @container movi-host (max-width: 400px) {
10968
+ .movi-resume-dialog {
10969
+ left: 16px;
10970
+ right: 16px;
10971
+ flex-direction: column;
10972
+ align-items: stretch;
10973
+ gap: 10px;
10974
+ text-align: center;
10975
+ }
10976
+ .movi-resume-text {
10977
+ white-space: normal;
10978
+ }
10979
+ .movi-resume-buttons {
10980
+ justify-content: center;
10981
+ }
10982
+ .movi-resume-btn {
10983
+ flex: 1;
10984
+ }
10985
+ }
10986
+
9970
10987
  /* ========================================
9971
10988
  RESPONSIVE STYLES - Mobile First
9972
10989
  ======================================== */
@@ -10110,9 +11127,11 @@ export class MoviElement extends HTMLElement {
10110
11127
  }
10111
11128
 
10112
11129
  .movi-progress-container {
10113
- padding: 6px 0 2px;
11130
+ /* Nudge the seek bar down a little on compact players so it isn't
11131
+ hugging the video edge. */
11132
+ padding: 14px 0 2px;
10114
11133
  }
10115
-
11134
+
10116
11135
  .movi-progress-bar {
10117
11136
  height: 6px;
10118
11137
  }
@@ -10157,21 +11176,45 @@ export class MoviElement extends HTMLElement {
10157
11176
  }
10158
11177
 
10159
11178
  .movi-controls-right.expanded .movi-mobile-expandable {
10160
- width: auto;
10161
11179
  opacity: 1;
10162
11180
  pointer-events: auto;
10163
11181
  gap: 4px;
10164
11182
  margin-right: 4px;
10165
- overflow: visible; /* Prevent clipping of hover backgrounds */
10166
- flex: 1;
10167
- justify-content: flex-end;
11183
+ /* When expanded on a narrow player the row can hold more buttons than
11184
+ fit — let them scroll horizontally instead of clipping or wrapping.
11185
+ overflow-y stays hidden (only the in-flow buttons are clipped
11186
+ vertically; the pop-up menus are positioned against the controls
11187
+ bar, an ancestor, so they're not clipped by this scroll box).
11188
+ flex:0 1 auto + justify-content:flex-start is deliberate: when the
11189
+ buttons fit, the box is content-sized and the PARENT's flex-end
11190
+ packs it right against the more (>) button (no gap); when they
11191
+ overflow, min-width:0 lets it shrink and scroll, and flex-start
11192
+ keeps the LEADING icons reachable — flex-end would strand the
11193
+ overflowed left-side icons off-screen with no way to scroll to them.
11194
+ width:auto is REQUIRED: flex-basis:auto reads the width, and the
11195
+ collapsed base rule sets width:0 — without this the expandable stays
11196
+ 0-wide and no icons show when expanded. */
11197
+ width: auto;
11198
+ flex: 0 1 auto;
11199
+ min-width: 0;
11200
+ flex-wrap: nowrap;
11201
+ justify-content: flex-start;
11202
+ overflow-x: auto;
11203
+ overflow-y: hidden;
11204
+ scrollbar-width: none; /* Firefox */
11205
+ -webkit-overflow-scrolling: touch;
11206
+ }
11207
+ .movi-controls-right.expanded .movi-mobile-expandable::-webkit-scrollbar {
11208
+ display: none; /* WebKit */
10168
11209
  }
10169
11210
 
10170
- /* Reset margins and restore dimensions */
11211
+ /* Reset margins and restore dimensions; keep buttons from shrinking so
11212
+ the icons don't squash — they scroll instead. */
10171
11213
  .movi-controls-right.expanded .movi-mobile-expandable > * {
10172
11214
  margin: 0 !important;
10173
11215
  width: auto;
10174
11216
  height: auto;
11217
+ flex: 0 0 auto;
10175
11218
  }
10176
11219
 
10177
11220
  /* Hide individual buttons by default on mobile */
@@ -10211,6 +11254,11 @@ export class MoviElement extends HTMLElement {
10211
11254
  /* Alternative for older browsers: shrink left instead of hiding if :has not supported */
10212
11255
  .movi-controls-right.expanded {
10213
11256
  flex: 1;
11257
+ /* min-width:0 lets this shrink below its content so the constraint
11258
+ reaches the expandable, which then scrolls. Without it the default
11259
+ min-width:auto keeps the whole cluster at content width and it
11260
+ overflows the player's left edge instead of scrolling. */
11261
+ min-width: 0;
10214
11262
  justify-content: flex-end;
10215
11263
  }
10216
11264
 
@@ -10241,17 +11289,13 @@ export class MoviElement extends HTMLElement {
10241
11289
  transform-origin: bottom center !important;
10242
11290
  width: 90% !important;
10243
11291
  max-width: 300px !important;
10244
- /* Cap against the PLAYER's own height (set by JS on connect/
10245
- resize), not the viewport an embedded small player on a
10246
- 1400px desktop window had 30vw=420px max-height but only
10247
- ~380px of player height, so the menu opened above the
10248
- controls bar and got chopped off the top by :host's
10249
- contain:paint. The reserve (controls bar ~60px + top
10250
- breathing room ~60px) keeps the menu visually inside the
10251
- player instead of butting against its top edge. The 70vh
10252
- fallback matches the variable's default elsewhere;
10253
- max(120px,…) keeps one visible row on tiny players. */
10254
- max-height: max(120px, calc(var(--movi-player-height, 70vh) - 120px)) !important;
11292
+ /* Cap to the viewport, not the player. Opening a dropdown now lifts
11293
+ the host's paint/overflow clip (movi-menu-overflow), so a tall menu
11294
+ on a SHORT player extends past the player's top edge into the page
11295
+ instead of being chopped there which used to hide the first rows
11296
+ (e.g. the 0.25x speed option). 70vh keeps it within the screen and
11297
+ lets it scroll only when the list itself is genuinely huge. */
11298
+ max-height: min(70vh, calc(100vh - 80px)) !important;
10255
11299
  overflow-y: auto !important;
10256
11300
  z-index: 2000 !important;
10257
11301
  -webkit-overflow-scrolling: touch !important;
@@ -10369,6 +11413,79 @@ export class MoviElement extends HTMLElement {
10369
11413
  }
10370
11414
  }
10371
11415
 
11416
+ /* Very small / compact players (≤400px). Tighten every gap, shrink the
11417
+ buttons and pull in the bar padding so the always-visible collapsed
11418
+ row (play · volume · time · more · fullscreen) fits without clipping —
11419
+ the rest of the icons live in the horizontally-scrollable expandable. */
11420
+ @container movi-host (max-width: 400px) {
11421
+ :host {
11422
+ --movi-btn-size: 36px;
11423
+ }
11424
+ .movi-controls-bar {
11425
+ padding: 2px 8px 8px;
11426
+ }
11427
+ .movi-buttons-row {
11428
+ gap: 4px;
11429
+ }
11430
+ .movi-controls-left,
11431
+ .movi-controls-right {
11432
+ gap: 2px;
11433
+ }
11434
+ .movi-btn {
11435
+ padding: 7px;
11436
+ }
11437
+ .movi-btn svg {
11438
+ width: 20px;
11439
+ height: 20px;
11440
+ }
11441
+ .movi-time {
11442
+ font-size: 11px;
11443
+ }
11444
+ .movi-progress-container {
11445
+ padding: 14px 0 6px;
11446
+ }
11447
+ }
11448
+
11449
+ /* Below ~290px the dropdown / context menus need a smaller type scale so
11450
+ rows aren't cramped or clipped on a tiny player. */
11451
+ @container movi-host (max-width: 289px) {
11452
+ .movi-speed-item {
11453
+ font-size: 10px;
11454
+ padding: 6px 10px;
11455
+ }
11456
+ .movi-audio-track-item,
11457
+ .movi-subtitle-track-item {
11458
+ font-size: 10px;
11459
+ padding: 5px 8px;
11460
+ gap: 6px;
11461
+ }
11462
+ .movi-track-item-icon {
11463
+ width: 13px;
11464
+ height: 13px;
11465
+ }
11466
+ .movi-audio-track-info,
11467
+ .movi-subtitle-track-info {
11468
+ font-size: 8px;
11469
+ padding: 1px 5px;
11470
+ }
11471
+ .movi-track-menu-header {
11472
+ font-size: 9px;
11473
+ padding: 7px 10px 6px;
11474
+ }
11475
+ .movi-track-menu-footer {
11476
+ font-size: 8px;
11477
+ }
11478
+ .movi-context-menu-item {
11479
+ font-size: 10px;
11480
+ padding: 7px 10px;
11481
+ }
11482
+ .movi-context-menu-icon svg,
11483
+ .movi-context-menu-item svg {
11484
+ width: 15px;
11485
+ height: 15px;
11486
+ }
11487
+ }
11488
+
10372
11489
  /* Large players (1025px and above) */
10373
11490
  @container movi-host (min-width: 1025px) {
10374
11491
  .movi-controls-bar {
@@ -10627,6 +11744,19 @@ export class MoviElement extends HTMLElement {
10627
11744
  block so it survives the !important cascade. */
10628
11745
 
10629
11746
  /* Subtitle overlay - HTML element for better performance */
11747
+ /* Off-screen live region for screen-reader caption announcements. */
11748
+ .movi-caption-live {
11749
+ position: absolute;
11750
+ width: 1px;
11751
+ height: 1px;
11752
+ margin: -1px;
11753
+ padding: 0;
11754
+ border: 0;
11755
+ overflow: hidden;
11756
+ clip: rect(0 0 0 0);
11757
+ clip-path: inset(50%);
11758
+ white-space: nowrap;
11759
+ }
10630
11760
  .movi-subtitle-overlay {
10631
11761
  position: absolute;
10632
11762
  bottom: 12%;
@@ -10897,7 +12027,7 @@ export class MoviElement extends HTMLElement {
10897
12027
  border: 1px solid rgba(255, 255, 255, 0.12);
10898
12028
  border-radius: 16px;
10899
12029
  padding: 8px 4px;
10900
- min-width: 160px;
12030
+ min-width: 230px;
10901
12031
  visibility: hidden;
10902
12032
  opacity: 0;
10903
12033
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
@@ -10927,7 +12057,7 @@ export class MoviElement extends HTMLElement {
10927
12057
  border: 1px solid rgba(255, 255, 255, 0.12);
10928
12058
  border-radius: 16px;
10929
12059
  padding: 8px 4px;
10930
- min-width: 160px;
12060
+ min-width: 230px;
10931
12061
  visibility: hidden;
10932
12062
  opacity: 0;
10933
12063
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
@@ -11062,7 +12192,7 @@ export class MoviElement extends HTMLElement {
11062
12192
  letter-spacing: 0.4px;
11063
12193
  text-transform: uppercase;
11064
12194
  background: var(--movi-primary);
11065
- color: #fff;
12195
+ color: var(--movi-chrome-fg, #fff);
11066
12196
  pointer-events: none;
11067
12197
  }
11068
12198
 
@@ -11148,7 +12278,7 @@ export class MoviElement extends HTMLElement {
11148
12278
  max-height: 200px;
11149
12279
  object-fit: contain;
11150
12280
  margin-bottom: 4px;
11151
- border: 1px solid #333;
12281
+ border: 1px solid var(--movi-border-color, #333);
11152
12282
  border-radius: 2px;
11153
12283
  pointer-events: none;
11154
12284
  }
@@ -11160,7 +12290,7 @@ export class MoviElement extends HTMLElement {
11160
12290
  display: none;
11161
12291
  font-size: 11px;
11162
12292
  font-weight: 600;
11163
- color: #fff;
12293
+ color: var(--movi-chrome-fg, #fff);
11164
12294
  text-align: center;
11165
12295
  max-width: 180px;
11166
12296
  overflow: hidden;
@@ -11249,7 +12379,7 @@ export class MoviElement extends HTMLElement {
11249
12379
  display: flex;
11250
12380
  align-items: center;
11251
12381
  justify-content: center;
11252
- color: #FFFFFF;
12382
+ color: var(--movi-chrome-fg, #FFFFFF);
11253
12383
  /* Subtle primary halo around the icon — keeps the OSD
11254
12384
  visually anchored to the brand without painting the bg. */
11255
12385
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--movi-primary) 45%, transparent));
@@ -11263,7 +12393,7 @@ export class MoviElement extends HTMLElement {
11263
12393
  .movi-osd-text {
11264
12394
  font-size: 14px;
11265
12395
  font-weight: 600;
11266
- color: #FFFFFF;
12396
+ color: var(--movi-chrome-fg, #FFFFFF);
11267
12397
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
11268
12398
  letter-spacing: 0.01em;
11269
12399
  line-height: 1.2;
@@ -11336,7 +12466,7 @@ export class MoviElement extends HTMLElement {
11336
12466
  font-weight: 700;
11337
12467
  margin: 0 0 10px 0;
11338
12468
  letter-spacing: -0.02em;
11339
- background: linear-gradient(to bottom, #fff, #bbb);
12469
+ background: linear-gradient(to bottom, var(--movi-chrome-fg, #fff), #bbb);
11340
12470
  -webkit-background-clip: text;
11341
12471
  -webkit-text-fill-color: transparent;
11342
12472
  text-align: center;
@@ -11367,7 +12497,7 @@ export class MoviElement extends HTMLElement {
11367
12497
  background: rgba(255, 255, 255, 0.15);
11368
12498
  border: 1px solid rgba(255, 255, 255, 0.2);
11369
12499
  border-radius: 8px;
11370
- color: #fff;
12500
+ color: var(--movi-chrome-fg, #fff);
11371
12501
  font-size: 14px;
11372
12502
  font-weight: 500;
11373
12503
  cursor: pointer;
@@ -11417,6 +12547,37 @@ export class MoviElement extends HTMLElement {
11417
12547
  padding-bottom: 40px;
11418
12548
  }
11419
12549
 
12550
+ /* A bare player (no controls attribute) is a headless, chrome-less
12551
+ surface — don't show the "No Video / Add a source" placeholder or the
12552
+ loading spinner there. !important beats the inline display:flex the JS
12553
+ sets on these indicators. */
12554
+ :host(.movi-no-controls) .movi-empty-state,
12555
+ :host(.movi-no-controls) .movi-loading-indicator {
12556
+ display: none !important;
12557
+ }
12558
+
12559
+ /* Bare player (no controls attribute): a display-only surface — disable
12560
+ ALL mouse interaction (click-to-play, double-click fullscreen, the
12561
+ right-click menu, look-around drag). */
12562
+ :host(.movi-no-controls) {
12563
+ pointer-events: none !important;
12564
+ }
12565
+
12566
+ /* An audio strip with no controls is a chrome-less bar with nothing to
12567
+ show or operate — hide it entirely. Audio keeps playing (Web Audio is
12568
+ independent of the host's display). */
12569
+ :host(.movi-audio-strip.movi-no-controls) {
12570
+ display: none !important;
12571
+ }
12572
+
12573
+ /* Opt-out: the noerrorscreen attribute suppresses the built-in error
12574
+ overlays ("Format Unsupported", "Browser not supported", codec/network
12575
+ errors) so an embedding app can render its own error UI. The error
12576
+ event + state still fire — only the default screen is hidden. */
12577
+ :host([noerrorscreen]) .movi-broken-indicator {
12578
+ display: none !important;
12579
+ }
12580
+
11420
12581
  /* Short / narrow players: shrink the placeholder so it fits above
11421
12582
  the controls bar without clipping into it. */
11422
12583
  @container movi-host (max-width: 720px) {
@@ -11521,6 +12682,30 @@ export class MoviElement extends HTMLElement {
11521
12682
  transform: translateX(0) !important;
11522
12683
  }
11523
12684
 
12685
+ /* STRIP MODE ONLY: the host is a ~78px bar with overflow:visible, so the
12686
+ default translateX drawer slides OUT past the player's right edge and
12687
+ the closed drawer visibly escapes the boundary. Here we pin it inside
12688
+ the player and FADE in place (no horizontal slide) + truly hide when
12689
+ closed. Non-strip (full-size) players keep the normal slide drawer. */
12690
+ :host(.movi-audio-strip) .movi-context-menu.movi-context-menu-mobile {
12691
+ bottom: 0 !important;
12692
+ height: auto !important;
12693
+ max-width: min(350px, 100%) !important;
12694
+ margin: 0 !important;
12695
+ transform: none !important;
12696
+ opacity: 0 !important;
12697
+ visibility: hidden !important;
12698
+ pointer-events: none !important;
12699
+ transition: opacity 0.2s ease, visibility 0s linear 0.2s !important;
12700
+ }
12701
+ :host(.movi-audio-strip) .movi-context-menu.movi-context-menu-mobile.visible {
12702
+ transform: none !important;
12703
+ opacity: 1 !important;
12704
+ visibility: visible !important;
12705
+ pointer-events: auto !important;
12706
+ transition: opacity 0.2s ease, visibility 0s linear 0s !important;
12707
+ }
12708
+
11524
12709
  .movi-context-menu-item {
11525
12710
  padding: 10px 14px !important;
11526
12711
  margin: 2px 4px !important;
@@ -11657,6 +12842,13 @@ export class MoviElement extends HTMLElement {
11657
12842
  }
11658
12843
  .movi-seek-thumbnail {
11659
12844
  transform: translateX(-50%) scale(0.85);
12845
+ /* Scale from the bottom edge, not the centre: the box is bottom-
12846
+ anchored (bottom:40px) and grows upward when the thumbnail image
12847
+ appears above the time. With the default centre origin a taller
12848
+ box shrinks toward its middle, lifting its bottom edge — so the
12849
+ time text jumps up the moment the thumb arrives. Anchoring the
12850
+ scale origin at the bottom keeps the time fixed either way. */
12851
+ transform-origin: bottom center;
11660
12852
  bottom: 40px;
11661
12853
  }
11662
12854
 
@@ -11748,6 +12940,20 @@ export class MoviElement extends HTMLElement {
11748
12940
  color: #11142d;
11749
12941
  text-shadow: none;
11750
12942
  }
12943
+ /* Strip mode zeroes the title bar's padding, but on touch the settings
12944
+ gear is shown pinned top-right (38px @ right:14px). Reserve room so the
12945
+ title truncates before it instead of running underneath. Only on
12946
+ coarse pointers — the gear is hidden on mouse/hover devices. */
12947
+ @media (pointer: coarse) {
12948
+ :host(.movi-audio-strip) .movi-title-bar {
12949
+ padding-right: 48px !important;
12950
+ }
12951
+ }
12952
+ /* The gear button otherwise centres a few px BELOW the small strip title
12953
+ text — nudge it up so their icon/text vertical centres line up. */
12954
+ :host(.movi-audio-strip) .movi-gear-btn {
12955
+ top: 1px !important;
12956
+ }
11751
12957
  /* Push the control row below the title band — only when titled. */
11752
12958
  :host(.movi-audio-strip.movi-has-title) .movi-controls-container,
11753
12959
  :host(.movi-audio-strip.movi-has-title) .movi-controls-container.movi-controls-hidden {
@@ -11788,7 +12994,7 @@ export class MoviElement extends HTMLElement {
11788
12994
  min-height: 56px !important;
11789
12995
  height: 56px !important;
11790
12996
  max-height: 56px !important;
11791
- background: #0f0f0f;
12997
+ background: var(--movi-surface, #0f0f0f);
11792
12998
  border-radius: 6px;
11793
12999
  /* "overflow: visible" is intentional — popups (speed menu,
11794
13000
  audio-track menu, right-click context menu) anchor inside the
@@ -12297,6 +13503,10 @@ export class MoviElement extends HTMLElement {
12297
13503
  this._src = srcAttr || null;
12298
13504
  this._autoplay = this.hasAttribute("autoplay");
12299
13505
  this._controls = this.hasAttribute("controls");
13506
+ // Mirror the controls attr as a host class so CSS can suppress chrome (e.g.
13507
+ // the "No Video" placeholder) on a bare, controls-less player — done with a
13508
+ // class rather than :host(:not([controls])), which is flaky in Safari/FF.
13509
+ this.classList.toggle("movi-no-controls", !this._controls);
12300
13510
  this._loop = this.hasAttribute("loop");
12301
13511
  this._muted = this.hasAttribute("muted");
12302
13512
  this._playsinline = this.hasAttribute("playsinline");
@@ -12410,6 +13620,7 @@ export class MoviElement extends HTMLElement {
12410
13620
  if (typeof ResizeObserver !== "undefined") {
12411
13621
  const resizeObserver = new ResizeObserver(() => {
12412
13622
  publishPlayerWidth();
13623
+ this.syncTinyLayout();
12413
13624
  this.updateCanvasSize();
12414
13625
  });
12415
13626
  resizeObserver.observe(this);
@@ -12418,9 +13629,12 @@ export class MoviElement extends HTMLElement {
12418
13629
  // Fallback for browsers without ResizeObserver
12419
13630
  window.addEventListener("resize", () => {
12420
13631
  publishPlayerWidth();
13632
+ this.syncTinyLayout();
12421
13633
  this.updateCanvasSize();
12422
13634
  });
12423
13635
  }
13636
+ // Run once now so an already-tiny player folds fullscreen away on load.
13637
+ this.syncTinyLayout();
12424
13638
  // Initial visibility check
12425
13639
  this.updateControlsVisibility();
12426
13640
  // Check for required security headers
@@ -12611,6 +13825,15 @@ export class MoviElement extends HTMLElement {
12611
13825
  });
12612
13826
  }
12613
13827
  disconnectedCallback() {
13828
+ // If removed while in the iOS pseudo-fullscreen fallback, restore the page
13829
+ // scroll we locked and drop the resize listeners (setPseudoFullscreen(false)
13830
+ // won't run otherwise).
13831
+ if (this._pseudoFullscreen) {
13832
+ document.body.style.overflow = this._prevBodyOverflow;
13833
+ window.removeEventListener("resize", this._onPseudoFsResize);
13834
+ window.removeEventListener("orientationchange", this._onPseudoFsResize);
13835
+ this._pseudoFullscreen = false;
13836
+ }
12614
13837
  // Remove WebGL context listeners
12615
13838
  this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
12616
13839
  this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
@@ -12644,6 +13867,10 @@ export class MoviElement extends HTMLElement {
12644
13867
  switch (name) {
12645
13868
  case "thumb":
12646
13869
  this._thumb = newValue !== null;
13870
+ // Sync an already-created player so toggling `thumb` at runtime — or
13871
+ // declaring it after `src` (whose callback builds the player first) —
13872
+ // enables/disables scrub previews live instead of silently no-opping.
13873
+ this.player?.setPreviewsEnabled(this._thumb && !this._audioOnly);
12647
13874
  break;
12648
13875
  case "hdr":
12649
13876
  this.hdr = newValue !== null;
@@ -12826,6 +14053,7 @@ export class MoviElement extends HTMLElement {
12826
14053
  break;
12827
14054
  case "controls":
12828
14055
  this._controls = newValue !== null;
14056
+ this.classList.toggle("movi-no-controls", !this._controls);
12829
14057
  this.updateControlsVisibility();
12830
14058
  this.updateUnmuteOverlay();
12831
14059
  break;
@@ -13008,6 +14236,14 @@ export class MoviElement extends HTMLElement {
13008
14236
  width = window.innerWidth;
13009
14237
  height = window.innerHeight;
13010
14238
  }
14239
+ else if (this._pseudoFullscreen) {
14240
+ // The host is CSS-sized (and, when forced-landscape, rotated) to fill the
14241
+ // viewport. Use its LAYOUT box (clientWidth/Height) — unlike
14242
+ // getBoundingClientRect it isn't affected by the rotate transform, so the
14243
+ // canvas buffer matches the un-rotated element the transform then turns.
14244
+ width = this.clientWidth;
14245
+ height = this.clientHeight;
14246
+ }
13011
14247
  else {
13012
14248
  const rect = this.getBoundingClientRect();
13013
14249
  width = widthAttr ? parseInt(widthAttr, 10) : rect.width;
@@ -13143,6 +14379,28 @@ export class MoviElement extends HTMLElement {
13143
14379
  };
13144
14380
  img.src = url;
13145
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
+ }
13146
14404
  updateCoverArtOverlay() {
13147
14405
  const overlay = this.coverArtOverlay;
13148
14406
  const canvas = this.coverArtCanvas;
@@ -13200,14 +14458,28 @@ export class MoviElement extends HTMLElement {
13200
14458
  const posterCoverPending = audioMode && !bitmap && this._posterCoverLoading;
13201
14459
  const stripMode = audioMode && !bitmap && !coverArtPending && !posterCoverPending;
13202
14460
  this.classList.toggle("movi-audio-mode", audioMode);
13203
- const wasStrip = this.classList.contains("movi-audio-strip");
13204
14461
  this.classList.toggle("movi-audio-strip", stripMode);
13205
- if (stripMode !== wasStrip) {
13206
- // Tell the embedding page so it can collapse its own wrapper. The
13207
- // host shrinks to 56px via :host CSS, but a parent .player-stage
13208
- // with aspect-ratio: 16/9 (or any fixed height) would still reserve
13209
- // its old box and leave a black gap underneath. Consumers listen on
13210
- // this event and adjust their layout — see index.html.
14462
+ // In strip mode the gear is part of the always-visible bar, so reveal it the
14463
+ // moment the layout is applied (on load) rather than waiting for the first
14464
+ // touch/showControls. hideControls already keeps it visible in strip mode.
14465
+ if (stripMode && this.hasMediaSource()) {
14466
+ this.shadowRoot
14467
+ ?.querySelector(".movi-gear-btn")
14468
+ ?.classList.add("movi-gear-visible");
14469
+ }
14470
+ // Notify the embedding page when the strip state changes vs what we LAST
14471
+ // TOLD it — not vs the current class. load() clears movi-audio-strip on
14472
+ // every source change, so comparing to the class made a strip→non-strip
14473
+ // switch (e.g. a no-cover track followed by an album-art track) look
14474
+ // unchanged: the event never fired and the page's wrapper stayed collapsed
14475
+ // at 56/78px, squashing the album art into a black bar. _lastStripDispatched
14476
+ // survives load(), so the transition is detected and the wrapper reflows.
14477
+ if (stripMode !== this._lastStripDispatched) {
14478
+ this._lastStripDispatched = stripMode;
14479
+ // Tell the embedding page so it can collapse/restore its own wrapper. The
14480
+ // host shrinks to 56px via :host CSS, but a parent .player-stage with
14481
+ // aspect-ratio: 16/9 (or any fixed height) would still reserve its old
14482
+ // box and leave a black gap. Consumers listen on this — see index.html.
13211
14483
  this.dispatchEvent(new CustomEvent("audiostripchange", {
13212
14484
  detail: { strip: stripMode },
13213
14485
  bubbles: true,
@@ -13260,11 +14532,12 @@ export class MoviElement extends HTMLElement {
13260
14532
  ctx.scale(dpr, dpr);
13261
14533
  // Blurred backdrop is now a CSS-blurred DOM element BEHIND this canvas — a
13262
14534
  // real Gaussian in every browser (canvas ctx.filter "blur()" is unsupported
13263
- // in Safari < 17 and looked bad faked). Point it at the poster URL; embedded
13264
- // art (an ImageBitmap, currently disabled) has no URL, so it falls back to
13265
- // 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.
13266
14539
  if (this._coverArtBgEl) {
13267
- const bgUrl = this.coverArtBitmap ? "" : this._posterCoverUrl;
14540
+ const bgUrl = this.coverArtBitmap ? this._coverArtBgUrl : this._posterCoverUrl;
13268
14541
  this._coverArtBgEl.style.backgroundImage = bgUrl
13269
14542
  ? `url("${bgUrl.replace(/"/g, '\\"')}")`
13270
14543
  : "none";
@@ -13498,6 +14771,8 @@ export class MoviElement extends HTMLElement {
13498
14771
  * Automatically create and initialize MoviPlayer
13499
14772
  */
13500
14773
  async initializePlayer() {
14774
+ // QoE: begin a new analytics session and start the startup timer.
14775
+ this._qoe.sessionStart(typeof this._src === "string" ? this._src : "");
13501
14776
  // Re-run the security-header check before any FFmpeg init. load() resets
13502
14777
  // _isUnsupported on every source change, which would otherwise let a
13503
14778
  // non-isolated context (e.g. embed iframe inside a third-party page that
@@ -13568,6 +14843,14 @@ export class MoviElement extends HTMLElement {
13568
14843
  if (this._headers && source && source.type === "url") {
13569
14844
  source.headers = this._headers;
13570
14845
  }
14846
+ // Read `thumb` straight from the DOM here rather than trusting the
14847
+ // cached `_thumb` field. The `src` attribute's own attributeChangedCallback
14848
+ // fires load() during element upgrade *before* the `thumb` callback (and
14849
+ // before connectedCallback) has populated `_thumb` — so with the usual
14850
+ // `<movi-player src=… thumb>` ordering, `_thumb` is still false at this
14851
+ // point and previews would silently never enable. hasAttribute reads the
14852
+ // live parsed attribute, which is already present, so order stops mattering.
14853
+ this._thumb = this.hasAttribute("thumb");
13571
14854
  // Create MoviPlayer instance
13572
14855
  // Configure MoviPlayer options
13573
14856
  const playerConfig = {
@@ -13810,6 +15093,8 @@ export class MoviElement extends HTMLElement {
13810
15093
  this.player.once("seeked", () => {
13811
15094
  this.player?.resetClockToStartForPoster();
13812
15095
  this._posterSeekActive = false;
15096
+ // Poster frame is now on the canvas — snapshot it for lock-screen art.
15097
+ this.captureMediaSessionArtwork();
13813
15098
  });
13814
15099
  this.player
13815
15100
  .seek(posterTime, { suppressSpinner: true })
@@ -13818,6 +15103,9 @@ export class MoviElement extends HTMLElement {
13818
15103
  });
13819
15104
  }
13820
15105
  else {
15106
+ // Frame 0 lands on the canvas when "seeked" fires (the seek promise
15107
+ // resolves earlier, before paint) — snapshot it there for artwork.
15108
+ this.player.once("seeked", () => this.captureMediaSessionArtwork());
13821
15109
  this.player.seek(0, { suppressSpinner: true }).catch(() => { });
13822
15110
  }
13823
15111
  }
@@ -13909,6 +15197,9 @@ export class MoviElement extends HTMLElement {
13909
15197
  const audioTrackChangeHandler = () => {
13910
15198
  this.updateAudioTrackMenu();
13911
15199
  this.updateSubtitleTrackMenu();
15200
+ // Switching between muxed (AudioContext, boostable) and native audio
15201
+ // changes the volume ceiling — re-cap the slider.
15202
+ this.updateVolumeCap();
13912
15203
  this.dispatchEvent(new Event("audiotrackchange"));
13913
15204
  };
13914
15205
  this.player.trackManager.on("audioTrackChange", audioTrackChangeHandler);
@@ -13940,6 +15231,12 @@ export class MoviElement extends HTMLElement {
13940
15231
  // Forward player events to element
13941
15232
  const stateChangeHandler = (state) => {
13942
15233
  Logger.info(TAG, `stateChange: ${state}`);
15234
+ // QoE: track stalls. Entering "buffering" starts a rebuffer timer; any
15235
+ // other state ends it (the first stall, before first frame, is startup).
15236
+ if (state === "buffering")
15237
+ this._qoe.bufferingStartNow();
15238
+ else
15239
+ this._qoe.bufferingEndNow();
13943
15240
  // A seek requested before the player was ready was held — apply it now
13944
15241
  // that we've reached a seekable state (fixes e.g. a PiP/handoff seek that
13945
15242
  // arrives while the source is still loading).
@@ -14005,11 +15302,13 @@ export class MoviElement extends HTMLElement {
14005
15302
  this.updatePlayPauseIcon();
14006
15303
  if (this._ambientMode)
14007
15304
  this._ambientSampleInterval = 200;
14008
- if ("mediaSession" in navigator && this._title) {
14009
- navigator.mediaSession.metadata = new MediaMetadata({
14010
- title: this._title,
14011
- });
14012
- }
15305
+ this.updateMediaSession();
15306
+ this.updateMediaSessionPosition();
15307
+ this.captureMediaSessionArtwork();
15308
+ this.startStutterMonitor();
15309
+ this._qoe.firstFrame();
15310
+ this._qoe.playing();
15311
+ this.startQoeHeartbeat();
14013
15312
  return;
14014
15313
  }
14015
15314
  // Native-like: when playback starts, hide the controls unless the
@@ -14046,15 +15345,23 @@ export class MoviElement extends HTMLElement {
14046
15345
  if (this._ambientMode) {
14047
15346
  this._ambientSampleInterval = 200;
14048
15347
  }
14049
- // Update Media Session metadata with clean title
14050
- if ("mediaSession" in navigator && this._title) {
14051
- navigator.mediaSession.metadata = new MediaMetadata({
14052
- title: this._title,
14053
- });
14054
- }
15348
+ // Update Media Session metadata + playing state + scrubber
15349
+ this.updateMediaSession();
15350
+ this.updateMediaSessionPosition();
15351
+ this.captureMediaSessionArtwork();
15352
+ // Watch for decode-can't-keep-up stutter to hint "play at 1x".
15353
+ this.startStutterMonitor();
15354
+ // QoE: first "playing" marks the first frame (startup time).
15355
+ this._qoe.firstFrame();
15356
+ this._qoe.playing();
15357
+ this.startQoeHeartbeat();
14055
15358
  }
14056
15359
  else if (state === "paused") {
14057
15360
  this.dispatchEvent(new Event("pause"));
15361
+ this.updateMediaSession();
15362
+ this.stopStutterMonitor();
15363
+ this._qoe.paused();
15364
+ this.stopQoeHeartbeat();
14058
15365
  // Don't auto-surface the bar on pause anymore — the centre
14059
15366
  // play button already comes back (via updatePlayPauseIcon's
14060
15367
  // paused branch) and tells the user playback is paused. The
@@ -14076,6 +15383,10 @@ export class MoviElement extends HTMLElement {
14076
15383
  // Clear resume position when video ends (start fresh next time)
14077
15384
  if (this._resume)
14078
15385
  this.clearResumePosition();
15386
+ this.updateMediaSession();
15387
+ this.stopStutterMonitor();
15388
+ this._qoe.ended();
15389
+ this.stopQoeHeartbeat();
14079
15390
  }
14080
15391
  };
14081
15392
  this.player.on("stateChange", stateChangeHandler);
@@ -14085,6 +15396,9 @@ export class MoviElement extends HTMLElement {
14085
15396
  this.updateLoadingIndicator(this.player?.getState() || "idle");
14086
15397
  this.updateControlsState();
14087
15398
  this.updatePlayPauseIcon();
15399
+ // Cap the volume slider at 100% for native-audio sources (streams /
15400
+ // native split-source), 200% otherwise, now that the audio path is known.
15401
+ this.updateVolumeCap();
14088
15402
  // Tracks are now finalised; pick the right visual mode (video
14089
15403
  // surface / cover-art overlay / audio strip). Cover-art extraction
14090
15404
  // is async, so the strip may flip OFF later when the coverart
@@ -14097,6 +15411,10 @@ export class MoviElement extends HTMLElement {
14097
15411
  // listener was wired (detection happens during the demux open).
14098
15412
  if (this.player?.isLinearPlayback?.())
14099
15413
  this.enterLinearMode();
15414
+ // Wire OS lock-screen / hardware-key controls and seed metadata now that
15415
+ // tracks (and thus title/duration) are finalised.
15416
+ this.updateMediaSession();
15417
+ this.updateMediaSessionPosition();
14100
15418
  this.dispatchEvent(new Event("loadeddata"));
14101
15419
  };
14102
15420
  this.player.on("loadEnd", loadEndHandler);
@@ -14130,6 +15448,15 @@ export class MoviElement extends HTMLElement {
14130
15448
  const timeUpdateHandler = (time) => {
14131
15449
  this.dispatchEvent(new CustomEvent("timeupdate", { detail: time }));
14132
15450
  this.updateLiveState();
15451
+ // Keep the seek slider's screen-reader ARIA current even while the visual
15452
+ // chrome is auto-hidden (updateTimeDisplay is gated on visible controls).
15453
+ this.updateSeekAria();
15454
+ // Refresh the OS scrubber at ~1s granularity, or immediately on a jump
15455
+ // (seek) so the lock-screen position doesn't lag.
15456
+ if (Math.abs(time - this._mediaSessionLastPos) >= 1) {
15457
+ this._mediaSessionLastPos = time;
15458
+ this.updateMediaSessionPosition();
15459
+ }
14133
15460
  };
14134
15461
  this.player.on("timeUpdate", timeUpdateHandler);
14135
15462
  this.eventHandlers.set("timeUpdate", () => this.player?.off("timeUpdate", timeUpdateHandler));
@@ -14139,6 +15466,7 @@ export class MoviElement extends HTMLElement {
14139
15466
  this.player.on("filerevoked", fileRevokedHandler);
14140
15467
  this.eventHandlers.set("filerevoked", () => this.player?.off("filerevoked", fileRevokedHandler));
14141
15468
  const errorHandler = (error) => {
15469
+ this._qoe.error(error instanceof Error ? error.message : String(error), true);
14142
15470
  this.dispatchEvent(new CustomEvent("error", { detail: error }));
14143
15471
  // Always log the raw error before the message gets prettified for
14144
15472
  // the overlay — without this, "State: ... -> error" appears in
@@ -14203,6 +15531,10 @@ export class MoviElement extends HTMLElement {
14203
15531
  // and reuses one on subsequent loads. Our reference can be dropped
14204
15532
  // safely; the player will close() when it stomps its own slot.
14205
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) : "";
14206
15538
  // Extraction has settled (success or failure) — release the strip-layout
14207
15539
  // hold so a failed extraction falls back to the strip cleanly.
14208
15540
  this._coverArtResolved = true;
@@ -14212,6 +15544,9 @@ export class MoviElement extends HTMLElement {
14212
15544
  // not close() it. bubbles/composed so it escapes the shadow root.
14213
15545
  // Only forward real bitmaps; the null signal is internal.
14214
15546
  if (bitmap) {
15547
+ // Refresh the OS lock-screen artwork with the real album art now that
15548
+ // it's decoded (metadata was seeded earlier with no / a fallback image).
15549
+ this.setMediaSessionArtworkFromBitmap(bitmap);
14215
15550
  this.dispatchEvent(new CustomEvent("coverart", {
14216
15551
  detail: { bitmap, width: bitmap.width, height: bitmap.height },
14217
15552
  bubbles: true,
@@ -14233,6 +15568,7 @@ export class MoviElement extends HTMLElement {
14233
15568
  // owned by MoviPlayer; we just clear our own pointer and hide the
14234
15569
  // overlay so the new load doesn't briefly show last track's art.
14235
15570
  this.coverArtBitmap = null;
15571
+ this._coverArtBgUrl = "";
14236
15572
  this._coverArtResolved = false;
14237
15573
  // Drop the poster-as-album-art bitmap too — we own it, so close to free it.
14238
15574
  if (this._posterCoverBitmap) {
@@ -14314,6 +15650,236 @@ export class MoviElement extends HTMLElement {
14314
15650
  this.player.pause();
14315
15651
  }
14316
15652
  }
15653
+ /**
15654
+ * Register OS Media Session action handlers once. These surface play/pause,
15655
+ * skip-back/forward and scrub controls on the lock screen, notification
15656
+ * shade, media-key hardware (headset / keyboard) and Bluetooth remotes, and
15657
+ * route them back into the player. Registered once (guarded); the handlers
15658
+ * close over `this` and read `this.player` lazily, so they keep working
15659
+ * across source swaps that recreate the internal player.
15660
+ */
15661
+ setupMediaSession() {
15662
+ if (this._mediaSessionReady)
15663
+ return;
15664
+ if (!("mediaSession" in navigator))
15665
+ return;
15666
+ const ms = navigator.mediaSession;
15667
+ const set = (action, handler) => {
15668
+ try {
15669
+ ms.setActionHandler(action, handler);
15670
+ }
15671
+ catch {
15672
+ // Chromium throws for actions it doesn't support (e.g. older builds);
15673
+ // ignore so the supported handlers still register.
15674
+ }
15675
+ };
15676
+ set("play", () => {
15677
+ this.play().catch(() => { });
15678
+ });
15679
+ set("pause", () => {
15680
+ this.pause();
15681
+ });
15682
+ set("stop", () => {
15683
+ this.pause();
15684
+ });
15685
+ set("seekbackward", (details) => {
15686
+ this.performRelativeSeek("left", details?.seekOffset || 10);
15687
+ });
15688
+ set("seekforward", (details) => {
15689
+ this.performRelativeSeek("right", details?.seekOffset || 10);
15690
+ });
15691
+ set("seekto", (details) => {
15692
+ if (!this.player || typeof details?.seekTime !== "number")
15693
+ return;
15694
+ this.player.seek(details.seekTime).catch(() => { });
15695
+ });
15696
+ this._mediaSessionReady = true;
15697
+ }
15698
+ /**
15699
+ * Refresh the lock-screen metadata (title + poster artwork) and the
15700
+ * playing/paused indicator. Cheap to call on every state/load change.
15701
+ */
15702
+ updateMediaSession() {
15703
+ if (!("mediaSession" in navigator))
15704
+ return;
15705
+ const ms = navigator.mediaSession;
15706
+ this.setupMediaSession();
15707
+ if (this._title) {
15708
+ const art = this._poster ||
15709
+ this._posterCoverUrl ||
15710
+ this._generatedPosterUrl ||
15711
+ this._mediaSessionArtworkUrl;
15712
+ const artwork = art
15713
+ ? [{ src: art, sizes: "512x512", type: this.artworkMime(art) }]
15714
+ : undefined;
15715
+ try {
15716
+ ms.metadata = new MediaMetadata({ title: this._title, artwork });
15717
+ }
15718
+ catch {
15719
+ // Some engines reject artwork with an unfetchable src; fall back to
15720
+ // a title-only metadata so the OS chrome still shows something.
15721
+ ms.metadata = new MediaMetadata({ title: this._title });
15722
+ }
15723
+ }
15724
+ const state = this.player?.getState();
15725
+ ms.playbackState =
15726
+ state === "playing" ? "playing" : state === "paused" ? "paused" : "none";
15727
+ }
15728
+ /**
15729
+ * Capture the current decoded frame off the WebGL canvas as a data: URL for
15730
+ * use as OS lock-screen artwork. A source with no explicit `poster` paints
15731
+ * its poster/first frame straight onto the canvas (via the postertime /
15732
+ * frame-0 seek) and never turns it into a URL — this is how that same still
15733
+ * becomes the lock-screen thumbnail. Skipped when an explicit poster/cover
15734
+ * URL already wins the artwork chain, or once a snapshot is already stored.
15735
+ * The canvas uses preserveDrawingBuffer, so toDataURL returns the real frame.
15736
+ * Cheap one-shot (mirrors the proven _lastFrameSnapshot capture) and only
15737
+ * runs at moments a real frame is guaranteed on-canvas (poster seek / play).
15738
+ */
15739
+ captureMediaSessionArtwork(attempt = 0) {
15740
+ if (!("mediaSession" in navigator))
15741
+ return;
15742
+ if (this._poster || this._posterCoverUrl)
15743
+ return; // explicit art wins
15744
+ if (this._mediaSessionArtworkUrl)
15745
+ return; // already captured
15746
+ // The poster/first frame is decoded by the time "seeked" fires, but the
15747
+ // renderer paints it on a later rAF — capturing synchronously grabs the
15748
+ // still-blank GL buffer (a black thumbnail). Wait, on rAF, until a real
15749
+ // (non-uniform) frame has actually landed, then snapshot it. Bounded so a
15750
+ // genuinely dark/audio-only canvas can't spin forever; "playing" re-arms it.
15751
+ if (this.isCanvasBlank()) {
15752
+ if (attempt < 30) {
15753
+ requestAnimationFrame(() => this.captureMediaSessionArtwork(attempt + 1));
15754
+ }
15755
+ return;
15756
+ }
15757
+ try {
15758
+ const dataUrl = this.canvas?.toDataURL("image/jpeg", 0.85);
15759
+ if (dataUrl && dataUrl.length > 512) {
15760
+ this._mediaSessionArtworkUrl = dataUrl;
15761
+ this.updateMediaSession();
15762
+ }
15763
+ }
15764
+ catch {
15765
+ // Tainted / lost GL context — leave artwork empty rather than throw.
15766
+ }
15767
+ }
15768
+ /**
15769
+ * Cheap synchronous test for whether the display canvas is still blank/black
15770
+ * (renderer hasn't painted a real frame yet). Downscales the WebGL canvas to
15771
+ * 16×16 on a throwaway 2D context and checks luminance spread — a near-flat
15772
+ * result means no frame content. Used to defer artwork capture until a real
15773
+ * frame lands. Returns true (treat as blank) on any failure, so capture just
15774
+ * retries rather than storing garbage.
15775
+ */
15776
+ isCanvasBlank() {
15777
+ const c = this.canvas;
15778
+ if (!c || !c.width || !c.height)
15779
+ return true;
15780
+ try {
15781
+ const small = document.createElement("canvas");
15782
+ small.width = 16;
15783
+ small.height = 16;
15784
+ const ctx = small.getContext("2d", { willReadFrequently: true });
15785
+ if (!ctx)
15786
+ return false; // can't test — assume drawable, let capture proceed
15787
+ ctx.drawImage(c, 0, 0, 16, 16);
15788
+ const { data } = ctx.getImageData(0, 0, 16, 16);
15789
+ let min = 255;
15790
+ let max = 0;
15791
+ for (let i = 0; i < data.length; i += 4) {
15792
+ const lum = (data[i] + data[i + 1] + data[i + 2]) / 3;
15793
+ if (lum < min)
15794
+ min = lum;
15795
+ if (lum > max)
15796
+ max = lum;
15797
+ }
15798
+ return max - min < 6; // near-uniform => blank / not yet painted
15799
+ }
15800
+ catch {
15801
+ return true; // treat failures as blank → capture retries, never throws
15802
+ }
15803
+ }
15804
+ /**
15805
+ * Use an extracted cover-art / thumbnail bitmap as the OS lock-screen artwork.
15806
+ * The player surfaces embedded album art (and other thumbnails) asynchronously
15807
+ * — after the initial metadata is seeded — so this refreshes the Media Session
15808
+ * once it lands, instead of the lock screen being stuck with no image. The
15809
+ * bitmap is rasterised to a bounded (≤512px) data: URL. Real art is
15810
+ * authoritative, so it overwrites any earlier canvas-snapshot fallback; an
15811
+ * explicit `poster`/cover URL still wins the chain in updateMediaSession().
15812
+ */
15813
+ setMediaSessionArtworkFromBitmap(bitmap) {
15814
+ if (!("mediaSession" in navigator))
15815
+ return;
15816
+ if (!bitmap || !bitmap.width || !bitmap.height)
15817
+ return;
15818
+ try {
15819
+ const scale = Math.min(1, 512 / Math.max(bitmap.width, bitmap.height));
15820
+ const canvas = document.createElement("canvas");
15821
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
15822
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
15823
+ const ctx = canvas.getContext("2d");
15824
+ if (!ctx)
15825
+ return;
15826
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
15827
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
15828
+ if (dataUrl && dataUrl.length > 512) {
15829
+ this._mediaSessionArtworkUrl = dataUrl;
15830
+ this.updateMediaSession();
15831
+ }
15832
+ }
15833
+ catch {
15834
+ // Tainted / decode failure — leave any existing artwork untouched.
15835
+ }
15836
+ }
15837
+ /** Best-effort MIME guess for poster artwork from its URL/extension. */
15838
+ artworkMime(url) {
15839
+ if (url.startsWith("data:")) {
15840
+ const m = url.slice(5, url.indexOf(";"));
15841
+ return m || "image/png";
15842
+ }
15843
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
15844
+ if (ext === "jpg" || ext === "jpeg")
15845
+ return "image/jpeg";
15846
+ if (ext === "webp")
15847
+ return "image/webp";
15848
+ if (ext === "gif")
15849
+ return "image/gif";
15850
+ return "image/png";
15851
+ }
15852
+ /**
15853
+ * Push the current playhead / duration / rate into the OS scrubber. Guarded
15854
+ * against live streams (Infinity), un-loaded state (NaN/0) and out-of-range
15855
+ * positions, which would otherwise make setPositionState throw.
15856
+ */
15857
+ updateMediaSessionPosition() {
15858
+ if (!("mediaSession" in navigator))
15859
+ return;
15860
+ const ms = navigator.mediaSession;
15861
+ if (typeof ms.setPositionState !== "function")
15862
+ return;
15863
+ const duration = this.duration;
15864
+ const rate = this.player?.getPlaybackRate?.() || 1;
15865
+ try {
15866
+ if (Number.isFinite(duration) && duration > 0) {
15867
+ const position = Math.min(Math.max(this.currentTime || 0, 0), duration);
15868
+ ms.setPositionState({
15869
+ duration,
15870
+ playbackRate: rate > 0 ? rate : 1,
15871
+ position,
15872
+ });
15873
+ }
15874
+ else {
15875
+ // Live / not-yet-loaded: clear any stale scrubber state.
15876
+ ms.setPositionState();
15877
+ }
15878
+ }
15879
+ catch {
15880
+ // Ignore — a transient position > duration during a seek shouldn't spam.
15881
+ }
15882
+ }
14317
15883
  /**
14318
15884
  * Tear down the internal player and reset transient UI (time, title,
14319
15885
  * subtitles, timeline) back to the initial, no-source state. Called
@@ -14330,6 +15896,27 @@ export class MoviElement extends HTMLElement {
14330
15896
  this._preloadGateActive = false;
14331
15897
  this._resumeDialogPending = false;
14332
15898
  this._autoplayPendingVisible = false;
15899
+ // Reset OS lock-screen chrome to a no-source state. Keep the action
15900
+ // handlers registered (setupMediaSession is guarded) — they read
15901
+ // this.player lazily and will drive the next source once it loads.
15902
+ if ("mediaSession" in navigator) {
15903
+ navigator.mediaSession.playbackState = "none";
15904
+ navigator.mediaSession.metadata = null;
15905
+ try {
15906
+ navigator.mediaSession.setPositionState?.();
15907
+ }
15908
+ catch {
15909
+ /* noop */
15910
+ }
15911
+ }
15912
+ this._mediaSessionLastPos = -1;
15913
+ this._mediaSessionArtworkUrl = null;
15914
+ this.stopStutterMonitor();
15915
+ this.resetStutterHint();
15916
+ if (this._captionLive)
15917
+ this._captionLive.textContent = "";
15918
+ this._lastCaptionText = "";
15919
+ this.stopQoeHeartbeat();
14333
15920
  // Tear down internal player
14334
15921
  if (this.player) {
14335
15922
  try {
@@ -14618,6 +16205,7 @@ export class MoviElement extends HTMLElement {
14618
16205
  if (durationEl) {
14619
16206
  durationEl.textContent = this.formatTime(this.duration);
14620
16207
  }
16208
+ this.updateSeekAria();
14621
16209
  // Trigger title auto-load when duration first becomes available
14622
16210
  if (this._lastDuration === 0 && this.duration > 0) {
14623
16211
  this._lastDuration = this.duration;
@@ -14625,6 +16213,78 @@ export class MoviElement extends HTMLElement {
14625
16213
  this.updateTitle();
14626
16214
  }
14627
16215
  }
16216
+ /**
16217
+ * Update the seek slider's ARIA so screen readers announce the position as a
16218
+ * real slider ("1:23 of 4:56"), not a bare number. Driven off timeUpdate so
16219
+ * it stays current even while the visual chrome is auto-hidden.
16220
+ */
16221
+ updateSeekAria() {
16222
+ const bar = this.shadowRoot?.querySelector(".movi-progress-bar");
16223
+ if (!bar)
16224
+ return;
16225
+ const ct = this._posterSeekActive ? 0 : this.currentTime;
16226
+ const live = this.player?.isLiveStream?.();
16227
+ const dur = Number.isFinite(this.duration) && this.duration > 0 ? this.duration : 0;
16228
+ bar.setAttribute("aria-valuemin", "0");
16229
+ bar.setAttribute("aria-valuemax", live ? "0" : String(Math.round(dur)));
16230
+ bar.setAttribute("aria-valuenow", String(Math.round(ct)));
16231
+ bar.setAttribute("aria-valuetext", live
16232
+ ? "Live"
16233
+ : dur > 0
16234
+ ? `${this.formatTime(ct)} of ${this.formatTime(dur)}`
16235
+ : this.formatTime(ct));
16236
+ }
16237
+ /** Push the visible caption's text into the off-screen aria-live region so
16238
+ * screen readers announce it. Deduped so identical re-renders stay quiet. */
16239
+ mirrorCaption() {
16240
+ if (!this._captionLive || !this.subtitleOverlay)
16241
+ return;
16242
+ const text = (this.subtitleOverlay.textContent || "")
16243
+ .replace(/\s+/g, " ")
16244
+ .trim();
16245
+ if (text === this._lastCaptionText)
16246
+ return;
16247
+ this._lastCaptionText = text;
16248
+ this._captionLive.textContent = text;
16249
+ }
16250
+ // ── QoE analytics ─────────────────────────────────────────────────────────
16251
+ startQoeHeartbeat() {
16252
+ if (this._qoeHeartbeat !== null)
16253
+ return;
16254
+ this._qoeHeartbeat = window.setInterval(() => {
16255
+ if (this.player?.getState?.() !== "playing")
16256
+ return;
16257
+ const stats = this.player?.getStats?.();
16258
+ const decoder = String(stats?.["Video Decoder"] ?? "unknown");
16259
+ // No dropped-frame counter is exposed yet; rebufferRatio carries the
16260
+ // stall cost. Report 0 so the schema stays stable for consumers.
16261
+ this._qoe.heartbeat(this.currentTime, 0, decoder);
16262
+ }, 10000);
16263
+ }
16264
+ stopQoeHeartbeat() {
16265
+ if (this._qoeHeartbeat !== null) {
16266
+ clearInterval(this._qoeHeartbeat);
16267
+ this._qoeHeartbeat = null;
16268
+ }
16269
+ }
16270
+ /** Register a QoE analytics sink; returns an unsubscribe fn. Every event is
16271
+ * also dispatched as a `movi-qoe` CustomEvent. */
16272
+ addQoeSink(sink) {
16273
+ this._qoe.addSink(sink);
16274
+ return () => this._qoe.removeSink(sink);
16275
+ }
16276
+ removeQoeSink(sink) {
16277
+ this._qoe.removeSink(sink);
16278
+ }
16279
+ /** POST every QoE event to `url` via sendBeacon — shorthand for
16280
+ * addQoeSink(beaconSink(url)). Returns an unsubscribe fn. */
16281
+ setAnalyticsBeacon(url) {
16282
+ return this.addQoeSink(beaconSink(url));
16283
+ }
16284
+ /** A rolled-up snapshot of the current QoE session. */
16285
+ getQoeSession() {
16286
+ return this._qoe.getSession();
16287
+ }
14628
16288
  /**
14629
16289
  * Render chapter markers on the progress bar (YouTube-style)
14630
16290
  */
@@ -14767,8 +16427,15 @@ export class MoviElement extends HTMLElement {
14767
16427
  // type=range> doesn't expose a separate filled portion across
14768
16428
  // browsers, so the CSS gradient below reads this custom prop
14769
16429
  // to draw a coloured segment from 0 to thumb and a muted one
14770
- // beyond.
14771
- volumeSlider.style.setProperty("--movi-volume-pct", `${Math.round(v * 100)}%`);
16430
+ // beyond. Track max is getMaxVolume() (2 normally, 1 on native audio), so
16431
+ // the thumb sits at v/max of the width.
16432
+ volumeSlider.style.setProperty("--movi-volume-pct", `${Math.round((v / this.getMaxVolume()) * 100)}%`);
16433
+ // Above 100% we're boosting — tint the fill so the boost zone is obvious.
16434
+ volumeSlider.classList.toggle("movi-volume-boosted", v > 1);
16435
+ // Real ARIA for screen readers: announce the percentage, not 0–2.
16436
+ const pct = Math.round(v * 100);
16437
+ volumeSlider.setAttribute("aria-valuenow", String(pct));
16438
+ volumeSlider.setAttribute("aria-valuetext", this._muted ? "Muted" : `Volume ${pct}%${v > 1 ? " (boosted)" : ""}`);
14772
16439
  }
14773
16440
  // Reset all first
14774
16441
  volumeHigh?.style.setProperty("display", "none");
@@ -14919,33 +16586,42 @@ export class MoviElement extends HTMLElement {
14919
16586
  * These headers are required for SharedArrayBuffer support (needed by FFmpeg)
14920
16587
  */
14921
16588
  checkSecurityHeaders() {
14922
- // Check if Cross-Origin-Isolated context is available
16589
+ // Cross-origin isolation (COOP+COEP → SharedArrayBuffer) is NOT required to
16590
+ // play anything. The WASM engine is single-threaded (USE_PTHREADS=0) and
16591
+ // does all its I/O through Asyncify (async js_read_async), so HttpSource's
16592
+ // reads bridge async network fetches without SAB. SAB is purely a zero-copy
16593
+ // optimisation; HttpSource's plain-buffer fallback works fine without it
16594
+ // (once atomicSetStreaming got its missing non-SAB branch — see HttpSource).
16595
+ // Blocking on the headers used to lock out perfectly capable browsers whose
16596
+ // embedder couldn't set them, and lite/mobile browsers (Opera Mini "high"
16597
+ // mode, UC, etc.) that never report crossOriginIsolated at all.
14923
16598
  if (!window.crossOriginIsolated) {
14924
- Logger.warn(TAG, "Security headers missing: Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy are required");
14925
- // Show error message
16599
+ Logger.warn(TAG, "Not cross-origin isolated (no COOP/COEP) — running without SharedArrayBuffer; single-threaded WASM + Asyncify I/O are unaffected, HTTP streaming just uses the plain-buffer path.");
16600
+ }
16601
+ // The one thing genuinely required is WebAssembly. Proxy/lite browsers that
16602
+ // render server-side (Opera Mini's extreme/proxy mode and similar) don't
16603
+ // provide it — surface a clear "unsupported browser" message rather than a
16604
+ // cryptic decode failure, and skip player init.
16605
+ if (typeof WebAssembly === "undefined") {
16606
+ Logger.warn(TAG, "WebAssembly unavailable — this browser can't run the player");
14926
16607
  if (this.brokenIndicator) {
14927
16608
  this.brokenIndicator.style.display = "flex";
14928
- // Hide the empty-state placeholder so it doesn't render behind the
14929
- // error overlay — both default to visible with no src, which stacks
14930
- // the "No Video" text under "Security Headers Missing".
14931
16609
  if (this.emptyStateIndicator) {
14932
16610
  this.emptyStateIndicator.style.display = "none";
14933
16611
  }
14934
16612
  const titleEl = this.brokenIndicator.querySelector(".movi-broken-title");
14935
16613
  if (titleEl)
14936
- titleEl.textContent = "Security Headers Missing";
16614
+ titleEl.textContent = "Browser not supported";
14937
16615
  const messageEl = this.brokenIndicator.querySelector(".movi-broken-message");
14938
16616
  if (messageEl) {
14939
16617
  messageEl.textContent =
14940
- "This player requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers to be set on the server.";
16618
+ "This player needs WebAssembly, which lite / proxy browsers (like Opera Mini) don't provide. Please open it in Chrome, Firefox, Safari, Edge, or full Opera.";
14941
16619
  }
14942
- // Hide the software fallback button (not applicable for header issues)
14943
16620
  const swFallbackBtn = this.brokenIndicator.querySelector(".movi-sw-fallback-btn");
14944
16621
  if (swFallbackBtn) {
14945
16622
  swFallbackBtn.style.display = "none";
14946
16623
  }
14947
16624
  }
14948
- // Prevent player initialization
14949
16625
  this._isUnsupported = true;
14950
16626
  }
14951
16627
  }
@@ -15806,16 +17482,31 @@ export class MoviElement extends HTMLElement {
15806
17482
  }
15807
17483
  this.updateLoopUI();
15808
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
+ }
15809
17499
  updateLoopUI() {
15810
17500
  const shadowRoot = this.shadowRoot;
15811
17501
  if (!shadowRoot)
15812
17502
  return;
17503
+ const menuRoot = this.contextMenuRoot();
15813
17504
  const loopBtn = shadowRoot.querySelector(".movi-loop-btn");
15814
- const loopMenuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="loop-toggle"]');
15815
- 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");
15816
17507
  // Context menu icons
15817
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-loop-outline");
15818
- 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");
15819
17510
  if (this._loop) {
15820
17511
  loopBtn?.classList.add("active");
15821
17512
  loopMenuItem?.classList.add("movi-context-menu-active");
@@ -15841,10 +17532,11 @@ export class MoviElement extends HTMLElement {
15841
17532
  const shadowRoot = this.shadowRoot;
15842
17533
  if (!shadowRoot)
15843
17534
  return;
15844
- const menuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="ambient-toggle"]');
15845
- const status = shadowRoot.querySelector(".movi-ambient-status");
15846
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-ambient-outline");
15847
- 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");
15848
17540
  if (this._ambientMode) {
15849
17541
  menuItem?.classList.add("movi-context-menu-active");
15850
17542
  if (status)
@@ -15868,12 +17560,13 @@ export class MoviElement extends HTMLElement {
15868
17560
  if (!shadowRoot)
15869
17561
  return;
15870
17562
  const isEnabled = this.player?.getStableAudio() ?? true;
17563
+ const menuRoot = this.contextMenuRoot();
15871
17564
  const stableBtn = shadowRoot.querySelector(".movi-stable-audio-btn");
15872
- const stableMenuItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="stable-audio-toggle"]');
15873
- 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");
15874
17567
  // Context menu icons
15875
- const ctxOutline = shadowRoot.querySelector(".movi-context-menu-stable-outline");
15876
- 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");
15877
17570
  if (isEnabled) {
15878
17571
  stableBtn?.classList.add("active");
15879
17572
  stableMenuItem?.classList.add("movi-context-menu-active");
@@ -16202,7 +17895,13 @@ export class MoviElement extends HTMLElement {
16202
17895
  maybeShowResumeDialog() {
16203
17896
  // Resume means seeking to the saved position — impossible in linear
16204
17897
  // (non-seekable) playback, so don't offer it.
16205
- if (!this._resume || !this.player || this._contextLostTime !== 0 || this._linearMode)
17898
+ // The dialog is interactive chrome, so it has no business appearing on a
17899
+ // bare player with no `controls` attribute — skip it there.
17900
+ if (!this._resume ||
17901
+ !this.player ||
17902
+ this._contextLostTime !== 0 ||
17903
+ this._linearMode ||
17904
+ !this._controls)
16206
17905
  return;
16207
17906
  const savedTime = this.getResumePosition();
16208
17907
  if (savedTime > 2 && savedTime < this.duration - 5) {
@@ -16381,6 +18080,11 @@ export class MoviElement extends HTMLElement {
16381
18080
  this._timelineCancelled = true;
16382
18081
  panel.style.display = "none";
16383
18082
  this.focus();
18083
+ // Re-arm auto-hide only if the bar is currently visible; never surface a
18084
+ // hidden bar on close (see the close-button handler for the reasoning).
18085
+ if (!this.controlsContainer?.classList.contains("movi-controls-hidden")) {
18086
+ this.showControls();
18087
+ }
16384
18088
  }
16385
18089
  }
16386
18090
  /**
@@ -16391,6 +18095,13 @@ export class MoviElement extends HTMLElement {
16391
18095
  return;
16392
18096
  if (this._timelineGenerating)
16393
18097
  return; // re-entrancy guard
18098
+ // The Timeline panel is an explicit user action (press "T") and must render
18099
+ // regardless of the `thumb` attribute — that attribute only gates the
18100
+ // passive seek-bar hover preview (which stays independently gated on
18101
+ // `_thumb` in updateSeekVisuals). Enable the shared thumbnail pipeline so
18102
+ // the getPreviewFrame() calls below can decode frames even when `thumb`
18103
+ // is off; turning it on here never triggers seek-bar previews on its own.
18104
+ this.player.setPreviewsEnabled(true);
16394
18105
  const strip = shadowRoot.querySelector(".movi-timeline-strip");
16395
18106
  const status = shadowRoot.querySelector(".movi-timeline-status");
16396
18107
  const titleEl = shadowRoot.querySelector(".movi-timeline-title");
@@ -16605,7 +18316,8 @@ export class MoviElement extends HTMLElement {
16605
18316
  return this._volume;
16606
18317
  }
16607
18318
  set volume(value) {
16608
- this._volume = Math.max(0, Math.min(1, value));
18319
+ // 0–2: values above 1 boost audio up to 200% (VLC-style).
18320
+ this._volume = Math.max(0, Math.min(this.getMaxVolume(), value));
16609
18321
  this.setAttribute("volume", this._volume.toString());
16610
18322
  // If user increases volume while muted, automatically unmute (like YouTube)
16611
18323
  const autoUnmuted = this._muted && this._volume > 0;
@@ -16643,6 +18355,10 @@ export class MoviElement extends HTMLElement {
16643
18355
  this.updatePlaybackRate();
16644
18356
  SettingsStorage.getInstance().save({ playbackRate: this._playbackRate });
16645
18357
  this.dispatchEvent(new CustomEvent("ratechange", { detail: { playbackRate: this._playbackRate } }));
18358
+ // Keep the OS lock-screen scrubber's speed indicator in sync.
18359
+ this.updateMediaSessionPosition();
18360
+ // Each new speed gets a fresh stutter warning if it can't keep up.
18361
+ this.resetStutterHint();
16646
18362
  }
16647
18363
  get subtitleDelay() {
16648
18364
  return this._subtitleDelay;
@@ -16811,9 +18527,12 @@ export class MoviElement extends HTMLElement {
16811
18527
  }
16812
18528
  }
16813
18529
  }
18530
+ /** @deprecated Use `playsInline` instead — an inline player now restricts
18531
+ * touch gestures to fullscreen on its own. Kept for backward compatibility. */
16814
18532
  get gesturefs() {
16815
18533
  return this._gesturefs;
16816
18534
  }
18535
+ /** @deprecated Use `playsInline` instead. */
16817
18536
  set gesturefs(value) {
16818
18537
  if (this._gesturefs !== value) {
16819
18538
  this._gesturefs = value;
@@ -17316,9 +19035,10 @@ export class MoviElement extends HTMLElement {
17316
19035
  return !!this.player?.isHDRSupported?.();
17317
19036
  }
17318
19037
  updateHDRUI() {
19038
+ const menuRoot = this.contextMenuRoot();
17319
19039
  const hdrBtn = this.shadowRoot?.querySelector(".movi-hdr-btn");
17320
- const hdrStatus = this.shadowRoot?.querySelector(".movi-hdr-status");
17321
- 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"]');
17322
19042
  if (this._hdr) {
17323
19043
  hdrBtn?.classList.add("movi-hdr-active");
17324
19044
  hdrMenuItem?.classList.add("movi-context-menu-active");
@@ -17335,7 +19055,7 @@ export class MoviElement extends HTMLElement {
17335
19055
  /*
17336
19056
  * Take a snapshot of the current frame and download it
17337
19057
  */
17338
- takeSnapshot() {
19058
+ async takeSnapshot() {
17339
19059
  if (!this.player)
17340
19060
  return;
17341
19061
  try {
@@ -17343,6 +19063,32 @@ export class MoviElement extends HTMLElement {
17343
19063
  // If we are in canvas mode, it's easy
17344
19064
  if (this.canvas && this.canvas.style.display !== "none") {
17345
19065
  dataUrl = this.canvas.toDataURL("image/png");
19066
+ // Some GPUs return an all-black buffer when a WebGL canvas that
19067
+ // displayed a HARDWARE-decoded frame (4K AV1/HEVC etc.) is read back
19068
+ // via toDataURL — even with preserveDrawingBuffer — so the snapshot
19069
+ // saves empty. Detect that and fall back to the raw decoded VideoFrame
19070
+ // drawn onto a plain 2D canvas (GPU-readback-independent). Trade-off:
19071
+ // this frame has no burned-in subtitles, but a real frame beats a
19072
+ // black one.
19073
+ if (await this.isDataUrlBlank(dataUrl)) {
19074
+ const frame = this.player.getCurrentVideoFrame?.();
19075
+ if (frame) {
19076
+ try {
19077
+ const c = document.createElement("canvas");
19078
+ c.width = frame.displayWidth;
19079
+ c.height = frame.displayHeight;
19080
+ const ctx = c.getContext("2d");
19081
+ if (ctx) {
19082
+ ctx.drawImage(frame, 0, 0);
19083
+ dataUrl = c.toDataURL("image/png");
19084
+ Logger.info(TAG, "Snapshot: WebGL readback was blank, used decoded VideoFrame");
19085
+ }
19086
+ }
19087
+ catch (e) {
19088
+ Logger.warn(TAG, "VideoFrame snapshot fallback failed", e);
19089
+ }
19090
+ }
19091
+ }
17346
19092
  }
17347
19093
  else if (this.video && this.video.style.display !== "none") {
17348
19094
  // If in video mode, draw video to a temporary canvas
@@ -17377,6 +19123,182 @@ export class MoviElement extends HTMLElement {
17377
19123
  Logger.error(TAG, "Error taking snapshot", e);
17378
19124
  }
17379
19125
  }
19126
+ /**
19127
+ * Decode a data-URL into a small sampling canvas and report whether every
19128
+ * pixel is (near-)black — i.e. the capture came out empty. Used to detect a
19129
+ * failed WebGL readback so takeSnapshot can fall back to the raw VideoFrame.
19130
+ */
19131
+ isDataUrlBlank(dataUrl) {
19132
+ return new Promise((resolve) => {
19133
+ if (!dataUrl || dataUrl.length < 32)
19134
+ return resolve(true);
19135
+ const img = new Image();
19136
+ img.onload = () => {
19137
+ try {
19138
+ const w = 32;
19139
+ const h = 18;
19140
+ const c = document.createElement("canvas");
19141
+ c.width = w;
19142
+ c.height = h;
19143
+ const ctx = c.getContext("2d", { willReadFrequently: true });
19144
+ if (!ctx)
19145
+ return resolve(false); // can't sample — trust the capture
19146
+ ctx.drawImage(img, 0, 0, w, h);
19147
+ const d = ctx.getImageData(0, 0, w, h).data;
19148
+ for (let i = 0; i < d.length; i += 4) {
19149
+ if (d[i] > 8 || d[i + 1] > 8 || d[i + 2] > 8)
19150
+ return resolve(false);
19151
+ }
19152
+ resolve(true); // every sampled pixel is essentially black
19153
+ }
19154
+ catch {
19155
+ resolve(false); // decode/read failed — don't force the fallback
19156
+ }
19157
+ };
19158
+ img.onerror = () => resolve(true);
19159
+ img.src = dataUrl;
19160
+ });
19161
+ }
19162
+ /**
19163
+ * Final safety net for context-menu placement. Whatever a path computed
19164
+ * (strip-mode position:fixed, host-relative absolute, …), the host's
19165
+ * `contain` re-anchors fixed/absolute to the host box — so a clamp done in
19166
+ * window coordinates can still leave the menu spilling off the real viewport
19167
+ * (seen in audio-strip mode on touch). Re-measure the ACTUAL rect and nudge
19168
+ * left/top by the overflow. Translation isn't affected by the containing
19169
+ * block, so this works regardless. The mobile drawer is a deliberate
19170
+ * right-edge panel — skip it.
19171
+ */
19172
+ /**
19173
+ * Below ~290px even the always-visible right cluster (more · fullscreen)
19174
+ * crowds the bar, so fold the fullscreen button into the collapsible
19175
+ * expandable too — leaving only the "more" (>) toggle outside, with
19176
+ * fullscreen reachable (and horizontally scrollable) once expanded. Restored
19177
+ * to its normal slot (last child of the right cluster, after the more button)
19178
+ * once there's room again. Idempotent: only moves when out of place, so it
19179
+ * survives repeated resize ticks without thrashing the DOM.
19180
+ */
19181
+ syncTinyLayout() {
19182
+ const root = this.shadowRoot;
19183
+ if (!root)
19184
+ return;
19185
+ const fsBtn = root.querySelector(".movi-fullscreen-btn");
19186
+ const expandable = root.querySelector(".movi-mobile-expandable");
19187
+ const right = root.querySelector(".movi-controls-right");
19188
+ if (!fsBtn || !expandable || !right)
19189
+ return;
19190
+ const tiny = this.clientWidth > 0 && this.clientWidth < 290;
19191
+ if (tiny) {
19192
+ if (expandable.lastElementChild !== fsBtn)
19193
+ expandable.appendChild(fsBtn);
19194
+ }
19195
+ else if (right.lastElementChild !== fsBtn) {
19196
+ right.appendChild(fsBtn);
19197
+ }
19198
+ }
19199
+ /**
19200
+ * A body-level shadow-DOM portal for the desktop context menu. Moving the
19201
+ * menu here lets it escape any overflow:hidden / clipping ancestor of the
19202
+ * player (page wrappers, cards, `body{overflow-x:hidden}`) — the player's own
19203
+ * shadow keeps `container-type` for its @container queries, which makes it a
19204
+ * containing block for fixed descendants, so a menu that stays inside can't
19205
+ * break out. The portal clones the player's shadow styles so the menu looks
19206
+ * identical, and carries over any inline theme custom-properties.
19207
+ */
19208
+ ensureMenuPortal() {
19209
+ if (this._menuPortalRoot)
19210
+ return this._menuPortalRoot;
19211
+ if (typeof document === "undefined" || !document.body)
19212
+ return null;
19213
+ const host = document.createElement("div");
19214
+ host.setAttribute("data-movi-menu-portal", "");
19215
+ // Plain fixed box at the origin. The cloned player styles below carry the
19216
+ // player's own `:host { overflow:hidden; contain:paint; container-type:...;
19217
+ // width:100% }` rules, which would apply to THIS host and clip the menu to
19218
+ // a 0×0 box / re-anchor its fixed positioning — so hard-override them inline
19219
+ // (inline beats the non-important :host rules). NO contain/container-type,
19220
+ // so a fixed-positioned menu inside is viewport-relative and clipped by
19221
+ // nothing.
19222
+ host.style.cssText =
19223
+ "position:fixed !important;top:0 !important;left:0 !important;" +
19224
+ "width:0 !important;height:0 !important;overflow:visible !important;" +
19225
+ "contain:none !important;container-type:normal !important;" +
19226
+ "background:none !important;border-radius:0 !important;" +
19227
+ "z-index:2147483647;";
19228
+ const root = host.attachShadow({ mode: "open" });
19229
+ for (const s of Array.from(this.shadowRoot?.querySelectorAll("style") || [])) {
19230
+ root.appendChild(s.cloneNode(true));
19231
+ }
19232
+ document.body.appendChild(host);
19233
+ this._menuPortalHost = host;
19234
+ this._menuPortalRoot = root;
19235
+ return root;
19236
+ }
19237
+ // Submenu panels (Speed / Fit / Audio / Subtitle / Audio-output) are
19238
+ // shadow-root SIBLINGS of the menu, not children — they must travel to the
19239
+ // portal with it, or they'd stay behind and open detached from the menu.
19240
+ _portaledSubs = [];
19241
+ /** Move the context menu (+ its submenu panels) into the body portal and mirror theme vars. */
19242
+ portalContextMenu(menu) {
19243
+ const root = this.ensureMenuPortal();
19244
+ if (!root)
19245
+ return;
19246
+ if (menu.parentNode !== root) {
19247
+ this._menuHome = menu.parentNode;
19248
+ this._portaledSubs = this.shadowRoot
19249
+ ? Array.from(this.shadowRoot.querySelectorAll(".movi-context-menu-submenu, .movi-context-menu-submenu-audio, .movi-context-menu-submenu-subtitle, .movi-context-menu-submenu-audiodevice"))
19250
+ : [];
19251
+ root.appendChild(menu);
19252
+ for (const sub of this._portaledSubs)
19253
+ root.appendChild(sub);
19254
+ }
19255
+ // Carry over inline theme overrides (e.g. themecolor -> --movi-primary) so
19256
+ // the portaled menu matches; the cloned styles supply the defaults.
19257
+ const hostStyle = this._menuPortalHost?.style;
19258
+ if (hostStyle) {
19259
+ for (const prop of Array.from(this.style)) {
19260
+ if (prop.startsWith("--movi")) {
19261
+ hostStyle.setProperty(prop, this.style.getPropertyValue(prop));
19262
+ }
19263
+ }
19264
+ }
19265
+ }
19266
+ /** Return the context menu (+ submenus) from the portal to the shadow root. */
19267
+ unportalContextMenu(menu) {
19268
+ if (!this._menuHome)
19269
+ return;
19270
+ if (menu.parentNode !== this._menuHome)
19271
+ this._menuHome.appendChild(menu);
19272
+ for (const sub of this._portaledSubs) {
19273
+ if (sub.parentNode !== this._menuHome)
19274
+ this._menuHome.appendChild(sub);
19275
+ }
19276
+ this._portaledSubs = [];
19277
+ }
19278
+ clampMenuToViewport(menu) {
19279
+ if (menu.classList.contains("movi-context-menu-mobile"))
19280
+ return;
19281
+ const m = 8;
19282
+ const r = menu.getBoundingClientRect();
19283
+ if (r.width === 0)
19284
+ return;
19285
+ const curLeft = parseFloat(menu.style.left) || 0;
19286
+ const curTop = parseFloat(menu.style.top) || 0;
19287
+ let dx = 0;
19288
+ let dy = 0;
19289
+ if (r.right > window.innerWidth - m)
19290
+ dx = window.innerWidth - m - r.right;
19291
+ if (r.left + dx < m)
19292
+ dx = m - r.left;
19293
+ if (r.bottom > window.innerHeight - m)
19294
+ dy = window.innerHeight - m - r.bottom;
19295
+ if (r.top + dy < m)
19296
+ dy = m - r.top;
19297
+ if (dx)
19298
+ menu.style.left = `${curLeft + dx}px`;
19299
+ if (dy)
19300
+ menu.style.top = `${curTop + dy}px`;
19301
+ }
17380
19302
  updateHDRVisibility() {
17381
19303
  if (!this.player)
17382
19304
  return;