fix(controller): keep video controls accessible

This commit is contained in:
2026-07-12 15:50:37 -04:00
parent a56742b421
commit bbab33f55f
8 changed files with 369 additions and 44 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: 2147483646 !important;
/* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important;
white-space: normal;
overflow: visible !important;
}
+190 -39
View File
@@ -1297,6 +1297,26 @@ chrome.storage.sync.get(tc.settings, function(storage) {
sendResponse({ url: location.href });
return false;
}
if (request.action === "set_force_last_saved_speed") {
tc.settings.forceLastSavedSpeed = Boolean(request.enabled);
if (isValidSpeed(Number(request.speed))) {
tc.settings.lastSpeed = Number(request.speed);
}
var forceVideo = getPrimaryVideoElement();
if (!forceVideo) return false;
if (tc.settings.forceLastSavedSpeed) {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
setSpeed(video, tc.settings.lastSpeed, false, true);
extendSpeedRestoreWindow(video);
});
}
sendResponse({
enabled: tc.settings.forceLastSavedSpeed,
speed: forceVideo.playbackRate
});
return false;
}
if (request.action === "run_action") {
var value = request.value;
if (value === undefined || value === null) {
@@ -1419,6 +1439,143 @@ function createControllerButton(doc, action, label, className) {
return button;
}
function getControllerMount(video) {
if (!video || !video.parentElement) return null;
var videoRect = video.getBoundingClientRect();
var mount = video.parentElement;
var candidate = mount;
var depth = 0;
// Player click-catchers are often siblings of the video's immediate parent.
// 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) {
var next = candidate.parentElement;
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);
var containsVideo =
nextRect.left <= videoRect.left + 1 &&
nextRect.top <= videoRect.top + 1 &&
nextRect.right >= videoRect.right - 1 &&
nextRect.bottom >= videoRect.bottom - 1;
if (
videoRect.width <= 0 ||
videoRect.height <= 0 ||
!containsVideo ||
nextRect.width > widthLimit ||
nextRect.height > heightLimit
) {
break;
}
mount = next;
candidate = next;
depth += 1;
}
return mount;
}
function positionControllerHost(wrapper, video, mount) {
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
var videoRect = video.getBoundingClientRect();
var mountRect = mount.getBoundingClientRect();
if (videoRect.width <= 0 || videoRect.height <= 0) return;
// Convert viewport pixels back into the mount's CSS pixel space. This keeps
// the overlay aligned even when a player or ancestor is scaled.
var mountWidth = mount.offsetWidth || mountRect.width || 1;
var mountHeight = mount.offsetHeight || mountRect.height || 1;
var scaleX = mountRect.width > 0 ? mountRect.width / mountWidth : 1;
var scaleY = mountRect.height > 0 ? mountRect.height / mountHeight : 1;
var left =
(videoRect.left - mountRect.left) / scaleX -
(mount.clientLeft || 0) +
(mount.scrollLeft || 0);
var top =
(videoRect.top - mountRect.top) / scaleY -
(mount.clientTop || 0) +
(mount.scrollTop || 0);
wrapper.style.setProperty("left", left + "px", "important");
wrapper.style.setProperty("top", top + "px", "important");
wrapper.style.setProperty(
"width",
videoRect.width / scaleX + "px",
"important"
);
wrapper.style.setProperty(
"height",
videoRect.height / scaleY + "px",
"important"
);
}
function setupControllerHostTracking(videoController, wrapper, mount) {
if (!videoController || !wrapper || !mount) return;
var doc = videoController.video.ownerDocument;
var win = doc.defaultView || window;
var frameId = null;
var update = function() {
frameId = null;
positionControllerHost(wrapper, videoController.video, mount);
};
var schedule = function() {
if (frameId !== null) return;
frameId = win.requestAnimationFrame(update);
};
if (win.getComputedStyle(mount).position === "static") {
mount.dataset.vscPositionOwner = "true";
mount.dataset.vscOriginalPosition = mount.style.getPropertyValue("position");
mount.dataset.vscOriginalPositionPriority =
mount.style.getPropertyPriority("position");
mount.style.setProperty("position", "relative");
}
var resizeObserver = null;
if (typeof win.ResizeObserver === "function") {
resizeObserver = new win.ResizeObserver(schedule);
resizeObserver.observe(videoController.video);
resizeObserver.observe(mount);
}
win.addEventListener("resize", schedule, { passive: true });
doc.addEventListener("fullscreenchange", schedule, { passive: true });
mount.addEventListener("scroll", schedule, { passive: true });
update();
videoController.controllerHostCleanup = function() {
if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", schedule);
mount.removeEventListener("scroll", schedule);
if (
mount.dataset.vscPositionOwner === "true" &&
!mount.querySelector(".vsc-controller")
) {
if (mount.dataset.vscOriginalPosition) {
mount.style.setProperty(
"position",
mount.dataset.vscOriginalPosition,
mount.dataset.vscOriginalPositionPriority || ""
);
} else {
mount.style.removeProperty("position");
}
delete mount.dataset.vscPositionOwner;
delete mount.dataset.vscOriginalPosition;
delete mount.dataset.vscOriginalPositionPriority;
}
};
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
@@ -1595,6 +1752,10 @@ function defineVideoController() {
this.genericAutoHideCleanup = null;
}
if (this.div) this.div.remove();
if (this.controllerHostCleanup) {
this.controllerHostCleanup();
this.controllerHostCleanup = null;
}
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
if (this.video) {
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
@@ -2004,65 +2165,55 @@ function defineVideoController() {
}
}
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
const parentEl = this.parent || this.video.parentElement;
var mountEl = getControllerMount(this.video) || parentEl;
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
if (!parentEl || !parentEl.parentNode) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(fragment);
doc.body.appendChild(wrapper);
setupControllerHostTracking(this, wrapper, doc.body);
return wrapper;
}
try {
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case location.hostname === "www.amazon.com":
case location.hostname === "www.reddit.com":
case /hbogo\./.test(location.hostname):
log("Using parentElement.parentElement insertion", 5);
parentEl.parentElement.insertBefore(fragment, parentEl);
mountEl = parentEl.parentElement || mountEl;
break;
case location.hostname == "www.facebook.com":
log("Using Facebook-specific insertion", 5);
let p =
parentEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "tv.apple.com":
log("Using Apple TV-specific insertion", 5);
const r = parentEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "www.youtube.com":
case location.hostname == "m.youtube.com":
case location.hostname == "music.youtube.com":
// YouTube's player DOM has .html5-video-container (video's parent) as a
// low layer with overlay siblings (.ytp-player-content, etc.) on top that
// intercept mouse events. Insert into .html5-video-player (the player
// root) so the controller sits above all overlay layers.
log("Using YouTube-specific insertion", 5);
var ytPlayer = parentEl.closest(".html5-video-player");
if (ytPlayer) {
ytPlayer.insertBefore(fragment, ytPlayer.firstChild);
} else {
parentEl.insertBefore(fragment, parentEl.firstChild);
case location.hostname === "www.facebook.com":
var facebookMount = parentEl;
for (var facebookDepth = 0; facebookDepth < 7; facebookDepth += 1) {
if (!facebookMount.parentElement) break;
facebookMount = facebookMount.parentElement;
}
mountEl = facebookMount || mountEl;
break;
case location.hostname === "tv.apple.com":
var appleRoot = parentEl.getRootNode();
var appleScrim =
appleRoot && appleRoot.querySelector
? appleRoot.querySelector(".scrim")
: null;
mountEl = appleScrim || mountEl;
break;
case location.hostname === "www.youtube.com":
case location.hostname === "m.youtube.com":
case location.hostname === "music.youtube.com":
mountEl = parentEl.closest(".html5-video-player") || mountEl;
break;
default:
log("Using default insertion method", 5);
parentEl.insertBefore(fragment, parentEl.firstChild);
}
mountEl.insertBefore(wrapper, mountEl.firstChild);
setupControllerHostTracking(this, wrapper, mountEl);
log("Controller successfully inserted into DOM", 4);
} catch (error) {
log(`Error inserting controller: ${error.message}`, 2);
// Fallback to body insertion
doc.body.appendChild(fragment);
doc.body.appendChild(wrapper);
setupControllerHostTracking(this, wrapper, doc.body);
}
return wrapper;
+30
View File
@@ -1,3 +1,33 @@
:host {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
z-index: 2147483647 !important;
white-space: normal;
overflow: visible !important;
}
:host(.vsc-nosource),
:host(.vsc-hidden) {
display: none !important;
}
:host(.ytp-autohide:not(.vsc-hidden)),
:host(.vsc-idle-hidden:not(.vsc-hidden)) {
visibility: hidden;
opacity: 0;
}
:host(.ytp-autohide.vsc-show:not(.vsc-hidden)),
:host(.vsc-idle-hidden.vsc-show:not(.vsc-hidden)),
:host(.vsc-forced-show:not(.vsc-hidden)) {
visibility: visible !important;
opacity: 1 !important;
}
* {
line-height: 1.9em;
font-family: sans-serif;
+24 -4
View File
@@ -94,10 +94,24 @@ button:focus-visible {
color: #fff;
}
#refresh:hover {
#refresh:hover {
background: #1f2937;
border-color: #1f2937;
}
}
.popup-compact-action {
min-height: 24px;
padding: 0 9px;
border-radius: 6px;
color: var(--muted);
font-size: 11px;
}
.popup-compact-action[aria-pressed="true"] {
background: #e8f3e5;
border-color: #9fbd98;
color: #285d21;
}
.popup-divider {
height: 1px;
@@ -304,10 +318,16 @@ button:focus-visible {
color: #111315;
}
#refresh:hover {
#refresh:hover {
background: #dfe3e8;
border-color: #dfe3e8;
}
}
.popup-compact-action[aria-pressed="true"] {
background: #21351f;
border-color: #52724d;
color: #b8ddb1;
}
.donate-icon-btn:hover {
background: #1f2226;
+6
View File
@@ -17,6 +17,12 @@
</div>
<div class="popup-actions">
<button id="refresh">Rescan page for videos</button>
<button
id="forceLastSavedSpeed"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Force last saved speed</button>
<div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
+50
View File
@@ -27,6 +27,8 @@ document.addEventListener("DOMContentLoaded", function () {
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
lastSpeed: 1.0,
forceLastSavedSpeed: false,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
@@ -35,6 +37,15 @@ document.addEventListener("DOMContentLoaded", function () {
};
var renderToken = 0;
function updateForceButton(enabled) {
var button = document.getElementById("forceLastSavedSpeed");
if (!button) return;
button.setAttribute("aria-pressed", enabled ? "true" : "false");
button.title = enabled
? "Stop forcing the saved speed"
: "Keep this page at the last speed saved by Speeder";
}
function matchSiteRule(url, siteRules) {
return siteRuleUtils.matchSiteRule(url, siteRules);
}
@@ -252,6 +263,43 @@ document.addEventListener("DOMContentLoaded", function () {
});
});
var forceLastSavedSpeedButton = document.querySelector(
"#forceLastSavedSpeed"
);
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
forceLastSavedSpeedButton.addEventListener("click", function () {
var button = this;
var enabled = button.getAttribute("aria-pressed") !== "true";
chrome.storage.sync.get({ lastSpeed: 1.0 }, function (storage) {
chrome.storage.sync.set({ forceLastSavedSpeed: enabled }, function () {
updateForceButton(enabled);
sendToActiveTab(
{
action: "set_force_last_saved_speed",
enabled: enabled,
speed: Number(storage.lastSpeed) || 1.0
},
function (response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
setStatusMessage(
enabled ? "Saved speed is now forced." : "Speed forcing is off."
);
} else {
setStatusMessage(
enabled
? "Force enabled. No video found on this page."
: "Speed forcing is off."
);
}
}
);
});
});
});
}
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
@@ -282,6 +330,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
toggleEnabledUI(storage.enabled !== false);
updateForceButton(storage.forceLastSavedSpeed === true);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
@@ -324,6 +373,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (areaName !== "sync") return;
if (
changes.enabled ||
changes.forceLastSavedSpeed ||
changes.showPopupControlBar ||
changes.controllerButtons ||
changes.popupMatchHoverControls ||
+43
View File
@@ -140,4 +140,47 @@ describe("inject.js helper logic", () => {
expect(window.tc.settings.controllerMarginBottom).toBe(0);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
it("sizes and positions the controller host to the video bounds", async () => {
bootInject();
await flushAsyncWork(3);
const mount = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
wrapper.className = "vsc-controller";
mount.append(video, wrapper);
document.body.appendChild(mount);
Object.defineProperties(mount, {
offsetWidth: { value: 400 },
offsetHeight: { value: 240 },
clientLeft: { value: 2 },
clientTop: { value: 2 }
});
mount.getBoundingClientRect = () => ({
left: 100,
top: 50,
right: 500,
bottom: 290,
width: 400,
height: 240
});
video.getBoundingClientRect = () => ({
left: 140,
top: 70,
right: 460,
bottom: 250,
width: 320,
height: 180
});
window.positionControllerHost(wrapper, video, mount);
expect(wrapper.style.getPropertyValue("left")).toBe("38px");
expect(wrapper.style.getPropertyValue("top")).toBe("18px");
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
expect(wrapper.style.getPropertyPriority("width")).toBe("important");
});
});
+24
View File
@@ -134,4 +134,28 @@ describe("popup UI", () => {
);
expect(chrome.tabs.executeScript).toHaveBeenCalled();
});
it("toggles force last saved speed and applies it to the active page", async () => {
const chrome = await setupPopup({
sync: { lastSpeed: 1.8, forceLastSavedSpeed: false }
});
chrome.tabs.sendMessage.mockClear();
document.getElementById("forceLastSavedSpeed").click();
await flushAsyncWork();
expect(chrome.storage.sync.__state.forceLastSavedSpeed).toBe(true);
expect(
document.getElementById("forceLastSavedSpeed").getAttribute("aria-pressed")
).toBe("true");
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
1,
{
action: "set_force_last_saved_speed",
enabled: true,
speed: 1.8
},
expect.any(Function)
);
});
});