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