fix(shortcuts): avoid layout work during key handling

This commit is contained in:
2026-08-20 01:38:58 -04:00
parent 807bd0a29d
commit 06b23b3a53
3 changed files with 78 additions and 69 deletions
-5
View File
@@ -1,10 +1,6 @@
/* Base styles for the controller wrapper (the shadow host) */
.vsc-controller {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
/* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important;
@@ -17,7 +13,6 @@
controller in the browser top layer without nesting it inside <video>. */
.vsc-controller.vsc-fullscreen-popover {
position: fixed !important;
inset: auto !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
+41 -52
View File
@@ -1,4 +1,3 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
@@ -28,6 +27,11 @@ function getSharedDefault(key, fallback) {
return fallback;
}
function getCachedVideoRect(video) {
var wrapper = video && video.vsc && video.vsc.div;
return (wrapper && wrapper.vscVideoRect) || null;
}
function getPrimaryVideoElement(mediaElements) {
var candidates = Array.isArray(mediaElements)
? mediaElements
@@ -39,10 +43,13 @@ function getPrimaryVideoElement(mediaElements) {
candidates.forEach(function(el, index) {
if (!el || !el.vsc || !el.isConnected) return;
var rect = null;
try {
rect = el.getBoundingClientRect();
} catch (_error) {}
var rect = getCachedVideoRect(el);
var hasCachedRect = Boolean(rect);
if (!rect) {
try {
rect = el.getBoundingClientRect();
} catch (_error) {}
}
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
@@ -57,17 +64,24 @@ function getPrimaryVideoElement(mediaElements) {
: 0;
var visibleArea = visibleWidth * visibleHeight;
var visuallyAvailable = visibleArea > 0;
try {
var computed = win && win.getComputedStyle(el);
if (
computed &&
(computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
if (
el.vsc.div &&
el.vsc.div.classList.contains("vsc-geometry-hidden")
) {
visuallyAvailable = false;
} else if (!hasCachedRect) {
try {
var computed = win && win.getComputedStyle(el);
if (
computed &&
(computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
}
var score = visibleArea;
if (visuallyAvailable) score += 1e12;
@@ -2894,8 +2908,6 @@ function getControllerMount(video, boundary) {
return isShadowRootNode(directRoot) ? directRoot : null;
}
var mountBoundary = null;
var videoRect = video.getBoundingClientRect();
var mount = video.parentElement;
var candidate = mount;
@@ -2913,15 +2925,7 @@ function getControllerMount(video, boundary) {
// Climb through tightly-sized wrappers so our host shares their stacking
// context, but stop before broad page-layout containers.
while (candidate && candidate.parentElement && depth < 5) {
if (mountBoundary && candidate === mountBoundary) break;
var next = candidate.parentElement;
if (
mountBoundary &&
next !== mountBoundary &&
!mountBoundary.contains(next)
) {
break;
}
var nextRect = next.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
@@ -2945,10 +2949,6 @@ function getControllerMount(video, boundary) {
candidate = next;
depth += 1;
// In fullscreen, the wrapper must remain inside the exact subtree the
// browser promotes to its top layer.
if (mountBoundary && next === mountBoundary) break;
// Never climb out of a player-owned stacking context. Doing so lets the
// controller's high local z-index escape above sticky page headers.
if (createsControllerStackingContext(next)) break;
@@ -2976,6 +2976,14 @@ function positionControllerHost(wrapper, video, mount) {
return;
}
var videoRect = video.getBoundingClientRect();
wrapper.vscVideoRect = {
left: Number(videoRect.left) || 0,
top: Number(videoRect.top) || 0,
right: Number(videoRect.right) || 0,
bottom: Number(videoRect.bottom) || 0,
width: Number(videoRect.width) || 0,
height: Number(videoRect.height) || 0
};
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
if (videoRect.width <= 0 || videoRect.height <= 0) {
wrapper.classList.add("vsc-geometry-hidden");
@@ -3500,9 +3508,6 @@ function defineVideoController() {
setSpeed(event.target, expectedSpeed, false, false);
}
if (isUserSeek) {
isUserSeek = false;
}
}
};
@@ -3896,11 +3901,6 @@ function defineVideoController() {
timer = setTimeout(() => {
timer = null;
if (this.controllerInteractionActive) return;
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000);
@@ -3923,8 +3923,8 @@ function defineVideoController() {
// Initial show/timer
resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles,
// but we listen on both the video and the wrapper for maximum coverage.
// Players dispatch activity at different layers, so observe the media,
// aligned controller host, and player mount.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl =
getControllerGeometryMount(this.controllerHostMount) ||
@@ -4923,16 +4923,7 @@ function getClosestMediaToPointer(candidates, pointerPosition) {
candidates.forEach(function(video) {
if (!video || !video.vsc || !video.isConnected) return;
var target = getControllerElement(video.vsc) || video;
var rect = null;
try {
rect = target.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
rect = video.getBoundingClientRect();
}
} catch (_error) {
return;
}
var rect = getCachedVideoRect(video);
if (!rect || rect.width <= 0 || rect.height <= 0) return;
var distance = distanceSquaredToRect(
pointerPosition.x,
@@ -5061,12 +5052,10 @@ function runAction(action, value, e) {
);
switch (action) {
case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime -= numValue;
break;
case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime += numValue;
break;
+37 -12
View File
@@ -281,15 +281,25 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
});
it("does not let shadow host defaults override measured geometry", () => {
const shadowCss = readWorkspaceFile("extension/content/shadow.css");
const hostRule = shadowCss.match(/:host\s*\{([^}]*)\}/)[1];
const fullscreenRule = shadowCss.match(
/:host\(\.vsc-fullscreen-popover\)\s*\{([^}]*)\}/
)[1];
expect(hostRule).not.toMatch(/\b(?:top|left|width|height)\s*:/);
expect(fullscreenRule).not.toMatch(/\binset\s*:/);
it("keeps measured geometry out of controller stylesheet defaults", () => {
[
{
css: readWorkspaceFile("extension/content/inject.css"),
host: /\.vsc-controller\s*\{([^}]*)\}/,
fullscreen:
/\.vsc-controller\.vsc-fullscreen-popover\s*\{([^}]*)\}/
},
{
css: readWorkspaceFile("extension/content/shadow.css"),
host: /:host\s*\{([^}]*)\}/,
fullscreen: /:host\(\.vsc-fullscreen-popover\)\s*\{([^}]*)\}/
}
].forEach(({ css, host, fullscreen }) => {
expect(css.match(host)[1]).not.toMatch(
/\b(?:top|left|width|height)\s*:/
);
expect(css.match(fullscreen)[1]).not.toMatch(/\binset\s*:/);
});
});
it("repositions a Shorts controller when playback moves the video on-screen", async () => {
@@ -332,16 +342,27 @@ describe("inject.js media/controller lifecycle regressions", () => {
src: "https://example.org/second.mp4",
mountRect: makeRect(500, 0, 320, 180)
});
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
const firstLayoutRead = vi.fn(() => first.mount.getBoundingClientRect());
const secondLayoutRead = vi.fn(() => second.mount.getBoundingClientRect());
first.video.getBoundingClientRect = firstLayoutRead;
second.video.getBoundingClientRect = secondLayoutRead;
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
);
window.runAction("faster", 0.1);
document.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyD",
key: "d"
})
);
expect(first.video.playbackRate).toBe(1);
expect(second.video.playbackRate).toBe(1.1);
expect(firstLayoutRead).not.toHaveBeenCalled();
expect(secondLayoutRead).not.toHaveBeenCalled();
window.tc.settings.shortcutTargetMode = "all";
window.runAction("faster", 0.1);
@@ -1026,9 +1047,13 @@ describe("inject.js media/controller lifecycle regressions", () => {
videoRect: makeRect(40, 40, 640, 360),
src: "https://example.org/visible.mp4"
}).video;
const offscreenLayoutRead = vi.spyOn(offscreen, "getBoundingClientRect");
const visibleLayoutRead = vi.spyOn(visible, "getBoundingClientRect");
expect(window.getPrimaryVideoElement()).toBe(visible);
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
expect(offscreenLayoutRead).not.toHaveBeenCalled();
expect(visibleLayoutRead).not.toHaveBeenCalled();
});
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {