mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-08-19 11:52:31 -04:00
fix(controller): keep video controls accessible
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user