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