Compare commits

..
Author SHA1 Message Date
joshpatra 1a7dc3097e Release v5.2.4 2026-04-10 15:04:29 -04:00
joshpatra c626aca89c Release v5.2.1 2026-04-09 16:17:27 -04:00
joshpatra 05b8456e94 release: v5.1.7 2026-04-02 22:37:10 -04:00
joshpatra 0cbfac4b82 Release v5.1.5 2026-04-02 18:08:33 -04:00
joshpatra b3707c0803 Release v5.1.4 2026-04-02 18:07:09 -04:00
joshpatra fb25c56230 Merge beta 2026-04-01 15:33:11 -04:00
joshpatra 4efc3e0acc Merge beta 2026-04-01 11:29:16 -04:00
joshpatra 7c0a188cd3 Merge beta 2026-04-01 11:19:08 -04:00
joshpatra d7ce1fd000 Merge beta 2026-03-31 15:04:56 -04:00
joshpatra 313832015b Merge beta 2026-03-31 15:01:18 -04:00
48 changed files with 559 additions and 1861 deletions
+5 -3
View File
@@ -10,6 +10,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
WEB_EXT_IGNORE_FILES: scripts/**
steps:
- uses: actions/checkout@v4
@@ -18,7 +20,7 @@ jobs:
run: npm install -g web-ext
- name: Lint
run: web-ext lint --source-dir extension
run: web-ext lint
# Beta tag (v*-beta) → Sign as unlisted on AMO, attach signed XPI to GitHub Prerelease
# Firefox blocks all unsigned XPIs, even for self-hosted installs — unlisted signing is required
@@ -28,7 +30,7 @@ jobs:
web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
--source-dir extension \
--source-dir . \
--artifacts-dir web-ext-artifacts \
--channel unlisted
@@ -68,6 +70,6 @@ jobs:
web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
--source-dir extension \
--source-dir . \
--artifacts-dir web-ext-artifacts \
--channel listed
+10 -26
View File
@@ -1,8 +1,8 @@
# Available for Firefox
# Available for Firefox
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/firefox/addon/speeder/)
## The science of accelerated playback
# The science of accelerated playback
**TL;DR: faster playback translates to better engagement and retention.**
@@ -33,11 +33,9 @@ last point to listen to it a few more times.
![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png)
## Using the extension
#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/speeder/) Extension*
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/firefox/addon/speeder/)
Once the extension is installed simply navigate to any page that offers
\*\* Once the extension is installed simply navigate to any page that offers
HTML5 video ([example](https://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll
see a speed indicator in top left corner. Hover over the indicator to reveal the
controls to accelerate, slowdown, and quickly rewind or advance the video. Or,
@@ -67,35 +65,21 @@ listens both for lower and upper case values (i.e. you can use
key. This is not a perfect solution, as some sites may listen to both, but works
most of the time.
## Development
### FAQ
The unpacked extension root is `extension/`. Load that directory in
`about:debugging`, and run extension tooling against it, for example:
```sh
npm test
npx --yes web-ext lint --source-dir extension
```
## FAQ
### The video controls are not showing up?
This extension is only compatible
**The video controls are not showing up?** This extension is only compatible
with HTML5 video. If you don't see the controls showing up, chances are you are
viewing a Flash video. If you want to confirm, try right-clicking on the video
and inspect the menu: if it mentions flash, then that's the issue. That said,
most sites will fallback to HTML5 if they detect that Flash is not available.
You can try manually disabling Flash from the browser.
### What is this fork all about?
This is a fork of
[CodeBicycle's Video Speed Controller extension for Firefox](https://github.com/codebicycle/videospeed)
**What is this fork all about?** This is a fork of
[CodeBicycle's Video Speed Controller extension for Firefox](https://github.com/codebicycle/videospeed)
which is a fork of [Igrigorik's Video Speed Controller extension for Chromium](https://github.com/igrigorik/videospeed).
The goal of this fork is fix bugs in the upstream code as well as add new features.
## License
### License
(GPLv3) - Copyright (c) 2025 Josh Patra
@@ -1,5 +1,5 @@
chrome.runtime.onMessage.addListener(function (request) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
}
});
+157
View File
@@ -0,0 +1,157 @@
import glob
import fnmatch
import os
import re
import shutil
import tempfile
import zipfile
SCRIPT_NAME = os.path.basename(__file__)
TARGET_FILE = "manifest.json"
DEFAULT_EXCLUDE_FILES = {".DS_Store"}
DEFAULT_EXCLUDE_DIRS = {"__pycache__", "temp"}
DEFAULT_EXCLUDE_PATTERNS = {"._*", "*.pyc"}
def should_exclude(rel_path, exclude_files, exclude_dirs):
rel_path = os.path.normpath(rel_path)
path_parts = rel_path.split(os.sep)
file_name = path_parts[-1]
if file_name in DEFAULT_EXCLUDE_FILES or rel_path in DEFAULT_EXCLUDE_FILES:
return True
if any(part in DEFAULT_EXCLUDE_DIRS for part in path_parts):
return True
if file_name in exclude_files or rel_path in exclude_files:
return True
if any(part in exclude_dirs for part in path_parts):
return True
if any(fnmatch.fnmatch(file_name, pattern) for pattern in DEFAULT_EXCLUDE_PATTERNS):
return True
return False
def zip_folder(output_name, folder, exclude_files, exclude_dirs):
with zipfile.ZipFile(output_name, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(folder):
dirs[:] = [
d
for d in dirs
if not should_exclude(
os.path.relpath(os.path.join(root, d), folder),
exclude_files,
exclude_dirs,
)
]
for file in files:
rel_path = os.path.relpath(os.path.join(root, file), folder)
if should_exclude(rel_path, exclude_files, exclude_dirs):
continue
zipf.write(os.path.join(root, file), arcname=rel_path)
def update_version_line(file_path, new_version):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
updated = False
for i, line in enumerate(lines):
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
if match:
old_version = match.group(1)
lines[i] = re.sub(
r'"version":\s*".+?"', f'"version": "{new_version}"', line
)
updated = True
print(
f"🛠️ Changed version in {file_path} from {old_version}{new_version}"
)
break
if updated:
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
else:
print(f"⚠️ No version line found in {file_path}.")
def main():
# Step 0: Remove all existing .xpi files upfront
xpi_files = glob.glob("*.xpi")
for f in xpi_files:
try:
os.remove(f)
print(f"🗑️ Removed existing archive: {f}")
except Exception as e:
print(f"⚠️ Failed to remove {f}: {e}")
# Read current version from manifest.json
current_dir = os.getcwd()
manifest_path = os.path.join(current_dir, TARGET_FILE)
current_version = "unknown"
if os.path.exists(manifest_path):
with open(manifest_path, "r", encoding="utf-8") as f:
for line in f:
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
if match:
current_version = match.group(1)
break
print(f"📦 Current version: {current_version}")
base_version = input("Enter the new base version (e.g., 2.0.1): ").strip()
if not base_version:
print("❌ No version entered. Exiting.")
return
github_version = f"{base_version}.0"
# Step 1: Update manifest.json on disk to base_version (for Firefox)
if os.path.exists(manifest_path):
update_version_line(manifest_path, base_version)
else:
print(f"{TARGET_FILE} not found. Aborting.")
return
# Step 2: Create videospeed-firefox.xpi (exclude script, .git, AND videospeed-firefox.xpi itself)
exclude_files = [SCRIPT_NAME, "videospeed-firefox.xpi"]
exclude_dirs = [".git"]
zip_folder("videospeed-firefox.xpi", current_dir, exclude_files, exclude_dirs)
print("✅ Created videospeed-firefox.xpi")
# Step 3: Re-scan for .xpi files after Firefox archive creation, exclude them for GitHub zip
current_xpi_files = set(glob.glob("*.xpi"))
exclude_temp_files = current_xpi_files.union({SCRIPT_NAME})
exclude_temp_dirs = set(exclude_dirs)
# Step 4: Create videospeed-github.xpi from temp folder with version bumped to .0
with tempfile.TemporaryDirectory() as temp_dir:
for item in os.listdir(current_dir):
if should_exclude(item, exclude_temp_files, exclude_temp_dirs):
continue
src = os.path.join(current_dir, item)
dst = os.path.join(temp_dir, item)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
temp_manifest = os.path.join(temp_dir, TARGET_FILE)
if os.path.exists(temp_manifest):
update_version_line(temp_manifest, github_version)
else:
print(f"⚠️ {TARGET_FILE} not found in temp folder.")
zip_folder(
"videospeed-github.xpi", temp_dir, exclude_files=[], exclude_dirs=[]
)
print("✅ Created videospeed-github.xpi")
if __name__ == "__main__":
main()
-110
View File
@@ -1,110 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="../shared/site-rules.js"></script>
<script src="../shared/popup-controls.js"></script>
<script src="../shared/ui-icons.js"></script>
<script src="popup.js"></script>
</head>
<body>
<div class="popup-shell">
<div class="popup-header">
<span class="popup-title">Speeder</span>
<span class="popup-version">v<span id="app-version"></span></span>
</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>
</div>
<div class="popup-divider"></div>
<button id="enable" class="hide">Enable</button>
<button id="disable">Disable</button>
</div>
<div id="status" class="popup-status hide"></div>
<div class="popup-links">
<button id="config">Settings</button>
<div class="popup-secondary">
<button id="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button>
</div>
<div id="donateWrap" class="donate-wrap">
<button id="donate" class="secondary">Donate</button>
<div id="donateOptions" class="donate-split hide">
<a
id="donateGithub"
class="donate-icon-btn donate-icon-btn--github"
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
aria-label="Sponsor on GitHub (opens in new tab)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
<a
id="donateKofi"
class="donate-icon-btn donate-icon-btn--kofi"
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Ko-fi (opens in new tab)"
>
<img
src="../assets/images/kofi_symbol.svg"
width="28"
height="22"
alt=""
decoding="async"
/>
</a>
<a
id="donateBmc"
class="donate-icon-btn donate-icon-btn--bmc"
href="https://buymeacoffee.com/treeman183"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Buy Me a Coffee (opens in new tab)"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="22"
height="22"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</body>
</html>

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+6 -2
View File
@@ -6,12 +6,16 @@
width: 100% !important;
height: 100% !important;
pointer-events: none !important;
/* Keep the interactive controller above player-owned click/pause panes. */
z-index: 2147483647 !important;
z-index: 2147483646 !important;
white-space: normal;
overflow: visible !important;
}
/* Use minimal z-index for non-YouTube sites to avoid overlapping modals */
.vsc-controller.vsc-non-youtube {
z-index: 1 !important;
}
.vsc-nosource {
display: none !important;
}
+65 -483
View File
@@ -39,7 +39,6 @@ var tc = {
defaultLogLevel: 3,
logLevel: 3,
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001,
customButtonIcons: {}
@@ -59,8 +58,6 @@ var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet();
var vscConnectedScannedRoots = new WeakSet();
var vscInitializedDocuments = new Set();
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
@@ -387,7 +384,6 @@ function captureSiteRuleBase() {
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
controllerButtons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons.slice()
@@ -414,7 +410,6 @@ function resetSettingsFromSiteRuleBase() {
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
tc.settings.subtitleNudgeEnabledByDefault = base.subtitleNudgeEnabledByDefault;
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
tc.settings.controllerButtons = Array.isArray(base.controllerButtons)
? base.controllerButtons.slice()
@@ -664,15 +659,13 @@ function isSubtitleNudgeAvailableForVideo(video) {
function isSubtitleNudgeEnabledForVideo(video) {
if (!isSubtitleNudgeAvailableForVideo(video)) return false;
if (!video || !video.vsc) {
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
}
if (!video || !video.vsc) return true;
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
return video.vsc.subtitleNudgeEnabledOverride;
}
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
return true;
}
function setSubtitleNudgeEnabledForVideo(video, enabled) {
@@ -976,8 +969,8 @@ function ensureController(node, parent) {
}
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) {
return null;
}
refreshAllControllerGeometry();
@@ -993,15 +986,6 @@ function ensureController(node, parent) {
return node.vsc;
}
function ensureControllerForMediaChild(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
if (node.nodeName !== "SOURCE") return null;
var media = node.parentElement;
if (!isMediaElement(media)) return null;
return ensureController(media, media.parentElement || media.parentNode);
}
function removeController(node) {
if (node && node.vsc) node.vsc.remove();
}
@@ -1028,10 +1012,6 @@ function scanNodeForMedia(node, parent, added) {
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
} else if (added) {
// Players such as Vidstack often connect an empty <video> and append its
// <source> later. Retry the owning video when that source becomes usable.
ensureControllerForMediaChild(node);
}
// Use querySelectorAll instead of recursive child walking — the browser's
@@ -1054,22 +1034,6 @@ function scanNodeForMedia(node, parent, added) {
if (node.shadowRoot) {
observeRoot(node.shadowRoot);
}
// Deep-scan descendant elements for shadow roots we haven't observed yet.
// This catches custom elements (like archive.org's <play-av>) whose shadow
// roots were created before our attachShadow patch was installed.
if (added && typeof node.querySelectorAll === "function") {
try {
var allElements = node.querySelectorAll("*");
for (var j = 0; j < allElements.length; j++) {
if (allElements[j].shadowRoot && !vscObservedRoots.has(allElements[j].shadowRoot)) {
observeRoot(allElements[j].shadowRoot);
}
}
} catch (e) {
// querySelectorAll may throw on detached or unusual nodes
}
}
}
function getScanNodeForRoot(root) {
@@ -1083,8 +1047,6 @@ function getScanNodeForRoot(root) {
function rootMayContainMedia(root) {
if (!root) return false;
if (root.nodeType === Node.DOCUMENT_NODE) return true;
// Always scan shadow roots so we can find nested shadow roots or media.
if (root.host || (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot)) return true;
if (typeof root.querySelector !== "function") return true;
try {
@@ -1094,7 +1056,6 @@ function rootMayContainMedia(root) {
}
}
function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return;
@@ -1105,30 +1066,13 @@ function scanRootForMedia(root) {
}
function observeRoot(root) {
if (!root) return;
var isConnected = false;
try {
isConnected = root.nodeType === Node.DOCUMENT_NODE ||
root.isConnected ||
(root.host && (root.host.isConnected || (root.host.ownerDocument && root.host.ownerDocument.contains(root.host)))) ||
(root.ownerDocument && root.ownerDocument.contains(root));
} catch (e) {
isConnected = true;
}
if (!vscObservedRoots.has(root)) {
vscObservedRoots.add(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
}
if (isConnected && !vscConnectedScannedRoots.has(root)) {
vscConnectedScannedRoots.add(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
}
if (!root || vscObservedRoots.has(root)) return;
vscObservedRoots.add(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
}
}
@@ -1173,12 +1117,6 @@ function log(message, level) {
}
}
// Patch attachShadow immediately — before any async operations — so we
// catch shadow roots created while chrome.storage.sync.get is pending.
// Sites like archive.org create Lit/LitElement shadow DOMs during page load;
// waiting for the storage callback would miss them entirely.
patchAttachShadow();
chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
@@ -1257,10 +1195,6 @@ chrome.storage.sync.get(tc.settings, function(storage) {
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
: tc.settings.enableSubtitleNudge;
tc.settings.subtitleNudgeEnabledByDefault =
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
? Boolean(storage.subtitleNudgeEnabledByDefault)
: tc.settings.subtitleNudgeEnabledByDefault;
tc.settings.subtitleNudgeInterval = Math.min(
1000,
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
@@ -1284,7 +1218,7 @@ chrome.storage.sync.get(tc.settings, function(storage) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
}
captureSiteRuleBase();
// patchAttachShadow() is now called at top-level before this callback
patchAttachShadow();
// Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
@@ -1310,26 +1244,6 @@ 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) {
@@ -1452,288 +1366,6 @@ function createControllerButton(doc, action, label, className) {
return button;
}
function createsControllerStackingContext(element) {
if (!element || !element.ownerDocument) return false;
var win = element.ownerDocument.defaultView || window;
var style = win.getComputedStyle(element);
var position = style.position;
var zIndex = style.zIndex;
var contain = style.contain || "";
var willChange = style.willChange || "";
return (
style.isolation === "isolate" ||
position === "fixed" ||
position === "sticky" ||
(position && position !== "static" && zIndex && zIndex !== "auto") ||
(style.opacity !== "" && Number(style.opacity) < 1) ||
(style.transform && style.transform !== "none") ||
(style.filter && style.filter !== "none") ||
(style.perspective && style.perspective !== "none") ||
contain.indexOf("paint") !== -1 ||
contain.indexOf("layout") !== -1 ||
willChange.indexOf("transform") !== -1 ||
willChange.indexOf("opacity") !== -1
);
}
function getControllerMount(video, boundary) {
if (!video || !video.parentElement) return null;
var mountBoundary =
boundary &&
boundary !== video &&
typeof boundary.contains === "function" &&
boundary.contains(video)
? boundary
: 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) {
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);
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;
// 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;
}
return mount;
}
function getFullscreenElement(doc) {
if (!doc) return null;
return (
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
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");
}
if (!createsControllerStackingContext(mount)) {
mount.dataset.vscIsolationOwner = "true";
mount.dataset.vscOriginalIsolation =
mount.style.getPropertyValue("isolation");
mount.dataset.vscOriginalIsolationPriority =
mount.style.getPropertyPriority("isolation");
mount.style.setProperty("isolation", "isolate");
}
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.controllerHostMount = mount;
videoController.controllerHostCleanup = function(forceRestore) {
if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", schedule);
mount.removeEventListener("scroll", schedule);
var hasOtherController = false;
try {
hasOtherController = Array.from(
mount.querySelectorAll(".vsc-controller")
).some(function(controllerHost) {
return controllerHost !== wrapper;
});
} catch (e) {}
if (
mount.dataset.vscPositionOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
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;
}
if (
mount.dataset.vscIsolationOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (mount.dataset.vscOriginalIsolation) {
mount.style.setProperty(
"isolation",
mount.dataset.vscOriginalIsolation,
mount.dataset.vscOriginalIsolationPriority || ""
);
} else {
mount.style.removeProperty("isolation");
}
delete mount.dataset.vscIsolationOwner;
delete mount.dataset.vscOriginalIsolation;
delete mount.dataset.vscOriginalIsolationPriority;
}
};
}
function remountControllerHost(videoController, mount) {
if (
!videoController ||
!videoController.div ||
!mount ||
!mount.isConnected ||
mount === videoController.controllerHostMount
) {
return false;
}
if (videoController.controllerHostCleanup) {
videoController.controllerHostCleanup(true);
videoController.controllerHostCleanup = null;
}
mount.insertBefore(videoController.div, mount.firstChild);
setupControllerHostTracking(videoController, videoController.div, mount);
return true;
}
function syncControllerFullscreenMount(videoController) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var video = videoController.video;
var doc = video.ownerDocument;
var fullscreenElement = getFullscreenElement(doc);
var targetMount = videoController.normalControllerMount;
if (
fullscreenElement &&
fullscreenElement !== video &&
typeof fullscreenElement.contains === "function" &&
fullscreenElement.contains(video)
) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
videoController.normalControllerMount = targetMount;
}
if (!targetMount) return false;
return remountControllerHost(videoController, targetMount);
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
@@ -1783,7 +1415,6 @@ function defineVideoController() {
event.type === "loadeddata" ||
event.type === "canplay"
) {
if (this.div) this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(event.target, false);
}
@@ -1879,10 +1510,7 @@ function defineVideoController() {
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.toggle(
"vsc-nosource",
!hasUsableMediaSource(mutation.target)
);
this.div.classList.add("vsc-nosource");
} else {
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
@@ -1914,10 +1542,6 @@ 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);
@@ -2196,17 +1820,15 @@ function defineVideoController() {
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!hasUsableMediaSource(this.video))
if (!this.video.src && !this.video.currentSrc)
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// z-index is handled by the base .vsc-controller CSS rule (2147483646).
// The controller lives inside the video container, so high z-index only
// makes it topmost within the local stacking context — it won't overlay
// page-level modals or dialogs.
// Use lower z-index for non-YouTube sites to avoid overlapping modals
if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube");
var shadow = wrapper.attachShadow({ mode: "open" });
var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet";
shadowStylesheet.href = chrome.runtime.getURL("content/shadow.css");
shadowStylesheet.href = chrome.runtime.getURL("shadow.css");
shadow.appendChild(shadowStylesheet);
var controller = doc.createElement("div");
@@ -2327,58 +1949,65 @@ 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(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
doc.body.appendChild(fragment);
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):
mountEl = parentEl.parentElement || mountEl;
log("Using parentElement.parentElement insertion", 5);
parentEl.parentElement.insertBefore(fragment, parentEl);
break;
case location.hostname === "www.facebook.com":
var facebookMount = parentEl;
for (var facebookDepth = 0; facebookDepth < 7; facebookDepth += 1) {
if (!facebookMount.parentElement) break;
facebookMount = facebookMount.parentElement;
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);
}
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);
this.normalControllerMount = mountEl;
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(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
doc.body.appendChild(fragment);
}
return wrapper;
@@ -2387,7 +2016,6 @@ function defineVideoController() {
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
@@ -2396,9 +2024,7 @@ function applySiteRuleOverrides() {
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) {
return false;
}
if (!matchedRule) return false;
tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4);
@@ -2422,7 +2048,6 @@ function applySiteRuleOverrides() {
"controllerMarginTop",
"controllerMarginBottom",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
];
@@ -2479,10 +2104,8 @@ function refreshAllControllerGeometry() {
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
refreshAllControllerGeometry();
}
@@ -2570,6 +2193,8 @@ function setupListener(root) {
root.vscRateListenerAttached = true;
}
var vscInitializedDocuments = new Set();
function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return;
@@ -2724,15 +2349,6 @@ function attachMutationObserver(root) {
return;
}
if (
target.nodeName === "SOURCE" &&
mutation.attributeName === "src"
) {
ensureControllerForMediaChild(target);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
@@ -2837,10 +2453,8 @@ function attachNavigationListeners() {
function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
@@ -2850,29 +2464,8 @@ function initializeNow(doc, forceReinit = false) {
attachNavigationListeners();
observeRoot(doc);
// Delayed rescan to catch custom elements whose shadow roots were created
// before our attachShadow patch was installed (e.g. archive.org's <play-av>
// Lit component, or any site that lazily creates shadow DOM video players).
if (!doc.vscDelayedShadowScanDone) {
doc.vscDelayedShadowScanDone = true;
setTimeout(function() {
if (!doc.body) return;
try {
var els = doc.body.querySelectorAll("*");
for (var i = 0; i < els.length; i++) {
if (els[i].shadowRoot && !vscObservedRoots.has(els[i].shadowRoot)) {
observeRoot(els[i].shadowRoot);
}
}
} catch (e) {}
}, 3000);
}
if (forceReinit) {
log("Force re-initialization requested", 4);
// A root is normally scanned only once. A user-requested rescan must also
// revisit media that was present but source-less during the initial scan.
scanRootForMedia(doc);
refreshAllControllerGeometry();
}
@@ -3313,22 +2906,11 @@ function showController(controller, duration = 2000, forced = false) {
}, duration);
}
// Keep each controller inside the subtree promoted to the fullscreen top layer,
// then restore its normal player-local mount when fullscreen exits.
function handleFullscreenControllerTransition() {
// Add global listener to handle fullscreen transitions and adjust controller positions
document.addEventListener("fullscreenchange", () => {
tc.mediaElements.forEach((video) => {
if (video.vsc) {
syncControllerFullscreenMount(video.vsc);
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
}
});
}
[
"fullscreenchange",
"webkitfullscreenchange",
"mozfullscreenchange",
"MSFullscreenChange"
].forEach(function(eventName) {
document.addEventListener(eventName, handleFullscreenControllerTransition);
});
+15 -15
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.3.5.0",
"version": "5.2.4",
"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",
@@ -16,13 +16,13 @@
}
},
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
"128": "assets/icons/icon128.png"
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"background": {
"scripts": [
"background/background.js"
"background.js"
]
},
"permissions": [
@@ -30,16 +30,16 @@
"https://cdn.jsdelivr.net/*"
],
"options_ui": {
"page": "options/options.html",
"page": "options.html",
"open_in_tab": true
},
"browser_action": {
"default_icon": {
"19": "assets/icons/icon19.png",
"38": "assets/icons/icon38.png",
"48": "assets/icons/icon48.png"
"19": "icons/icon19.png",
"38": "icons/icon38.png",
"48": "icons/icon48.png"
},
"default_popup": "popup/popup.html"
"default_popup": "popup.html"
},
"content_scripts": [
{
@@ -56,19 +56,19 @@
"https://meet.google.com/*"
],
"css": [
"content/inject.css"
"inject.css"
],
"js": [
"shared/controller-utils.js",
"shared/key-bindings.js",
"shared/site-rules.js",
"shared/ui-icons.js",
"content/inject.js"
"ui-icons.js",
"inject.js"
]
}
],
"web_accessible_resources": [
"content/inject.css",
"content/shadow.css"
"inject.css",
"shadow.css"
]
}
+4 -55
View File
@@ -343,40 +343,11 @@ label em {
font-weight: 500;
}
.shortcut-label em {
display: block;
margin-top: 4px;
color: var(--muted);
font-style: normal;
font-weight: 400;
}
.customKey,
.customValue {
text-align: center;
}
/* Chevron: native menu indicator is often missing with themed controls */
#addShortcutSelector,
.site-add-shortcut-selector {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--panel);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px 16px;
padding-right: 38px;
cursor: pointer;
}
#addShortcutSelector:disabled,
.site-add-shortcut-selector:disabled {
cursor: not-allowed;
opacity: 0.72;
}
#addShortcutSelector {
width: min(220px, 100%);
margin-top: 12px;
@@ -518,7 +489,7 @@ label em {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
align-items: center;
font-weight: 600;
margin-bottom: 8px;
cursor: pointer;
@@ -531,11 +502,6 @@ label em {
.site-override-lead span {
margin: 0;
font-weight: 600;
}
.site-override-lead span em {
font-weight: 400;
}
.site-rule-override-section .site-override-fields,
@@ -969,10 +935,6 @@ button.lucide-result-tile.lucide-picked {
color: var(--text);
}
.site-rule-split-label span em {
font-weight: 400;
}
.site-rule-split-label input[type="checkbox"] {
justify-self: end;
margin-top: 0;
@@ -1007,22 +969,16 @@ button.lucide-result-tile.lucide-picked {
}
.site-shortcuts-container .shortcut-row {
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px;
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr);
padding: 8px 0;
border-top: 1px solid var(--border);
}
.site-shortcuts-rows .shortcut-row:first-child {
.site-shortcuts-container .shortcut-row:first-child {
padding-top: 0;
border-top: 0;
}
.site-add-shortcut-selector {
width: min(220px, 100%);
align-self: flex-start;
margin-top: 0;
}
.force-label {
display: flex;
align-items: center;
@@ -1164,8 +1120,7 @@ button.lucide-result-tile.lucide-picked {
}
.action-row button,
#addShortcutSelector,
.site-add-shortcut-selector {
#addShortcutSelector {
width: 100%;
}
@@ -1249,12 +1204,6 @@ button.lucide-result-tile.lucide-picked {
background: rgba(255, 255, 255, 0.04);
}
#addShortcutSelector,
.site-add-shortcut-selector {
background-color: var(--panel);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}
input[type="text"]:focus,
select:focus,
textarea:focus {
+42 -238
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" />
<script src="../shared/controller-utils.js"></script>
<script src="../shared/key-bindings.js"></script>
<script src="../shared/popup-controls.js"></script>
<script src="../shared/ui-icons.js"></script>
<script src="shared/controller-utils.js"></script>
<script src="shared/key-bindings.js"></script>
<script src="shared/popup-controls.js"></script>
<script src="ui-icons.js"></script>
<script src="lucide-client.js"></script>
<script src="options.js"></script>
<script src="../shared/import-export.js"></script>
<script src="import-export.js"></script>
<script src="shared/import-export.js"></script>
<script src="importExport.js"></script>
</head>
<body>
<div class="page-shell">
@@ -38,7 +38,7 @@
>
<img
class="support-cta-kofi-img"
src="../assets/images/kofi_symbol.svg"
src="images/kofi_symbol.svg"
width="241"
height="194"
alt=""
@@ -96,11 +96,7 @@
<section id="customs" class="settings-card">
<div class="section-heading">
<h3>Shortcuts</h3>
<p class="section-intro">
Backspace clears a key. Escape disables optional shortcuts. If a site
steals a shortcut, use a site rule with Override shortcuts (and
per-key blocking) for that URL.
</p>
<p class="section-intro">Backspace clears a shortcut. Escape disables it.</p>
</div>
<div class="shortcuts-grid">
<div class="shortcut-row" id="display" data-action="display">
@@ -136,10 +132,7 @@
/>
</div>
<div class="shortcut-row" id="slower" data-action="slower">
<div class="shortcut-label">
Decrease speed
<em>Required: Speeder needs a key for this action.</em>
</div>
<div class="shortcut-label">Decrease speed</div>
<input
class="customKey"
type="text"
@@ -153,10 +146,7 @@
/>
</div>
<div class="shortcut-row" id="faster" data-action="faster">
<div class="shortcut-label">
Increase speed
<em>Required: Speeder needs a key for this action.</em>
</div>
<div class="shortcut-label">Increase speed</div>
<input
class="customKey"
type="text"
@@ -263,22 +253,11 @@
<h4 class="defaults-sub-heading">General</h4>
<div class="row row-checkbox">
<label for="enabled"
>Enable<br />
<em
>On: site rules can block sites (blacklist). Off: only matched rules keep Speeder on (whitelist).</em
>
</label>
<label for="enabled">Enable</label>
<input id="enabled" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="audioBoolean"
>Work on audio<br />
<em
>Also controls plain HTML5 audio (not just video). Turn off if
you only want Speeder on video players.</em
>
</label>
<label for="audioBoolean">Work on audio</label>
<input id="audioBoolean" type="checkbox" />
</div>
@@ -286,14 +265,7 @@
<h4 class="defaults-sub-heading">Playback</h4>
<div class="row row-checkbox">
<label for="rememberSpeed"
>Remember playback speed<br />
<em
>Stores speed per source so revisiting the same media can restore
it. Separate from &ldquo;Force last saved speed,&rdquo; which
fights players that reset rate.</em
>
</label>
<label for="rememberSpeed">Remember playback speed</label>
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row row-checkbox">
@@ -311,23 +283,11 @@
<h4 class="defaults-sub-heading">Controller</h4>
<div class="row row-checkbox">
<label for="startHidden"
>Hide controller by default<br />
<em
>Starts with the overlay hidden; use shortcuts (show/hide,
move) or site behavior to reveal it.</em
>
</label>
<label for="startHidden">Hide controller by default</label>
<input id="startHidden" type="checkbox" />
</div>
<div class="row">
<label for="controllerLocation"
>Default controller location<br />
<em
>Corner or edge anchor for the hover bar. Site rules can override
this for specific URLs.</em
>
</label>
<label for="controllerLocation">Default controller location</label>
<select id="controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
@@ -340,13 +300,7 @@
</select>
</div>
<div class="row">
<label for="controllerOpacity"
>Controller opacity<br />
<em
>0&ndash;1 (decimals). Lower is more transparent. Applies to the
in-page controller only.</em
>
</label>
<label for="controllerOpacity">Controller opacity</label>
<input id="controllerOpacity" type="text" value="" />
</div>
<div class="row row-controller-margin">
@@ -394,21 +348,11 @@
<div class="row row-checkbox">
<label for="enableSubtitleNudge"
>Enable subtitle nudge<br /><em
>Makes tiny playback changes to help keep subtitles aligned and
allows nudge toggle controls.</em
>Makes tiny playback changes to help keep subtitles aligned.</em
>
</label>
<input id="enableSubtitleNudge" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="subtitleNudgeEnabledByDefault"
>Start subtitle nudge enabled<br /><em
>When off, each video starts with nudging disabled until you
toggle it.</em
>
</label>
<input id="subtitleNudgeEnabledByDefault" type="checkbox" />
</div>
<div class="row">
<label for="subtitleNudgeInterval"
>Nudge interval (milliseconds)<br /><em
@@ -472,23 +416,11 @@
</p>
</div>
<div class="row row-checkbox">
<label for="showPopupControlBar"
>Show popup control bar<br />
<em
>Shows buttons in the extension popup (toolbar icon). Can be
overridden per site in site rules.</em
>
</label>
<label for="showPopupControlBar">Show popup control bar</label>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="popupMatchHoverControls"
>Match hover controls<br />
<em
>When on, the popup copies the hover bar&rsquo;s buttons and
order. When off, customize the popup layout below.</em
>
</label>
<label for="popupMatchHoverControls">Match hover controls</label>
<input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
@@ -636,36 +568,19 @@
<div class="site-rule-body">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span
>Enable Speeder on this site<br /><em
>For this URL pattern only: off blocks when global Speeder
is on (blacklist); on allows when global is off
(whitelist)&mdash;same pairing as Defaults &rarr;
Enable.</em
></span
>
<span>Enable Speeder on this site</span>
<input type="checkbox" class="site-enabled" />
</label>
</div>
<div class="site-rule-content">
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override placement for this site<br /><em
>When on, location and margin below replace general
defaults for matching URLs.</em
></span
>
<span>Override placement for this site</span>
<input type="checkbox" class="override-placement" />
</label>
<div class="site-placement-container">
<div class="site-rule-option site-rule-option-field">
<label
>Default controller location:<br /><em
>Corner or edge anchor for the hover bar. Replaces the
general default for matching URLs only.</em
></label
>
<label>Default controller location:</label>
<select class="site-controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
@@ -680,8 +595,7 @@
<div class="site-rule-option site-rule-margin-option">
<label
>Controller margin (px):<br /><em
>Shifts the whole control from its preset position (CSS
margins). Top and bottom. 0&ndash;200.</em
>Shifts the whole control. 0&ndash;200.</em
></label
>
<div class="controller-margin-inputs">
@@ -699,172 +613,85 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override hide-by-default for this site<br /><em
>When on, the hide-by-default toggle below replaces the
general default for matching URLs.</em
></span
>
<span>Override hide-by-default for this site</span>
<input type="checkbox" class="override-visibility" />
</label>
<div class="site-visibility-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Hide controller by default:<br /><em
>Starts with the overlay hidden; use shortcuts
(show/hide, move) or site behavior to reveal it.</em
></label
>
<label>Hide controller by default:</label>
<input type="checkbox" class="site-startHidden" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override auto-hide for this site<br /><em
>When on, hide-with-controls and timer below replace
general defaults for matching URLs.</em
></span
>
<span>Override auto-hide for this site</span>
<input type="checkbox" class="override-autohide" />
</label>
<div class="site-autohide-container">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span
>Hide with controls (idle-based)<br /><em
>Fade the controller in and out with the video
interface: perfect sync on YouTube, idle-based
elsewhere.</em
></span
>
<span>Hide with controls (idle-based)</span>
<input type="checkbox" class="site-hideWithControls" />
</label>
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Auto-hide timer (0.1&ndash;15s):<br /><em
>Seconds of inactivity before hiding: 0.1&ndash;15 for
non-YouTube sites.</em
></label
>
<label>Auto-hide timer (0.1&ndash;15s):</label>
<input type="text" class="site-hideWithControlsTimer" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<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
></span
>
<span>Override playback for this site</span>
<input type="checkbox" class="override-playback" />
</label>
<div class="site-playback-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Remember playback speed:<br /><em
>Stores speed per source so revisiting the same media
can restore it. Separate from &ldquo;Force last saved
speed,&rdquo; which fights players that reset
rate.</em
></label
>
<label>Remember playback speed:</label>
<input type="checkbox" class="site-rememberSpeed" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label
>Force last saved speed:<br /><em
>Useful when a video player tries to override the speed
you set in Speeder.</em
></label
>
<label>Force last saved speed:</label>
<input type="checkbox" class="site-forceLastSavedSpeed" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label
>Work on audio:<br /><em
>Also controls plain HTML5 audio (not just video). Turn
off if you only want Speeder on video players.</em
></label
>
<label>Work on audio:</label>
<input type="checkbox" class="site-audioBoolean" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override opacity for this site<br /><em
>When on, opacity below replaces the general default for
matching URLs.</em
></span
>
<span>Override opacity for this site</span>
<input type="checkbox" class="override-opacity" />
</label>
<div class="site-opacity-container">
<div class="site-rule-option site-rule-option-field">
<label
>Controller opacity:<br /><em
>0&ndash;1 (decimals). Lower is more transparent.
Applies to the in-page controller only.</em
></label
>
<label>Controller opacity:</label>
<input type="text" class="site-controllerOpacity" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override subtitle nudge for this site<br /><em
>When on, nudge options below replace general defaults for
matching URLs.</em
></span
>
<span>Override subtitle nudge for this site</span>
<input type="checkbox" class="override-subtitleNudge" />
</label>
<div class="site-subtitleNudge-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Enable subtitle nudge:<br /><em
>Makes tiny playback changes to help keep subtitles
aligned.</em
></label
>
<label>Enable subtitle nudge:</label>
<input type="checkbox" class="site-enableSubtitleNudge" />
</div>
<div class="site-rule-option site-rule-option-checkbox">
<label
>Start subtitle nudge enabled:<br /><em
>When off, matching videos start with nudging disabled
until you toggle it.</em
></label
>
<input type="checkbox" class="site-subtitleNudgeEnabledByDefault" />
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Nudge interval (10&ndash;1000ms):<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are
more frequent. Default: 50.</em
></label
>
<label>Nudge interval (10&ndash;1000ms):</label>
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
</div>
</div>
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span
>Override in-player control bar for this site<br /><em
>Same idea as Hover control bar: drag blocks between
Active and Available for matching URLs only.</em
></span
>
<span>Override in-player control bar for this site</span>
<input type="checkbox" class="override-controlbar" />
</label>
<div class="site-controlbar-container">
@@ -882,22 +709,12 @@
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span
>Override extension popup for this site<br /><em
>Popup layout for matching URLs; mirrors the global Popup
control bar when you customize it here.</em
></span
>
<span>Override extension popup for this site</span>
<input type="checkbox" class="override-popup-controlbar" />
</label>
<div class="site-popup-controlbar-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Show popup control bar<br /><em
>Shows buttons in the extension popup (toolbar icon).
Replaces the general default for this pattern.</em
></label
>
<label>Show popup control bar</label>
<input type="checkbox" class="site-showPopupControlBar" />
</div>
<div class="cb-editor">
@@ -914,23 +731,10 @@
</div>
<div class="site-rule-shortcuts">
<label class="site-override-lead">
<span
>Override shortcuts for this site<br /><em
>Add shortcuts from the menu; none by default. Leave off
to use global Shortcuts.</em
></span
>
<span>Override shortcuts for this site</span>
<input type="checkbox" class="override-shortcuts" />
</label>
<div class="site-shortcuts-container">
<div class="site-shortcuts-rows"></div>
<select
class="site-add-shortcut-selector"
aria-label="Add shortcut for this site"
>
<option value="">Add shortcut&hellip;</option>
</select>
</div>
<div class="site-shortcuts-container"></div>
</div>
</div>
</div>
+59 -117
View File
@@ -210,7 +210,6 @@ var tcDefaults = {
popupMatchHoverControls: true,
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
@@ -234,7 +233,7 @@ const actionLabels = {
};
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
const requiredShortcutActions = new Set(["slower", "faster"]);
const requiredShortcutActions = new Set(["display", "slower", "faster"]);
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
@@ -320,70 +319,6 @@ function refreshAddShortcutSelector() {
}
}
function refreshSiteRuleAddShortcutSelector(ruleEl) {
if (!ruleEl) return;
var selector = ruleEl.querySelector(".site-add-shortcut-selector");
if (!selector) return;
while (selector.options.length > 1) {
selector.remove(1);
}
var usedActions = new Set();
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
var action = row.dataset.action;
if (action) usedActions.add(action);
});
Object.keys(actionLabels).forEach(function (action) {
if (!usedActions.has(action)) {
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
}
});
var overrideShortcutsOn =
ruleEl.querySelector(".override-shortcuts") &&
ruleEl.querySelector(".override-shortcuts").checked;
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
}
function getGlobalBindingSnapshotForSiteShortcut(action) {
var row = document.querySelector(
'#customs .shortcut-row[data-action="' + action + '"]'
);
if (row) {
var keyInput = row.querySelector(".customKey");
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
if (binding) {
var valueInput = row.querySelector(".customValue");
var value = customActionsNoValues.includes(action)
? 0
: Number(valueInput && valueInput.value);
return { binding: binding, value: value };
}
}
var def = tcDefaults.keyBindings.find(function (b) {
return b.action === action;
});
if (def) {
return {
binding: normalizeStoredBinding(def),
value: def.value
};
}
return { binding: null, value: undefined };
}
function ensureDefaultBinding(storage, action, code, value) {
if (storage.keyBindings.some((item) => item.action === action)) return;
@@ -824,8 +759,6 @@ function save_options() {
settings.keyBindings = keyBindings;
settings.enableSubtitleNudge =
document.getElementById("enableSubtitleNudge").checked;
settings.subtitleNudgeEnabledByDefault =
document.getElementById("subtitleNudgeEnabledByDefault").checked;
settings.subtitleNudgeInterval =
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
tcDefaults.subtitleNudgeInterval;
@@ -909,8 +842,6 @@ function save_options() {
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
rule.enableSubtitleNudge =
ruleEl.querySelector(".site-enableSubtitleNudge").checked;
rule.subtitleNudgeEnabledByDefault =
ruleEl.querySelector(".site-subtitleNudgeEnabledByDefault").checked;
var nudgeIv = parseInt(
ruleEl.querySelector(".site-subtitleNudgeInterval").value,
10
@@ -1011,18 +942,35 @@ function ensureAllDefaultBindings(storage) {
});
}
function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
if (!rowsEl) return;
function addSiteRuleShortcut(container, action, binding, value, force) {
var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs");
div.dataset.action = action;
var actionLabel = document.createElement("div");
actionLabel.className = "shortcut-label";
var actionLabels = {
display: "Show/hide controller",
move: "Move controller",
slower: "Decrease speed",
faster: "Increase speed",
rewind: "Rewind",
advance: "Advance",
reset: "Reset speed",
fast: "Preferred speed",
toggleSubtitleNudge: "Toggle subtitle nudge",
pause: "Play / Pause",
muted: "Mute / Unmute",
louder: "Increase volume",
softer: "Decrease volume",
mark: "Set marker",
jump: "Jump to marker"
};
var actionLabelText = actionLabels[action] || action;
if (action === "toggleSubtitleNudge") {
var ruleEl = rowsEl.closest(".site-rule");
// Check if the site rule is for YouTube.
// We look up the pattern from the site rule element this container belongs to.
var ruleEl = container.closest(".site-rule");
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
if (!pattern.toLowerCase().includes("youtube.com")) {
actionLabelText += " (only for YouTube embeds)";
@@ -1066,18 +1014,12 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
forceLabel.appendChild(forceCheckbox);
forceLabel.appendChild(forceText);
var removeButton = document.createElement("button");
removeButton.className = "removeParent";
removeButton.type = "button";
removeButton.textContent = "\u00d7";
div.appendChild(actionLabel);
div.appendChild(keyInput);
div.appendChild(valueInput);
div.appendChild(forceLabel);
div.appendChild(removeButton);
rowsEl.appendChild(div);
container.appendChild(div);
}
function createSiteRule(rule) {
@@ -1167,12 +1109,10 @@ function createSiteRule(rule) {
var hasSubtitleNudgeOverride = Boolean(
rule &&
(rule.enableSubtitleNudge !== undefined ||
rule.subtitleNudgeEnabledByDefault !== undefined ||
rule.subtitleNudgeInterval !== undefined)
);
ruleEl.querySelector(".override-subtitleNudge").checked = hasSubtitleNudgeOverride;
syncSiteRuleField(ruleEl, rule, "enableSubtitleNudge", true);
syncSiteRuleField(ruleEl, rule, "subtitleNudgeEnabledByDefault", true);
syncSiteRuleField(ruleEl, rule, "subtitleNudgeInterval", false);
applySiteRuleOverrideState(
ruleEl,
@@ -1217,24 +1157,56 @@ function createSiteRule(rule) {
rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
);
ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
var rowsEl = ruleEl.querySelector(".site-shortcuts-rows");
var container = ruleEl.querySelector(".site-shortcuts-container");
if (hasShortcutOverride) {
rule.shortcuts.forEach((shortcut) => {
addSiteRuleShortcut(
rowsEl,
container,
shortcut.action,
shortcut,
shortcut.value,
shortcut.force
);
});
} else {
populateDefaultSiteShortcuts(container);
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
function populateDefaultSiteShortcuts(container) {
var bindings = [];
document.querySelectorAll("#customs .shortcut-row").forEach((row) => {
var action = row.dataset.action;
if (!action) return;
var keyInput = row.querySelector(".customKey");
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
if (!binding) return;
var valueInput = row.querySelector(".customValue");
bindings.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput && valueInput.value),
force: false
});
});
if (bindings.length === 0) {
bindings = tcDefaults.keyBindings.slice();
}
bindings.forEach((binding) => {
addSiteRuleShortcut(container, binding.action, binding, binding.value, false);
});
}
function createControlBarBlock(buttonId) {
var def = controllerButtonDefs[buttonId];
if (!def) return null;
@@ -1657,8 +1629,6 @@ function restore_options() {
storage.showPopupControlBar !== false;
document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge;
document.getElementById("subtitleNudgeEnabledByDefault").checked =
storage.subtitleNudgeEnabledByDefault;
document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval;
@@ -1810,13 +1780,8 @@ document.addEventListener("DOMContentLoaded", function () {
var removeParentButton = targetEl.closest(".removeParent");
if (removeParentButton) {
var removedRow = removeParentButton.parentNode;
var siteRuleForShortcut = removedRow.closest(".site-rule");
removedRow.remove();
removeParentButton.parentNode.remove();
refreshAddShortcutSelector();
if (siteRuleForShortcut) {
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
}
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
@@ -1843,26 +1808,6 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
if (event.target.classList.contains("site-add-shortcut-selector")) {
var action = event.target.value;
if (!action) return;
var siteRuleRoot = event.target.closest(".site-rule");
var rows = siteRuleRoot && siteRuleRoot.querySelector(".site-shortcuts-rows");
if (rows) {
var snap = getGlobalBindingSnapshotForSiteShortcut(action);
addSiteRuleShortcut(
rows,
action,
snap.binding,
snap.value,
false
);
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
event.target.value = "";
return;
}
// Site rule: show/hide optional override sections
var siteOverrideContainers = {
"override-placement": "site-placement-container",
@@ -1884,9 +1829,6 @@ document.addEventListener("DOMContentLoaded", function () {
if (targetBox) {
setSiteOverrideContainerState(targetBox, event.target.checked);
}
if (ocb === "override-shortcuts") {
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
return;
}
}
+12 -83
View File
@@ -94,24 +94,10 @@ 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;
@@ -224,63 +210,21 @@ button:focus-visible {
.donate-split {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr 1fr;
}
.donate-icon-btn {
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 6px 4px;
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
text-decoration: none;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.donate-icon-btn:hover {
background: #f8f9fb;
border-color: #c5ccd5;
}
.donate-icon-btn:active {
background: #f1f3f5;
}
.donate-icon-btn:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline-offset: 2px;
position: relative;
z-index: 1;
}
.donate-icon-btn svg {
display: block;
flex-shrink: 0;
}
.donate-icon-btn--kofi img {
display: block;
height: 22px;
.donate-split button {
width: auto;
max-width: 40px;
object-fit: contain;
}
.donate-icon-btn:first-child {
border-radius: 8px 0 0 8px;
border-right-width: 0;
}
.donate-icon-btn:nth-child(2) {
border-radius: 0;
border-right-width: 0;
min-height: 28px;
}
.donate-icon-btn:last-child {
.donate-split button:first-child {
border-radius: 8px 0 0 8px;
border-right: 0;
}
.donate-split button:last-child {
border-radius: 0 8px 8px 0;
}
@@ -318,23 +262,8 @@ 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;
border-color: #4a515a;
}
.donate-icon-btn:active {
background: #252a2f;
}
}
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="shared/site-rules.js"></script>
<script src="shared/popup-controls.js"></script>
<script src="ui-icons.js"></script>
<script src="popup.js"></script>
</head>
<body>
<div class="popup-shell">
<div class="popup-header">
<span class="popup-title">Speeder</span>
<span class="popup-version">v<span id="app-version"></span></span>
</div>
<div class="popup-actions">
<button id="refresh">Rescan page for videos</button>
<div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
</div>
<div class="popup-divider"></div>
<button id="enable" class="hide">Enable</button>
<button id="disable">Disable</button>
</div>
<div id="status" class="popup-status hide"></div>
<div class="popup-links">
<button id="config">Settings</button>
<div class="popup-secondary">
<button id="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button>
</div>
<div id="donateWrap" class="donate-wrap">
<button id="donate" class="secondary">Donate</button>
<div id="donateOptions" class="donate-split hide">
<button id="donateKofi" class="secondary">Ko-fi</button>
<button id="donateGithub" class="secondary">Sponsors</button>
</div>
</div>
</div>
</div>
</body>
</html>
+19 -64
View File
@@ -4,7 +4,7 @@ document.addEventListener("DOMContentLoaded", function () {
var siteRuleUtils = speederShared.siteRules || {};
var popupControlUtils = speederShared.popupControls || {};
/* `label` is only used if shared/ui-icons.js has no path for this action (fallback). */
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
@@ -27,8 +27,6 @@ document.addEventListener("DOMContentLoaded", function () {
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
lastSpeed: 1.0,
forceLastSavedSpeed: false,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
@@ -37,15 +35,6 @@ 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);
}
@@ -139,7 +128,7 @@ document.addEventListener("DOMContentLoaded", function () {
var tabId = tabs[0].id;
chrome.tabs.executeScript(
tabId,
{ allFrames: true, file: "content/frame-speed-snapshot.js" },
{ allFrames: true, file: "frameSpeedSnapshot.js" },
function (results) {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
@@ -203,7 +192,7 @@ document.addEventListener("DOMContentLoaded", function () {
btn.addEventListener("click", function () {
if (btnId === "settings") {
window.open(chrome.runtime.getURL("options/options.html"));
window.open(chrome.runtime.getURL("options.html"));
return;
}
sendToActiveTab(
@@ -221,11 +210,11 @@ document.addEventListener("DOMContentLoaded", function () {
var manifest = chrome.runtime.getManifest();
var versionElement = document.querySelector("#app-version");
if (versionElement) {
versionElement.textContent = manifest.version;
versionElement.innerText = manifest.version;
}
document.querySelector("#config").addEventListener("click", function () {
window.open(chrome.runtime.getURL("options/options.html"));
window.open(chrome.runtime.getURL("options.html"));
});
document.querySelector("#about").addEventListener("click", function () {
@@ -241,6 +230,14 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelector("#donateOptions").classList.remove("hide");
});
document.querySelector("#donateKofi").addEventListener("click", function () {
window.open("https://ko-fi.com/joshpatra");
});
document.querySelector("#donateGithub").addEventListener("click", function () {
window.open("https://github.com/sponsors/SoPat712");
});
document.querySelector("#enable").addEventListener("click", function () {
toggleEnabled(true, settingsSavedReloadMessage);
});
@@ -263,43 +260,6 @@ 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;
@@ -319,10 +279,7 @@ document.addEventListener("DOMContentLoaded", function () {
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
storage.enabled,
siteRule
);
var siteAvailable = storage.enabled !== false && !siteDisabled;
var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
@@ -330,7 +287,6 @@ document.addEventListener("DOMContentLoaded", function () {
}
toggleEnabledUI(storage.enabled !== false);
updateForceButton(storage.forceLastSavedSpeed === true);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
@@ -373,7 +329,6 @@ document.addEventListener("DOMContentLoaded", function () {
if (areaName !== "sync") return;
if (
changes.enabled ||
changes.forceLastSavedSpeed ||
changes.showPopupControlBar ||
changes.controllerButtons ||
changes.popupMatchHoverControls ||
@@ -400,9 +355,9 @@ document.addEventListener("DOMContentLoaded", function () {
const suffix = `${enabled ? "" : "_disabled"}.png`;
chrome.browserAction.setIcon({
path: {
19: "assets/icons/icon19" + suffix,
38: "assets/icons/icon38" + suffix,
48: "assets/icons/icon48" + suffix
19: "icons/icon19" + suffix,
38: "icons/icon38" + suffix,
48: "icons/icon48" + suffix
}
});
}
@@ -416,12 +371,12 @@ document.addEventListener("DOMContentLoaded", function () {
function setStatusMessage(str) {
const status_element = document.querySelector("#status");
status_element.classList.toggle("hide", false);
status_element.textContent = str;
status_element.innerText = str;
}
function clearStatusMessage() {
const status_element = document.querySelector("#status");
status_element.classList.toggle("hide", true);
status_element.textContent = "";
status_element.innerText = "";
}
});
+6 -7
View File
@@ -7,20 +7,19 @@ set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
MANIFEST_PATH="extension/manifest.json"
manifest_version() {
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = os.environ["MANIFEST_PATH"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
@@ -59,8 +58,8 @@ fi
git checkout beta
git pull origin beta
echo "Current version on beta ($MANIFEST_PATH): $(manifest_version)"
read -r -p "Release version for $MANIFEST_PATH + tag (e.g. 5.0.4): " SEMVER_IN
echo "Current version on beta (manifest.json): $(manifest_version)"
read -r -p "Release version for manifest.json + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
@@ -74,7 +73,7 @@ fi
echo
echo "This will:"
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
echo " 2. set $MANIFEST_PATH to $SEMVER in that commit (if anything else changed, it is included too)"
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
echo " 3. push origin main, create tag $TAG, push tag (triggers listed AMO submit)"
echo " 4. checkout dev (merge main→dev yourself if you want them aligned)"
read -r -p "Continue? [y/N] " confirm
+7 -8
View File
@@ -6,20 +6,19 @@ set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
MANIFEST_PATH="extension/manifest.json"
manifest_version() {
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = os.environ["MANIFEST_PATH"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
@@ -58,8 +57,8 @@ fi
git checkout dev
git pull origin dev
echo "Current version in $MANIFEST_PATH: $(manifest_version)"
read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
echo "Current version in manifest.json: $(manifest_version)"
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
@@ -77,7 +76,7 @@ fi
echo
echo "This will:"
echo " 1. set $MANIFEST_PATH version to $SEMVER, commit on dev, push origin dev"
echo " 1. set manifest.json version to $SEMVER, commit on dev, push origin dev"
echo " 2. checkout beta, merge dev (no-ff), push origin beta"
echo " 3. create tag $TAG and push it (triggers beta AMO + prerelease)"
echo " 4. checkout dev (main is not modified)"
@@ -87,7 +86,7 @@ read -r -p "Continue? [y/N] " confirm
echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER"
git add "$MANIFEST_PATH"
git add manifest.json
git commit -m "Bump version to $SEMVER"
git push origin dev
@@ -15,7 +15,6 @@
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
@@ -44,7 +43,6 @@
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
@@ -182,7 +180,6 @@
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
@@ -1,33 +1,3 @@
: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;
@@ -33,7 +33,6 @@
"speed",
"startHidden",
"subtitleNudgeAmount",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
]);
@@ -50,7 +49,7 @@
/**
* Local-only keys excluded from backup JSON. These are disposable caches
* (e.g. Lucide tags.json) that bloat exports and are refetched when needed.
* Keep in sync with options/lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
* Keep in sync with lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
*/
var localSettingsKeysOmittedFromExport = [
"lucideTagsCacheV1",
@@ -60,28 +60,10 @@
);
}
/**
* Whether Speeder should run on this URL given global enabled and the matched rule (if any).
* - No rule: follows global (enabled unless explicitly false).
* - Rule with site "off" / disableExtension: always inactive (blacklist).
* - Rule with site "on": active even when global is off (whitelist).
*/
function isSpeederActiveForSite(globalEnabled, siteRule) {
var globalOn = globalEnabled !== false;
if (!siteRule) {
return globalOn;
}
if (isSiteRuleDisabled(siteRule)) {
return false;
}
return true;
}
return {
compileSiteRulePattern: compileSiteRulePattern,
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
isSpeederActiveForSite: isSpeederActiveForSite,
matchSiteRule: matchSiteRule
};
});
+2 -6
View File
@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
const { JSDOM } = require("jsdom");
const vi = globalThis.vi;
const { vi } = require("vitest");
const ROOT = path.resolve(__dirname, "..", "..");
@@ -118,11 +118,7 @@ async function flushAsyncWork(turns) {
const count = turns || 2;
for (let i = 0; i < count; i += 1) {
await Promise.resolve();
if (vi && typeof vi.isFakeTimers === "function" && vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0);
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
+3 -3
View File
@@ -6,14 +6,14 @@ import {
} from "./helpers/browser.js";
async function setupImportExport(overrides = {}) {
loadHtml("extension/options/options.html");
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
const restoreSpy = vi.fn();
globalThis.restore_options = restoreSpy;
window.restore_options = restoreSpy;
loadScript("extension/shared/import-export.js");
loadScript("extension/options/import-export.js");
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
return globalThis.chrome;
}
+16 -37
View File
@@ -1,8 +1,8 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
fireDOMContentLoaded,
installCommonWindowMocks,
loadHtmlString
} = require("./helpers/extension-test-utils");
@@ -26,38 +26,18 @@ function bootImportExport(options) {
global.chrome = chrome;
window.chrome = chrome;
class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
}
async text() {
return this.parts.join("");
}
}
global.Blob = TestBlob;
window.Blob = TestBlob;
const createObjectURL = vi.fn(() => "blob:test");
const revokeObjectURL = vi.fn();
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: createObjectURL
vi.stubGlobal("URL", {
createObjectURL,
revokeObjectURL
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectURL
});
global.URL = window.URL;
evaluateScript("extension/shared/import-export.js");
evaluateScript("extension/options/import-export.js");
fireDOMContentLoaded();
evaluateScript("importExport.js");
return { chrome, createObjectURL, revokeObjectURL };
}
describe("options/import-export.js", () => {
describe("importExport.js", () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -73,15 +53,14 @@ describe("options/import-export.js", () => {
bootImportExport();
expect(window.generateBackupFilename()).toBe(
"speeder-backup_2026-04-04_09.14.15.json"
"speeder-backup_2026-04-04_13.14.15.json"
);
});
it("exports sync and local settings into a downloadable backup", async () => {
Object.defineProperty(window.HTMLAnchorElement.prototype, "click", {
configurable: true,
value: vi.fn()
});
const clickSpy = vi
.spyOn(window.HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const { createObjectURL, revokeObjectURL } = bootImportExport({
syncData: {
rememberSpeed: true,
@@ -95,6 +74,7 @@ describe("options/import-export.js", () => {
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0];
@@ -102,15 +82,15 @@ describe("options/import-export.js", () => {
expect(backup.settings.rememberSpeed).toBe(true);
expect(backup.localSettings.customButtonIcons.faster.slug).toBe("rocket");
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
expect(document.querySelector("#status").textContent).toContain("exported");
});
it("omits Lucide tags cache from exported localSettings", async () => {
Object.defineProperty(window.HTMLAnchorElement.prototype, "click", {
configurable: true,
value: vi.fn()
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
const { createObjectURL } = bootImportExport({
syncData: { rememberSpeed: true },
localData: {
@@ -123,6 +103,7 @@ describe("options/import-export.js", () => {
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
@@ -178,7 +159,6 @@ describe("options/import-export.js", () => {
}
vi.stubGlobal("FileReader", FakeFileReader);
window.FileReader = FakeFileReader;
document.querySelector("#importSettings").click();
await flushAsyncWork();
@@ -228,7 +208,6 @@ describe("options/import-export.js", () => {
}
vi.stubGlobal("FileReader", FakeFileReader);
window.FileReader = FakeFileReader;
document.querySelector("#importSettings").click();
await flushAsyncWork();
+5 -183
View File
@@ -1,3 +1,4 @@
const { afterEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
@@ -30,11 +31,8 @@ function bootInject(options) {
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("extension/shared/controller-utils.js");
evaluateScript("extension/shared/key-bindings.js");
evaluateScript("extension/shared/site-rules.js");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/content/inject.js");
evaluateScript("ui-icons.js");
evaluateScript("inject.js");
return chrome;
}
@@ -118,14 +116,14 @@ describe("inject.js helper logic", () => {
bootInject();
await flushAsyncWork(3);
window.tc.settings.siteRules = [{ pattern: "example.org", enabled: false }];
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(true);
window.resetSettingsFromSiteRuleBase();
window.tc.settings.siteRules = [
{
pattern: "example.org",
pattern: "localhost",
controllerLocation: "bottom-left",
controllerMarginTop: 300,
controllerMarginBottom: -10,
@@ -140,180 +138,4 @@ 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");
});
it("does not mount the controller outside a player stacking boundary", async () => {
bootInject();
await flushAsyncWork(3);
const page = document.createElement("main");
const outsidePlayer = document.createElement("section");
const isolatedPlayer = document.createElement("div");
const videoParent = document.createElement("div");
const video = document.createElement("video");
isolatedPlayer.style.isolation = "isolate";
videoParent.appendChild(video);
isolatedPlayer.appendChild(videoParent);
outsidePlayer.appendChild(isolatedPlayer);
page.appendChild(outsidePlayer);
document.body.appendChild(page);
const rect = {
left: 10,
top: 100,
right: 650,
bottom: 460,
width: 640,
height: 360
};
[video, videoParent, isolatedPlayer, outsidePlayer].forEach((element) => {
element.getBoundingClientRect = () => rect;
});
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
});
it("keeps the controller inside a nested fullscreen player subtree", async () => {
bootInject();
await flushAsyncWork(3);
const isolatedPlayer = document.createElement("div");
isolatedPlayer.style.isolation = "isolate";
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
provider.appendChild(video);
fullscreenPlayer.appendChild(provider);
isolatedPlayer.appendChild(fullscreenPlayer);
document.body.appendChild(isolatedPlayer);
const rect = {
left: 0,
top: 0,
right: 640,
bottom: 360,
width: 640,
height: 360
};
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
element.getBoundingClientRect = () => rect;
});
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
fullscreenPlayer
);
});
it("remounts the controller on fullscreen entry and restores it on exit", async () => {
bootInject();
await flushAsyncWork(3);
const isolatedPlayer = document.createElement("div");
isolatedPlayer.style.isolation = "isolate";
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
const wrapper = document.createElement("div");
wrapper.className = "vsc-controller";
provider.appendChild(video);
fullscreenPlayer.appendChild(provider);
isolatedPlayer.append(fullscreenPlayer, wrapper);
document.body.appendChild(isolatedPlayer);
const rect = {
left: 0,
top: 0,
right: 640,
bottom: 360,
width: 640,
height: 360
};
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
element.getBoundingClientRect = () => rect;
});
const controller = {
video,
div: wrapper,
normalControllerMount: isolatedPlayer
};
window.setupControllerHostTracking(controller, wrapper, isolatedPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentElement).toBe(fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentElement).toBe(isolatedPlayer);
wrapper.remove();
controller.controllerHostCleanup();
});
it("force-rescans media missed during initial hydration", async () => {
bootInject();
await flushAsyncWork(3);
const video = document.createElement("video");
const source = document.createElement("source");
source.src = "https://example.org/late-source.mp4";
video.appendChild(source);
document.body.appendChild(video);
expect(video.vsc).toBeUndefined();
window.initializeWhenReady(document, true);
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
});
});
+5 -177
View File
@@ -38,11 +38,11 @@ async function bootInject({ sync = {}, local = {} } = {}) {
);
globalThis.cancelIdleCallback = (id) => clearTimeout(id);
loadScript("extension/shared/controller-utils.js");
loadScript("extension/shared/key-bindings.js");
loadScript("extension/shared/site-rules.js");
loadScript("extension/shared/ui-icons.js");
loadScript("extension/content/inject.js");
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/site-rules.js");
loadScript("ui-icons.js");
loadScript("inject.js");
for (let i = 0; i < 3; i += 1) {
await flushAsyncWork();
@@ -51,28 +51,6 @@ async function bootInject({ sync = {}, local = {} } = {}) {
}
describe("inject runtime", () => {
it("treats a matching site rule with site enabled as active when global enable is off", async () => {
await bootInject({
sync: {
enabled: false,
siteRules: [{ pattern: "example.org", enabled: true }]
}
});
expect(window.tc.settings.enabled).toBe(false);
window.captureSiteRuleBase();
window.applySiteRuleOverrides();
expect(window.tc.activeSiteRule).toEqual(
expect.objectContaining({ pattern: "example.org", enabled: true })
);
expect(
window.SpeederShared.siteRules.isSpeederActiveForSite(
window.tc.settings.enabled,
window.tc.activeSiteRule
)
).toBe(true);
});
it("keeps subtitle nudge disabled when the effective setting is off", async () => {
await bootInject({
sync: {
@@ -112,154 +90,4 @@ describe("inject runtime", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("uses the configured subtitle nudge default until a video is toggled", async () => {
await bootInject({
sync: {
enableSubtitleNudge: true,
subtitleNudgeEnabledByDefault: false
}
});
const startSubtitleNudge = vi.fn();
const video = {
paused: false,
playbackRate: 1.5,
vsc: {
startSubtitleNudge,
stopSubtitleNudge: vi.fn(),
subtitleNudgeEnabledOverride: null,
subtitleNudgeIndicator: null,
nudgeFlashIndicator: document.createElement("span")
}
};
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(true);
expect(video.vsc.subtitleNudgeEnabledOverride).toBe(true);
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(true);
expect(startSubtitleNudge).toHaveBeenCalledTimes(1);
});
it("applies subtitle nudge default state from matching site rules", async () => {
await bootInject();
window.tc.settings.enableSubtitleNudge = true;
window.tc.settings.subtitleNudgeEnabledByDefault = true;
window.tc.settings.siteRules = [
{
pattern: "example.org",
subtitleNudgeEnabledByDefault: false
}
];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(false);
expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(false);
window.resetSettingsFromSiteRuleBase();
expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(true);
});
it("detects media inside dynamically added shadow DOMs", async () => {
await bootInject();
vi.useFakeTimers();
expect(window.vscAttachShadowPatched).toBe(true);
const host = document.createElement("custom-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
video.src = "https://example.org/dynamic.mp4";
shadow.appendChild(video);
document.body.appendChild(host);
// Flush MutationObserver microtasks so that the observer callback runs
// and schedules requestIdleCallback's setTimeout.
await flushAsyncWork();
// Run the scheduled timers (requestIdleCallback)
vi.runAllTimers();
// Flush any remaining microtasks/promises
await flushAsyncWork();
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false);
vi.useRealTimers();
});
it("attaches a controller when Vidstack appends a source after the video", async () => {
await bootInject();
vi.useFakeTimers();
const provider = document.createElement("media-provider");
const video = document.createElement("video");
provider.appendChild(video);
document.body.appendChild(provider);
await flushAsyncWork();
vi.runAllTimers();
await flushAsyncWork();
expect(video.vsc).toBeUndefined();
const source = document.createElement("source");
source.src = "https://example.org/vidstack.mp4";
video.appendChild(source);
await flushAsyncWork();
vi.runAllTimers();
await flushAsyncWork();
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
expect(video.vsc.div.classList.contains("vsc-nosource")).toBe(false);
vi.useRealTimers();
});
it("detects media in pre-existing shadow DOMs via delayed rescan", async () => {
vi.useFakeTimers();
loadHtmlString("<!doctype html><html><body></body></html>");
const host = document.createElement("custom-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
video.src = "https://example.org/pre-existing.mp4";
shadow.appendChild(video);
document.body.appendChild(host);
globalThis.chrome = createChromeMock({ sync: {}, local: {} });
window.chrome = globalThis.chrome;
globalThis.chrome.runtime.onMessage = {
addListener: vi.fn()
};
const originalSyncGet = globalThis.chrome.storage.sync.get;
const originalLocalGet = globalThis.chrome.storage.local.get;
globalThis.chrome.storage.sync.get = vi.fn((keys, callback) => {
Promise.resolve().then(() => originalSyncGet(keys, callback));
});
globalThis.chrome.storage.local.get = vi.fn((keys, callback) => {
Promise.resolve().then(() => originalLocalGet(keys, callback));
});
loadScript("extension/shared/controller-utils.js");
loadScript("extension/shared/key-bindings.js");
loadScript("extension/shared/site-rules.js");
loadScript("extension/shared/ui-icons.js");
loadScript("extension/content/inject.js");
// Fast-forward 3000ms for delayed rescan to trigger
vi.advanceTimersByTime(3000);
for (let i = 0; i < 5; i += 1) {
await flushAsyncWork();
}
expect(video.vsc).toBeDefined();
expect(video.vsc.div).toBeDefined();
expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false);
vi.useRealTimers();
});
});
+7 -6
View File
@@ -1,3 +1,4 @@
const { afterEach, describe, expect, it } = require("vitest");
const {
evaluateScript,
loadHtmlString
@@ -10,8 +11,8 @@ describe("lucide-client.js", () => {
it("builds icon URLs and rejects invalid slugs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/options/lucide-client.js");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
"/icons/alarm-clock.svg"
@@ -22,8 +23,8 @@ describe("lucide-client.js", () => {
it("sanitizes SVG before persisting a Lucide icon", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/options/lucide-client.js");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const sanitized = window.sanitizeLucideSvg(`
<svg width="10" height="10" onclick="evil()">
@@ -42,8 +43,8 @@ describe("lucide-client.js", () => {
it("searches and ranks icon slugs by query", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/options/lucide-client.js");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const results = window.searchLucideSlugs(
{
+7 -54
View File
@@ -7,16 +7,16 @@ import {
} from "./helpers/browser.js";
async function setupOptions(overrides = {}) {
loadHtml("extension/options/options.html");
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.fetch = vi.fn();
loadScript("extension/shared/controller-utils.js");
loadScript("extension/shared/key-bindings.js");
loadScript("extension/shared/popup-controls.js");
loadScript("extension/shared/ui-icons.js");
loadScript("extension/options/lucide-client.js");
loadScript("extension/options/options.js");
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("lucide-client.js");
loadScript("options.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
@@ -29,7 +29,6 @@ describe("options page", () => {
sync: {
rememberSpeed: true,
enabled: false,
subtitleNudgeEnabledByDefault: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
keyBindings: [
@@ -40,7 +39,6 @@ describe("options page", () => {
{
pattern: "youtube.com",
enabled: true,
subtitleNudgeEnabledByDefault: false,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
}
@@ -51,22 +49,12 @@ describe("options page", () => {
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("enabled").checked).toBe(false);
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
false
);
expect(document.querySelector('.shortcut-row[data-action="pause"]')).not.toBe(
null
);
expect(document.getElementById("siteRulesContainer").children.length).toBe(
1
);
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
true
);
expect(
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
.checked
).toBe(false);
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
@@ -108,32 +96,6 @@ describe("options page", () => {
expect(toggle.getAttribute("aria-label")).toBe("Collapse site rule");
});
it("site rule shortcut override shows no rows by default and adds via selector", async () => {
await setupOptions({ sync: { siteRules: [] } });
globalThis.createSiteRule({ pattern: "example.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const rows = rule.querySelector(".site-shortcuts-rows");
const selector = rule.querySelector(".site-add-shortcut-selector");
expect(rows.querySelectorAll(".shortcut-row").length).toBe(0);
expect(selector).not.toBeNull();
expect(selector.disabled).toBe(true);
rule.querySelector(".override-shortcuts").checked = true;
rule.querySelector(".override-shortcuts").dispatchEvent(
new Event("change", { bubbles: true })
);
expect(selector.disabled).toBe(false);
expect(selector.options.length).toBeGreaterThan(1);
selector.value = "pause";
selector.dispatchEvent(new Event("change", { bubbles: true }));
expect(rows.querySelectorAll('.shortcut-row[data-action="pause"]').length).toBe(1);
});
it("keeps site override settings visible but disabled until enabled", async () => {
await setupOptions({ sync: { siteRules: [] } });
@@ -171,7 +133,6 @@ describe("options page", () => {
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-4";
document.getElementById("enableSubtitleNudge").checked = true;
document.getElementById("subtitleNudgeEnabledByDefault").checked = false;
document.getElementById("subtitleNudgeInterval").value = "5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = false;
@@ -190,10 +151,6 @@ describe("options page", () => {
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".override-opacity").checked = true;
rule.querySelector(".site-controllerOpacity").value = "0";
rule.querySelector(".override-subtitleNudge").checked = true;
rule.querySelector(".site-enableSubtitleNudge").checked = true;
rule.querySelector(".site-subtitleNudgeEnabledByDefault").checked = false;
rule.querySelector(".site-subtitleNudgeInterval").value = "75";
rule.querySelector(".override-popup-controlbar").checked = true;
rule.querySelector(".site-showPopupControlBar").checked = false;
globalThis.populateControlBarZones(
@@ -218,7 +175,6 @@ describe("options page", () => {
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
@@ -229,9 +185,6 @@ describe("options page", () => {
pattern: "youtube.com",
rememberSpeed: true,
controllerOpacity: 0,
enableSubtitleNudge: true,
subtitleNudgeEnabledByDefault: false,
subtitleNudgeInterval: 75,
showPopupControlBar: false,
popupControllerButtons: ["advance"]
})
+19 -25
View File
@@ -1,3 +1,4 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
@@ -10,7 +11,7 @@ const {
function bootOptions(options) {
const config = options || {};
loadHtmlFile("extension/options/options.html");
loadHtmlFile("options.html");
installCommonWindowMocks();
const chrome = createChromeMock({
@@ -30,12 +31,9 @@ function bootOptions(options) {
);
window.fetch = global.fetch;
evaluateScript("extension/shared/controller-utils.js");
evaluateScript("extension/shared/key-bindings.js");
evaluateScript("extension/shared/popup-controls.js");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/options/lucide-client.js");
evaluateScript("extension/options/options.js");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
evaluateScript("options.js");
fireDOMContentLoaded();
return chrome;
@@ -121,8 +119,7 @@ describe("options.js", () => {
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
window.createSiteRule({ pattern: "youtube.com" });
const siteRuleEls = document.querySelectorAll(".site-rule");
const ruleEl = siteRuleEls[siteRuleEls.length - 1];
const ruleEl = document.querySelector(".site-rule");
ruleEl.querySelector(".override-placement").checked = true;
ruleEl.querySelector(".site-controllerLocation").value = "top-right";
ruleEl.querySelector(".site-controllerMarginTop").value = "300";
@@ -170,22 +167,19 @@ describe("options.js", () => {
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
expect(savedSettings.siteRules).toHaveLength(3);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "youtube.com",
enabled: true,
controllerLocation: "top-right",
controllerMarginTop: 200,
controllerMarginBottom: 0,
hideWithControls: true,
hideWithControlsTimer: 0.1,
showPopupControlBar: false,
popupControllerButtons: ["advance", "rewind"]
})
])
);
expect(savedSettings.siteRules).toEqual([
{
pattern: "youtube.com",
enabled: true,
controllerLocation: "top-right",
controllerMarginTop: 200,
controllerMarginBottom: 0,
hideWithControls: true,
hideWithControlsTimer: 0.1,
showPopupControlBar: false,
popupControllerButtons: ["advance", "rewind"]
}
]);
});
it("blocks save when a site rule regex is invalid", async () => {
+12 -52
View File
@@ -7,13 +7,13 @@ import {
} from "./helpers/browser.js";
async function setupPopup(overrides = {}) {
loadHtml("extension/popup/popup.html");
loadHtml("popup.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
loadScript("extension/shared/site-rules.js");
loadScript("extension/shared/popup-controls.js");
loadScript("extension/shared/ui-icons.js");
loadScript("extension/popup/popup.js");
loadScript("shared/site-rules.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("popup.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
@@ -29,29 +29,13 @@ describe("popup UI", () => {
]
});
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("app-version").innerText).toBe("5.1.7.0");
expect(document.getElementById("popupSpeed").textContent).toBe("1.75");
expect(
document.querySelectorAll("#popupControlBar button").length
).toBeGreaterThan(0);
});
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
await setupPopup({
sync: {
enabled: false,
siteRules: [{ pattern: "example.com", enabled: true }]
}
});
expect(document.getElementById("status").classList.contains("hide")).toBe(
true
);
expect(document.getElementById("popupControlBar").style.display).not.toBe(
"none"
);
});
it("shows disabled state for a matching site rule", async () => {
await setupPopup({
sync: {
@@ -60,7 +44,7 @@ describe("popup UI", () => {
}
});
expect(document.getElementById("status").textContent).toBe(
expect(document.getElementById("status").innerText).toBe(
"Speeder is disabled for this site."
);
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
@@ -85,9 +69,9 @@ describe("popup UI", () => {
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "assets/icons/icon19_disabled.png",
38: "assets/icons/icon38_disabled.png",
48: "assets/icons/icon48_disabled.png"
19: "icons/icon19_disabled.png",
38: "icons/icon38_disabled.png",
48: "icons/icon48_disabled.png"
}
});
});
@@ -105,13 +89,13 @@ describe("popup UI", () => {
});
document.getElementById("refresh").click();
expect(document.getElementById("status").textContent).toBe(
expect(document.getElementById("status").innerText).toBe(
"Cannot run on this page."
);
response = { status: "complete" };
document.getElementById("refresh").click();
expect(document.getElementById("status").textContent).toBe(
expect(document.getElementById("status").innerText).toBe(
"Scan complete. Closing..."
);
vi.advanceTimersByTime(500);
@@ -134,28 +118,4 @@ 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)
);
});
});
+21 -21
View File
@@ -1,3 +1,4 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
@@ -10,7 +11,7 @@ const {
function bootPopup(options) {
const config = options || {};
loadHtmlFile("extension/popup/popup.html");
loadHtmlFile("popup.html");
installCommonWindowMocks();
const chrome = createChromeMock({
@@ -46,10 +47,8 @@ function bootPopup(options) {
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/shared/site-rules.js");
evaluateScript("extension/shared/popup-controls.js");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/popup/popup.js");
evaluateScript("ui-icons.js");
evaluateScript("popup.js");
fireDOMContentLoaded();
return chrome;
@@ -91,26 +90,25 @@ describe("popup.js", () => {
});
it("builds sanitized popup buttons and refreshes speed after an action", async () => {
let speedQueryCount = 0;
const chrome = bootPopup({
syncData: {
enabled: true,
controllerButtons: ["faster", "settings", "rewind", "faster"],
popupMatchHoverControls: true
},
executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1;
callback(
speedQueryCount <= 2
? [
{ speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true }
]
: [{ speed: 1.75, preferred: true }]
);
}
});
chrome.tabs.executeScript
.mockImplementationOnce((tabId, details, callback) => {
callback([
{ speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true }
]);
})
.mockImplementationOnce((tabId, details, callback) => {
callback([{ speed: 1.75, preferred: true }]);
});
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "run_action") {
callback({ speed: 1.75 });
@@ -119,6 +117,7 @@ describe("popup.js", () => {
callback({ speed: 1.0 });
});
document.dispatchEvent(new window.Event("DOMContentLoaded"));
await flushAsyncWork();
const buttons = Array.from(
@@ -139,6 +138,8 @@ describe("popup.js", () => {
});
it("toggles enablement and closes after a successful refresh", async () => {
vi.useFakeTimers();
const chrome = bootPopup({
syncData: {
enabled: false
@@ -146,7 +147,6 @@ describe("popup.js", () => {
});
await flushAsyncWork();
vi.useFakeTimers();
expect(document.querySelector("#enable").classList.contains("hide")).toBe(false);
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
@@ -158,9 +158,9 @@ describe("popup.js", () => {
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "assets/icons/icon19.png",
38: "assets/icons/icon38.png",
48: "assets/icons/icon48.png"
19: "icons/icon19.png",
38: "icons/icon38.png",
48: "icons/icon48.png"
}
});
+5 -29
View File
@@ -1,8 +1,8 @@
import controllerUtils from "../extension/shared/controller-utils.js";
import importExportUtils from "../extension/shared/import-export.js";
import keyBindingUtils from "../extension/shared/key-bindings.js";
import popupControls from "../extension/shared/popup-controls.js";
import siteRules from "../extension/shared/site-rules.js";
import controllerUtils from "../shared/controller-utils.js";
import importExportUtils from "../shared/import-export.js";
import keyBindingUtils from "../shared/key-bindings.js";
import popupControls from "../shared/popup-controls.js";
import siteRules from "../shared/site-rules.js";
describe("shared helpers", () => {
it("matches site rules and skips invalid regex patterns", () => {
@@ -24,20 +24,6 @@ describe("shared helpers", () => {
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
const allowSite = { pattern: "good.test", enabled: true };
const blockSite = { pattern: "bad.test", enabled: false };
expect(siteRules.isSpeederActiveForSite(true, null)).toBe(true);
expect(siteRules.isSpeederActiveForSite(false, null)).toBe(false);
expect(siteRules.isSpeederActiveForSite(true, blockSite)).toBe(false);
expect(siteRules.isSpeederActiveForSite(false, blockSite)).toBe(false);
expect(siteRules.isSpeederActiveForSite(true, allowSite)).toBe(true);
expect(siteRules.isSpeederActiveForSite(false, allowSite)).toBe(true);
});
it("sanitizes and resolves popup button orders", () => {
const controllerButtonDefs = {
rewind: {},
@@ -152,16 +138,6 @@ describe("shared helpers", () => {
localSettings: null
});
expect(
importExportUtils.extractImportSettings({
subtitleNudgeEnabledByDefault: false
})
).toEqual({
isWrappedBackup: false,
settings: { subtitleNudgeEnabledByDefault: false },
localSettings: null
});
expect(
importExportUtils.extractImportSettings({ enabled: true })
).toEqual({
+2 -2
View File
@@ -76,7 +76,7 @@ function vscSanitizeSvgTree(svg) {
n.remove();
});
[svg].concat(Array.from(svg.querySelectorAll("*"))).forEach(function (el) {
svg.querySelectorAll("*").forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
@@ -94,7 +94,7 @@ function vscSanitizeSvgTree(svg) {
}
});
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", VSC_SVG_NS);
svg.setAttribute("xmlns", VSC_SVG_NS);
svg.setAttribute("aria-hidden", "true");
return svg;
}
+1 -1
View File
@@ -6,7 +6,7 @@ module.exports = defineConfig({
clearMocks: true,
globals: true,
restoreMocks: true,
include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
include: ["tests/**/*.test.js"],
setupFiles: ["./tests/setup.js"]
}
});