v6.0.0-beta.1

This commit is contained in:
2026-07-16 17:42:12 -04:00
9 changed files with 297 additions and 25 deletions
+104 -18
View File
@@ -98,6 +98,10 @@ var tc = {
rememberSpeed: getSharedDefault("rememberSpeed", false),
forceLastSavedSpeed: getSharedDefault("forceLastSavedSpeed", false),
audioBoolean: getSharedDefault("audioBoolean", false),
showAmbientLoopControls: getSharedDefault(
"showAmbientLoopControls",
false
),
startHidden: getSharedDefault("startHidden", false),
hideWithYouTubeControls: getSharedDefault(
"hideWithYouTubeControls",
@@ -488,6 +492,7 @@ function captureSiteRuleBase() {
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
showAmbientLoopControls: tc.settings.showAmbientLoopControls,
controllerOpacity: tc.settings.controllerOpacity,
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
@@ -516,6 +521,7 @@ function resetSettingsFromSiteRuleBase() {
tc.settings.rememberSpeed = base.rememberSpeed;
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
tc.settings.audioBoolean = base.audioBoolean;
tc.settings.showAmbientLoopControls = base.showAmbientLoopControls;
tc.settings.controllerOpacity = base.controllerOpacity;
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
@@ -1364,8 +1370,70 @@ function isMediaElement(node) {
);
}
function hasInteractivePlayerChrome(node) {
if (!node || node.nodeName !== "VIDEO") return false;
if (node.controls === true) return true;
if (typeof node.closest === "function") {
var knownPlayer = node.closest(
".html5-video-player, .video-js, .jwplayer, .plyr, " +
"[class~='video-player'], [data-testid*='video-player'], " +
"media-player, amp-video"
);
if (knownPlayer) return true;
}
var ancestor = node.parentElement;
var videoRect = node.getBoundingClientRect();
var depth = 0;
while (ancestor && depth < 4) {
var ancestorRect = ancestor.getBoundingClientRect();
if (
videoRect.width > 0 &&
videoRect.height > 0 &&
(ancestorRect.width > Math.max(videoRect.width * 1.75, videoRect.width + 160) ||
ancestorRect.height > Math.max(videoRect.height * 1.75, videoRect.height + 160))
) {
break;
}
if (
(videoRect.width <= 0 || videoRect.height <= 0) &&
depth > 1
) {
break;
}
try {
var controls = ancestor.querySelectorAll(
"button, [role='button'], input[type='range'], [role='slider']"
);
for (var i = 0; i < Math.min(controls.length, 40); i += 1) {
var control = controls[i];
var description = [
control.getAttribute("aria-label"),
control.getAttribute("title"),
control.getAttribute("data-title-no-tooltip"),
control.className
]
.filter(Boolean)
.join(" ");
if (
/(^|\b)(play|pause|mute|volume|seek|captions?|subtitles?|fullscreen|full screen)(\b|$)/i.test(
description
)
) {
return true;
}
}
} catch (_error) {}
ancestor = ancestor.parentElement;
depth += 1;
}
return false;
}
function isAmbientLoopMedia(node) {
return Boolean(
var hasAmbientSignature = Boolean(
node &&
node.nodeName === "VIDEO" &&
node.autoplay === true &&
@@ -1374,6 +1442,8 @@ function isAmbientLoopMedia(node) {
node.playsInline === true &&
node.controls !== true
);
if (!hasAmbientSignature || tc.settings.showAmbientLoopControls) return false;
return !hasInteractivePlayerChrome(node);
}
function hasUsableMediaSource(node) {
@@ -1424,10 +1494,6 @@ function ensureController(node, parent) {
return null;
}
if (!isMediaElement(node)) return null;
if (isAmbientLoopMedia(node)) {
if (node.vsc) removeController(node);
return null;
}
if (typeof tc.videoController === "undefined") defineVideoController();
// href selects site rules; re-run on every new/usable media so all runtime
@@ -1437,6 +1503,10 @@ function ensureController(node, parent) {
if (node.vsc) removeController(node);
return null;
}
if (isAmbientLoopMedia(node)) {
if (node.vsc) removeController(node);
return null;
}
if (
node.vsc &&
@@ -1913,6 +1983,8 @@ function hydrateRuntimeSettings(rawStorage, options) {
tc.settings.rememberSpeed = storage.rememberSpeed === true;
tc.settings.forceLastSavedSpeed = storage.forceLastSavedSpeed === true;
tc.settings.audioBoolean = storage.audioBoolean === true;
tc.settings.showAmbientLoopControls =
storage.showAmbientLoopControls === true;
tc.settings.enabled = storage.enabled !== false;
tc.settings.startHidden = storage.startHidden === true;
tc.settings.hideWithControls =
@@ -3065,14 +3137,14 @@ function disableDirectFullscreenPopover(videoController) {
wrapper.removeAttribute("popover");
}
function enableDirectFullscreenPopover(videoController) {
function enableFullscreenPopover(videoController, preferredMount) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false;
var normalMount = videoController.normalControllerMount;
var normalMount = preferredMount || videoController.normalControllerMount;
var normalMountIsConnected = Boolean(
normalMount &&
(normalMount.isConnected ||
@@ -3113,12 +3185,6 @@ function syncControllerFullscreenMount(videoController) {
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
if (fullscreenElement === video) {
if (enableDirectFullscreenPopover(videoController)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
if (
fullscreenElement &&
(fullscreenElement === video ||
@@ -3131,6 +3197,18 @@ function syncControllerFullscreenMount(videoController) {
}
if (!targetMount) return false;
if (fullscreenElement) {
// Fullscreen elements and popovers both participate in the browser's top
// layer. Showing Speeder's host after the player enters fullscreen keeps it
// above provider-owned surfaces even when the provider clips descendants or
// creates a new fullscreen stacking context (notably Firefox + YouTube).
// Browsers without the Popover API retain the player-local remount fallback.
if (enableFullscreenPopover(videoController, targetMount)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
return remountControllerHost(videoController, targetMount);
}
@@ -3573,9 +3651,13 @@ function defineVideoController() {
) {
wrapper.classList.add("ytp-autohide");
// Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) {
// Preserve an active pointer reveal or shortcut-forced reveal. The
// ytp-autohide class remains in place, so CSS hides the host as soon as
// vsc-show's bounded timer expires.
if (
!wrapper.classList.contains("vsc-forced-show") &&
!wrapper.classList.contains("vsc-show")
) {
wrapper.classList.remove("vsc-show");
if (wrapper.showTimeOut) {
clearTimeout(wrapper.showTimeOut);
@@ -3984,6 +4066,7 @@ function applySiteRuleOverrides() {
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
@@ -4033,9 +4116,12 @@ function applySiteRuleOverrides() {
}
function removeIneligibleMediaControllers() {
if (tc.settings.audioBoolean) return;
tc.mediaElements.slice().forEach(function(media) {
if (media && media.nodeName === "AUDIO") {
if (
media &&
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
isAmbientLoopMedia(media))
) {
removeController(media);
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.3.6.1",
"version": "6.0.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
+23 -2
View File
@@ -282,6 +282,17 @@
</label>
<input id="audioBoolean" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="showAmbientLoopControls"
>Show controls on ambient/slideshow videos<br />
<em
>Also shows Speeder on muted autoplay loops used as animated
cards or slideshow artwork. Genuine video players are always
controlled regardless of this setting.</em
>
</label>
<input id="showAmbientLoopControls" type="checkbox" />
</div>
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Playback</h4>
@@ -803,8 +814,8 @@
<label class="site-override-lead">
<span
>Override playback for this site<br /><em
>When on, remember speed / force / audio below replace
general defaults for matching URLs.</em
>When on, remember speed / force / media eligibility
below replace general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-playback" />
@@ -854,6 +865,16 @@
>
<input type="checkbox" class="site-audioBoolean" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label
>Show controls on ambient/slideshow videos:<br /><em
>Also includes muted autoplay loops used as animated
cards or slideshow artwork. Genuine video players are
always included.</em
></label
>
<input type="checkbox" class="site-showAmbientLoopControls" />
</div>
</div>
</div>
<div class="site-rule-override-section">
+11 -1
View File
@@ -835,6 +835,8 @@ function save_options() {
settings.forceLastSavedSpeed =
document.getElementById("forceLastSavedSpeed").checked;
settings.audioBoolean = document.getElementById("audioBoolean").checked;
settings.showAmbientLoopControls =
document.getElementById("showAmbientLoopControls").checked;
settings.enabled = document.getElementById("enabled").checked;
settings.startHidden = document.getElementById("startHidden").checked;
settings.shortcutTargetMode =
@@ -974,11 +976,15 @@ function save_options() {
rule.preferredSpeed = preferredSpeed;
}
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
rule.showAmbientLoopControls = ruleEl.querySelector(
".site-showAmbientLoopControls"
).checked;
} else {
delete rule.rememberSpeed;
delete rule.forceLastSavedSpeed;
delete rule.preferredSpeed;
delete rule.audioBoolean;
delete rule.showAmbientLoopControls;
}
if (ruleEl.querySelector(".override-opacity").checked) {
@@ -1278,7 +1284,8 @@ function createSiteRule(rule) {
(rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined ||
rule.preferredSpeed !== undefined ||
rule.audioBoolean !== undefined)
rule.audioBoolean !== undefined ||
rule.showAmbientLoopControls !== undefined)
);
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
@@ -1288,6 +1295,7 @@ function createSiteRule(rule) {
? String(rule.preferredSpeed)
: "";
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
syncSiteRuleField(ruleEl, rule, "showAmbientLoopControls", true);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
ruleEl.querySelector(".override-opacity").checked = Boolean(
@@ -1780,6 +1788,8 @@ function restore_options(callback) {
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("showAmbientLoopControls").checked =
storage.showAmbientLoopControls;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
document.getElementById("shortcutTargetMode").value =
+4
View File
@@ -24,6 +24,7 @@
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enabled",
"startHidden",
"hideWithControls",
@@ -58,6 +59,7 @@
lastSpeed: 1.0,
rememberSpeed: false,
audioBoolean: false,
showAmbientLoopControls: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
@@ -324,6 +326,7 @@
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"showPopupControlBar",
@@ -865,6 +868,7 @@
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enabled",
"startHidden",
"hideWithControls",
+2
View File
@@ -25,6 +25,7 @@ describe("import/export flows", () => {
const settings = globalThis.vscGetSettingsDefaults();
settings.siteRules[1].title = "My Shorts rule";
settings.siteRules[1].shortcutTargetMode = "all";
settings.siteRules[1].showAmbientLoopControls = true;
const exported = globalThis.normalizedSettingsForExport(
globalThis.vscBuildStoredSettingsDiff(settings)
@@ -33,6 +34,7 @@ describe("import/export flows", () => {
expect(restored.siteRules[1].title).toBe("My Shorts rule");
expect(restored.siteRules[1].shortcutTargetMode).toBe("all");
expect(restored.siteRules[1].showAmbientLoopControls).toBe(true);
expect(exported.siteRules.map((rule) => rule.title)).toEqual([
"YouTube videos",
"My Shorts rule",
+116 -3
View File
@@ -309,7 +309,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(second.video.playbackRate).toBe(1.2);
});
it("skips GIF-like ambient loops but accepts them if they become real players", async () => {
it("skips ambient loops by default and includes them when explicitly enabled", async () => {
bootInject();
await settleLifecycle();
@@ -332,12 +332,71 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(window.ensureController(video, mount)).toBeNull();
expect(video.vsc).toBeUndefined();
video.loop = false;
video.controls = true;
window.tc.settings.showAmbientLoopControls = true;
window.tc.siteRuleBase.showAmbientLoopControls = true;
expect(window.ensureController(video, mount)).toBeTruthy();
expect(video.vsc.div.isConnected).toBe(true);
});
it("always includes a genuine player even when its video has an ambient-loop signature", async () => {
bootInject();
await settleLifecycle();
const player = document.createElement("media-player");
const video = document.createElement("video");
const playButton = document.createElement("button");
const rect = makeRect(0, 0, 640, 360);
playButton.setAttribute("aria-label", "Play");
video.autoplay = true;
video.defaultMuted = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.src = "https://example.org/interactive-loop.mp4";
player.append(video, playButton);
document.body.appendChild(player);
[player, video].forEach((element) => setRect(element, rect));
setBoxMetrics(player, rect.width, rect.height);
expect(window.tc.settings.showAmbientLoopControls).toBe(false);
expect(window.ensureController(video, player)).toBeTruthy();
expect(video.vsc.div.isConnected).toBe(true);
});
it("applies the ambient-loop setting from a matching site rule", async () => {
bootInject({
url: "https://news.example.org/story",
syncData: {
showAmbientLoopControls: false,
siteRules: [
{
pattern: "news.example.org",
enabled: true,
showAmbientLoopControls: true
}
]
}
});
await settleLifecycle();
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 480, 270);
video.autoplay = true;
video.defaultMuted = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.src = "https://news.example.org/card.mp4";
mount.appendChild(video);
document.body.appendChild(mount);
[mount, video].forEach((element) => setRect(element, rect));
setBoxMetrics(mount, rect.width, rect.height);
expect(window.ensureController(video, mount)).toBeTruthy();
expect(window.tc.settings.showAmbientLoopControls).toBe(true);
});
it("moves the host into an ancestor fullscreen subtree and restores it", async () => {
bootInject();
await settleLifecycle();
@@ -384,6 +443,52 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup();
});
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 1280, 720);
fullscreenPlayer.append(video, wrapper);
document.body.appendChild(fullscreenPlayer);
[fullscreenPlayer, video].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
const controller = {
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentNode).toBe(fullscreenPlayer);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
wrapper.remove();
controller.controllerHostCleanup();
});
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
bootInject();
await settleLifecycle();
@@ -610,6 +715,14 @@ describe("inject.js media/controller lifecycle regressions", () => {
controllerElement.dispatchEvent(new Event("pointerleave"));
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
player.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
expect(wrapper.classList.contains("vsc-show")).toBe(true);
player.classList.remove("ytp-autohide");
player.classList.add("ytp-autohide");
await settleLifecycle();
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
expect(wrapper.classList.contains("vsc-show")).toBe(true);
});
it("suppresses a zero-size host and reveals it when the video becomes visible", async () => {
+10
View File
@@ -29,6 +29,7 @@ describe("options page", () => {
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
showAmbientLoopControls: true,
enabled: false,
shortcutTargetMode: "all",
subtitleNudgeEnabledByDefault: false,
@@ -45,6 +46,7 @@ describe("options page", () => {
enabled: true,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
showAmbientLoopControls: false,
subtitleNudgeEnabledByDefault: false,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
@@ -55,6 +57,7 @@ describe("options page", () => {
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("showAmbientLoopControls").checked).toBe(true);
expect(document.getElementById("enabled").checked).toBe(false);
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
@@ -72,6 +75,9 @@ describe("options page", () => {
expect(document.querySelector(".site-rule .site-preferredSpeed").value).toBe(
"2.4"
);
expect(
document.querySelector(".site-rule .site-showAmbientLoopControls").checked
).toBe(false);
expect(
document.querySelector(".site-rule .site-shortcutTargetMode").value
).toBe("all");
@@ -181,6 +187,7 @@ describe("options page", () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("showAmbientLoopControls").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0";
document.getElementById("controllerMarginTop").value = "250";
@@ -204,6 +211,7 @@ describe("options page", () => {
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").checked = true;
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".site-showAmbientLoopControls").checked = false;
rule.querySelector(".site-preferredSpeed").value = "2.4";
rule.querySelector(".override-shortcut-target").checked = true;
rule.querySelector(".site-shortcutTargetMode").value = "all";
@@ -231,6 +239,7 @@ describe("options page", () => {
);
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.showAmbientLoopControls).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
@@ -246,6 +255,7 @@ describe("options page", () => {
title: "My YouTube rule",
pattern: "youtube.com",
rememberSpeed: true,
showAmbientLoopControls: false,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
controllerOpacity: 0,
+26
View File
@@ -34,10 +34,36 @@ describe("canonical settings storage", () => {
})
);
expect(settings.shortcutTargetMode).toBe("closest");
expect(settings.showAmbientLoopControls).toBe(false);
expect(settings.subtitleNudgeInterval).toBe(250);
expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined();
});
it("round-trips global and site-specific ambient-loop visibility", () => {
const settings = window.vscExpandStoredSettings({
showAmbientLoopControls: true,
siteRules: [
{
pattern: "cnn.com",
showAmbientLoopControls: false
}
]
});
const restored = window.vscExpandStoredSettings(
window.vscBuildStoredSettingsDiff(settings)
);
expect(restored.showAmbientLoopControls).toBe(true);
expect(restored.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "cnn.com",
showAmbientLoopControls: false
})
])
);
});
it("round-trips global and site-specific shortcut targeting", () => {
const settings = window.vscExpandStoredSettings({
shortcutTargetMode: "all",