movi-player 0.3.3 → 0.3.4

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 +239 -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 +2904 -1867
  20. package/dist/element.d.ts +48 -0
  21. package/dist/element.d.ts.map +1 -1
  22. package/dist/element.js +7935 -6894
  23. package/dist/element.js.map +1 -1
  24. package/dist/index.cjs +2904 -1867
  25. package/dist/index.js +7935 -6894
  26. package/dist/player.cjs +3060 -2930
  27. package/dist/player.js +1763 -1633
  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 +31 -0
  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 +223 -0
  43. package/dist/render/MoviElement.d.ts.map +1 -1
  44. package/dist/render/MoviElement.js +2072 -209
  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;
@@ -68,6 +79,10 @@ export class MoviElement extends HTMLElement {
68
79
  // arrived OR extraction failed). Until then, if the source has an art track,
69
80
  // we hold the audio-strip layout off so the player doesn't flash strip→cover.
70
81
  _coverArtResolved = false;
82
+ // The last audio-strip value we dispatched to the embedding page. Survives
83
+ // load() (unlike the movi-audio-strip class) so a strip↔non-strip switch is
84
+ // detected even across a source change. null = never told the page yet.
85
+ _lastStripDispatched = null;
71
86
  controlsTimeout = null;
72
87
  isOverControls = false;
73
88
  isSeeking = false;
@@ -92,7 +107,37 @@ export class MoviElement extends HTMLElement {
92
107
  _seekChainTarget = null;
93
108
  _contextMenuVisible = false;
94
109
  _contextMenuJustClosed = false;
110
+ // Body-level portal for the desktop right-click menu so it can escape any
111
+ // overflow:hidden / clipping ancestor of the player and feel native. The menu
112
+ // element is moved here on open and back to the shadow root on close.
113
+ _menuPortalHost = null;
114
+ _menuPortalRoot = null;
115
+ _menuHome = null;
95
116
  lastTouchTime = 0;
117
+ // Press-and-hold-to-2x (touch): a long-press on the video speeds playback to
118
+ // 2x while held and reverts on release (YouTube-style). Since long-press now
119
+ // drives this gesture, the context menu opens from the gear button instead —
120
+ // _openMenuViaGear lets the gear's synthetic contextmenu through the
121
+ // touch-long-press gate in preventDefaultContextMenu.
122
+ _holdSpeedTimer = null;
123
+ _holdSpeedActive = false;
124
+ _rateBeforeHold = 1;
125
+ _openMenuViaGear = false;
126
+ // Media Session (OS lock-screen / notification / hardware-key controls).
127
+ // Action handlers are registered once per element (idempotent overwrite is
128
+ // cheap but the flag avoids re-binding on every source load); metadata,
129
+ // playbackState and positionState are refreshed as the player changes.
130
+ _mediaSessionReady = false;
131
+ // Last position pushed to the OS scrubber, to throttle setPositionState to
132
+ // ~1s granularity (timeUpdate can fire far more often). A jump > 1s (seek)
133
+ // also trips the update so the lock-screen scrubber snaps immediately.
134
+ _mediaSessionLastPos = -1;
135
+ // Data-URL snapshot of the decoded poster/first frame, captured off the
136
+ // canvas for a source with NO explicit `poster` attribute — the still frame
137
+ // such a source paints on canvas (via the postertime / frame-0 seek) never
138
+ // becomes a URL otherwise, so the OS lock screen would have no thumbnail.
139
+ // Reset on dispose.
140
+ _mediaSessionArtworkUrl = null;
96
141
  // Nerd Stats
97
142
  _nerdStatsVisible = false;
98
143
  _currentManualRotation = 0; // Track for thumbnail margin re-apply
@@ -102,6 +147,15 @@ export class MoviElement extends HTMLElement {
102
147
  _timelineNextIndex = 0;
103
148
  nerdStatsInterval = null;
104
149
  networkSpeedHistory = []; // speed samples for graph
150
+ // Stutter hint: on a heavy source the decoder can't keep up above 1x, so the
151
+ // video drops frames (audio stays smooth). Detect a sustained low presented-
152
+ // fps while playing >1x and nudge the user toward 1x once, with a long
153
+ // cooldown so it never nags.
154
+ _stutterInterval = null;
155
+ _stutterLastPresented = 0;
156
+ _stutterSeconds = 0;
157
+ _stutterCooldown = false;
158
+ _stutterCooldownTimer = null;
105
159
  static GRAPH_MAX_SAMPLES = 60; // 30 seconds of data (500ms interval)
106
160
  // Internal state
107
161
  _src = null;
@@ -209,7 +263,9 @@ export class MoviElement extends HTMLElement {
209
263
  // Guards the sw-attr-change callback from auto-reloading during dispose().
210
264
  _suppressSwReload = false;
211
265
  _fps = 0; // Custom frame rate (0 = auto from video)
212
- _gesturefs = false; // Gestures only in fullscreen if true
266
+ // @deprecated alias for `playsinline` — gestures-only-in-fullscreen is now
267
+ // driven by playsinline; kept so existing `gesturefs` markup keeps working.
268
+ _gesturefs = false;
213
269
  _noHotkeys = false; // Disable keyboard shortcuts if true
214
270
  _startAt = 0; // Start time in seconds
215
271
  _fastSeek = false; // Enable skip controls (buttons, keys, gestures) if true
@@ -235,6 +291,7 @@ export class MoviElement extends HTMLElement {
235
291
  _vrLastX = 0; // Last pointer/touch X for incremental pan
236
292
  _vrLastY = 0;
237
293
  _vrPinchDist = 0; // Two-finger pinch baseline (touch zoom)
294
+ _aspectPinchDist = 0; // Two-finger pinch baseline (fullscreen aspect-fit)
238
295
  _vrSuppressClick = false; // Swallow the click that ends a drag
239
296
  _vrPadDragging = false; // On-screen joystick is being dragged
240
297
  _encrypted = false; // Encrypted source mode
@@ -490,10 +547,29 @@ export class MoviElement extends HTMLElement {
490
547
  this.coverArtCanvas.style.display = "block";
491
548
  this.coverArtOverlay.appendChild(this.coverArtCanvas);
492
549
  shadowRoot.appendChild(this.coverArtOverlay);
493
- // Create subtitle overlay element
550
+ // Create subtitle overlay element. Decorative for screen readers — the
551
+ // live region below is their source, so the overlay is aria-hidden.
494
552
  this.subtitleOverlay = document.createElement("div");
495
553
  this.subtitleOverlay.className = "movi-subtitle-overlay";
554
+ this.subtitleOverlay.setAttribute("aria-hidden", "true");
496
555
  shadowRoot.appendChild(this.subtitleOverlay);
556
+ // Off-screen aria-live region: announce each caption to screen readers.
557
+ this._captionLive = document.createElement("div");
558
+ this._captionLive.className = "movi-caption-live";
559
+ this._captionLive.setAttribute("aria-live", "polite");
560
+ this._captionLive.setAttribute("aria-atomic", "true");
561
+ this._captionLive.setAttribute("role", "status");
562
+ shadowRoot.appendChild(this._captionLive);
563
+ // Mirror the visible cue's text into the live region whenever it changes.
564
+ this._captionObserver = new MutationObserver(() => this.mirrorCaption());
565
+ this._captionObserver.observe(this.subtitleOverlay, {
566
+ childList: true,
567
+ characterData: true,
568
+ subtree: true,
569
+ });
570
+ // QoE: mirror every analytics event out as a `movi-qoe` DOM event so
571
+ // embedders can forward it without registering a JS sink.
572
+ this._qoe.addSink((e) => this.dispatchEvent(new CustomEvent("movi-qoe", { detail: e, bubbles: true, composed: true })));
497
573
  // Click on the live caption text → open the transcript browser
498
574
  // (scrolled to the current cue). The overlay itself stays
499
575
  // pointer-events:none so clicks elsewhere keep falling through to
@@ -600,6 +676,12 @@ export class MoviElement extends HTMLElement {
600
676
  <div class="movi-osd-text"></div>
601
677
  `;
602
678
  shadowRoot.appendChild(osdContainer);
679
+ // Persistent "2×" pill shown while a press-and-hold speeds playback up.
680
+ const holdSpeed = document.createElement("div");
681
+ holdSpeed.className = "movi-hold-speed";
682
+ holdSpeed.style.display = "none";
683
+ 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>`;
684
+ shadowRoot.appendChild(holdSpeed);
603
685
  // Create context menu FIRST (before setupContextMenu is called)
604
686
  this.createContextMenu(shadowRoot);
605
687
  // Create controls UI (this will call setupContextMenu)
@@ -870,7 +952,7 @@ export class MoviElement extends HTMLElement {
870
952
  container.innerHTML = `
871
953
  <div class="movi-controls-bar" style="position: relative;">
872
954
  <div class="movi-progress-container">
873
- <div class="movi-progress-bar">
955
+ <div class="movi-progress-bar" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuenow="0" aria-valuetext="0:00">
874
956
  <div class="movi-progress-buffer"></div>
875
957
  <div class="movi-progress-filled"></div>
876
958
  <div class="movi-chapter-markers"></div>
@@ -928,7 +1010,7 @@ export class MoviElement extends HTMLElement {
928
1010
  </svg>
929
1011
  </button>
930
1012
  <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">
1013
+ <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
1014
  </div>
933
1015
  </div>
934
1016
 
@@ -1087,8 +1169,14 @@ export class MoviElement extends HTMLElement {
1087
1169
  <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
1170
  </svg>
1089
1171
  </button>
1172
+
1173
+ <button class="movi-btn movi-pip-btn" aria-label="Picture in Picture" style="display:none">
1174
+ <svg class="movi-icon-pip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1175
+ <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"/>
1176
+ </svg>
1177
+ </button>
1090
1178
  </div>
1091
-
1179
+
1092
1180
  <button class="movi-btn movi-more-btn" aria-label="More Settings">
1093
1181
  <svg class="movi-icon-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1094
1182
  <polyline points="15 18 9 12 15 6"></polyline>
@@ -1097,12 +1185,7 @@ export class MoviElement extends HTMLElement {
1097
1185
  <polyline points="9 18 15 12 9 6"></polyline>
1098
1186
  </svg>
1099
1187
  </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>
1188
+
1106
1189
  <button class="movi-btn movi-fullscreen-btn" aria-label="Fullscreen">
1107
1190
  <svg class="movi-icon-fullscreen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1108
1191
  <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 +1260,29 @@ export class MoviElement extends HTMLElement {
1177
1260
  titleBar.style.display = "none";
1178
1261
  titleBar.innerHTML = `<span class="movi-title-text"></span>`;
1179
1262
  shadowRoot.appendChild(titleBar);
1263
+ // Gear button (top-right) → opens the context menu. This is how touch users
1264
+ // reach it now that long-press drives hold-to-2x; on desktop it's a
1265
+ // discoverable alternative to right-click. Shown with the chrome (its
1266
+ // movi-gear-visible class is toggled in show/hideControls).
1267
+ const gearBtn = document.createElement("button");
1268
+ gearBtn.className = "movi-btn movi-gear-btn";
1269
+ gearBtn.setAttribute("aria-label", "Settings");
1270
+ 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>`;
1271
+ gearBtn.addEventListener("click", (e) => {
1272
+ e.stopPropagation();
1273
+ // Open the context menu via a synthetic contextmenu event; the flag lets
1274
+ // it through the touch-long-press gate in preventDefaultContextMenu.
1275
+ this._openMenuViaGear = true;
1276
+ const rect = this.getBoundingClientRect();
1277
+ this.dispatchEvent(new MouseEvent("contextmenu", {
1278
+ bubbles: true,
1279
+ composed: true,
1280
+ cancelable: true,
1281
+ clientX: rect.right - 44,
1282
+ clientY: rect.top + 44,
1283
+ }));
1284
+ });
1285
+ shadowRoot.appendChild(gearBtn);
1180
1286
  // Create Nerd Stats overlay
1181
1287
  const nerdStats = document.createElement("div");
1182
1288
  nerdStats.className = "movi-nerd-stats";
@@ -1213,6 +1319,13 @@ export class MoviElement extends HTMLElement {
1213
1319
  this._timelineCancelled = true;
1214
1320
  timelinePanel.style.display = "none";
1215
1321
  this.focus();
1322
+ // Only re-arm the auto-hide countdown if the bar is currently showing —
1323
+ // that restarts the timer the open panel had suspended so a stuck bar
1324
+ // fades during playback. Never surface a hidden bar here: showControls()
1325
+ // on a hidden bar would fade it in and straight back out (a "flash").
1326
+ if (!this.controlsContainer?.classList.contains("movi-controls-hidden")) {
1327
+ this.showControls();
1328
+ }
1216
1329
  });
1217
1330
  // Create Resume Dialog
1218
1331
  const resumeDialog = document.createElement("div");
@@ -1254,6 +1367,17 @@ export class MoviElement extends HTMLElement {
1254
1367
  this.clearResumePosition();
1255
1368
  this.focus();
1256
1369
  });
1370
+ // Move the selection ring to whichever button the pointer is over, so it
1371
+ // tracks the mouse the same way arrow keys move it — hovering Cancel puts
1372
+ // the ring on Cancel instead of leaving it stuck on Resume.
1373
+ const resumeYes = resumeDialog.querySelector(".movi-resume-yes");
1374
+ const resumeNo = resumeDialog.querySelector(".movi-resume-no");
1375
+ const focusResumeBtn = (btn) => {
1376
+ resumeYes?.classList.toggle("movi-resume-focused", btn === resumeYes);
1377
+ resumeNo?.classList.toggle("movi-resume-focused", btn === resumeNo);
1378
+ };
1379
+ resumeYes?.addEventListener("pointerenter", () => focusResumeBtn(resumeYes));
1380
+ resumeNo?.addEventListener("pointerenter", () => focusResumeBtn(resumeNo));
1257
1381
  // Create Keyboard Shortcuts Panel
1258
1382
  const shortcutsPanel = document.createElement("div");
1259
1383
  shortcutsPanel.className = "movi-shortcuts-panel";
@@ -1438,7 +1562,12 @@ export class MoviElement extends HTMLElement {
1438
1562
  try {
1439
1563
  if (!this.player)
1440
1564
  return;
1441
- const blob = await this.player.getPreviewFrame?.(timeToFetch);
1565
+ // 360°: pass the live viewing angle so the preview reprojects the
1566
+ // equirect frame to what the user currently sees (2D → undefined).
1567
+ const vrView = this._vr360
1568
+ ? this.player.getVR360View?.()
1569
+ : undefined;
1570
+ const blob = await this.player.getPreviewFrame?.(timeToFetch, vrView);
1442
1571
  // Update UI if we got a blob
1443
1572
  if (blob && thumbnailImg) {
1444
1573
  if (lastPreviewUrl)
@@ -1450,8 +1579,13 @@ export class MoviElement extends HTMLElement {
1450
1579
  if (thumbnailPlaceholder)
1451
1580
  thumbnailPlaceholder.style.display = "none";
1452
1581
  thumbnailImg.style.display = "block";
1453
- // Re-apply rotation transform + margin on each preview load
1454
- this.applyThumbnailRotation(thumbnailImg);
1582
+ // Re-apply rotation transform + margin on each preview load. Skip for
1583
+ // 360° previews — those are already reprojected to the upright view,
1584
+ // so a CSS rotate would double-transform them.
1585
+ if (!this._vr360)
1586
+ this.applyThumbnailRotation(thumbnailImg);
1587
+ else
1588
+ thumbnailImg.style.transform = "";
1455
1589
  }
1456
1590
  }
1457
1591
  catch (e) {
@@ -1976,20 +2110,40 @@ export class MoviElement extends HTMLElement {
1976
2110
  e.stopPropagation(); // Prevent triggering overlay click
1977
2111
  this.toggleFullscreen();
1978
2112
  });
1979
- // Picture-in-Picture
2113
+ // In an iframe, fullscreen / Document PiP need to be delegated via the
2114
+ // iframe's `allow` attribute. Without it the Permissions Policy blocks them
2115
+ // and the buttons would be dead controls — so hide them. Scoped to the
2116
+ // iframe case only: at top level `fullscreenEnabled` can legitimately be
2117
+ // false (e.g. iOS Safari, which has no element-fullscreen) yet still has a
2118
+ // working fallback, so we must not hide there.
2119
+ const inIframe = this.isEmbeddedIframe();
2120
+ // Picture-in-Picture — needs the Document PiP API and, in an iframe,
2121
+ // allow="picture-in-picture" (which `pictureInPictureEnabled` reflects, since
2122
+ // Document PiP shares that policy).
1980
2123
  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
- }
2124
+ const pipCtxItem = shadowRoot.querySelector(".movi-context-menu-pip");
2125
+ const pipAvailable = "documentPictureInPicture" in window &&
2126
+ (!inIframe || document.pictureInPictureEnabled !== false);
2127
+ if (pipBtn)
2128
+ pipBtn.style.display = pipAvailable ? "" : "none";
2129
+ if (pipCtxItem)
2130
+ pipCtxItem.style.display = pipAvailable ? "" : "none";
1989
2131
  pipBtn?.addEventListener("click", (e) => {
1990
2132
  e.stopPropagation();
1991
2133
  this.togglePiP();
1992
2134
  });
2135
+ // Fullscreen — hide the button + menu item inside an iframe that wasn't
2136
+ // granted allow="fullscreen" (document.fullscreenEnabled is false there). A
2137
+ // host that drives its own fullscreen via the `movi-fullscreen-request`
2138
+ // event can still trigger it through the F shortcut / its own chrome.
2139
+ if (inIframe && !document.fullscreenEnabled) {
2140
+ const fsBtn = shadowRoot.querySelector(".movi-fullscreen-btn");
2141
+ if (fsBtn)
2142
+ fsBtn.style.display = "none";
2143
+ const fsCtxItem = shadowRoot.querySelector('.movi-context-menu-item[data-action="fullscreen"]');
2144
+ if (fsCtxItem)
2145
+ fsCtxItem.style.display = "none";
2146
+ }
1993
2147
  // More Settings (Mobile Horizontal Expansion)
1994
2148
  const moreBtn = shadowRoot.querySelector(".movi-more-btn");
1995
2149
  const controlsRight = shadowRoot.querySelector(".movi-controls-right");
@@ -2022,6 +2176,10 @@ export class MoviElement extends HTMLElement {
2022
2176
  // Click on video to play/pause (only on canvas/video area, not controls)
2023
2177
  // Handle clicks on both overlay and canvas
2024
2178
  const handleVideoClick = (e) => {
2179
+ // A bare player (no controls attr) is a non-interactive display surface —
2180
+ // a click on it must not toggle play/pause.
2181
+ if (!this._controls)
2182
+ return;
2025
2183
  // A 360° look-around drag ends with a synthetic click — swallow it so the
2026
2184
  // drag doesn't also toggle play/pause. A pure click (no drag) still does.
2027
2185
  if (this._vrSuppressClick) {
@@ -2116,7 +2274,7 @@ export class MoviElement extends HTMLElement {
2116
2274
  this.setupKeyboardShortcuts();
2117
2275
  // Setup gestures
2118
2276
  this.setupGestures(shadowRoot);
2119
- // Setup 360° look-around (mouse drag + wheel zoom)
2277
+ // Setup 360° look-around (mouse/touch drag; two-finger pinch to zoom)
2120
2278
  this.setupVRControls(shadowRoot);
2121
2279
  // Setup context menu
2122
2280
  this.setupContextMenu(shadowRoot);
@@ -2124,6 +2282,7 @@ export class MoviElement extends HTMLElement {
2124
2282
  document.addEventListener("fullscreenchange", () => {
2125
2283
  const isFullscreen = !!document.fullscreenElement;
2126
2284
  this.applyFullscreenUiState(isFullscreen);
2285
+ this.applyFullscreenOrientation(isFullscreen);
2127
2286
  requestAnimationFrame(() => {
2128
2287
  requestAnimationFrame(() => {
2129
2288
  this.updateCanvasSize();
@@ -2309,6 +2468,41 @@ export class MoviElement extends HTMLElement {
2309
2468
  this.isSeeking = false;
2310
2469
  }
2311
2470
  }
2471
+ /** Begin press-and-hold fast playback (2x). No-op unless actively playing, so
2472
+ * a long-press while paused doesn't silently change the saved rate. */
2473
+ startHoldSpeed() {
2474
+ this._holdSpeedTimer = null;
2475
+ if (this._holdSpeedActive || !this.player)
2476
+ return;
2477
+ if (this.player.getState() !== "playing")
2478
+ return;
2479
+ this._holdSpeedActive = true;
2480
+ this.gesturePerformed = true; // suppress the touchend tap (show/hide chrome)
2481
+ this._rateBeforeHold = this._playbackRate || 1;
2482
+ this.player.setPlaybackRate(2);
2483
+ this._playbackRate = 2;
2484
+ this.updateMediaSessionPosition();
2485
+ this.resetStutterHint();
2486
+ const pill = this.shadowRoot?.querySelector(".movi-hold-speed");
2487
+ if (pill)
2488
+ pill.style.display = "flex";
2489
+ }
2490
+ /** End press-and-hold fast playback, restoring the pre-hold rate. */
2491
+ stopHoldSpeed() {
2492
+ if (this._holdSpeedTimer !== null) {
2493
+ clearTimeout(this._holdSpeedTimer);
2494
+ this._holdSpeedTimer = null;
2495
+ }
2496
+ if (!this._holdSpeedActive)
2497
+ return;
2498
+ this._holdSpeedActive = false;
2499
+ this.player?.setPlaybackRate(this._rateBeforeHold);
2500
+ this._playbackRate = this._rateBeforeHold;
2501
+ this.updateMediaSessionPosition();
2502
+ const pill = this.shadowRoot?.querySelector(".movi-hold-speed");
2503
+ if (pill)
2504
+ pill.style.display = "none";
2505
+ }
2312
2506
  setupGestures(shadowRoot) {
2313
2507
  const overlay = shadowRoot.querySelector(".movi-controls-overlay");
2314
2508
  const canvas = this.canvas;
@@ -2450,9 +2644,78 @@ export class MoviElement extends HTMLElement {
2450
2644
  initialSeekTime = this.currentTime;
2451
2645
  isVerticalGesture = false;
2452
2646
  isHorizontalGesture = false;
2647
+ // Arm press-and-hold-to-2x. A move (scrub/swipe/pan) or a release
2648
+ // before the threshold cancels it (touchmove/touchend below). VR
2649
+ // owns its own touch, so skip there.
2650
+ if (this._holdSpeedTimer !== null) {
2651
+ clearTimeout(this._holdSpeedTimer);
2652
+ this._holdSpeedTimer = null;
2653
+ }
2654
+ if (!this._vr360 && !isEdgeStart) {
2655
+ // 600ms, not 400 — a shorter threshold fired 2x on ordinary
2656
+ // taps/brief holds. This matches the ~500-600ms long-press feel
2657
+ // users expect on iOS/Android.
2658
+ this._holdSpeedTimer = window.setTimeout(() => this.startHoldSpeed(), 600);
2659
+ }
2660
+ }
2661
+ else if (e.touches.length >= 2) {
2662
+ // A second finger down means a pinch (aspect-fit / 360° zoom), never
2663
+ // a press-and-hold-to-2x — cancel the arm the first finger set, and
2664
+ // drop out of 2x if it already engaged, so the two don't conflict.
2665
+ if (this._holdSpeedTimer !== null) {
2666
+ clearTimeout(this._holdSpeedTimer);
2667
+ this._holdSpeedTimer = null;
2668
+ }
2669
+ if (this._holdSpeedActive)
2670
+ this.stopHoldSpeed();
2453
2671
  }
2454
2672
  }, { passive: true });
2455
2673
  target.addEventListener("touchmove", (e) => {
2674
+ // Two-finger pinch in fullscreen → switch aspect fit, YouTube-style:
2675
+ // spread out zooms to fill (cover / crop to edges), pinch in returns
2676
+ // to fit (contain). Handled before the edge-swipe guard so a pinch
2677
+ // that happens to begin near a screen edge still registers, and gated
2678
+ // to fullscreen so it never fights the page's own pinch-zoom inline.
2679
+ // (360° mode has its own two-finger pinch below, so exclude it here.)
2680
+ if (!this._vr360 &&
2681
+ e.touches.length === 2 &&
2682
+ this.isFullscreenActive()) {
2683
+ const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
2684
+ if (this._aspectPinchDist === 0) {
2685
+ this._aspectPinchDist = dist;
2686
+ }
2687
+ else {
2688
+ const ratio = dist / this._aspectPinchDist;
2689
+ const current = this._objectFit === "control"
2690
+ ? this._currentFit
2691
+ : this._objectFit;
2692
+ let next = null;
2693
+ if (ratio > 1.2 && current !== "cover")
2694
+ next = "cover";
2695
+ else if (ratio < 0.83 && current !== "contain")
2696
+ next = "contain";
2697
+ if (next) {
2698
+ if (this._objectFit === "control")
2699
+ this._currentFit = next;
2700
+ else
2701
+ this._objectFit = next;
2702
+ this.updateFitMode();
2703
+ const labels = {
2704
+ contain: "Fit",
2705
+ cover: "Fill",
2706
+ };
2707
+ const osdSvg = MoviElement.ASPECT_ICONS[next] ||
2708
+ MoviElement.ASPECT_ICONS.contain;
2709
+ this.showOSD(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${osdSvg}</svg>`, labels[next]);
2710
+ // Re-baseline so the opposite pinch can toggle straight back.
2711
+ this._aspectPinchDist = dist;
2712
+ }
2713
+ }
2714
+ this.gesturePerformed = true;
2715
+ if (e.cancelable)
2716
+ e.preventDefault();
2717
+ return;
2718
+ }
2456
2719
  if (isEdgeStart)
2457
2720
  return;
2458
2721
  // 360° mode owns touch: one finger looks around, two fingers pinch
@@ -2484,9 +2747,23 @@ export class MoviElement extends HTMLElement {
2484
2747
  const touch = e.touches[0];
2485
2748
  const deltaX = touch.clientX - this.touchStartX;
2486
2749
  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) {
2750
+ // Any real finger travel means the press wasn't a stationary hold —
2751
+ // cancel the pending hold-to-2x so a page scroll / swipe / pan
2752
+ // doesn't trip it. Done here (before the gesturefs gate and the
2753
+ // gesture-type classification) so it fires regardless of side,
2754
+ // fullscreen, or gesture config — a scroll must always win.
2755
+ if (this._holdSpeedTimer !== null &&
2756
+ Math.hypot(deltaX, deltaY) > 10) {
2757
+ clearTimeout(this._holdSpeedTimer);
2758
+ this._holdSpeedTimer = null;
2759
+ }
2760
+ // Determine gesture type early.
2761
+ // An inline player (`playsinline`) shares the page's scroll, so its
2762
+ // touch gestures (swipe-seek / volume) must not fight it — restrict
2763
+ // them to fullscreen. `gesturefs` is the deprecated alias for the
2764
+ // same behaviour, still honoured.
2765
+ if ((this._playsinline || this._gesturefs) &&
2766
+ !document.fullscreenElement) {
2490
2767
  return;
2491
2768
  }
2492
2769
  if (!isVerticalGesture &&
@@ -2500,6 +2777,12 @@ export class MoviElement extends HTMLElement {
2500
2777
  isHorizontalGesture = true;
2501
2778
  this.gesturePerformed = true;
2502
2779
  }
2780
+ // A real move means this isn't a stationary press — cancel the
2781
+ // pending hold-to-2x so scrub/swipe/volume gestures win.
2782
+ if (this.gesturePerformed && this._holdSpeedTimer !== null) {
2783
+ clearTimeout(this._holdSpeedTimer);
2784
+ this._holdSpeedTimer = null;
2785
+ }
2503
2786
  }
2504
2787
  if (isVerticalGesture) {
2505
2788
  const rect = this.getBoundingClientRect();
@@ -2509,7 +2792,7 @@ export class MoviElement extends HTMLElement {
2509
2792
  if (e.cancelable)
2510
2793
  e.preventDefault();
2511
2794
  const volumeChange = -deltaY / 200;
2512
- const newVolume = Math.max(0, Math.min(1, initialVolume + volumeChange));
2795
+ const newVolume = Math.max(0, Math.min(this.getMaxVolume(), initialVolume + volumeChange));
2513
2796
  this.volume = newVolume;
2514
2797
  }
2515
2798
  }
@@ -2538,6 +2821,16 @@ export class MoviElement extends HTMLElement {
2538
2821
  }
2539
2822
  }, { passive: false });
2540
2823
  target.addEventListener("touchend", (e) => {
2824
+ // A lifted finger ends any pinch — clear the aspect-fit baseline so
2825
+ // the next two-finger gesture re-measures from scratch.
2826
+ this._aspectPinchDist = 0;
2827
+ // Release ends press-and-hold-to-2x (and cancels a pending arm). When
2828
+ // it was active this restores the pre-hold rate; gesturePerformed was
2829
+ // set on activation so the tap show/hide-chrome branch below is skipped.
2830
+ const wasHolding = this._holdSpeedActive;
2831
+ this.stopHoldSpeed();
2832
+ if (wasHolding)
2833
+ return;
2541
2834
  if (e.changedTouches.length === 1) {
2542
2835
  const touch = e.changedTouches[0];
2543
2836
  const endTime = Date.now();
@@ -2551,17 +2844,18 @@ export class MoviElement extends HTMLElement {
2551
2844
  isVerticalGesture &&
2552
2845
  startXPercent <= 0.5) {
2553
2846
  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));
2847
+ // Swipe UP -> Enter Fullscreen (skip for any audio source).
2848
+ // Route through toggleFullscreen so the iOS pseudo-fullscreen
2849
+ // fallback and the host-fullscreen event apply here too.
2850
+ if (!this.isFullscreenActive() &&
2851
+ !this.classList.contains("movi-audio-mode")) {
2852
+ this.toggleFullscreen();
2557
2853
  }
2558
2854
  }
2559
2855
  else if (deltaY > 60) {
2560
2856
  // Swipe DOWN -> Exit Fullscreen
2561
- if (document.fullscreenElement) {
2562
- document
2563
- .exitFullscreen()
2564
- .catch((err) => Logger.error(TAG, "Error exiting fullscreen", err));
2857
+ if (this.isFullscreenActive()) {
2858
+ this.toggleFullscreen();
2565
2859
  }
2566
2860
  }
2567
2861
  }
@@ -2593,6 +2887,11 @@ export class MoviElement extends HTMLElement {
2593
2887
  });
2594
2888
  // Mouse double click for fullscreen / fast seek
2595
2889
  const handleDoubleClick = (e) => {
2890
+ // Bare player (no controls attr): no double-click-to-fullscreen. The
2891
+ // host's pointer-events:none is defeated by the many explicit
2892
+ // pointer-events:auto layers, so guard the handler directly.
2893
+ if (!this._controls)
2894
+ return;
2596
2895
  // Cancel any pending single click
2597
2896
  if (this.clickTimer) {
2598
2897
  clearTimeout(this.clickTimer);
@@ -2670,12 +2969,6 @@ export class MoviElement extends HTMLElement {
2670
2969
  };
2671
2970
  target.addEventListener("pointerup", endDrag);
2672
2971
  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
2972
  });
2680
2973
  // ── On-screen pan joystick (the YouTube-style compass, desktop only) ──
2681
2974
  const pad = shadowRoot.querySelector(".movi-vr360-pad");
@@ -2896,6 +3189,18 @@ export class MoviElement extends HTMLElement {
2896
3189
  void this.renderVRPosterIfNeeded();
2897
3190
  }
2898
3191
  setupKeyboardShortcuts() {
3192
+ // Keyboard shortcuts require the host to hold focus. handleVideoClick grabs
3193
+ // it on a canvas/video click, but in audio-strip mode the whole surface is
3194
+ // the control bar, so that click never fires and the hotkeys look dead.
3195
+ // Take focus on any pointerdown on the player (except real form fields) so
3196
+ // shortcuts work regardless of what part of the chrome was clicked.
3197
+ this.addEventListener("pointerdown", (e) => {
3198
+ const t = e.composedPath()[0];
3199
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))
3200
+ return;
3201
+ if (document.activeElement !== this)
3202
+ this.focus({ preventScroll: true });
3203
+ }, true);
2899
3204
  this.addEventListener("keydown", (e) => {
2900
3205
  // Check if keyboard controls are disabled
2901
3206
  if (this._noHotkeys)
@@ -3070,7 +3375,7 @@ export class MoviElement extends HTMLElement {
3070
3375
  // Up Arrow: Increase volume
3071
3376
  e.preventDefault();
3072
3377
  if (this.player && this.player.hasAudibleSource()) {
3073
- this.volume = Math.min(1, this.volume + 0.1);
3378
+ this.volume = Math.min(this.getMaxVolume(), this.volume + 0.1);
3074
3379
  }
3075
3380
  break;
3076
3381
  case "ArrowDown":
@@ -3349,6 +3654,24 @@ export class MoviElement extends HTMLElement {
3349
3654
  this.currentTime = 0;
3350
3655
  this.showControls();
3351
3656
  break;
3657
+ case "1":
3658
+ case "2":
3659
+ case "3":
3660
+ case "4":
3661
+ case "5":
3662
+ case "6":
3663
+ case "7":
3664
+ case "8":
3665
+ case "9": {
3666
+ // 1-9: Seek to 10%–90% of the video (YouTube-style).
3667
+ e.preventDefault();
3668
+ const d = this.duration;
3669
+ if (Number.isFinite(d) && d > 0) {
3670
+ this.currentTime = d * (parseInt(e.key, 10) / 10);
3671
+ this.showControls();
3672
+ }
3673
+ break;
3674
+ }
3352
3675
  case "Home":
3353
3676
  // Home: Seek to start
3354
3677
  e.preventDefault();
@@ -3456,6 +3779,15 @@ export class MoviElement extends HTMLElement {
3456
3779
  e.preventDefault();
3457
3780
  e.stopPropagation();
3458
3781
  e.stopImmediatePropagation();
3782
+ // On touch, a long-press now drives hold-to-2x, so it must NOT open the
3783
+ // context menu — that opens from the gear button, which sets
3784
+ // _openMenuViaGear before dispatching this event. (Desktop right-click is
3785
+ // unaffected: pointer is fine, so this gate is skipped.)
3786
+ const isCoarse = window.matchMedia("(pointer: coarse)").matches;
3787
+ if (isCoarse && !this._openMenuViaGear) {
3788
+ return false;
3789
+ }
3790
+ this._openMenuViaGear = false;
3459
3791
  // Don't show on controls
3460
3792
  const target = e.target;
3461
3793
  const shadowTarget = e.composedPath
@@ -3492,8 +3824,6 @@ export class MoviElement extends HTMLElement {
3492
3824
  // Re-enumerate output devices each open (cheap) so a just-plugged
3493
3825
  // headset shows up; the async result re-renders the submenu in place.
3494
3826
  this.refreshAudioOutputs();
3495
- // Show custom context menu
3496
- const rect = this.getBoundingClientRect();
3497
3827
  // Touch-only: narrow desktop windows still get the hover-based menu,
3498
3828
  // because slide-panel submenus require tap-to-open semantics.
3499
3829
  const isTouchDevice = window.matchMedia("(pointer: coarse)").matches;
@@ -3542,59 +3872,39 @@ export class MoviElement extends HTMLElement {
3542
3872
  fy = 10;
3543
3873
  contextMenu.style.left = `${fx}px`;
3544
3874
  contextMenu.style.top = `${fy}px`;
3875
+ this.clampMenuToViewport(contextMenu);
3545
3876
  // skip the host-relative absolute-position path below
3546
3877
  requestAnimationFrame(() => contextMenu.classList.add("visible"));
3547
3878
  this._contextMenuVisible = true;
3548
3879
  return;
3549
3880
  }
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`;
3881
+ // Portal the menu to a body-level shadow root and switch to fixed
3882
+ // positioning so it escapes EVERY clipping ancestor of the player (a
3883
+ // page wrapper's overflow:hidden, body{overflow-x:hidden}, etc.) and
3884
+ // spills freely into the page like a native context menu. Lifting the
3885
+ // player's own clip isn't enough — the ancestors still chop it. Once
3886
+ // portaled + fixed, positions are plain viewport coordinates.
3887
+ this.portalContextMenu(contextMenu);
3888
+ contextMenu.style.position = "fixed";
3889
+ let x = e.clientX;
3890
+ let y = e.clientY;
3891
+ // Cap height to the VIEWPORT so a tall menu extends downward instead of
3892
+ // scrolling inside a tiny box.
3893
+ contextMenu.style.maxHeight = `${Math.max(180, window.innerHeight - 40)}px`;
3564
3894
  // Temporarily show menu to get its dimensions
3565
3895
  contextMenu.style.display = "block";
3566
3896
  contextMenu.style.visibility = "hidden";
3567
3897
  const menuWidth = contextMenu.offsetWidth;
3568
3898
  const menuHeight = contextMenu.offsetHeight;
3569
- Logger.debug(TAG, "[ContextMenu] Menu dimensions", {
3570
- menuWidth,
3571
- menuHeight,
3572
- });
3573
3899
  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
- }
3900
+ // Flip to the left of / above the cursor near the viewport edge.
3901
+ if (x + menuWidth > window.innerWidth - 10)
3902
+ x -= menuWidth;
3903
+ if (y + menuHeight > window.innerHeight - 10)
3904
+ y -= menuHeight;
3596
3905
  contextMenu.style.left = `${x}px`;
3597
3906
  contextMenu.style.top = `${y}px`;
3907
+ this.clampMenuToViewport(contextMenu);
3598
3908
  }
3599
3909
  // Delay adding visible class slightly to ensure transition works
3600
3910
  requestAnimationFrame(() => {
@@ -3614,6 +3924,9 @@ export class MoviElement extends HTMLElement {
3614
3924
  // Helper to hide context menu
3615
3925
  const hideContextMenu = () => {
3616
3926
  contextMenu.classList.remove("visible");
3927
+ // Restore the host's paint/overflow clip (lifted while the desktop menu
3928
+ // was open so it could spill outside the player).
3929
+ this.classList.remove("movi-menu-overflow");
3617
3930
  this._contextMenuVisible = false;
3618
3931
  // Set just-closed flag to prevent play/pause toggle
3619
3932
  this._contextMenuJustClosed = true;
@@ -3638,6 +3951,11 @@ export class MoviElement extends HTMLElement {
3638
3951
  }
3639
3952
  else {
3640
3953
  contextMenu.style.display = "none";
3954
+ // Return the menu from the body portal to the shadow root and clear its
3955
+ // fixed positioning so the next open (incl. strip/touch modes) starts
3956
+ // clean and the submenu query below finds it back home.
3957
+ contextMenu.style.position = "";
3958
+ this.unportalContextMenu(contextMenu);
3641
3959
  }
3642
3960
  // Hide all submenus
3643
3961
  shadowRoot
@@ -3651,6 +3969,73 @@ export class MoviElement extends HTMLElement {
3651
3969
  if (!isTouchDevice)
3652
3970
  this.showControls();
3653
3971
  };
3972
+ // Touch: drag the mobile menu drawer to the right to dismiss it. The drawer
3973
+ // slides in from the right edge (translateX(100%) -> 0), so dragging it back
3974
+ // toward the right is the natural close gesture. Only the full-height drawer
3975
+ // participates (the audio-strip variant is an opacity-fade, not a slide),
3976
+ // and vertical/leftward moves are handed back to native scrolling / taps so
3977
+ // menu buttons and scrolling still work. The drawer's CSS transform/
3978
+ // transition carry `!important`, so inline overrides must use setProperty
3979
+ // with the "important" priority to win.
3980
+ const SWIPE_SLOP = 8; // px of travel before we lock an axis
3981
+ const SWIPE_CLOSE_RATIO = 0.35; // fraction of drawer width that dismisses
3982
+ let swStartX = 0;
3983
+ let swStartY = 0;
3984
+ let swDecided = false; // axis for this gesture chosen
3985
+ let swDragging = false; // horizontal drag-to-close has taken over
3986
+ const drawerActive = () => contextMenu.classList.contains("movi-context-menu-mobile") &&
3987
+ contextMenu.classList.contains("visible") &&
3988
+ !this.classList.contains("movi-audio-strip");
3989
+ contextMenu.addEventListener("touchstart", (e) => {
3990
+ if (!drawerActive() || e.touches.length !== 1)
3991
+ return;
3992
+ swStartX = e.touches[0].clientX;
3993
+ swStartY = e.touches[0].clientY;
3994
+ swDecided = false;
3995
+ swDragging = false;
3996
+ }, { passive: true });
3997
+ contextMenu.addEventListener("touchmove", (e) => {
3998
+ if (!drawerActive() || e.touches.length !== 1)
3999
+ return;
4000
+ const dx = e.touches[0].clientX - swStartX;
4001
+ const dy = e.touches[0].clientY - swStartY;
4002
+ if (!swDecided) {
4003
+ if (Math.abs(dx) < SWIPE_SLOP && Math.abs(dy) < SWIPE_SLOP)
4004
+ return;
4005
+ swDecided = true;
4006
+ // Own the gesture only when it's a clear rightward horizontal swipe;
4007
+ // otherwise leave it to the menu's own vertical scroll.
4008
+ swDragging = Math.abs(dx) > Math.abs(dy) && dx > 0;
4009
+ if (swDragging)
4010
+ contextMenu.style.setProperty("transition", "none", "important");
4011
+ }
4012
+ if (!swDragging)
4013
+ return;
4014
+ e.preventDefault(); // we own this gesture now — no scroll/tap
4015
+ const offset = Math.max(0, dx); // rightward only
4016
+ contextMenu.style.setProperty("transform", `translateX(${offset}px)`, "important");
4017
+ }, { passive: false });
4018
+ const endSwipe = (e) => {
4019
+ if (!swDragging) {
4020
+ swDecided = false;
4021
+ return;
4022
+ }
4023
+ swDragging = false;
4024
+ swDecided = false;
4025
+ const dx = (e.changedTouches[0]?.clientX ?? swStartX) - swStartX;
4026
+ const width = contextMenu.offsetWidth || 1;
4027
+ // Restore the CSS 0.4s slide and commit the dragged position as its
4028
+ // start, then hand the transform back to CSS so it animates to the
4029
+ // resting/closed state instead of jumping.
4030
+ contextMenu.style.removeProperty("transition");
4031
+ void contextMenu.offsetWidth; // reflow: lock in the current translateX
4032
+ if (dx > width * SWIPE_CLOSE_RATIO) {
4033
+ hideContextMenu(); // drops .visible -> CSS target translateX(100%)
4034
+ }
4035
+ contextMenu.style.removeProperty("transform");
4036
+ };
4037
+ contextMenu.addEventListener("touchend", endSwipe, { passive: true });
4038
+ contextMenu.addEventListener("touchcancel", endSwipe, { passive: true });
3654
4039
  // Add event listeners with capture phase and passive: false to allow preventDefault
3655
4040
  // Use capture phase to intercept before it reaches other handlers
3656
4041
  // Add to multiple elements to ensure we catch it everywhere
@@ -3797,6 +4182,7 @@ export class MoviElement extends HTMLElement {
3797
4182
  else if (action === "fit") {
3798
4183
  const submenu = shadowRoot.querySelector('.movi-context-menu-submenu[data-submenu="fit"]');
3799
4184
  if (submenu) {
4185
+ this.syncFitSubmenuActive(submenu); // touch: reflect current fit
3800
4186
  contextMenu.scrollTop = 0;
3801
4187
  submenu.classList.add("movi-context-menu-submenu-visible");
3802
4188
  }
@@ -3905,12 +4291,12 @@ export class MoviElement extends HTMLElement {
3905
4291
  this._playbackRate = playbackSpeed;
3906
4292
  this.setAttribute("playbackrate", playbackSpeed.toString());
3907
4293
  }
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
- });
4294
+ // Update active state (query the item's own submenu — the speed submenu
4295
+ // is a moved-out sibling of contextMenu, so contextMenu wouldn't find
4296
+ // these items and the active class would accumulate; see the fit case).
4297
+ item.parentElement
4298
+ ?.querySelectorAll(".movi-context-menu-item[data-speed]")
4299
+ .forEach((el) => el.classList.remove("movi-context-menu-active"));
3914
4300
  item.classList.add("movi-context-menu-active");
3915
4301
  this.showOSD(OSD.speed, `Speed ${playbackSpeed}x`);
3916
4302
  hideContextMenu();
@@ -3925,16 +4311,24 @@ export class MoviElement extends HTMLElement {
3925
4311
  }
3926
4312
  this.updateFitMode();
3927
4313
  this.updateAspectRatioIcon();
3928
- contextMenu.querySelectorAll(".movi-context-menu-item[data-fit]").forEach((el) => {
3929
- el.classList.remove("movi-context-menu-active");
3930
- });
4314
+ // Query within the item's own submenu, NOT contextMenu: the fit submenu
4315
+ // is moved out to be a sibling of contextMenu (so it escapes the menu's
4316
+ // overflow), so contextMenu.querySelectorAll finds none of these items —
4317
+ // the active class was never cleared and every tapped option stayed
4318
+ // highlighted (most visible on touch, where the submenu lingers open).
4319
+ item.parentElement
4320
+ ?.querySelectorAll(".movi-context-menu-item[data-fit]")
4321
+ .forEach((el) => el.classList.remove("movi-context-menu-active"));
3931
4322
  item.classList.add("movi-context-menu-active");
3932
4323
  // Update the new context menu status too
3933
4324
  const aspectStatus = shadowRoot.querySelector(".movi-aspect-status");
4325
+ const fitLabels = { contain: "Fit", cover: "Fill", fill: "Stretch", zoom: "Zoom" };
3934
4326
  if (aspectStatus) {
3935
- const labels = { contain: "Fit", cover: "Fill", fill: "Stretch", zoom: "Zoom" };
3936
- aspectStatus.textContent = labels[fitMode] || fitMode;
4327
+ aspectStatus.textContent = fitLabels[fitMode] || fitMode;
3937
4328
  }
4329
+ // Surface the same Fit/Fill/… OSD the aspect button and keyboard show —
4330
+ // changing the fit from the menu was silently skipping it.
4331
+ 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
4332
  hideContextMenu();
3939
4333
  }
3940
4334
  else if (action === "hdr-toggle") {
@@ -4178,7 +4572,7 @@ export class MoviElement extends HTMLElement {
4178
4572
  }
4179
4573
  // Update HDR visibility/state in context menu
4180
4574
  this.updateHDRVisibility();
4181
- // Update active state for Fit mode
4575
+ // Update active state for Fit mode.
4182
4576
  const currentActiveFit = this._objectFit === "control" ? this._currentFit : this._objectFit;
4183
4577
  const fitItems = contextMenu.querySelectorAll(".movi-context-menu-item[data-fit]");
4184
4578
  fitItems.forEach((item) => {
@@ -4196,6 +4590,20 @@ export class MoviElement extends HTMLElement {
4196
4590
  rotateItem.classList.toggle("movi-context-menu-disabled", !!this._pipWindow);
4197
4591
  }
4198
4592
  }
4593
+ /**
4594
+ * Highlight the current fit as the active row in the aspect-ratio submenu, so
4595
+ * it reflects the live fit however it was last changed — context menu, the
4596
+ * bottom-controls aspect button, or the keyboard shortcut. Called each time
4597
+ * the fit submenu is shown: on hover (desktop, via showSubmenu) and on tap
4598
+ * (touch, via the action==="fit" branch). Kept OUT of updateContextMenuContent
4599
+ * (which runs on the main menu's open) to avoid touch-open side effects.
4600
+ */
4601
+ syncFitSubmenuActive(submenu) {
4602
+ const currentFit = this._objectFit === "control" ? this._currentFit : this._objectFit;
4603
+ submenu
4604
+ .querySelectorAll(".movi-context-menu-item[data-fit]")
4605
+ .forEach((el) => el.classList.toggle("movi-context-menu-active", el.dataset.fit === currentFit));
4606
+ }
4199
4607
  setupSubmenuHover(item, submenu) {
4200
4608
  // Check if listeners are already attached (using a data attribute)
4201
4609
  if (item.dataset.hoverSetup === "true") {
@@ -4210,38 +4618,56 @@ export class MoviElement extends HTMLElement {
4210
4618
  clearTimeout(hideTimeout);
4211
4619
  hideTimeout = null;
4212
4620
  }
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");
4621
+ // The menu (and its submenu panels) may be in the body portal (desktop)
4622
+ // rather than the shadow root find it wherever it lives.
4623
+ const contextMenu = (this._menuPortalRoot?.querySelector(".movi-context-menu") ||
4624
+ this.shadowRoot?.querySelector(".movi-context-menu"));
4216
4625
  if (contextMenu) {
4626
+ // Use VIEWPORT coordinates when the menu itself is viewport-positioned:
4627
+ // the desktop menu is portaled (absolute inside the fixed portal host at
4628
+ // 0,0), and the audio-strip menu is fixed (strip mode drops the host's
4629
+ // container-type, so a fixed submenu is viewport-relative too and lines
4630
+ // up with it — the tiny 56px strip's bounds must NOT confine it).
4631
+ // Otherwise the submenu is :host-relative and confined to the player.
4632
+ const portaled = contextMenu.getRootNode() === this._menuPortalRoot;
4633
+ const strip = this.classList.contains("movi-audio-strip");
4634
+ const useViewport = portaled || strip;
4635
+ // Strip: pin the submenu to the viewport like the strip menu.
4636
+ submenu.style.position = strip ? "fixed" : "";
4217
4637
  const itemRect = item.getBoundingClientRect();
4218
4638
  const menuRect = contextMenu.getBoundingClientRect();
4219
4639
  const playerRect = this.getBoundingClientRect();
4220
4640
  const submenuWidth = submenu.offsetWidth || 160;
4221
4641
  const gap = 4;
4222
4642
  const padding = 10;
4223
- const spaceOnRight = playerRect.right - menuRect.right;
4224
- const spaceOnLeft = menuRect.left - playerRect.left;
4643
+ const originLeft = useViewport ? 0 : playerRect.left;
4644
+ const originTop = useViewport ? 0 : playerRect.top;
4645
+ const boundsLeft = useViewport ? 0 : playerRect.left;
4646
+ const boundsRight = useViewport ? window.innerWidth : playerRect.right;
4647
+ const boundsBottom = useViewport ? window.innerHeight : playerRect.bottom;
4648
+ const spaceOnRight = boundsRight - menuRect.right;
4649
+ const spaceOnLeft = menuRect.left - boundsLeft;
4225
4650
  submenu.style.right = "auto";
4226
4651
  submenu.style.marginLeft = "0";
4227
4652
  submenu.style.marginRight = "0";
4228
- // Convert viewport coords :host-relative
4653
+ // Positions are relative to the submenu's containing block (viewport
4654
+ // origin when portaled, else :host), so subtract the origin.
4229
4655
  if (spaceOnRight >= submenuWidth + padding) {
4230
4656
  // 1. RIGHT (Preferred)
4231
- submenu.style.left = `${menuRect.right + gap - playerRect.left}px`;
4657
+ submenu.style.left = `${menuRect.right + gap - originLeft}px`;
4232
4658
  submenu.style.transform = "translateX(-8px)";
4233
4659
  }
4234
4660
  else if (spaceOnLeft >= submenuWidth + padding) {
4235
4661
  // 2. LEFT
4236
- submenu.style.left = `${menuRect.left - submenuWidth - gap - playerRect.left}px`;
4662
+ submenu.style.left = `${menuRect.left - submenuWidth - gap - originLeft}px`;
4237
4663
  submenu.style.transform = "translateX(8px)";
4238
4664
  }
4239
4665
  else {
4240
4666
  // 3. OVERLAP (tight space)
4241
- submenu.style.left = `${menuRect.left + 20 - playerRect.left}px`;
4667
+ submenu.style.left = `${menuRect.left + 20 - originLeft}px`;
4242
4668
  submenu.style.transform = "translateY(10px)";
4243
4669
  }
4244
- let topPx = itemRect.top - playerRect.top;
4670
+ let topPx = itemRect.top - originTop;
4245
4671
  submenu.style.top = `${topPx}px`;
4246
4672
  // Measure submenu height (force layout if hidden)
4247
4673
  const wasClassVisible = submenu.classList.contains("movi-context-menu-submenu-visible");
@@ -4254,15 +4680,16 @@ export class MoviElement extends HTMLElement {
4254
4680
  submenu.style.display = "";
4255
4681
  submenu.style.visibility = "";
4256
4682
  }
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;
4683
+ // Clamp to the bounds (viewport when portaled, else player).
4684
+ const maxTop = boundsBottom - originTop - padding - submenuHeight;
4685
+ if (topPx > maxTop) {
4686
+ topPx = Math.max(padding, maxTop);
4263
4687
  submenu.style.top = `${topPx}px`;
4264
4688
  }
4265
4689
  }
4690
+ // Desktop hover-open of the aspect submenu: reflect the current fit.
4691
+ if (submenu.dataset.submenu === "fit")
4692
+ this.syncFitSubmenuActive(submenu);
4266
4693
  submenu.classList.add("movi-context-menu-submenu-visible");
4267
4694
  };
4268
4695
  const hideSubmenu = () => {
@@ -4321,7 +4748,7 @@ export class MoviElement extends HTMLElement {
4321
4748
  // requestFullscreen is blocked, or embedded apps that drive their own
4322
4749
  // fullscreen layout). Hosts call event.preventDefault() and then drive
4323
4750
  // setHostFullscreen() themselves to keep the UI in sync.
4324
- const currentlyActive = !!document.fullscreenElement || this._hostFullscreen;
4751
+ const currentlyActive = this.isFullscreenActive();
4325
4752
  const requestEvent = new CustomEvent("movi-fullscreen-request", {
4326
4753
  cancelable: true,
4327
4754
  bubbles: true,
@@ -4332,6 +4759,13 @@ export class MoviElement extends HTMLElement {
4332
4759
  if (requestEvent.defaultPrevented) {
4333
4760
  return;
4334
4761
  }
4762
+ // iOS Safari (and any engine without element-fullscreen): the Fullscreen
4763
+ // API only works on <video>, and we render to a canvas — so fall back to a
4764
+ // CSS viewport-fill "pseudo fullscreen" instead of throwing on requestFullscreen.
4765
+ if (!this.nativeFullscreenSupported()) {
4766
+ this.setPseudoFullscreen(!this._pseudoFullscreen);
4767
+ return;
4768
+ }
4335
4769
  try {
4336
4770
  if (!document.fullscreenElement) {
4337
4771
  await this.requestFullscreen();
@@ -4462,7 +4896,7 @@ export class MoviElement extends HTMLElement {
4462
4896
  }
4463
4897
  .pip-progress-bar:hover { height: 5px; }
4464
4898
  .pip-progress-fill {
4465
- height: 100%; background: #8B5CF6; border-radius: 2px;
4899
+ height: 100%; background: var(--movi-primary, #8B5CF6); border-radius: 2px;
4466
4900
  width: 0%; pointer-events: none;
4467
4901
  }
4468
4902
  .pip-time { font: 500 10px/1 -apple-system, sans-serif; color: rgba(255,255,255,0.7); white-space: nowrap; }
@@ -4470,7 +4904,7 @@ export class MoviElement extends HTMLElement {
4470
4904
  .pip-btn-row { display: flex; align-items: center; justify-content: center; gap: 16px; position: relative; }
4471
4905
  .pip-btn {
4472
4906
  background: none; border: none; cursor: pointer; padding: 4px;
4473
- color: #fff; opacity: 0.85; display: flex; align-items: center; justify-content: center;
4907
+ color: var(--movi-chrome-fg, #fff); opacity: 0.85; display: flex; align-items: center; justify-content: center;
4474
4908
  }
4475
4909
  .pip-btn:hover { opacity: 1; }
4476
4910
  .pip-btn svg { width: 20px; height: 20px; }
@@ -4483,6 +4917,9 @@ export class MoviElement extends HTMLElement {
4483
4917
  pipWindow.document.head.appendChild(style);
4484
4918
  // Move canvas to PiP window
4485
4919
  pipWindow.document.body.appendChild(this.canvas);
4920
+ // Clear any leftover cursor:none (from a hidden-controls state before
4921
+ // entering PiP) so the pointer is visible in the PiP window immediately.
4922
+ this.canvas.style.cursor = "default";
4486
4923
  Logger.info(TAG, `PiP: canvas moved to PiP window, isConnected=${this.canvas.isConnected}`);
4487
4924
  // Build PiP controls
4488
4925
  const controls = pipWindow.document.createElement("div");
@@ -4652,12 +5089,94 @@ export class MoviElement extends HTMLElement {
4652
5089
  catch (error) {
4653
5090
  Logger.error(TAG, "Failed to open PiP", error);
4654
5091
  this._pipWindow = null;
5092
+ // The Document PiP API exists but requestWindow was blocked — e.g. a
5093
+ // sandboxed iframe without allow-popups, or a Permissions-Policy the
5094
+ // upfront check couldn't see. That's a static property of the embedding
5095
+ // context, not a one-off, so retire the (dead) control rather than let it
5096
+ // fail again on every click.
5097
+ const pipBtn = this.shadowRoot?.querySelector(".movi-pip-btn");
5098
+ if (pipBtn)
5099
+ pipBtn.style.display = "none";
5100
+ const pipCtxItem = this.shadowRoot?.querySelector(".movi-context-menu-pip");
5101
+ if (pipCtxItem)
5102
+ pipCtxItem.style.display = "none";
4655
5103
  }
4656
5104
  }
4657
5105
  /** Tracks host-driven fullscreen so toggleFullscreen() and the
4658
5106
  * movi-fullscreen-request event can reflect it correctly even when
4659
5107
  * document.fullscreenElement is null. */
4660
5108
  _hostFullscreen = false;
5109
+ /** CSS "fill the viewport" fallback used where the element Fullscreen API
5110
+ * isn't available (iOS Safari only exposes it for <video>, and we render to
5111
+ * a canvas). Not real fullscreen — no chrome hiding — but the player takes
5112
+ * over the visible viewport. */
5113
+ _pseudoFullscreen = false;
5114
+ _prevBodyOverflow = "";
5115
+ /** True when the player is fullscreen by any route: native element FS, a
5116
+ * host-driven FS, or the iOS pseudo-fullscreen fallback. */
5117
+ isFullscreenActive() {
5118
+ return (document.fullscreenElement === this ||
5119
+ this._hostFullscreen ||
5120
+ this._pseudoFullscreen);
5121
+ }
5122
+ /** Whether the browser exposes the element Fullscreen API for this element
5123
+ * (false on iOS Safari, which only fullscreens <video>). */
5124
+ nativeFullscreenSupported() {
5125
+ return (typeof this.requestFullscreen === "function" && !!document.fullscreenEnabled);
5126
+ }
5127
+ /** Re-evaluate on device rotation / viewport change while pseudo-fullscreen. */
5128
+ _onPseudoFsResize = () => {
5129
+ this.updatePseudoFsRotation();
5130
+ this.updateCanvasSize();
5131
+ };
5132
+ /**
5133
+ * Forced-landscape for the pseudo-fullscreen fallback: iOS won't rotate the
5134
+ * screen for us, so a landscape video on a portrait screen is turned 90° via
5135
+ * CSS (the .movi-pseudo-fs-rotate class) to fill it — matching what Chrome /
5136
+ * Android do with an orientation lock. Once the device itself is landscape
5137
+ * (window wider than tall) the rotation is dropped.
5138
+ */
5139
+ updatePseudoFsRotation() {
5140
+ if (!this._pseudoFullscreen) {
5141
+ this.classList.remove("movi-pseudo-fs-rotate");
5142
+ return;
5143
+ }
5144
+ const track = this.player?.trackManager?.getActiveVideoTrack?.();
5145
+ const rawW = track?.width ?? 0;
5146
+ const rawH = track?.height ?? 0;
5147
+ const rot = this.player?.getVideoRotation?.() ?? track?.rotation ?? 0;
5148
+ const swap = Math.abs(rot) % 180 !== 0;
5149
+ const vidW = swap ? rawH : rawW;
5150
+ const vidH = swap ? rawW : rawH;
5151
+ const videoLandscape = vidW > vidH && vidW > 0;
5152
+ const windowPortrait = window.innerHeight > window.innerWidth;
5153
+ this.classList.toggle("movi-pseudo-fs-rotate", videoLandscape && windowPortrait);
5154
+ }
5155
+ /** Enter/leave the CSS viewport-fill fallback. */
5156
+ setPseudoFullscreen(on) {
5157
+ if (this._pseudoFullscreen === on)
5158
+ return;
5159
+ this._pseudoFullscreen = on;
5160
+ this.classList.toggle("movi-pseudo-fullscreen", on);
5161
+ if (on) {
5162
+ // Lock the page scroll behind the overlay while active.
5163
+ this._prevBodyOverflow = document.body.style.overflow;
5164
+ document.body.style.overflow = "hidden";
5165
+ this.updatePseudoFsRotation();
5166
+ window.addEventListener("resize", this._onPseudoFsResize);
5167
+ window.addEventListener("orientationchange", this._onPseudoFsResize);
5168
+ }
5169
+ else {
5170
+ this.classList.remove("movi-pseudo-fs-rotate");
5171
+ window.removeEventListener("resize", this._onPseudoFsResize);
5172
+ window.removeEventListener("orientationchange", this._onPseudoFsResize);
5173
+ document.body.style.overflow = this._prevBodyOverflow;
5174
+ }
5175
+ this.applyFullscreenUiState(on);
5176
+ this.applyFullscreenOrientation(on);
5177
+ // Repaint the canvas at the new (viewport) size.
5178
+ this.updateCanvasSize();
5179
+ }
4661
5180
  updateFullscreenIcon(isFullscreen) {
4662
5181
  const fullscreenIcon = this.shadowRoot?.querySelector(".movi-icon-fullscreen");
4663
5182
  const fullscreenExitIcon = this.shadowRoot?.querySelector(".movi-icon-fullscreen-exit");
@@ -4675,6 +5194,47 @@ export class MoviElement extends HTMLElement {
4675
5194
  if (label)
4676
5195
  label.textContent = isFullscreen ? "Exit Fullscreen" : "Fullscreen";
4677
5196
  }
5197
+ /**
5198
+ * On phones/tablets, rotate to match the video when entering fullscreen and
5199
+ * release the lock on exit. Wide clips lock landscape, tall clips portrait;
5200
+ * defaults to landscape (the common case) if dimensions aren't known. No-op on
5201
+ * desktop, audio-only, or where the Screen Orientation lock API is missing
5202
+ * (e.g. iOS Safari, which rejects programmatic lock — caught and ignored).
5203
+ */
5204
+ applyFullscreenOrientation(isFullscreen) {
5205
+ const so = screen.orientation;
5206
+ if (!so || typeof so.lock !== "function")
5207
+ return;
5208
+ if (!window.matchMedia("(pointer: coarse)").matches)
5209
+ return;
5210
+ if (this.classList.contains("movi-audio-mode"))
5211
+ return;
5212
+ if (isFullscreen) {
5213
+ const track = this.player?.trackManager?.getActiveVideoTrack?.();
5214
+ const rawW = track?.width ?? 0;
5215
+ const rawH = track?.height ?? 0;
5216
+ // Portrait clips shot on phones are usually stored as landscape frames
5217
+ // plus a 90°/270° rotation flag, so the raw width/height read landscape.
5218
+ // Use the effective display rotation (metadata + manual) to swap them,
5219
+ // otherwise a tall video would wrongly lock landscape on entry.
5220
+ const rot = this.player?.getVideoRotation?.() ?? track?.rotation ?? 0;
5221
+ const rotated = Math.abs(rot) % 180 !== 0;
5222
+ const w = rotated ? rawH : rawW;
5223
+ const h = rotated ? rawW : rawH;
5224
+ const target = h > w && h > 0 ? "portrait" : "landscape";
5225
+ Promise.resolve(so.lock(target)).catch(() => {
5226
+ /* unsupported / user-denied — leave orientation as-is */
5227
+ });
5228
+ }
5229
+ else {
5230
+ try {
5231
+ so.unlock?.();
5232
+ }
5233
+ catch {
5234
+ /* ignore */
5235
+ }
5236
+ }
5237
+ }
4678
5238
  applyFullscreenUiState(isFullscreen) {
4679
5239
  this.updateFullscreenIcon(isFullscreen);
4680
5240
  this.updateFullscreenContextMenu(isFullscreen);
@@ -4853,8 +5413,14 @@ export class MoviElement extends HTMLElement {
4853
5413
  }
4854
5414
  this.updateAudioTrackMenu();
4855
5415
  const menu = this.shadowRoot?.querySelector(".movi-audio-track-menu");
4856
- if (menu)
4857
- menu.style.display = "none";
5416
+ // Close via setBottomMenuOpen, NOT display:none directly. Setting
5417
+ // display:none leaves the `is-open` class on the menu, so
5418
+ // isBottomMenuOpen()/isAnyMenuOpen() stay true FOREVER — every later
5419
+ // showControls() then no-ops and the controls auto-hide never
5420
+ // re-arms again (not just for audio, but for any subsequent menu or
5421
+ // playback). setBottomMenuOpen(false) removes is-open and re-arms
5422
+ // the auto-hide once the exit transition finishes.
5423
+ this.setBottomMenuOpen(menu, false);
4858
5424
  }
4859
5425
  });
4860
5426
  });
@@ -5251,6 +5817,107 @@ export class MoviElement extends HTMLElement {
5251
5817
  getMaxAllowedRate() {
5252
5818
  return 2;
5253
5819
  }
5820
+ /** Volume ceiling: 200% normally (boost via AudioContext gain), but 100% when
5821
+ * audio plays through a native element (adaptive stream / native split-source)
5822
+ * which can't boost — so the UI doesn't promise a boost that won't happen. */
5823
+ getMaxVolume() {
5824
+ return this.player?.usesNativeAudio?.() ? 1 : 2;
5825
+ }
5826
+ /** Re-apply the volume ceiling to the slider + current volume when the audio
5827
+ * path changes (source load, audio-track switch between muxed and native). */
5828
+ updateVolumeCap() {
5829
+ const max = this.getMaxVolume();
5830
+ const slider = this.shadowRoot?.querySelector(".movi-volume-slider");
5831
+ if (slider) {
5832
+ slider.max = String(max);
5833
+ slider.setAttribute("aria-valuemax", String(max * 100));
5834
+ }
5835
+ // Clamp a boosted volume back down when switching to a native (no-boost) path.
5836
+ if (this._volume > max)
5837
+ this.volume = max;
5838
+ else
5839
+ this.updateVolume();
5840
+ }
5841
+ /**
5842
+ * Start sampling render health once per second to detect sustained stutter
5843
+ * (decoder can't keep up above 1x on a heavy source → dropped video frames,
5844
+ * smooth audio). Runs only during active playback; cheap (reads a couple of
5845
+ * numbers). Idempotent.
5846
+ */
5847
+ startStutterMonitor() {
5848
+ if (this._stutterInterval !== null)
5849
+ return;
5850
+ const h = this.player?.getRenderHealth?.();
5851
+ this._stutterLastPresented = h ? h.framesPresented : 0;
5852
+ this._stutterSeconds = 0;
5853
+ this._stutterInterval = window.setInterval(() => this.sampleStutter(), 1000);
5854
+ }
5855
+ stopStutterMonitor() {
5856
+ if (this._stutterInterval !== null) {
5857
+ clearInterval(this._stutterInterval);
5858
+ this._stutterInterval = null;
5859
+ }
5860
+ this._stutterSeconds = 0;
5861
+ }
5862
+ /**
5863
+ * Clear the stutter-hint cooldown so the "play at 1x" warning can fire again.
5864
+ * Called on every playback-rate change — each new speed the user picks gets a
5865
+ * fresh chance to warn if it stutters (instead of showing only once per
5866
+ * source). The 3-second sustained requirement still prevents instant spam.
5867
+ */
5868
+ resetStutterHint() {
5869
+ this._stutterSeconds = 0;
5870
+ this._stutterCooldown = false;
5871
+ if (this._stutterCooldownTimer !== null) {
5872
+ clearTimeout(this._stutterCooldownTimer);
5873
+ this._stutterCooldownTimer = null;
5874
+ }
5875
+ }
5876
+ /** One stutter sample: compare presented FPS to the smooth-playback baseline. */
5877
+ sampleStutter() {
5878
+ // Only judge during genuine playback — buffering/seeking legitimately
5879
+ // present few/no frames and would false-positive.
5880
+ if (this.player?.getState?.() !== "playing") {
5881
+ this._stutterSeconds = 0;
5882
+ return;
5883
+ }
5884
+ const h = this.player?.getRenderHealth?.();
5885
+ if (!h)
5886
+ return;
5887
+ const presented = h.framesPresented - this._stutterLastPresented;
5888
+ this._stutterLastPresented = h.framesPresented;
5889
+ // framesPresented resets to 0 on seek → negative delta; skip that sample.
5890
+ if (presented < 0) {
5891
+ this._stutterSeconds = 0;
5892
+ return;
5893
+ }
5894
+ // Only meaningful above 1x — at ≤1x there's nothing slower to suggest.
5895
+ // Smooth playback presents ~min(60, sourceFps × rate) distinct frames/s
5896
+ // (display-capped); sustained < 60% of that means the decoder is dropping a
5897
+ // lot of frames. Profile-agnostic: works for any heavy source (4K/8K,
5898
+ // high-bitrate, software decode) that can't keep up above 1x.
5899
+ const expected = Math.min(60, h.sourceFps * this._playbackRate);
5900
+ if (this._playbackRate > 1 && presented < expected * 0.6) {
5901
+ this._stutterSeconds++;
5902
+ }
5903
+ else {
5904
+ this._stutterSeconds = 0;
5905
+ }
5906
+ if (this._stutterSeconds >= 3 && !this._stutterCooldown) {
5907
+ this._stutterSeconds = 0;
5908
+ this._stutterCooldown = true;
5909
+ // Cooldown so it doesn't nag while stuttering at the SAME rate; a rate
5910
+ // change (resetStutterHint) lifts it early so each new speed can warn.
5911
+ if (this._stutterCooldownTimer !== null) {
5912
+ clearTimeout(this._stutterCooldownTimer);
5913
+ }
5914
+ this._stutterCooldownTimer = window.setTimeout(() => {
5915
+ this._stutterCooldown = false;
5916
+ this._stutterCooldownTimer = null;
5917
+ }, 45000);
5918
+ this.showOSD(OSD.speed, "Play at 1x for smoother playback");
5919
+ }
5920
+ }
5254
5921
  /**
5255
5922
  * Show On-Screen Display (OSD) notification
5256
5923
  */
@@ -6516,6 +7183,12 @@ export class MoviElement extends HTMLElement {
6516
7183
  titleBar.classList.add("movi-title-visible");
6517
7184
  }
6518
7185
  }
7186
+ // Reveal the settings gear with the rest of the chrome — but only once a
7187
+ // source is set (nothing to configure in the empty "No Video" state).
7188
+ const gearEl = this.shadowRoot?.querySelector(".movi-gear-btn");
7189
+ if (gearEl) {
7190
+ gearEl.classList.toggle("movi-gear-visible", this.hasMediaSource());
7191
+ }
6519
7192
  // Clear existing timeout
6520
7193
  if (this.controlsTimeout) {
6521
7194
  clearTimeout(this.controlsTimeout);
@@ -6618,6 +7291,42 @@ export class MoviElement extends HTMLElement {
6618
7291
  // fixed with the button's bounding rect — viewport coordinates
6619
7292
  // can't be clipped by ancestor overflow.
6620
7293
  this.applyStripFixedMenuPosition(el);
7294
+ // Non-strip: keep the upward-opening dropdown INSIDE the player and scroll
7295
+ // it internally, rather than letting it spill into the page above — on a
7296
+ // short embedded player that overlapped unrelated page content and hid the
7297
+ // first rows. The menu's bottom edge is anchored just above its button;
7298
+ // cap its height to the room between the player's OWN top and that bottom.
7299
+ // This is player-relative (subtract the host's top), NOT viewport-relative,
7300
+ // so it stays contained wherever the player sits on a scrolled page.
7301
+ // scrollTop 0 keeps the first row (0.25x / Audio 1 / first subtitle)
7302
+ // visible; the rest is reachable by scrolling. The mobile menu CSS sets
7303
+ // max-height with !important, so the inline override must also carry it.
7304
+ if (!this.classList.contains("movi-audio-strip")) {
7305
+ // Cap the upward-opening menu to the room between the player's top and
7306
+ // the controls bar, measured from STABLE elements — the bar and the
7307
+ // host — NOT the menu's own rect, which on a very short player is
7308
+ // unreliable before .is-open (it sits at natural height, so the cap
7309
+ // wouldn't bite and the first rows would clip above the player instead
7310
+ // of scrolling). The menu's containing block is the bar and it anchors
7311
+ // ~15px above the bar's top; leave ~8px breathing room -> subtract ~23.
7312
+ // scrollTop 0 keeps the first row (0.25x / Audio 1 / first subtitle)
7313
+ // reachable at the top. The mobile menu CSS sets max-height !important,
7314
+ // so the inline override must carry it too.
7315
+ const hostTop = this.getBoundingClientRect().top;
7316
+ const bar = this.shadowRoot?.querySelector(".movi-controls-bar");
7317
+ const barTop = bar
7318
+ ? bar.getBoundingClientRect().top
7319
+ : el.getBoundingClientRect().bottom;
7320
+ // Floor kept low (not ~96) on purpose: on a very short player the room
7321
+ // above the bar can be < 96px, and a floor larger than the room would
7322
+ // push the menu's top back out above the player and clip the first row.
7323
+ el.style.setProperty("max-height", `${Math.max(32, barTop - hostTop - 23)}px`, "important");
7324
+ el.scrollTop = 0;
7325
+ }
7326
+ // The dropdowns live inside the controls container's stacking context
7327
+ // (z-index 10), so the top-right gear (z-index 30) would paint on top of a
7328
+ // menu that opens up over it. Hide the gear while any dropdown is open.
7329
+ this.classList.add("movi-bottom-menu-open");
6621
7330
  void el.getBoundingClientRect(); // flush layout so transition starts
6622
7331
  el.classList.add("is-open");
6623
7332
  return;
@@ -6635,18 +7344,23 @@ export class MoviElement extends HTMLElement {
6635
7344
  if (el.classList.contains("is-open"))
6636
7345
  return;
6637
7346
  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.
7347
+ // Reset the strip-mode fixed positioning + the viewport max-height cap so
7348
+ // a subsequent open (in a different mode / at a different scroll spot)
7349
+ // doesn't inherit stale inline values.
6640
7350
  el.style.position = "";
6641
7351
  el.style.top = "";
6642
7352
  el.style.left = "";
6643
7353
  el.style.right = "";
6644
7354
  el.style.bottom = "";
7355
+ el.style.removeProperty("max-height");
6645
7356
  // Menu fully closed — if nothing else is open, re-arm the auto-hide
6646
7357
  // timer. Covers toggling a popup shut from its own button (no
6647
7358
  // closeAllBottomMenus / mouseleave fires on touch), which otherwise
6648
7359
  // left the bar pinned open on touch devices.
6649
7360
  if (!this.isAnyMenuOpen()) {
7361
+ // Restore the host clip + gear once every dropdown is closed.
7362
+ this.classList.remove("movi-menu-overflow");
7363
+ this.classList.remove("movi-bottom-menu-open");
6650
7364
  this.showControls();
6651
7365
  }
6652
7366
  };
@@ -6813,6 +7527,38 @@ export class MoviElement extends HTMLElement {
6813
7527
  typeof AudioContext.prototype
6814
7528
  .setSinkId === "function");
6815
7529
  }
7530
+ /** Running inside a (possibly cross-origin) iframe. */
7531
+ isEmbeddedIframe() {
7532
+ try {
7533
+ return window.self !== window.top;
7534
+ }
7535
+ catch {
7536
+ // Cross-origin access to window.top can throw in some sandboxes — if we
7537
+ // can't even read it, we're definitely framed.
7538
+ return true;
7539
+ }
7540
+ }
7541
+ /**
7542
+ * Whether a Permissions-Policy feature (e.g. "fullscreen",
7543
+ * "picture-in-picture", "speaker-selection") is allowed in this document.
7544
+ * At the top level features are allowed by default; inside an iframe they
7545
+ * must be delegated via the `allow` attribute. Uses the (Chromium) feature
7546
+ * policy API when present and defaults to allowed when it isn't — the
7547
+ * features we gate this way are all Chromium-only, so a missing API means a
7548
+ * browser that wouldn't expose the capability regardless.
7549
+ */
7550
+ featureAllowed(feature) {
7551
+ const fp = document.featurePolicy;
7552
+ if (fp && typeof fp.allowsFeature === "function") {
7553
+ try {
7554
+ return fp.allowsFeature(feature);
7555
+ }
7556
+ catch {
7557
+ return true;
7558
+ }
7559
+ }
7560
+ return true;
7561
+ }
6816
7562
  /**
6817
7563
  * List the available audio output devices. Labels may be empty until the
6818
7564
  * page has been granted audio-device access (the desktop app has it; a bare
@@ -6945,8 +7691,15 @@ export class MoviElement extends HTMLElement {
6945
7691
  // opening the submenu triggers the Meet-style prompt and the list fills
6946
7692
  // in. The desktop app already has permission, so `real` is populated and
6947
7693
  // no prompt is needed.
6948
- const canUnlock = !!navigator.mediaDevices?.getUserMedia;
6949
- if (!MoviElement.audioSinkSupported() || (real.length < 1 && !canUnlock)) {
7694
+ // In an iframe, routing to a device needs allow="speaker-selection", and
7695
+ // the getUserMedia unlock needs allow="microphone". Without those the menu
7696
+ // would be a dead control (setSinkId / getUserMedia throw NotAllowedError),
7697
+ // so gate on the Permissions Policy.
7698
+ const speakerAllowed = this.featureAllowed("speaker-selection");
7699
+ const canUnlock = !!navigator.mediaDevices?.getUserMedia && this.featureAllowed("microphone");
7700
+ if (!MoviElement.audioSinkSupported() ||
7701
+ !speakerAllowed ||
7702
+ (real.length < 1 && !canUnlock)) {
6950
7703
  divider.style.display = "none";
6951
7704
  item.style.display = "none";
6952
7705
  submenu.style.display = "none";
@@ -7023,8 +7776,12 @@ export class MoviElement extends HTMLElement {
7023
7776
  // (and canvas/video, which sit underneath the overlay) via
7024
7777
  // inline style makes the change take effect immediately.
7025
7778
  this.style.cursor = "none";
7779
+ // In document-PiP the canvas is MOVED into the PiP window (out of the
7780
+ // shadow tree, so the cursor-hiding CSS can't reach it) — this inline
7781
+ // style still travels with the element and would hide the pointer over
7782
+ // the whole PiP video area above its own controls. Keep it visible there.
7026
7783
  if (this.canvas)
7027
- this.canvas.style.cursor = "none";
7784
+ this.canvas.style.cursor = this._pipWindow ? "default" : "none";
7028
7785
  if (this.video)
7029
7786
  this.video.style.cursor = "none";
7030
7787
  // The center play/pause button stays visible with pointer-events:auto
@@ -7079,6 +7836,14 @@ export class MoviElement extends HTMLElement {
7079
7836
  titleBar.classList.remove("movi-title-visible");
7080
7837
  }
7081
7838
  }
7839
+ // Hide the settings gear with the rest of the chrome — EXCEPT in audio
7840
+ // strip mode, where the bar is the whole (always-visible) player, so its
7841
+ // gear must stay put rather than auto-hiding with the transient controls.
7842
+ if (!this.classList.contains("movi-audio-strip")) {
7843
+ this.shadowRoot
7844
+ ?.querySelector(".movi-gear-btn")
7845
+ ?.classList.remove("movi-gear-visible");
7846
+ }
7082
7847
  }
7083
7848
  updateControlsVisibility() {
7084
7849
  const container = this.controlsContainer;
@@ -7147,6 +7912,25 @@ export class MoviElement extends HTMLElement {
7147
7912
  requestAnimationFrame(updateUI);
7148
7913
  }
7149
7914
  addStyles(shadowRoot) {
7915
+ // Load the webfont in its OWN sheet, appended a frame AFTER the main styles.
7916
+ // WebKit/Safari treats a shadow-DOM <style> whose first rule is a remote
7917
+ // @import as "pending" until that font finishes downloading — so on the
7918
+ // first (uncached) load the ENTIRE stylesheet is withheld and the shadow
7919
+ // content paints UNSTYLED for ~1s: the VR pad, gear, etc. flash in at their
7920
+ // default (static, opacity 1) positions before snapping to their real
7921
+ // hidden state. Isolating the @import — and adding it only after the first
7922
+ // paint — guarantees the layout/visibility rules below apply immediately;
7923
+ // Inter just swaps in late (font-display: swap). Chromium never had this
7924
+ // bug (it applies the other rules right away regardless of the @import).
7925
+ requestAnimationFrame(() => {
7926
+ if (shadowRoot.querySelector("style[data-movi-font]"))
7927
+ return;
7928
+ const fontStyle = document.createElement("style");
7929
+ fontStyle.setAttribute("data-movi-font", "");
7930
+ fontStyle.textContent =
7931
+ "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');";
7932
+ shadowRoot.appendChild(fontStyle);
7933
+ });
7150
7934
  const style = document.createElement("style");
7151
7935
  style.textContent = `
7152
7936
  /* ========================================
@@ -7154,9 +7938,6 @@ export class MoviElement extends HTMLElement {
7154
7938
  Rich, Elegant & Responsive Design
7155
7939
  ======================================== */
7156
7940
 
7157
- /* Import premium fonts */
7158
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
7159
-
7160
7941
  /* Global rules to remove all outlines and focus rings */
7161
7942
  * {
7162
7943
  outline: none !important;
@@ -7239,6 +8020,10 @@ export class MoviElement extends HTMLElement {
7239
8020
 
7240
8021
  /* Text Colors */
7241
8022
  --movi-controls-color: #FFFFFF;
8023
+ /* Foreground for chrome that is ALWAYS dark (bottom bar, OSD capsule,
8024
+ dark pills) — stays white in both themes, unlike --movi-controls-color
8025
+ which flips to dark for menus on light surfaces. Public theming hook. */
8026
+ --movi-chrome-fg: #ffffff;
7242
8027
  --movi-text-secondary: rgba(255, 255, 255, 0.7);
7243
8028
  --movi-text-tertiary: rgba(255, 255, 255, 0.5);
7244
8029
 
@@ -7297,6 +8082,56 @@ export class MoviElement extends HTMLElement {
7297
8082
  -moz-osx-font-smoothing: grayscale;
7298
8083
  }
7299
8084
 
8085
+ /* While a desktop context menu is open, let it spill past the player's
8086
+ own box the way strip mode does: lift the host's paint containment and
8087
+ overflow clip. container-type stays, so the responsive @container
8088
+ queries keep firing and the menu is still positioned relative to the
8089
+ host — it just isn't clipped to the player edges anymore. Transient:
8090
+ the class is removed the moment the menu closes. */
8091
+ :host(.movi-menu-overflow) {
8092
+ overflow: visible !important;
8093
+ contain: layout style !important;
8094
+ }
8095
+
8096
+ /* iOS pseudo-fullscreen: iOS Safari only fullscreens <video>, and we
8097
+ render to a canvas, so the element Fullscreen API is unavailable. Fill
8098
+ the whole screen via CSS instead. Sized in LARGE viewport units
8099
+ (100lvw/100lvh, 100vw/100vh fallback) so the box spans the entire
8100
+ screen — behind Safari's toolbars — instead of just the shrunken
8101
+ visible area (dvh/inset left page content peeking at the edges). */
8102
+ :host(.movi-pseudo-fullscreen) {
8103
+ position: fixed !important;
8104
+ top: 0 !important;
8105
+ left: 0 !important;
8106
+ right: auto !important;
8107
+ bottom: auto !important;
8108
+ width: 100vw !important;
8109
+ width: 100lvw !important;
8110
+ height: 100vh !important;
8111
+ height: 100lvh !important;
8112
+ max-width: none !important;
8113
+ max-height: none !important;
8114
+ margin: 0 !important;
8115
+ border-radius: 0 !important;
8116
+ z-index: 2147483647 !important;
8117
+ background: #000 !important;
8118
+ }
8119
+ /* Forced-landscape: a landscape video on a portrait screen (where iOS
8120
+ won't rotate for us) is turned 90° via CSS so it fills the screen the
8121
+ way Chrome/Android's orientation lock does. Swapped large-viewport
8122
+ dimensions so, after the rotate, the box maps onto the full screen;
8123
+ centred so the rotate pivots about the middle. */
8124
+ :host(.movi-pseudo-fs-rotate) {
8125
+ width: 100vh !important;
8126
+ width: 100lvh !important;
8127
+ height: 100vw !important;
8128
+ height: 100lvw !important;
8129
+ top: 50% !important;
8130
+ left: 50% !important;
8131
+ transform: translate(-50%, -50%) rotate(90deg) !important;
8132
+ transform-origin: center center !important;
8133
+ }
8134
+
7300
8135
  /* The :host{display:block} above is an author rule, so it beats the
7301
8136
  UA stylesheet's [hidden]{display:none}. Without this, the standard
7302
8137
  hidden attribute does nothing and the player stays visible. Honour
@@ -7494,6 +8329,14 @@ export class MoviElement extends HTMLElement {
7494
8329
  background: color-mix(in srgb, var(--movi-primary) 0.15) !important;
7495
8330
  }
7496
8331
 
8332
+ /* The active item's info badge hard-codes color:var(--movi-controls-color)
8333
+ (white) for the dark theme; in light theme that leaves the selected
8334
+ track's codec text white-on-light (unreadable). Match the item text. */
8335
+ :host([theme="light"]) .movi-audio-track-item.movi-audio-track-active .movi-audio-track-info,
8336
+ :host([theme="light"]) .movi-subtitle-track-item.movi-subtitle-track-active .movi-subtitle-track-info {
8337
+ color: #11142d !important;
8338
+ }
8339
+
7497
8340
  :host([theme="light"]) .movi-quality-item:hover {
7498
8341
  background: rgba(0, 0, 0, 0.05) !important;
7499
8342
  }
@@ -7601,6 +8444,25 @@ export class MoviElement extends HTMLElement {
7601
8444
  display: none !important;
7602
8445
  }
7603
8446
 
8447
+ /* Compact players: when the storyboard timeline (T) is open, hide the
8448
+ controls bar so the thumbnail strip gets the whole area, and drop the
8449
+ panel into the freed space. Keyed off the panel's inline display:flex
8450
+ (same signal as the seek-thumbnail rule above), so every close path
8451
+ restores the bar automatically. Desktop keeps the bar visible. */
8452
+ @container movi-host (max-width: 720px) {
8453
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-controls-container {
8454
+ opacity: 0 !important;
8455
+ pointer-events: none !important;
8456
+ transform: translateY(10px) !important;
8457
+ }
8458
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-controls-bar {
8459
+ pointer-events: none !important;
8460
+ }
8461
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-timeline-panel {
8462
+ bottom: 12px !important;
8463
+ }
8464
+ }
8465
+
7604
8466
  /* Force enable pointer events on all interactive controls */
7605
8467
  .movi-controls-container.movi-controls-visible .movi-controls-bar,
7606
8468
  .movi-controls-container.movi-controls-visible .movi-controls-bar *,
@@ -7653,7 +8515,7 @@ export class MoviElement extends HTMLElement {
7653
8515
  gap: 8px;
7654
8516
  padding: 8px 14px;
7655
8517
  background: rgba(0, 0, 0, 0.75);
7656
- color: #fff;
8518
+ color: var(--movi-chrome-fg, #fff);
7657
8519
  font-size: 13px;
7658
8520
  font-weight: 600;
7659
8521
  border: 1px solid rgba(255, 255, 255, 0.15);
@@ -7686,7 +8548,9 @@ export class MoviElement extends HTMLElement {
7686
8548
  top: 0;
7687
8549
  left: 0;
7688
8550
  right: 0;
7689
- padding: 16px 20px;
8551
+ /* Extra right padding keeps the title text from running under the
8552
+ settings gear pinned in the top-right corner. */
8553
+ padding: 16px 62px 16px 20px;
7690
8554
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
7691
8555
  z-index: 5;
7692
8556
  opacity: 0;
@@ -7700,6 +8564,89 @@ export class MoviElement extends HTMLElement {
7700
8564
  transform: translateY(0);
7701
8565
  }
7702
8566
 
8567
+ /* Settings gear (top-right) — opens the context menu. Vertically centred
8568
+ on the title text (title padding-top 16px + ~half the ~20px line) and
8569
+ inset to match; the title bar reserves right padding so the title
8570
+ doesn't run under it. */
8571
+ .movi-gear-btn {
8572
+ position: absolute;
8573
+ top: 9px;
8574
+ right: 14px;
8575
+ z-index: 30;
8576
+ width: 38px;
8577
+ height: 38px;
8578
+ padding: 8px;
8579
+ display: flex;
8580
+ align-items: center;
8581
+ justify-content: center;
8582
+ border: none;
8583
+ cursor: pointer;
8584
+ color: var(--movi-chrome-fg, #fff);
8585
+ background: rgba(0, 0, 0, 0.35);
8586
+ border-radius: 50%;
8587
+ opacity: 0;
8588
+ visibility: hidden;
8589
+ transform: translateY(-4px);
8590
+ transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease;
8591
+ pointer-events: none;
8592
+ }
8593
+ .movi-gear-btn.movi-gear-visible {
8594
+ opacity: 1;
8595
+ visibility: visible;
8596
+ transform: translateY(0);
8597
+ pointer-events: auto;
8598
+ }
8599
+ /* Hide the gear while a bottom dropdown OR the storyboard timeline is
8600
+ open — it lives in a higher stacking context than either and would
8601
+ otherwise paint over them. */
8602
+ :host(.movi-bottom-menu-open) .movi-gear-btn,
8603
+ :host:has(.movi-timeline-panel[style*="flex"]) .movi-gear-btn {
8604
+ opacity: 0 !important;
8605
+ visibility: hidden !important;
8606
+ pointer-events: none !important;
8607
+ }
8608
+ .movi-gear-btn:hover {
8609
+ background: rgba(0, 0, 0, 0.55);
8610
+ }
8611
+ .movi-gear-btn svg {
8612
+ width: 22px;
8613
+ height: 22px;
8614
+ }
8615
+ /* The gear is a TOUCH affordance — it only exists because touch has no
8616
+ right-click and the long-press context menu is gated. On non-touch
8617
+ (mouse/hover) devices, right-clicking the video opens the same menu,
8618
+ so the gear is redundant chrome; hide it there. (Mirrors the VR pad,
8619
+ which does the inverse — hidden on touch, shown on desktop.) */
8620
+ @media (hover: hover) and (pointer: fine) {
8621
+ .movi-gear-btn {
8622
+ display: none !important;
8623
+ }
8624
+ }
8625
+
8626
+ /* Press-and-hold-to-2x pill (top-centre, shown only while held). */
8627
+ .movi-hold-speed {
8628
+ position: absolute;
8629
+ top: 16px;
8630
+ left: 50%;
8631
+ transform: translateX(-50%);
8632
+ z-index: 40;
8633
+ display: flex;
8634
+ align-items: center;
8635
+ gap: 6px;
8636
+ padding: 6px 14px;
8637
+ background: rgba(0, 0, 0, 0.6);
8638
+ color: var(--movi-chrome-fg, #fff);
8639
+ border-radius: 999px;
8640
+ font-size: 14px;
8641
+ font-weight: 700;
8642
+ letter-spacing: 0.02em;
8643
+ pointer-events: none;
8644
+ }
8645
+ .movi-hold-speed svg {
8646
+ width: 14px;
8647
+ height: 14px;
8648
+ }
8649
+
7703
8650
  .movi-title-text {
7704
8651
  color: var(--movi-controls-color);
7705
8652
  font-size: clamp(16px, 4vw, 20px);
@@ -7845,7 +8792,7 @@ export class MoviElement extends HTMLElement {
7845
8792
  touch-action: none;
7846
8793
  }
7847
8794
  /* 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
8795
+ drag-to-look already covers desktop navigation; the pad is
7849
8796
  for those who want a YouTube-style continuous-pan compass. */
7850
8797
  :host(.movi-vr360-active[vrpad]) .movi-vr360-pad {
7851
8798
  opacity: 1;
@@ -8176,6 +9123,19 @@ export class MoviElement extends HTMLElement {
8176
9123
  rgba(255, 255, 255, 0.2) 100%
8177
9124
  );
8178
9125
  border-radius: 100px;
9126
+ }
9127
+ /* Boosting above 100% (VLC-style): amber fill flags the boost zone, and
9128
+ a tick at the 50%-of-track unity point marks where "normal" sits. */
9129
+ .movi-volume-slider.movi-volume-boosted {
9130
+ background: linear-gradient(
9131
+ to right,
9132
+ var(--movi-primary) 0%,
9133
+ var(--movi-primary) 50%,
9134
+ var(--movi-volume-boost, #f5a623) 50%,
9135
+ var(--movi-volume-boost, #f5a623) var(--movi-volume-pct, 100%),
9136
+ rgba(255, 255, 255, 0.2) var(--movi-volume-pct, 100%),
9137
+ rgba(255, 255, 255, 0.2) 100%
9138
+ );
8179
9139
  outline: none !important;
8180
9140
  pointer-events: auto !important;
8181
9141
  cursor: pointer;
@@ -8220,7 +9180,7 @@ export class MoviElement extends HTMLElement {
8220
9180
  appearance: none;
8221
9181
  width: 14px;
8222
9182
  height: 14px;
8223
- background: #fff;
9183
+ background: var(--movi-chrome-fg, #fff);
8224
9184
  border-radius: 50%;
8225
9185
  cursor: pointer;
8226
9186
  margin-top: -5px;
@@ -8245,7 +9205,7 @@ export class MoviElement extends HTMLElement {
8245
9205
  .movi-volume-slider::-moz-range-thumb {
8246
9206
  width: 14px;
8247
9207
  height: 14px;
8248
- background: #fff;
9208
+ background: var(--movi-chrome-fg, #fff);
8249
9209
  border-radius: 50%;
8250
9210
  cursor: pointer;
8251
9211
  border: none !important;
@@ -8633,7 +9593,7 @@ export class MoviElement extends HTMLElement {
8633
9593
  rgba(255,255,255,0.04) 0% 25%,
8634
9594
  rgba(255,255,255,0.02) 0% 50%
8635
9595
  ),
8636
- #1a1a1a;
9596
+ var(--movi-surface, #1a1a1a);
8637
9597
  background-size: auto, 16px 16px, auto;
8638
9598
  border: 1px solid rgba(255, 255, 255, 0.08);
8639
9599
  }
@@ -8718,7 +9678,7 @@ export class MoviElement extends HTMLElement {
8718
9678
  height: 18px;
8719
9679
  border-radius: 50%;
8720
9680
  background: var(--movi-primary);
8721
- border: 3px solid #FFFFFF;
9681
+ border: 3px solid var(--movi-chrome-fg, #FFFFFF);
8722
9682
  margin-top: -6px;
8723
9683
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
8724
9684
  cursor: pointer;
@@ -8742,7 +9702,7 @@ export class MoviElement extends HTMLElement {
8742
9702
  height: 18px;
8743
9703
  border-radius: 50%;
8744
9704
  background: var(--movi-primary);
8745
- border: 3px solid #FFFFFF;
9705
+ border: 3px solid var(--movi-chrome-fg, #FFFFFF);
8746
9706
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
8747
9707
  cursor: pointer;
8748
9708
  }
@@ -9100,7 +10060,7 @@ export class MoviElement extends HTMLElement {
9100
10060
  font-size: 11px;
9101
10061
  font-weight: 800;
9102
10062
  letter-spacing: 0.08em;
9103
- color: #fff;
10063
+ color: var(--movi-chrome-fg, #fff);
9104
10064
  line-height: 1;
9105
10065
  text-transform: uppercase;
9106
10066
  }
@@ -9137,7 +10097,10 @@ export class MoviElement extends HTMLElement {
9137
10097
  border: 1px solid var(--movi-glass-border);
9138
10098
  border-radius: 12px;
9139
10099
  min-width: 140px;
9140
- max-height: 280px;
10100
+ /* Tall enough for all 8 speeds (0.25x…2x) without scrolling on a normal
10101
+ player; capped to 70vh so a short player still fits — its top rows no
10102
+ longer clip because opening lifts the host clip (movi-menu-overflow). */
10103
+ max-height: min(70vh, 380px);
9141
10104
  overflow-y: auto;
9142
10105
  box-shadow: var(--movi-shadow-lg);
9143
10106
  z-index: 1000;
@@ -9270,7 +10233,7 @@ export class MoviElement extends HTMLElement {
9270
10233
  }
9271
10234
 
9272
10235
  .movi-nerd-stats-close:hover {
9273
- color: #fff;
10236
+ color: var(--movi-chrome-fg, #fff);
9274
10237
  }
9275
10238
 
9276
10239
  .movi-nerd-stats-body {
@@ -9457,7 +10420,7 @@ export class MoviElement extends HTMLElement {
9457
10420
  .movi-timeline-generate-btn {
9458
10421
  background: var(--movi-primary);
9459
10422
  border: none;
9460
- color: #fff;
10423
+ color: var(--movi-chrome-fg, #fff);
9461
10424
  font-size: 11px;
9462
10425
  font-weight: 600;
9463
10426
  padding: 5px 12px;
@@ -9488,12 +10451,12 @@ export class MoviElement extends HTMLElement {
9488
10451
  }
9489
10452
 
9490
10453
  .movi-timeline-close:hover {
9491
- color: #fff;
10454
+ color: var(--movi-chrome-fg, #fff);
9492
10455
  }
9493
10456
 
9494
10457
  .movi-timeline-strip {
9495
10458
  display: flex;
9496
- gap: 4px;
10459
+ gap: 8px;
9497
10460
  padding: 10px 14px;
9498
10461
  overflow-x: auto;
9499
10462
  overflow-y: hidden;
@@ -9527,7 +10490,10 @@ export class MoviElement extends HTMLElement {
9527
10490
  .movi-timeline-item:hover,
9528
10491
  .movi-timeline-item.movi-timeline-selected {
9529
10492
  border-color: var(--movi-primary);
9530
- transform: scale(1.05);
10493
+ /* Keep the pop small enough that an active + adjacent-hovered item
10494
+ don't grow into each other across the 8px gap (scale doesn't
10495
+ reflow neighbours, so an over-large scale visually overlaps). */
10496
+ transform: scale(1.03);
9531
10497
  }
9532
10498
 
9533
10499
  .movi-timeline-item img {
@@ -9554,7 +10520,7 @@ export class MoviElement extends HTMLElement {
9554
10520
  text-align: center;
9555
10521
  font-size: 10px;
9556
10522
  font-weight: 600;
9557
- color: #fff;
10523
+ color: var(--movi-chrome-fg, #fff);
9558
10524
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
9559
10525
  padding: 12px 4px 4px;
9560
10526
  font-variant-numeric: tabular-nums;
@@ -9579,7 +10545,7 @@ export class MoviElement extends HTMLElement {
9579
10545
  .movi-timeline-chapter-title {
9580
10546
  font-size: 10px;
9581
10547
  font-weight: 600;
9582
- color: #fff;
10548
+ color: var(--movi-chrome-fg, #fff);
9583
10549
  white-space: nowrap;
9584
10550
  overflow: hidden;
9585
10551
  text-overflow: ellipsis;
@@ -9661,7 +10627,7 @@ export class MoviElement extends HTMLElement {
9661
10627
  }
9662
10628
 
9663
10629
  .movi-shortcuts-close:hover {
9664
- color: #fff;
10630
+ color: var(--movi-chrome-fg, #fff);
9665
10631
  }
9666
10632
 
9667
10633
  .movi-shortcuts-body {
@@ -9751,7 +10717,7 @@ export class MoviElement extends HTMLElement {
9751
10717
  all: unset;
9752
10718
  flex: 1;
9753
10719
  font-size: 13px;
9754
- color: #fff;
10720
+ color: var(--movi-chrome-fg, #fff);
9755
10721
  font-family: inherit;
9756
10722
  }
9757
10723
  .movi-cues-search::placeholder { color: rgba(255, 255, 255, 0.4); }
@@ -9766,7 +10732,7 @@ export class MoviElement extends HTMLElement {
9766
10732
  padding: 0 6px;
9767
10733
  transition: color 0.15s;
9768
10734
  }
9769
- .movi-cues-close:hover { color: #fff; }
10735
+ .movi-cues-close:hover { color: var(--movi-chrome-fg, #fff); }
9770
10736
  .movi-cues-meta {
9771
10737
  padding: 6px 18px;
9772
10738
  font-size: 11px;
@@ -9934,15 +10900,28 @@ export class MoviElement extends HTMLElement {
9934
10900
  transform: scale(0.97);
9935
10901
  }
9936
10902
 
9937
- .movi-resume-btn.movi-resume-focused {
9938
- outline: 2px solid var(--movi-primary);
9939
- outline-offset: 2px;
9940
- transform: scale(1.05);
10903
+ /* The selection ring is a KEYBOARD affordance — the arrow keys move it
10904
+ between Resume/Cancel. Touch has no arrow-key nav (you just tap), so
10905
+ the auto-focused ring is meaningless noise there; only draw it on
10906
+ mouse/hover devices. */
10907
+ @media (hover: hover) and (pointer: fine) {
10908
+ .movi-resume-btn.movi-resume-focused {
10909
+ /* This stylesheet globally nukes outline (the universal reset at
10910
+ line ~8515 sets outline none with !important) and box-shadow on
10911
+ :focus, so the selection ring MUST be a box-shadow WITH !important
10912
+ to survive — a plain outline never renders. White ring (NOT
10913
+ --movi-primary — that's the Resume button's own fill) + dark halo
10914
+ reads on both the primary and grey buttons. The two-class selector
10915
+ out-specifies the reset among !important rules. */
10916
+ box-shadow: 0 0 0 3px #fff, 0 0 0 6px rgba(0, 0, 0, 0.5) !important;
10917
+ transform: scale(1.06);
10918
+ transition: transform 0.1s, box-shadow 0.1s;
10919
+ }
9941
10920
  }
9942
10921
 
9943
10922
  .movi-resume-yes {
9944
10923
  background: var(--movi-primary);
9945
- color: #fff;
10924
+ color: var(--movi-chrome-fg, #fff);
9946
10925
  }
9947
10926
 
9948
10927
  .movi-resume-no {
@@ -9967,6 +10946,31 @@ export class MoviElement extends HTMLElement {
9967
10946
  }
9968
10947
  }
9969
10948
 
10949
+ /* Very small players (≤400px): the horizontal pill (text + two buttons)
10950
+ is wider than the player and, being right-anchored + nowrap, clipped
10951
+ its left edge ("...ume from"). Span the player instead and stack the
10952
+ buttons under the text. left/right (not transform) so the slide-up
10953
+ animation's translateY isn't overridden. */
10954
+ @container movi-host (max-width: 400px) {
10955
+ .movi-resume-dialog {
10956
+ left: 16px;
10957
+ right: 16px;
10958
+ flex-direction: column;
10959
+ align-items: stretch;
10960
+ gap: 10px;
10961
+ text-align: center;
10962
+ }
10963
+ .movi-resume-text {
10964
+ white-space: normal;
10965
+ }
10966
+ .movi-resume-buttons {
10967
+ justify-content: center;
10968
+ }
10969
+ .movi-resume-btn {
10970
+ flex: 1;
10971
+ }
10972
+ }
10973
+
9970
10974
  /* ========================================
9971
10975
  RESPONSIVE STYLES - Mobile First
9972
10976
  ======================================== */
@@ -10110,9 +11114,11 @@ export class MoviElement extends HTMLElement {
10110
11114
  }
10111
11115
 
10112
11116
  .movi-progress-container {
10113
- padding: 6px 0 2px;
11117
+ /* Nudge the seek bar down a little on compact players so it isn't
11118
+ hugging the video edge. */
11119
+ padding: 14px 0 2px;
10114
11120
  }
10115
-
11121
+
10116
11122
  .movi-progress-bar {
10117
11123
  height: 6px;
10118
11124
  }
@@ -10157,21 +11163,45 @@ export class MoviElement extends HTMLElement {
10157
11163
  }
10158
11164
 
10159
11165
  .movi-controls-right.expanded .movi-mobile-expandable {
10160
- width: auto;
10161
11166
  opacity: 1;
10162
11167
  pointer-events: auto;
10163
11168
  gap: 4px;
10164
11169
  margin-right: 4px;
10165
- overflow: visible; /* Prevent clipping of hover backgrounds */
10166
- flex: 1;
10167
- justify-content: flex-end;
11170
+ /* When expanded on a narrow player the row can hold more buttons than
11171
+ fit — let them scroll horizontally instead of clipping or wrapping.
11172
+ overflow-y stays hidden (only the in-flow buttons are clipped
11173
+ vertically; the pop-up menus are positioned against the controls
11174
+ bar, an ancestor, so they're not clipped by this scroll box).
11175
+ flex:0 1 auto + justify-content:flex-start is deliberate: when the
11176
+ buttons fit, the box is content-sized and the PARENT's flex-end
11177
+ packs it right against the more (>) button (no gap); when they
11178
+ overflow, min-width:0 lets it shrink and scroll, and flex-start
11179
+ keeps the LEADING icons reachable — flex-end would strand the
11180
+ overflowed left-side icons off-screen with no way to scroll to them.
11181
+ width:auto is REQUIRED: flex-basis:auto reads the width, and the
11182
+ collapsed base rule sets width:0 — without this the expandable stays
11183
+ 0-wide and no icons show when expanded. */
11184
+ width: auto;
11185
+ flex: 0 1 auto;
11186
+ min-width: 0;
11187
+ flex-wrap: nowrap;
11188
+ justify-content: flex-start;
11189
+ overflow-x: auto;
11190
+ overflow-y: hidden;
11191
+ scrollbar-width: none; /* Firefox */
11192
+ -webkit-overflow-scrolling: touch;
11193
+ }
11194
+ .movi-controls-right.expanded .movi-mobile-expandable::-webkit-scrollbar {
11195
+ display: none; /* WebKit */
10168
11196
  }
10169
11197
 
10170
- /* Reset margins and restore dimensions */
11198
+ /* Reset margins and restore dimensions; keep buttons from shrinking so
11199
+ the icons don't squash — they scroll instead. */
10171
11200
  .movi-controls-right.expanded .movi-mobile-expandable > * {
10172
11201
  margin: 0 !important;
10173
11202
  width: auto;
10174
11203
  height: auto;
11204
+ flex: 0 0 auto;
10175
11205
  }
10176
11206
 
10177
11207
  /* Hide individual buttons by default on mobile */
@@ -10211,6 +11241,11 @@ export class MoviElement extends HTMLElement {
10211
11241
  /* Alternative for older browsers: shrink left instead of hiding if :has not supported */
10212
11242
  .movi-controls-right.expanded {
10213
11243
  flex: 1;
11244
+ /* min-width:0 lets this shrink below its content so the constraint
11245
+ reaches the expandable, which then scrolls. Without it the default
11246
+ min-width:auto keeps the whole cluster at content width and it
11247
+ overflows the player's left edge instead of scrolling. */
11248
+ min-width: 0;
10214
11249
  justify-content: flex-end;
10215
11250
  }
10216
11251
 
@@ -10241,17 +11276,13 @@ export class MoviElement extends HTMLElement {
10241
11276
  transform-origin: bottom center !important;
10242
11277
  width: 90% !important;
10243
11278
  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;
11279
+ /* Cap to the viewport, not the player. Opening a dropdown now lifts
11280
+ the host's paint/overflow clip (movi-menu-overflow), so a tall menu
11281
+ on a SHORT player extends past the player's top edge into the page
11282
+ instead of being chopped there which used to hide the first rows
11283
+ (e.g. the 0.25x speed option). 70vh keeps it within the screen and
11284
+ lets it scroll only when the list itself is genuinely huge. */
11285
+ max-height: min(70vh, calc(100vh - 80px)) !important;
10255
11286
  overflow-y: auto !important;
10256
11287
  z-index: 2000 !important;
10257
11288
  -webkit-overflow-scrolling: touch !important;
@@ -10369,6 +11400,79 @@ export class MoviElement extends HTMLElement {
10369
11400
  }
10370
11401
  }
10371
11402
 
11403
+ /* Very small / compact players (≤400px). Tighten every gap, shrink the
11404
+ buttons and pull in the bar padding so the always-visible collapsed
11405
+ row (play · volume · time · more · fullscreen) fits without clipping —
11406
+ the rest of the icons live in the horizontally-scrollable expandable. */
11407
+ @container movi-host (max-width: 400px) {
11408
+ :host {
11409
+ --movi-btn-size: 36px;
11410
+ }
11411
+ .movi-controls-bar {
11412
+ padding: 2px 8px 8px;
11413
+ }
11414
+ .movi-buttons-row {
11415
+ gap: 4px;
11416
+ }
11417
+ .movi-controls-left,
11418
+ .movi-controls-right {
11419
+ gap: 2px;
11420
+ }
11421
+ .movi-btn {
11422
+ padding: 7px;
11423
+ }
11424
+ .movi-btn svg {
11425
+ width: 20px;
11426
+ height: 20px;
11427
+ }
11428
+ .movi-time {
11429
+ font-size: 11px;
11430
+ }
11431
+ .movi-progress-container {
11432
+ padding: 14px 0 6px;
11433
+ }
11434
+ }
11435
+
11436
+ /* Below ~290px the dropdown / context menus need a smaller type scale so
11437
+ rows aren't cramped or clipped on a tiny player. */
11438
+ @container movi-host (max-width: 289px) {
11439
+ .movi-speed-item {
11440
+ font-size: 10px;
11441
+ padding: 6px 10px;
11442
+ }
11443
+ .movi-audio-track-item,
11444
+ .movi-subtitle-track-item {
11445
+ font-size: 10px;
11446
+ padding: 5px 8px;
11447
+ gap: 6px;
11448
+ }
11449
+ .movi-track-item-icon {
11450
+ width: 13px;
11451
+ height: 13px;
11452
+ }
11453
+ .movi-audio-track-info,
11454
+ .movi-subtitle-track-info {
11455
+ font-size: 8px;
11456
+ padding: 1px 5px;
11457
+ }
11458
+ .movi-track-menu-header {
11459
+ font-size: 9px;
11460
+ padding: 7px 10px 6px;
11461
+ }
11462
+ .movi-track-menu-footer {
11463
+ font-size: 8px;
11464
+ }
11465
+ .movi-context-menu-item {
11466
+ font-size: 10px;
11467
+ padding: 7px 10px;
11468
+ }
11469
+ .movi-context-menu-icon svg,
11470
+ .movi-context-menu-item svg {
11471
+ width: 15px;
11472
+ height: 15px;
11473
+ }
11474
+ }
11475
+
10372
11476
  /* Large players (1025px and above) */
10373
11477
  @container movi-host (min-width: 1025px) {
10374
11478
  .movi-controls-bar {
@@ -10627,6 +11731,19 @@ export class MoviElement extends HTMLElement {
10627
11731
  block so it survives the !important cascade. */
10628
11732
 
10629
11733
  /* Subtitle overlay - HTML element for better performance */
11734
+ /* Off-screen live region for screen-reader caption announcements. */
11735
+ .movi-caption-live {
11736
+ position: absolute;
11737
+ width: 1px;
11738
+ height: 1px;
11739
+ margin: -1px;
11740
+ padding: 0;
11741
+ border: 0;
11742
+ overflow: hidden;
11743
+ clip: rect(0 0 0 0);
11744
+ clip-path: inset(50%);
11745
+ white-space: nowrap;
11746
+ }
10630
11747
  .movi-subtitle-overlay {
10631
11748
  position: absolute;
10632
11749
  bottom: 12%;
@@ -11062,7 +12179,7 @@ export class MoviElement extends HTMLElement {
11062
12179
  letter-spacing: 0.4px;
11063
12180
  text-transform: uppercase;
11064
12181
  background: var(--movi-primary);
11065
- color: #fff;
12182
+ color: var(--movi-chrome-fg, #fff);
11066
12183
  pointer-events: none;
11067
12184
  }
11068
12185
 
@@ -11148,7 +12265,7 @@ export class MoviElement extends HTMLElement {
11148
12265
  max-height: 200px;
11149
12266
  object-fit: contain;
11150
12267
  margin-bottom: 4px;
11151
- border: 1px solid #333;
12268
+ border: 1px solid var(--movi-border-color, #333);
11152
12269
  border-radius: 2px;
11153
12270
  pointer-events: none;
11154
12271
  }
@@ -11160,7 +12277,7 @@ export class MoviElement extends HTMLElement {
11160
12277
  display: none;
11161
12278
  font-size: 11px;
11162
12279
  font-weight: 600;
11163
- color: #fff;
12280
+ color: var(--movi-chrome-fg, #fff);
11164
12281
  text-align: center;
11165
12282
  max-width: 180px;
11166
12283
  overflow: hidden;
@@ -11249,7 +12366,7 @@ export class MoviElement extends HTMLElement {
11249
12366
  display: flex;
11250
12367
  align-items: center;
11251
12368
  justify-content: center;
11252
- color: #FFFFFF;
12369
+ color: var(--movi-chrome-fg, #FFFFFF);
11253
12370
  /* Subtle primary halo around the icon — keeps the OSD
11254
12371
  visually anchored to the brand without painting the bg. */
11255
12372
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--movi-primary) 45%, transparent));
@@ -11263,7 +12380,7 @@ export class MoviElement extends HTMLElement {
11263
12380
  .movi-osd-text {
11264
12381
  font-size: 14px;
11265
12382
  font-weight: 600;
11266
- color: #FFFFFF;
12383
+ color: var(--movi-chrome-fg, #FFFFFF);
11267
12384
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
11268
12385
  letter-spacing: 0.01em;
11269
12386
  line-height: 1.2;
@@ -11336,7 +12453,7 @@ export class MoviElement extends HTMLElement {
11336
12453
  font-weight: 700;
11337
12454
  margin: 0 0 10px 0;
11338
12455
  letter-spacing: -0.02em;
11339
- background: linear-gradient(to bottom, #fff, #bbb);
12456
+ background: linear-gradient(to bottom, var(--movi-chrome-fg, #fff), #bbb);
11340
12457
  -webkit-background-clip: text;
11341
12458
  -webkit-text-fill-color: transparent;
11342
12459
  text-align: center;
@@ -11367,7 +12484,7 @@ export class MoviElement extends HTMLElement {
11367
12484
  background: rgba(255, 255, 255, 0.15);
11368
12485
  border: 1px solid rgba(255, 255, 255, 0.2);
11369
12486
  border-radius: 8px;
11370
- color: #fff;
12487
+ color: var(--movi-chrome-fg, #fff);
11371
12488
  font-size: 14px;
11372
12489
  font-weight: 500;
11373
12490
  cursor: pointer;
@@ -11417,6 +12534,37 @@ export class MoviElement extends HTMLElement {
11417
12534
  padding-bottom: 40px;
11418
12535
  }
11419
12536
 
12537
+ /* A bare player (no controls attribute) is a headless, chrome-less
12538
+ surface — don't show the "No Video / Add a source" placeholder or the
12539
+ loading spinner there. !important beats the inline display:flex the JS
12540
+ sets on these indicators. */
12541
+ :host(.movi-no-controls) .movi-empty-state,
12542
+ :host(.movi-no-controls) .movi-loading-indicator {
12543
+ display: none !important;
12544
+ }
12545
+
12546
+ /* Bare player (no controls attribute): a display-only surface — disable
12547
+ ALL mouse interaction (click-to-play, double-click fullscreen, the
12548
+ right-click menu, look-around drag). */
12549
+ :host(.movi-no-controls) {
12550
+ pointer-events: none !important;
12551
+ }
12552
+
12553
+ /* An audio strip with no controls is a chrome-less bar with nothing to
12554
+ show or operate — hide it entirely. Audio keeps playing (Web Audio is
12555
+ independent of the host's display). */
12556
+ :host(.movi-audio-strip.movi-no-controls) {
12557
+ display: none !important;
12558
+ }
12559
+
12560
+ /* Opt-out: the noerrorscreen attribute suppresses the built-in error
12561
+ overlays ("Format Unsupported", "Browser not supported", codec/network
12562
+ errors) so an embedding app can render its own error UI. The error
12563
+ event + state still fire — only the default screen is hidden. */
12564
+ :host([noerrorscreen]) .movi-broken-indicator {
12565
+ display: none !important;
12566
+ }
12567
+
11420
12568
  /* Short / narrow players: shrink the placeholder so it fits above
11421
12569
  the controls bar without clipping into it. */
11422
12570
  @container movi-host (max-width: 720px) {
@@ -11521,6 +12669,30 @@ export class MoviElement extends HTMLElement {
11521
12669
  transform: translateX(0) !important;
11522
12670
  }
11523
12671
 
12672
+ /* STRIP MODE ONLY: the host is a ~78px bar with overflow:visible, so the
12673
+ default translateX drawer slides OUT past the player's right edge and
12674
+ the closed drawer visibly escapes the boundary. Here we pin it inside
12675
+ the player and FADE in place (no horizontal slide) + truly hide when
12676
+ closed. Non-strip (full-size) players keep the normal slide drawer. */
12677
+ :host(.movi-audio-strip) .movi-context-menu.movi-context-menu-mobile {
12678
+ bottom: 0 !important;
12679
+ height: auto !important;
12680
+ max-width: min(350px, 100%) !important;
12681
+ margin: 0 !important;
12682
+ transform: none !important;
12683
+ opacity: 0 !important;
12684
+ visibility: hidden !important;
12685
+ pointer-events: none !important;
12686
+ transition: opacity 0.2s ease, visibility 0s linear 0.2s !important;
12687
+ }
12688
+ :host(.movi-audio-strip) .movi-context-menu.movi-context-menu-mobile.visible {
12689
+ transform: none !important;
12690
+ opacity: 1 !important;
12691
+ visibility: visible !important;
12692
+ pointer-events: auto !important;
12693
+ transition: opacity 0.2s ease, visibility 0s linear 0s !important;
12694
+ }
12695
+
11524
12696
  .movi-context-menu-item {
11525
12697
  padding: 10px 14px !important;
11526
12698
  margin: 2px 4px !important;
@@ -11657,6 +12829,13 @@ export class MoviElement extends HTMLElement {
11657
12829
  }
11658
12830
  .movi-seek-thumbnail {
11659
12831
  transform: translateX(-50%) scale(0.85);
12832
+ /* Scale from the bottom edge, not the centre: the box is bottom-
12833
+ anchored (bottom:40px) and grows upward when the thumbnail image
12834
+ appears above the time. With the default centre origin a taller
12835
+ box shrinks toward its middle, lifting its bottom edge — so the
12836
+ time text jumps up the moment the thumb arrives. Anchoring the
12837
+ scale origin at the bottom keeps the time fixed either way. */
12838
+ transform-origin: bottom center;
11660
12839
  bottom: 40px;
11661
12840
  }
11662
12841
 
@@ -11748,6 +12927,20 @@ export class MoviElement extends HTMLElement {
11748
12927
  color: #11142d;
11749
12928
  text-shadow: none;
11750
12929
  }
12930
+ /* Strip mode zeroes the title bar's padding, but on touch the settings
12931
+ gear is shown pinned top-right (38px @ right:14px). Reserve room so the
12932
+ title truncates before it instead of running underneath. Only on
12933
+ coarse pointers — the gear is hidden on mouse/hover devices. */
12934
+ @media (pointer: coarse) {
12935
+ :host(.movi-audio-strip) .movi-title-bar {
12936
+ padding-right: 48px !important;
12937
+ }
12938
+ }
12939
+ /* The gear button otherwise centres a few px BELOW the small strip title
12940
+ text — nudge it up so their icon/text vertical centres line up. */
12941
+ :host(.movi-audio-strip) .movi-gear-btn {
12942
+ top: 1px !important;
12943
+ }
11751
12944
  /* Push the control row below the title band — only when titled. */
11752
12945
  :host(.movi-audio-strip.movi-has-title) .movi-controls-container,
11753
12946
  :host(.movi-audio-strip.movi-has-title) .movi-controls-container.movi-controls-hidden {
@@ -11788,7 +12981,7 @@ export class MoviElement extends HTMLElement {
11788
12981
  min-height: 56px !important;
11789
12982
  height: 56px !important;
11790
12983
  max-height: 56px !important;
11791
- background: #0f0f0f;
12984
+ background: var(--movi-surface, #0f0f0f);
11792
12985
  border-radius: 6px;
11793
12986
  /* "overflow: visible" is intentional — popups (speed menu,
11794
12987
  audio-track menu, right-click context menu) anchor inside the
@@ -12297,6 +13490,10 @@ export class MoviElement extends HTMLElement {
12297
13490
  this._src = srcAttr || null;
12298
13491
  this._autoplay = this.hasAttribute("autoplay");
12299
13492
  this._controls = this.hasAttribute("controls");
13493
+ // Mirror the controls attr as a host class so CSS can suppress chrome (e.g.
13494
+ // the "No Video" placeholder) on a bare, controls-less player — done with a
13495
+ // class rather than :host(:not([controls])), which is flaky in Safari/FF.
13496
+ this.classList.toggle("movi-no-controls", !this._controls);
12300
13497
  this._loop = this.hasAttribute("loop");
12301
13498
  this._muted = this.hasAttribute("muted");
12302
13499
  this._playsinline = this.hasAttribute("playsinline");
@@ -12410,6 +13607,7 @@ export class MoviElement extends HTMLElement {
12410
13607
  if (typeof ResizeObserver !== "undefined") {
12411
13608
  const resizeObserver = new ResizeObserver(() => {
12412
13609
  publishPlayerWidth();
13610
+ this.syncTinyLayout();
12413
13611
  this.updateCanvasSize();
12414
13612
  });
12415
13613
  resizeObserver.observe(this);
@@ -12418,9 +13616,12 @@ export class MoviElement extends HTMLElement {
12418
13616
  // Fallback for browsers without ResizeObserver
12419
13617
  window.addEventListener("resize", () => {
12420
13618
  publishPlayerWidth();
13619
+ this.syncTinyLayout();
12421
13620
  this.updateCanvasSize();
12422
13621
  });
12423
13622
  }
13623
+ // Run once now so an already-tiny player folds fullscreen away on load.
13624
+ this.syncTinyLayout();
12424
13625
  // Initial visibility check
12425
13626
  this.updateControlsVisibility();
12426
13627
  // Check for required security headers
@@ -12611,6 +13812,15 @@ export class MoviElement extends HTMLElement {
12611
13812
  });
12612
13813
  }
12613
13814
  disconnectedCallback() {
13815
+ // If removed while in the iOS pseudo-fullscreen fallback, restore the page
13816
+ // scroll we locked and drop the resize listeners (setPseudoFullscreen(false)
13817
+ // won't run otherwise).
13818
+ if (this._pseudoFullscreen) {
13819
+ document.body.style.overflow = this._prevBodyOverflow;
13820
+ window.removeEventListener("resize", this._onPseudoFsResize);
13821
+ window.removeEventListener("orientationchange", this._onPseudoFsResize);
13822
+ this._pseudoFullscreen = false;
13823
+ }
12614
13824
  // Remove WebGL context listeners
12615
13825
  this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
12616
13826
  this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
@@ -12644,6 +13854,10 @@ export class MoviElement extends HTMLElement {
12644
13854
  switch (name) {
12645
13855
  case "thumb":
12646
13856
  this._thumb = newValue !== null;
13857
+ // Sync an already-created player so toggling `thumb` at runtime — or
13858
+ // declaring it after `src` (whose callback builds the player first) —
13859
+ // enables/disables scrub previews live instead of silently no-opping.
13860
+ this.player?.setPreviewsEnabled(this._thumb && !this._audioOnly);
12647
13861
  break;
12648
13862
  case "hdr":
12649
13863
  this.hdr = newValue !== null;
@@ -12826,6 +14040,7 @@ export class MoviElement extends HTMLElement {
12826
14040
  break;
12827
14041
  case "controls":
12828
14042
  this._controls = newValue !== null;
14043
+ this.classList.toggle("movi-no-controls", !this._controls);
12829
14044
  this.updateControlsVisibility();
12830
14045
  this.updateUnmuteOverlay();
12831
14046
  break;
@@ -13008,6 +14223,14 @@ export class MoviElement extends HTMLElement {
13008
14223
  width = window.innerWidth;
13009
14224
  height = window.innerHeight;
13010
14225
  }
14226
+ else if (this._pseudoFullscreen) {
14227
+ // The host is CSS-sized (and, when forced-landscape, rotated) to fill the
14228
+ // viewport. Use its LAYOUT box (clientWidth/Height) — unlike
14229
+ // getBoundingClientRect it isn't affected by the rotate transform, so the
14230
+ // canvas buffer matches the un-rotated element the transform then turns.
14231
+ width = this.clientWidth;
14232
+ height = this.clientHeight;
14233
+ }
13011
14234
  else {
13012
14235
  const rect = this.getBoundingClientRect();
13013
14236
  width = widthAttr ? parseInt(widthAttr, 10) : rect.width;
@@ -13200,14 +14423,28 @@ export class MoviElement extends HTMLElement {
13200
14423
  const posterCoverPending = audioMode && !bitmap && this._posterCoverLoading;
13201
14424
  const stripMode = audioMode && !bitmap && !coverArtPending && !posterCoverPending;
13202
14425
  this.classList.toggle("movi-audio-mode", audioMode);
13203
- const wasStrip = this.classList.contains("movi-audio-strip");
13204
14426
  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.
14427
+ // In strip mode the gear is part of the always-visible bar, so reveal it the
14428
+ // moment the layout is applied (on load) rather than waiting for the first
14429
+ // touch/showControls. hideControls already keeps it visible in strip mode.
14430
+ if (stripMode && this.hasMediaSource()) {
14431
+ this.shadowRoot
14432
+ ?.querySelector(".movi-gear-btn")
14433
+ ?.classList.add("movi-gear-visible");
14434
+ }
14435
+ // Notify the embedding page when the strip state changes vs what we LAST
14436
+ // TOLD it — not vs the current class. load() clears movi-audio-strip on
14437
+ // every source change, so comparing to the class made a strip→non-strip
14438
+ // switch (e.g. a no-cover track followed by an album-art track) look
14439
+ // unchanged: the event never fired and the page's wrapper stayed collapsed
14440
+ // at 56/78px, squashing the album art into a black bar. _lastStripDispatched
14441
+ // survives load(), so the transition is detected and the wrapper reflows.
14442
+ if (stripMode !== this._lastStripDispatched) {
14443
+ this._lastStripDispatched = stripMode;
14444
+ // Tell the embedding page so it can collapse/restore its own wrapper. The
14445
+ // host shrinks to 56px via :host CSS, but a parent .player-stage with
14446
+ // aspect-ratio: 16/9 (or any fixed height) would still reserve its old
14447
+ // box and leave a black gap. Consumers listen on this — see index.html.
13211
14448
  this.dispatchEvent(new CustomEvent("audiostripchange", {
13212
14449
  detail: { strip: stripMode },
13213
14450
  bubbles: true,
@@ -13498,6 +14735,8 @@ export class MoviElement extends HTMLElement {
13498
14735
  * Automatically create and initialize MoviPlayer
13499
14736
  */
13500
14737
  async initializePlayer() {
14738
+ // QoE: begin a new analytics session and start the startup timer.
14739
+ this._qoe.sessionStart(typeof this._src === "string" ? this._src : "");
13501
14740
  // Re-run the security-header check before any FFmpeg init. load() resets
13502
14741
  // _isUnsupported on every source change, which would otherwise let a
13503
14742
  // non-isolated context (e.g. embed iframe inside a third-party page that
@@ -13568,6 +14807,14 @@ export class MoviElement extends HTMLElement {
13568
14807
  if (this._headers && source && source.type === "url") {
13569
14808
  source.headers = this._headers;
13570
14809
  }
14810
+ // Read `thumb` straight from the DOM here rather than trusting the
14811
+ // cached `_thumb` field. The `src` attribute's own attributeChangedCallback
14812
+ // fires load() during element upgrade *before* the `thumb` callback (and
14813
+ // before connectedCallback) has populated `_thumb` — so with the usual
14814
+ // `<movi-player src=… thumb>` ordering, `_thumb` is still false at this
14815
+ // point and previews would silently never enable. hasAttribute reads the
14816
+ // live parsed attribute, which is already present, so order stops mattering.
14817
+ this._thumb = this.hasAttribute("thumb");
13571
14818
  // Create MoviPlayer instance
13572
14819
  // Configure MoviPlayer options
13573
14820
  const playerConfig = {
@@ -13810,6 +15057,8 @@ export class MoviElement extends HTMLElement {
13810
15057
  this.player.once("seeked", () => {
13811
15058
  this.player?.resetClockToStartForPoster();
13812
15059
  this._posterSeekActive = false;
15060
+ // Poster frame is now on the canvas — snapshot it for lock-screen art.
15061
+ this.captureMediaSessionArtwork();
13813
15062
  });
13814
15063
  this.player
13815
15064
  .seek(posterTime, { suppressSpinner: true })
@@ -13818,6 +15067,9 @@ export class MoviElement extends HTMLElement {
13818
15067
  });
13819
15068
  }
13820
15069
  else {
15070
+ // Frame 0 lands on the canvas when "seeked" fires (the seek promise
15071
+ // resolves earlier, before paint) — snapshot it there for artwork.
15072
+ this.player.once("seeked", () => this.captureMediaSessionArtwork());
13821
15073
  this.player.seek(0, { suppressSpinner: true }).catch(() => { });
13822
15074
  }
13823
15075
  }
@@ -13909,6 +15161,9 @@ export class MoviElement extends HTMLElement {
13909
15161
  const audioTrackChangeHandler = () => {
13910
15162
  this.updateAudioTrackMenu();
13911
15163
  this.updateSubtitleTrackMenu();
15164
+ // Switching between muxed (AudioContext, boostable) and native audio
15165
+ // changes the volume ceiling — re-cap the slider.
15166
+ this.updateVolumeCap();
13912
15167
  this.dispatchEvent(new Event("audiotrackchange"));
13913
15168
  };
13914
15169
  this.player.trackManager.on("audioTrackChange", audioTrackChangeHandler);
@@ -13940,6 +15195,12 @@ export class MoviElement extends HTMLElement {
13940
15195
  // Forward player events to element
13941
15196
  const stateChangeHandler = (state) => {
13942
15197
  Logger.info(TAG, `stateChange: ${state}`);
15198
+ // QoE: track stalls. Entering "buffering" starts a rebuffer timer; any
15199
+ // other state ends it (the first stall, before first frame, is startup).
15200
+ if (state === "buffering")
15201
+ this._qoe.bufferingStartNow();
15202
+ else
15203
+ this._qoe.bufferingEndNow();
13943
15204
  // A seek requested before the player was ready was held — apply it now
13944
15205
  // that we've reached a seekable state (fixes e.g. a PiP/handoff seek that
13945
15206
  // arrives while the source is still loading).
@@ -14005,11 +15266,13 @@ export class MoviElement extends HTMLElement {
14005
15266
  this.updatePlayPauseIcon();
14006
15267
  if (this._ambientMode)
14007
15268
  this._ambientSampleInterval = 200;
14008
- if ("mediaSession" in navigator && this._title) {
14009
- navigator.mediaSession.metadata = new MediaMetadata({
14010
- title: this._title,
14011
- });
14012
- }
15269
+ this.updateMediaSession();
15270
+ this.updateMediaSessionPosition();
15271
+ this.captureMediaSessionArtwork();
15272
+ this.startStutterMonitor();
15273
+ this._qoe.firstFrame();
15274
+ this._qoe.playing();
15275
+ this.startQoeHeartbeat();
14013
15276
  return;
14014
15277
  }
14015
15278
  // Native-like: when playback starts, hide the controls unless the
@@ -14046,15 +15309,23 @@ export class MoviElement extends HTMLElement {
14046
15309
  if (this._ambientMode) {
14047
15310
  this._ambientSampleInterval = 200;
14048
15311
  }
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
- }
15312
+ // Update Media Session metadata + playing state + scrubber
15313
+ this.updateMediaSession();
15314
+ this.updateMediaSessionPosition();
15315
+ this.captureMediaSessionArtwork();
15316
+ // Watch for decode-can't-keep-up stutter to hint "play at 1x".
15317
+ this.startStutterMonitor();
15318
+ // QoE: first "playing" marks the first frame (startup time).
15319
+ this._qoe.firstFrame();
15320
+ this._qoe.playing();
15321
+ this.startQoeHeartbeat();
14055
15322
  }
14056
15323
  else if (state === "paused") {
14057
15324
  this.dispatchEvent(new Event("pause"));
15325
+ this.updateMediaSession();
15326
+ this.stopStutterMonitor();
15327
+ this._qoe.paused();
15328
+ this.stopQoeHeartbeat();
14058
15329
  // Don't auto-surface the bar on pause anymore — the centre
14059
15330
  // play button already comes back (via updatePlayPauseIcon's
14060
15331
  // paused branch) and tells the user playback is paused. The
@@ -14076,6 +15347,10 @@ export class MoviElement extends HTMLElement {
14076
15347
  // Clear resume position when video ends (start fresh next time)
14077
15348
  if (this._resume)
14078
15349
  this.clearResumePosition();
15350
+ this.updateMediaSession();
15351
+ this.stopStutterMonitor();
15352
+ this._qoe.ended();
15353
+ this.stopQoeHeartbeat();
14079
15354
  }
14080
15355
  };
14081
15356
  this.player.on("stateChange", stateChangeHandler);
@@ -14085,6 +15360,9 @@ export class MoviElement extends HTMLElement {
14085
15360
  this.updateLoadingIndicator(this.player?.getState() || "idle");
14086
15361
  this.updateControlsState();
14087
15362
  this.updatePlayPauseIcon();
15363
+ // Cap the volume slider at 100% for native-audio sources (streams /
15364
+ // native split-source), 200% otherwise, now that the audio path is known.
15365
+ this.updateVolumeCap();
14088
15366
  // Tracks are now finalised; pick the right visual mode (video
14089
15367
  // surface / cover-art overlay / audio strip). Cover-art extraction
14090
15368
  // is async, so the strip may flip OFF later when the coverart
@@ -14097,6 +15375,10 @@ export class MoviElement extends HTMLElement {
14097
15375
  // listener was wired (detection happens during the demux open).
14098
15376
  if (this.player?.isLinearPlayback?.())
14099
15377
  this.enterLinearMode();
15378
+ // Wire OS lock-screen / hardware-key controls and seed metadata now that
15379
+ // tracks (and thus title/duration) are finalised.
15380
+ this.updateMediaSession();
15381
+ this.updateMediaSessionPosition();
14100
15382
  this.dispatchEvent(new Event("loadeddata"));
14101
15383
  };
14102
15384
  this.player.on("loadEnd", loadEndHandler);
@@ -14130,6 +15412,15 @@ export class MoviElement extends HTMLElement {
14130
15412
  const timeUpdateHandler = (time) => {
14131
15413
  this.dispatchEvent(new CustomEvent("timeupdate", { detail: time }));
14132
15414
  this.updateLiveState();
15415
+ // Keep the seek slider's screen-reader ARIA current even while the visual
15416
+ // chrome is auto-hidden (updateTimeDisplay is gated on visible controls).
15417
+ this.updateSeekAria();
15418
+ // Refresh the OS scrubber at ~1s granularity, or immediately on a jump
15419
+ // (seek) so the lock-screen position doesn't lag.
15420
+ if (Math.abs(time - this._mediaSessionLastPos) >= 1) {
15421
+ this._mediaSessionLastPos = time;
15422
+ this.updateMediaSessionPosition();
15423
+ }
14133
15424
  };
14134
15425
  this.player.on("timeUpdate", timeUpdateHandler);
14135
15426
  this.eventHandlers.set("timeUpdate", () => this.player?.off("timeUpdate", timeUpdateHandler));
@@ -14139,6 +15430,7 @@ export class MoviElement extends HTMLElement {
14139
15430
  this.player.on("filerevoked", fileRevokedHandler);
14140
15431
  this.eventHandlers.set("filerevoked", () => this.player?.off("filerevoked", fileRevokedHandler));
14141
15432
  const errorHandler = (error) => {
15433
+ this._qoe.error(error instanceof Error ? error.message : String(error), true);
14142
15434
  this.dispatchEvent(new CustomEvent("error", { detail: error }));
14143
15435
  // Always log the raw error before the message gets prettified for
14144
15436
  // the overlay — without this, "State: ... -> error" appears in
@@ -14212,6 +15504,9 @@ export class MoviElement extends HTMLElement {
14212
15504
  // not close() it. bubbles/composed so it escapes the shadow root.
14213
15505
  // Only forward real bitmaps; the null signal is internal.
14214
15506
  if (bitmap) {
15507
+ // Refresh the OS lock-screen artwork with the real album art now that
15508
+ // it's decoded (metadata was seeded earlier with no / a fallback image).
15509
+ this.setMediaSessionArtworkFromBitmap(bitmap);
14215
15510
  this.dispatchEvent(new CustomEvent("coverart", {
14216
15511
  detail: { bitmap, width: bitmap.width, height: bitmap.height },
14217
15512
  bubbles: true,
@@ -14314,6 +15609,236 @@ export class MoviElement extends HTMLElement {
14314
15609
  this.player.pause();
14315
15610
  }
14316
15611
  }
15612
+ /**
15613
+ * Register OS Media Session action handlers once. These surface play/pause,
15614
+ * skip-back/forward and scrub controls on the lock screen, notification
15615
+ * shade, media-key hardware (headset / keyboard) and Bluetooth remotes, and
15616
+ * route them back into the player. Registered once (guarded); the handlers
15617
+ * close over `this` and read `this.player` lazily, so they keep working
15618
+ * across source swaps that recreate the internal player.
15619
+ */
15620
+ setupMediaSession() {
15621
+ if (this._mediaSessionReady)
15622
+ return;
15623
+ if (!("mediaSession" in navigator))
15624
+ return;
15625
+ const ms = navigator.mediaSession;
15626
+ const set = (action, handler) => {
15627
+ try {
15628
+ ms.setActionHandler(action, handler);
15629
+ }
15630
+ catch {
15631
+ // Chromium throws for actions it doesn't support (e.g. older builds);
15632
+ // ignore so the supported handlers still register.
15633
+ }
15634
+ };
15635
+ set("play", () => {
15636
+ this.play().catch(() => { });
15637
+ });
15638
+ set("pause", () => {
15639
+ this.pause();
15640
+ });
15641
+ set("stop", () => {
15642
+ this.pause();
15643
+ });
15644
+ set("seekbackward", (details) => {
15645
+ this.performRelativeSeek("left", details?.seekOffset || 10);
15646
+ });
15647
+ set("seekforward", (details) => {
15648
+ this.performRelativeSeek("right", details?.seekOffset || 10);
15649
+ });
15650
+ set("seekto", (details) => {
15651
+ if (!this.player || typeof details?.seekTime !== "number")
15652
+ return;
15653
+ this.player.seek(details.seekTime).catch(() => { });
15654
+ });
15655
+ this._mediaSessionReady = true;
15656
+ }
15657
+ /**
15658
+ * Refresh the lock-screen metadata (title + poster artwork) and the
15659
+ * playing/paused indicator. Cheap to call on every state/load change.
15660
+ */
15661
+ updateMediaSession() {
15662
+ if (!("mediaSession" in navigator))
15663
+ return;
15664
+ const ms = navigator.mediaSession;
15665
+ this.setupMediaSession();
15666
+ if (this._title) {
15667
+ const art = this._poster ||
15668
+ this._posterCoverUrl ||
15669
+ this._generatedPosterUrl ||
15670
+ this._mediaSessionArtworkUrl;
15671
+ const artwork = art
15672
+ ? [{ src: art, sizes: "512x512", type: this.artworkMime(art) }]
15673
+ : undefined;
15674
+ try {
15675
+ ms.metadata = new MediaMetadata({ title: this._title, artwork });
15676
+ }
15677
+ catch {
15678
+ // Some engines reject artwork with an unfetchable src; fall back to
15679
+ // a title-only metadata so the OS chrome still shows something.
15680
+ ms.metadata = new MediaMetadata({ title: this._title });
15681
+ }
15682
+ }
15683
+ const state = this.player?.getState();
15684
+ ms.playbackState =
15685
+ state === "playing" ? "playing" : state === "paused" ? "paused" : "none";
15686
+ }
15687
+ /**
15688
+ * Capture the current decoded frame off the WebGL canvas as a data: URL for
15689
+ * use as OS lock-screen artwork. A source with no explicit `poster` paints
15690
+ * its poster/first frame straight onto the canvas (via the postertime /
15691
+ * frame-0 seek) and never turns it into a URL — this is how that same still
15692
+ * becomes the lock-screen thumbnail. Skipped when an explicit poster/cover
15693
+ * URL already wins the artwork chain, or once a snapshot is already stored.
15694
+ * The canvas uses preserveDrawingBuffer, so toDataURL returns the real frame.
15695
+ * Cheap one-shot (mirrors the proven _lastFrameSnapshot capture) and only
15696
+ * runs at moments a real frame is guaranteed on-canvas (poster seek / play).
15697
+ */
15698
+ captureMediaSessionArtwork(attempt = 0) {
15699
+ if (!("mediaSession" in navigator))
15700
+ return;
15701
+ if (this._poster || this._posterCoverUrl)
15702
+ return; // explicit art wins
15703
+ if (this._mediaSessionArtworkUrl)
15704
+ return; // already captured
15705
+ // The poster/first frame is decoded by the time "seeked" fires, but the
15706
+ // renderer paints it on a later rAF — capturing synchronously grabs the
15707
+ // still-blank GL buffer (a black thumbnail). Wait, on rAF, until a real
15708
+ // (non-uniform) frame has actually landed, then snapshot it. Bounded so a
15709
+ // genuinely dark/audio-only canvas can't spin forever; "playing" re-arms it.
15710
+ if (this.isCanvasBlank()) {
15711
+ if (attempt < 30) {
15712
+ requestAnimationFrame(() => this.captureMediaSessionArtwork(attempt + 1));
15713
+ }
15714
+ return;
15715
+ }
15716
+ try {
15717
+ const dataUrl = this.canvas?.toDataURL("image/jpeg", 0.85);
15718
+ if (dataUrl && dataUrl.length > 512) {
15719
+ this._mediaSessionArtworkUrl = dataUrl;
15720
+ this.updateMediaSession();
15721
+ }
15722
+ }
15723
+ catch {
15724
+ // Tainted / lost GL context — leave artwork empty rather than throw.
15725
+ }
15726
+ }
15727
+ /**
15728
+ * Cheap synchronous test for whether the display canvas is still blank/black
15729
+ * (renderer hasn't painted a real frame yet). Downscales the WebGL canvas to
15730
+ * 16×16 on a throwaway 2D context and checks luminance spread — a near-flat
15731
+ * result means no frame content. Used to defer artwork capture until a real
15732
+ * frame lands. Returns true (treat as blank) on any failure, so capture just
15733
+ * retries rather than storing garbage.
15734
+ */
15735
+ isCanvasBlank() {
15736
+ const c = this.canvas;
15737
+ if (!c || !c.width || !c.height)
15738
+ return true;
15739
+ try {
15740
+ const small = document.createElement("canvas");
15741
+ small.width = 16;
15742
+ small.height = 16;
15743
+ const ctx = small.getContext("2d", { willReadFrequently: true });
15744
+ if (!ctx)
15745
+ return false; // can't test — assume drawable, let capture proceed
15746
+ ctx.drawImage(c, 0, 0, 16, 16);
15747
+ const { data } = ctx.getImageData(0, 0, 16, 16);
15748
+ let min = 255;
15749
+ let max = 0;
15750
+ for (let i = 0; i < data.length; i += 4) {
15751
+ const lum = (data[i] + data[i + 1] + data[i + 2]) / 3;
15752
+ if (lum < min)
15753
+ min = lum;
15754
+ if (lum > max)
15755
+ max = lum;
15756
+ }
15757
+ return max - min < 6; // near-uniform => blank / not yet painted
15758
+ }
15759
+ catch {
15760
+ return true; // treat failures as blank → capture retries, never throws
15761
+ }
15762
+ }
15763
+ /**
15764
+ * Use an extracted cover-art / thumbnail bitmap as the OS lock-screen artwork.
15765
+ * The player surfaces embedded album art (and other thumbnails) asynchronously
15766
+ * — after the initial metadata is seeded — so this refreshes the Media Session
15767
+ * once it lands, instead of the lock screen being stuck with no image. The
15768
+ * bitmap is rasterised to a bounded (≤512px) data: URL. Real art is
15769
+ * authoritative, so it overwrites any earlier canvas-snapshot fallback; an
15770
+ * explicit `poster`/cover URL still wins the chain in updateMediaSession().
15771
+ */
15772
+ setMediaSessionArtworkFromBitmap(bitmap) {
15773
+ if (!("mediaSession" in navigator))
15774
+ return;
15775
+ if (!bitmap || !bitmap.width || !bitmap.height)
15776
+ return;
15777
+ try {
15778
+ const scale = Math.min(1, 512 / Math.max(bitmap.width, bitmap.height));
15779
+ const canvas = document.createElement("canvas");
15780
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
15781
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
15782
+ const ctx = canvas.getContext("2d");
15783
+ if (!ctx)
15784
+ return;
15785
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
15786
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
15787
+ if (dataUrl && dataUrl.length > 512) {
15788
+ this._mediaSessionArtworkUrl = dataUrl;
15789
+ this.updateMediaSession();
15790
+ }
15791
+ }
15792
+ catch {
15793
+ // Tainted / decode failure — leave any existing artwork untouched.
15794
+ }
15795
+ }
15796
+ /** Best-effort MIME guess for poster artwork from its URL/extension. */
15797
+ artworkMime(url) {
15798
+ if (url.startsWith("data:")) {
15799
+ const m = url.slice(5, url.indexOf(";"));
15800
+ return m || "image/png";
15801
+ }
15802
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
15803
+ if (ext === "jpg" || ext === "jpeg")
15804
+ return "image/jpeg";
15805
+ if (ext === "webp")
15806
+ return "image/webp";
15807
+ if (ext === "gif")
15808
+ return "image/gif";
15809
+ return "image/png";
15810
+ }
15811
+ /**
15812
+ * Push the current playhead / duration / rate into the OS scrubber. Guarded
15813
+ * against live streams (Infinity), un-loaded state (NaN/0) and out-of-range
15814
+ * positions, which would otherwise make setPositionState throw.
15815
+ */
15816
+ updateMediaSessionPosition() {
15817
+ if (!("mediaSession" in navigator))
15818
+ return;
15819
+ const ms = navigator.mediaSession;
15820
+ if (typeof ms.setPositionState !== "function")
15821
+ return;
15822
+ const duration = this.duration;
15823
+ const rate = this.player?.getPlaybackRate?.() || 1;
15824
+ try {
15825
+ if (Number.isFinite(duration) && duration > 0) {
15826
+ const position = Math.min(Math.max(this.currentTime || 0, 0), duration);
15827
+ ms.setPositionState({
15828
+ duration,
15829
+ playbackRate: rate > 0 ? rate : 1,
15830
+ position,
15831
+ });
15832
+ }
15833
+ else {
15834
+ // Live / not-yet-loaded: clear any stale scrubber state.
15835
+ ms.setPositionState();
15836
+ }
15837
+ }
15838
+ catch {
15839
+ // Ignore — a transient position > duration during a seek shouldn't spam.
15840
+ }
15841
+ }
14317
15842
  /**
14318
15843
  * Tear down the internal player and reset transient UI (time, title,
14319
15844
  * subtitles, timeline) back to the initial, no-source state. Called
@@ -14330,6 +15855,27 @@ export class MoviElement extends HTMLElement {
14330
15855
  this._preloadGateActive = false;
14331
15856
  this._resumeDialogPending = false;
14332
15857
  this._autoplayPendingVisible = false;
15858
+ // Reset OS lock-screen chrome to a no-source state. Keep the action
15859
+ // handlers registered (setupMediaSession is guarded) — they read
15860
+ // this.player lazily and will drive the next source once it loads.
15861
+ if ("mediaSession" in navigator) {
15862
+ navigator.mediaSession.playbackState = "none";
15863
+ navigator.mediaSession.metadata = null;
15864
+ try {
15865
+ navigator.mediaSession.setPositionState?.();
15866
+ }
15867
+ catch {
15868
+ /* noop */
15869
+ }
15870
+ }
15871
+ this._mediaSessionLastPos = -1;
15872
+ this._mediaSessionArtworkUrl = null;
15873
+ this.stopStutterMonitor();
15874
+ this.resetStutterHint();
15875
+ if (this._captionLive)
15876
+ this._captionLive.textContent = "";
15877
+ this._lastCaptionText = "";
15878
+ this.stopQoeHeartbeat();
14333
15879
  // Tear down internal player
14334
15880
  if (this.player) {
14335
15881
  try {
@@ -14618,6 +16164,7 @@ export class MoviElement extends HTMLElement {
14618
16164
  if (durationEl) {
14619
16165
  durationEl.textContent = this.formatTime(this.duration);
14620
16166
  }
16167
+ this.updateSeekAria();
14621
16168
  // Trigger title auto-load when duration first becomes available
14622
16169
  if (this._lastDuration === 0 && this.duration > 0) {
14623
16170
  this._lastDuration = this.duration;
@@ -14625,6 +16172,78 @@ export class MoviElement extends HTMLElement {
14625
16172
  this.updateTitle();
14626
16173
  }
14627
16174
  }
16175
+ /**
16176
+ * Update the seek slider's ARIA so screen readers announce the position as a
16177
+ * real slider ("1:23 of 4:56"), not a bare number. Driven off timeUpdate so
16178
+ * it stays current even while the visual chrome is auto-hidden.
16179
+ */
16180
+ updateSeekAria() {
16181
+ const bar = this.shadowRoot?.querySelector(".movi-progress-bar");
16182
+ if (!bar)
16183
+ return;
16184
+ const ct = this._posterSeekActive ? 0 : this.currentTime;
16185
+ const live = this.player?.isLiveStream?.();
16186
+ const dur = Number.isFinite(this.duration) && this.duration > 0 ? this.duration : 0;
16187
+ bar.setAttribute("aria-valuemin", "0");
16188
+ bar.setAttribute("aria-valuemax", live ? "0" : String(Math.round(dur)));
16189
+ bar.setAttribute("aria-valuenow", String(Math.round(ct)));
16190
+ bar.setAttribute("aria-valuetext", live
16191
+ ? "Live"
16192
+ : dur > 0
16193
+ ? `${this.formatTime(ct)} of ${this.formatTime(dur)}`
16194
+ : this.formatTime(ct));
16195
+ }
16196
+ /** Push the visible caption's text into the off-screen aria-live region so
16197
+ * screen readers announce it. Deduped so identical re-renders stay quiet. */
16198
+ mirrorCaption() {
16199
+ if (!this._captionLive || !this.subtitleOverlay)
16200
+ return;
16201
+ const text = (this.subtitleOverlay.textContent || "")
16202
+ .replace(/\s+/g, " ")
16203
+ .trim();
16204
+ if (text === this._lastCaptionText)
16205
+ return;
16206
+ this._lastCaptionText = text;
16207
+ this._captionLive.textContent = text;
16208
+ }
16209
+ // ── QoE analytics ─────────────────────────────────────────────────────────
16210
+ startQoeHeartbeat() {
16211
+ if (this._qoeHeartbeat !== null)
16212
+ return;
16213
+ this._qoeHeartbeat = window.setInterval(() => {
16214
+ if (this.player?.getState?.() !== "playing")
16215
+ return;
16216
+ const stats = this.player?.getStats?.();
16217
+ const decoder = String(stats?.["Video Decoder"] ?? "unknown");
16218
+ // No dropped-frame counter is exposed yet; rebufferRatio carries the
16219
+ // stall cost. Report 0 so the schema stays stable for consumers.
16220
+ this._qoe.heartbeat(this.currentTime, 0, decoder);
16221
+ }, 10000);
16222
+ }
16223
+ stopQoeHeartbeat() {
16224
+ if (this._qoeHeartbeat !== null) {
16225
+ clearInterval(this._qoeHeartbeat);
16226
+ this._qoeHeartbeat = null;
16227
+ }
16228
+ }
16229
+ /** Register a QoE analytics sink; returns an unsubscribe fn. Every event is
16230
+ * also dispatched as a `movi-qoe` CustomEvent. */
16231
+ addQoeSink(sink) {
16232
+ this._qoe.addSink(sink);
16233
+ return () => this._qoe.removeSink(sink);
16234
+ }
16235
+ removeQoeSink(sink) {
16236
+ this._qoe.removeSink(sink);
16237
+ }
16238
+ /** POST every QoE event to `url` via sendBeacon — shorthand for
16239
+ * addQoeSink(beaconSink(url)). Returns an unsubscribe fn. */
16240
+ setAnalyticsBeacon(url) {
16241
+ return this.addQoeSink(beaconSink(url));
16242
+ }
16243
+ /** A rolled-up snapshot of the current QoE session. */
16244
+ getQoeSession() {
16245
+ return this._qoe.getSession();
16246
+ }
14628
16247
  /**
14629
16248
  * Render chapter markers on the progress bar (YouTube-style)
14630
16249
  */
@@ -14767,8 +16386,15 @@ export class MoviElement extends HTMLElement {
14767
16386
  // type=range> doesn't expose a separate filled portion across
14768
16387
  // browsers, so the CSS gradient below reads this custom prop
14769
16388
  // 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)}%`);
16389
+ // beyond. Track max is getMaxVolume() (2 normally, 1 on native audio), so
16390
+ // the thumb sits at v/max of the width.
16391
+ volumeSlider.style.setProperty("--movi-volume-pct", `${Math.round((v / this.getMaxVolume()) * 100)}%`);
16392
+ // Above 100% we're boosting — tint the fill so the boost zone is obvious.
16393
+ volumeSlider.classList.toggle("movi-volume-boosted", v > 1);
16394
+ // Real ARIA for screen readers: announce the percentage, not 0–2.
16395
+ const pct = Math.round(v * 100);
16396
+ volumeSlider.setAttribute("aria-valuenow", String(pct));
16397
+ volumeSlider.setAttribute("aria-valuetext", this._muted ? "Muted" : `Volume ${pct}%${v > 1 ? " (boosted)" : ""}`);
14772
16398
  }
14773
16399
  // Reset all first
14774
16400
  volumeHigh?.style.setProperty("display", "none");
@@ -14919,33 +16545,42 @@ export class MoviElement extends HTMLElement {
14919
16545
  * These headers are required for SharedArrayBuffer support (needed by FFmpeg)
14920
16546
  */
14921
16547
  checkSecurityHeaders() {
14922
- // Check if Cross-Origin-Isolated context is available
16548
+ // Cross-origin isolation (COOP+COEP → SharedArrayBuffer) is NOT required to
16549
+ // play anything. The WASM engine is single-threaded (USE_PTHREADS=0) and
16550
+ // does all its I/O through Asyncify (async js_read_async), so HttpSource's
16551
+ // reads bridge async network fetches without SAB. SAB is purely a zero-copy
16552
+ // optimisation; HttpSource's plain-buffer fallback works fine without it
16553
+ // (once atomicSetStreaming got its missing non-SAB branch — see HttpSource).
16554
+ // Blocking on the headers used to lock out perfectly capable browsers whose
16555
+ // embedder couldn't set them, and lite/mobile browsers (Opera Mini "high"
16556
+ // mode, UC, etc.) that never report crossOriginIsolated at all.
14923
16557
  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
16558
+ 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.");
16559
+ }
16560
+ // The one thing genuinely required is WebAssembly. Proxy/lite browsers that
16561
+ // render server-side (Opera Mini's extreme/proxy mode and similar) don't
16562
+ // provide it — surface a clear "unsupported browser" message rather than a
16563
+ // cryptic decode failure, and skip player init.
16564
+ if (typeof WebAssembly === "undefined") {
16565
+ Logger.warn(TAG, "WebAssembly unavailable — this browser can't run the player");
14926
16566
  if (this.brokenIndicator) {
14927
16567
  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
16568
  if (this.emptyStateIndicator) {
14932
16569
  this.emptyStateIndicator.style.display = "none";
14933
16570
  }
14934
16571
  const titleEl = this.brokenIndicator.querySelector(".movi-broken-title");
14935
16572
  if (titleEl)
14936
- titleEl.textContent = "Security Headers Missing";
16573
+ titleEl.textContent = "Browser not supported";
14937
16574
  const messageEl = this.brokenIndicator.querySelector(".movi-broken-message");
14938
16575
  if (messageEl) {
14939
16576
  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.";
16577
+ "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
16578
  }
14942
- // Hide the software fallback button (not applicable for header issues)
14943
16579
  const swFallbackBtn = this.brokenIndicator.querySelector(".movi-sw-fallback-btn");
14944
16580
  if (swFallbackBtn) {
14945
16581
  swFallbackBtn.style.display = "none";
14946
16582
  }
14947
16583
  }
14948
- // Prevent player initialization
14949
16584
  this._isUnsupported = true;
14950
16585
  }
14951
16586
  }
@@ -16202,7 +17837,13 @@ export class MoviElement extends HTMLElement {
16202
17837
  maybeShowResumeDialog() {
16203
17838
  // Resume means seeking to the saved position — impossible in linear
16204
17839
  // (non-seekable) playback, so don't offer it.
16205
- if (!this._resume || !this.player || this._contextLostTime !== 0 || this._linearMode)
17840
+ // The dialog is interactive chrome, so it has no business appearing on a
17841
+ // bare player with no `controls` attribute — skip it there.
17842
+ if (!this._resume ||
17843
+ !this.player ||
17844
+ this._contextLostTime !== 0 ||
17845
+ this._linearMode ||
17846
+ !this._controls)
16206
17847
  return;
16207
17848
  const savedTime = this.getResumePosition();
16208
17849
  if (savedTime > 2 && savedTime < this.duration - 5) {
@@ -16381,6 +18022,11 @@ export class MoviElement extends HTMLElement {
16381
18022
  this._timelineCancelled = true;
16382
18023
  panel.style.display = "none";
16383
18024
  this.focus();
18025
+ // Re-arm auto-hide only if the bar is currently visible; never surface a
18026
+ // hidden bar on close (see the close-button handler for the reasoning).
18027
+ if (!this.controlsContainer?.classList.contains("movi-controls-hidden")) {
18028
+ this.showControls();
18029
+ }
16384
18030
  }
16385
18031
  }
16386
18032
  /**
@@ -16391,6 +18037,13 @@ export class MoviElement extends HTMLElement {
16391
18037
  return;
16392
18038
  if (this._timelineGenerating)
16393
18039
  return; // re-entrancy guard
18040
+ // The Timeline panel is an explicit user action (press "T") and must render
18041
+ // regardless of the `thumb` attribute — that attribute only gates the
18042
+ // passive seek-bar hover preview (which stays independently gated on
18043
+ // `_thumb` in updateSeekVisuals). Enable the shared thumbnail pipeline so
18044
+ // the getPreviewFrame() calls below can decode frames even when `thumb`
18045
+ // is off; turning it on here never triggers seek-bar previews on its own.
18046
+ this.player.setPreviewsEnabled(true);
16394
18047
  const strip = shadowRoot.querySelector(".movi-timeline-strip");
16395
18048
  const status = shadowRoot.querySelector(".movi-timeline-status");
16396
18049
  const titleEl = shadowRoot.querySelector(".movi-timeline-title");
@@ -16605,7 +18258,8 @@ export class MoviElement extends HTMLElement {
16605
18258
  return this._volume;
16606
18259
  }
16607
18260
  set volume(value) {
16608
- this._volume = Math.max(0, Math.min(1, value));
18261
+ // 0–2: values above 1 boost audio up to 200% (VLC-style).
18262
+ this._volume = Math.max(0, Math.min(this.getMaxVolume(), value));
16609
18263
  this.setAttribute("volume", this._volume.toString());
16610
18264
  // If user increases volume while muted, automatically unmute (like YouTube)
16611
18265
  const autoUnmuted = this._muted && this._volume > 0;
@@ -16643,6 +18297,10 @@ export class MoviElement extends HTMLElement {
16643
18297
  this.updatePlaybackRate();
16644
18298
  SettingsStorage.getInstance().save({ playbackRate: this._playbackRate });
16645
18299
  this.dispatchEvent(new CustomEvent("ratechange", { detail: { playbackRate: this._playbackRate } }));
18300
+ // Keep the OS lock-screen scrubber's speed indicator in sync.
18301
+ this.updateMediaSessionPosition();
18302
+ // Each new speed gets a fresh stutter warning if it can't keep up.
18303
+ this.resetStutterHint();
16646
18304
  }
16647
18305
  get subtitleDelay() {
16648
18306
  return this._subtitleDelay;
@@ -16811,9 +18469,12 @@ export class MoviElement extends HTMLElement {
16811
18469
  }
16812
18470
  }
16813
18471
  }
18472
+ /** @deprecated Use `playsInline` instead — an inline player now restricts
18473
+ * touch gestures to fullscreen on its own. Kept for backward compatibility. */
16814
18474
  get gesturefs() {
16815
18475
  return this._gesturefs;
16816
18476
  }
18477
+ /** @deprecated Use `playsInline` instead. */
16817
18478
  set gesturefs(value) {
16818
18479
  if (this._gesturefs !== value) {
16819
18480
  this._gesturefs = value;
@@ -17335,7 +18996,7 @@ export class MoviElement extends HTMLElement {
17335
18996
  /*
17336
18997
  * Take a snapshot of the current frame and download it
17337
18998
  */
17338
- takeSnapshot() {
18999
+ async takeSnapshot() {
17339
19000
  if (!this.player)
17340
19001
  return;
17341
19002
  try {
@@ -17343,6 +19004,32 @@ export class MoviElement extends HTMLElement {
17343
19004
  // If we are in canvas mode, it's easy
17344
19005
  if (this.canvas && this.canvas.style.display !== "none") {
17345
19006
  dataUrl = this.canvas.toDataURL("image/png");
19007
+ // Some GPUs return an all-black buffer when a WebGL canvas that
19008
+ // displayed a HARDWARE-decoded frame (4K AV1/HEVC etc.) is read back
19009
+ // via toDataURL — even with preserveDrawingBuffer — so the snapshot
19010
+ // saves empty. Detect that and fall back to the raw decoded VideoFrame
19011
+ // drawn onto a plain 2D canvas (GPU-readback-independent). Trade-off:
19012
+ // this frame has no burned-in subtitles, but a real frame beats a
19013
+ // black one.
19014
+ if (await this.isDataUrlBlank(dataUrl)) {
19015
+ const frame = this.player.getCurrentVideoFrame?.();
19016
+ if (frame) {
19017
+ try {
19018
+ const c = document.createElement("canvas");
19019
+ c.width = frame.displayWidth;
19020
+ c.height = frame.displayHeight;
19021
+ const ctx = c.getContext("2d");
19022
+ if (ctx) {
19023
+ ctx.drawImage(frame, 0, 0);
19024
+ dataUrl = c.toDataURL("image/png");
19025
+ Logger.info(TAG, "Snapshot: WebGL readback was blank, used decoded VideoFrame");
19026
+ }
19027
+ }
19028
+ catch (e) {
19029
+ Logger.warn(TAG, "VideoFrame snapshot fallback failed", e);
19030
+ }
19031
+ }
19032
+ }
17346
19033
  }
17347
19034
  else if (this.video && this.video.style.display !== "none") {
17348
19035
  // If in video mode, draw video to a temporary canvas
@@ -17377,6 +19064,182 @@ export class MoviElement extends HTMLElement {
17377
19064
  Logger.error(TAG, "Error taking snapshot", e);
17378
19065
  }
17379
19066
  }
19067
+ /**
19068
+ * Decode a data-URL into a small sampling canvas and report whether every
19069
+ * pixel is (near-)black — i.e. the capture came out empty. Used to detect a
19070
+ * failed WebGL readback so takeSnapshot can fall back to the raw VideoFrame.
19071
+ */
19072
+ isDataUrlBlank(dataUrl) {
19073
+ return new Promise((resolve) => {
19074
+ if (!dataUrl || dataUrl.length < 32)
19075
+ return resolve(true);
19076
+ const img = new Image();
19077
+ img.onload = () => {
19078
+ try {
19079
+ const w = 32;
19080
+ const h = 18;
19081
+ const c = document.createElement("canvas");
19082
+ c.width = w;
19083
+ c.height = h;
19084
+ const ctx = c.getContext("2d", { willReadFrequently: true });
19085
+ if (!ctx)
19086
+ return resolve(false); // can't sample — trust the capture
19087
+ ctx.drawImage(img, 0, 0, w, h);
19088
+ const d = ctx.getImageData(0, 0, w, h).data;
19089
+ for (let i = 0; i < d.length; i += 4) {
19090
+ if (d[i] > 8 || d[i + 1] > 8 || d[i + 2] > 8)
19091
+ return resolve(false);
19092
+ }
19093
+ resolve(true); // every sampled pixel is essentially black
19094
+ }
19095
+ catch {
19096
+ resolve(false); // decode/read failed — don't force the fallback
19097
+ }
19098
+ };
19099
+ img.onerror = () => resolve(true);
19100
+ img.src = dataUrl;
19101
+ });
19102
+ }
19103
+ /**
19104
+ * Final safety net for context-menu placement. Whatever a path computed
19105
+ * (strip-mode position:fixed, host-relative absolute, …), the host's
19106
+ * `contain` re-anchors fixed/absolute to the host box — so a clamp done in
19107
+ * window coordinates can still leave the menu spilling off the real viewport
19108
+ * (seen in audio-strip mode on touch). Re-measure the ACTUAL rect and nudge
19109
+ * left/top by the overflow. Translation isn't affected by the containing
19110
+ * block, so this works regardless. The mobile drawer is a deliberate
19111
+ * right-edge panel — skip it.
19112
+ */
19113
+ /**
19114
+ * Below ~290px even the always-visible right cluster (more · fullscreen)
19115
+ * crowds the bar, so fold the fullscreen button into the collapsible
19116
+ * expandable too — leaving only the "more" (>) toggle outside, with
19117
+ * fullscreen reachable (and horizontally scrollable) once expanded. Restored
19118
+ * to its normal slot (last child of the right cluster, after the more button)
19119
+ * once there's room again. Idempotent: only moves when out of place, so it
19120
+ * survives repeated resize ticks without thrashing the DOM.
19121
+ */
19122
+ syncTinyLayout() {
19123
+ const root = this.shadowRoot;
19124
+ if (!root)
19125
+ return;
19126
+ const fsBtn = root.querySelector(".movi-fullscreen-btn");
19127
+ const expandable = root.querySelector(".movi-mobile-expandable");
19128
+ const right = root.querySelector(".movi-controls-right");
19129
+ if (!fsBtn || !expandable || !right)
19130
+ return;
19131
+ const tiny = this.clientWidth > 0 && this.clientWidth < 290;
19132
+ if (tiny) {
19133
+ if (expandable.lastElementChild !== fsBtn)
19134
+ expandable.appendChild(fsBtn);
19135
+ }
19136
+ else if (right.lastElementChild !== fsBtn) {
19137
+ right.appendChild(fsBtn);
19138
+ }
19139
+ }
19140
+ /**
19141
+ * A body-level shadow-DOM portal for the desktop context menu. Moving the
19142
+ * menu here lets it escape any overflow:hidden / clipping ancestor of the
19143
+ * player (page wrappers, cards, `body{overflow-x:hidden}`) — the player's own
19144
+ * shadow keeps `container-type` for its @container queries, which makes it a
19145
+ * containing block for fixed descendants, so a menu that stays inside can't
19146
+ * break out. The portal clones the player's shadow styles so the menu looks
19147
+ * identical, and carries over any inline theme custom-properties.
19148
+ */
19149
+ ensureMenuPortal() {
19150
+ if (this._menuPortalRoot)
19151
+ return this._menuPortalRoot;
19152
+ if (typeof document === "undefined" || !document.body)
19153
+ return null;
19154
+ const host = document.createElement("div");
19155
+ host.setAttribute("data-movi-menu-portal", "");
19156
+ // Plain fixed box at the origin. The cloned player styles below carry the
19157
+ // player's own `:host { overflow:hidden; contain:paint; container-type:...;
19158
+ // width:100% }` rules, which would apply to THIS host and clip the menu to
19159
+ // a 0×0 box / re-anchor its fixed positioning — so hard-override them inline
19160
+ // (inline beats the non-important :host rules). NO contain/container-type,
19161
+ // so a fixed-positioned menu inside is viewport-relative and clipped by
19162
+ // nothing.
19163
+ host.style.cssText =
19164
+ "position:fixed !important;top:0 !important;left:0 !important;" +
19165
+ "width:0 !important;height:0 !important;overflow:visible !important;" +
19166
+ "contain:none !important;container-type:normal !important;" +
19167
+ "background:none !important;border-radius:0 !important;" +
19168
+ "z-index:2147483647;";
19169
+ const root = host.attachShadow({ mode: "open" });
19170
+ for (const s of Array.from(this.shadowRoot?.querySelectorAll("style") || [])) {
19171
+ root.appendChild(s.cloneNode(true));
19172
+ }
19173
+ document.body.appendChild(host);
19174
+ this._menuPortalHost = host;
19175
+ this._menuPortalRoot = root;
19176
+ return root;
19177
+ }
19178
+ // Submenu panels (Speed / Fit / Audio / Subtitle / Audio-output) are
19179
+ // shadow-root SIBLINGS of the menu, not children — they must travel to the
19180
+ // portal with it, or they'd stay behind and open detached from the menu.
19181
+ _portaledSubs = [];
19182
+ /** Move the context menu (+ its submenu panels) into the body portal and mirror theme vars. */
19183
+ portalContextMenu(menu) {
19184
+ const root = this.ensureMenuPortal();
19185
+ if (!root)
19186
+ return;
19187
+ if (menu.parentNode !== root) {
19188
+ this._menuHome = menu.parentNode;
19189
+ this._portaledSubs = this.shadowRoot
19190
+ ? Array.from(this.shadowRoot.querySelectorAll(".movi-context-menu-submenu, .movi-context-menu-submenu-audio, .movi-context-menu-submenu-subtitle, .movi-context-menu-submenu-audiodevice"))
19191
+ : [];
19192
+ root.appendChild(menu);
19193
+ for (const sub of this._portaledSubs)
19194
+ root.appendChild(sub);
19195
+ }
19196
+ // Carry over inline theme overrides (e.g. themecolor -> --movi-primary) so
19197
+ // the portaled menu matches; the cloned styles supply the defaults.
19198
+ const hostStyle = this._menuPortalHost?.style;
19199
+ if (hostStyle) {
19200
+ for (const prop of Array.from(this.style)) {
19201
+ if (prop.startsWith("--movi")) {
19202
+ hostStyle.setProperty(prop, this.style.getPropertyValue(prop));
19203
+ }
19204
+ }
19205
+ }
19206
+ }
19207
+ /** Return the context menu (+ submenus) from the portal to the shadow root. */
19208
+ unportalContextMenu(menu) {
19209
+ if (!this._menuHome)
19210
+ return;
19211
+ if (menu.parentNode !== this._menuHome)
19212
+ this._menuHome.appendChild(menu);
19213
+ for (const sub of this._portaledSubs) {
19214
+ if (sub.parentNode !== this._menuHome)
19215
+ this._menuHome.appendChild(sub);
19216
+ }
19217
+ this._portaledSubs = [];
19218
+ }
19219
+ clampMenuToViewport(menu) {
19220
+ if (menu.classList.contains("movi-context-menu-mobile"))
19221
+ return;
19222
+ const m = 8;
19223
+ const r = menu.getBoundingClientRect();
19224
+ if (r.width === 0)
19225
+ return;
19226
+ const curLeft = parseFloat(menu.style.left) || 0;
19227
+ const curTop = parseFloat(menu.style.top) || 0;
19228
+ let dx = 0;
19229
+ let dy = 0;
19230
+ if (r.right > window.innerWidth - m)
19231
+ dx = window.innerWidth - m - r.right;
19232
+ if (r.left + dx < m)
19233
+ dx = m - r.left;
19234
+ if (r.bottom > window.innerHeight - m)
19235
+ dy = window.innerHeight - m - r.bottom;
19236
+ if (r.top + dy < m)
19237
+ dy = m - r.top;
19238
+ if (dx)
19239
+ menu.style.left = `${curLeft + dx}px`;
19240
+ if (dy)
19241
+ menu.style.top = `${curTop + dy}px`;
19242
+ }
17380
19243
  updateHDRVisibility() {
17381
19244
  if (!this.player)
17382
19245
  return;