Compare commits

..
49 Commits
Author SHA1 Message Date
joshpatra a2b041e225 v5.1.8.0-beta.1 2026-04-07 14:33:07 -04:00
joshpatra 4f87aadfd7 Bump version to 5.1.8.0 2026-04-07 14:32:21 -04:00
joshpatra 8456111bf0 fix: correct AMO URL and lucide spec object key 2026-04-07 14:31:45 -04:00
joshpatra 6efe92a036 Refine site rule toggle and override UI 2026-04-07 14:31:41 -04:00
joshpatra 0cb13905ff Fix subtitle nudge site gating 2026-04-07 14:31:39 -04:00
joshpatra f32d1b3f71 Accept raw settings backups during import 2026-04-07 14:31:36 -04:00
joshpatra e6c56bcecb Allow zero controller opacity in settings 2026-04-07 14:31:34 -04:00
joshpatra a7a0aafd68 Add Vitest suite and fix wrapped local import restore 2026-04-07 14:31:27 -04:00
joshpatra 3cf1a4acd1 style(inject): normalize formatting 2026-04-04 16:14:56 -04:00
joshpatra a9956831c4 refactor(shortcuts): switch shortcut bindings to event.code 2026-04-04 13:33:11 -04:00
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 7b8b4324af Bump version to 5.1.7.0 2026-04-02 22:33:59 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 6bd319c8cc Bump version to 5.1.6 2026-04-02 18:20:48 -04:00
joshpatra 3aee8c8f9a fix: errors from web-ext 2026-04-02 18:20:33 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra 5a175c3cf8 Bump version to 5.1.4 2026-04-02 14:17:17 -04:00
joshpatra 805e5a82e5 fix: unicode reset glyph fallback in extension popup 2026-04-02 14:16:53 -04:00
joshpatra df34b1fee9 feat: Lucide subtitle nudge on/off targets and dual preview in options 2026-04-02 14:16:46 -04:00
joshpatra 0741c6e535 feat: custom Lucide icons for subtitle nudge on/off in inject 2026-04-02 14:16:40 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 66075fb6f3 Bump version to 5.1.3 2026-04-02 13:56:21 -04:00
joshpatra bf4025dcb4 fix: settings update 2026-04-02 13:54:01 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra 1cd533fc5c Bump version to 5.1.2 2026-04-02 13:52:02 -04:00
joshpatra 8c5bd68d39 fix: popup control bar section layout in options 2026-04-02 13:44:03 -04:00
joshpatra 9c257af446 feat: omit settings from popup control bar 2026-04-02 13:43:56 -04:00
joshpatra 64a9b85587 fix: control bar icon clicks, hover/focus-within, nudge action 2026-04-02 13:43:43 -04:00
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra f85a1f9f29 Bump version to 5.1.1 2026-04-02 13:11:46 -04:00
joshpatra 97366b76b6 chore: open options in tab 2026-04-02 13:09:09 -04:00
joshpatra 8269875bb1 fix: removed divider 2026-04-02 13:01:14 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 8d3905b654 Bump version to 5.1.0 2026-04-02 12:53:09 -04:00
joshpatra 7fd8a931d8 deploy: squash beta→main for stable; beta script pushes dev then pulls 2026-04-02 12:52:27 -04:00
joshpatra 17319c1e25 Re-run site rules on DOM media attach; extract refreshAllControllerGeometry 2026-04-02 12:52:27 -04:00
joshpatra 841c1a246e fix: nudge flash layout, Lucide icons, hover bar spacing 2026-04-02 12:52:27 -04:00
joshpatra ed0f63e8bc feat: user-customizable Lucide controller button icons 2026-04-02 12:52:27 -04:00
joshpatra 53f66f1eeb v5.0.4-beta.1 2026-04-01 16:31:49 -04:00
joshpatra f106ab490a Bump version to 5.0.4 2026-04-01 16:31:48 -04:00
joshpatra 5a38121e09 refactor: scripts update 2026-04-01 16:31:29 -04:00
joshpatra 36ed922b5c Add interactive deploy scripts for beta and AMO stable releases 2026-04-01 16:29:19 -04:00
joshpatra 3275d1f322 v5.0.2-beta.1 2026-04-01 16:24:24 -04:00
joshpatra f6d706f096 chore: version bump, deployment update 2026-04-01 16:21:44 -04:00
joshpatra 04292a8018 refactor: update settings, feat: change reset speed indicator to show speed it changes to/from 2026-04-01 16:18:36 -04:00
61 changed files with 4553 additions and 12495 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: treeman183
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+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
+3
View File
@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
}
+11 -27
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/videospeed/) 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,
@@ -58,7 +56,7 @@ shortcuts with different values, which will allow you to quickly toggle between
your most commonly used speeds. To add a new shortcut, open extension settings
and click "Add New".
<img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
![settings Add New shortcut](https://user-images.githubusercontent.com/121805/50726471-50242200-1172-11e9-902f-0e5958387617.jpg)
Some sites may assign other functionality to one of the assigned shortcut keys —
these collisions are inevitable, unfortunately. As a workaround, the extension
@@ -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()
-13
View File
@@ -1,13 +0,0 @@
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="kofiSymbolMask" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
</mask>
<g mask="url(#kofiSymbolMask)">
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-5235
View File
@@ -1,5235 +0,0 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var siteRuleUtils = speederShared.siteRules || {};
var sharedSettingsDefaults =
typeof vscGetSettingsDefaults === "function"
? vscGetSettingsDefaults()
: null;
function getSharedDefault(key, fallback) {
if (
sharedSettingsDefaults &&
Object.prototype.hasOwnProperty.call(sharedSettingsDefaults, key)
) {
var value = sharedSettingsDefaults[key];
if (Array.isArray(value)) {
return value.map(function(item) {
return item && typeof item === "object"
? Object.assign({}, item)
: item;
});
}
return value;
}
return fallback;
}
function getPrimaryVideoElement(mediaElements) {
var candidates = Array.isArray(mediaElements)
? mediaElements
: tc.mediaElements;
if (!candidates || candidates.length === 0) return null;
var best = null;
var bestScore = -Infinity;
candidates.forEach(function(el, index) {
if (!el || !el.vsc || !el.isConnected) return;
var rect = null;
try {
rect = el.getBoundingClientRect();
} catch (_error) {}
var width = rect && Number(rect.width) > 0 ? Number(rect.width) : 0;
var height = rect && Number(rect.height) > 0 ? Number(rect.height) : 0;
var win = el.ownerDocument && el.ownerDocument.defaultView;
var viewportWidth = (win && win.innerWidth) || width;
var viewportHeight = (win && win.innerHeight) || height;
var visibleWidth = rect
? Math.max(0, Math.min(rect.right, viewportWidth) - Math.max(rect.left, 0))
: 0;
var visibleHeight = rect
? Math.max(0, Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0))
: 0;
var visibleArea = visibleWidth * visibleHeight;
var visuallyAvailable = visibleArea > 0;
try {
var computed = win && win.getComputedStyle(el);
if (
computed &&
(computed.display === "none" ||
computed.visibility === "hidden" ||
Number(computed.opacity) === 0)
) {
visuallyAvailable = false;
}
} catch (_error) {}
var score = visibleArea;
if (visuallyAvailable) score += 1e12;
if (!el.paused && !el.ended) {
score += visuallyAvailable ? 1e11 : 1e6;
}
if (el.vsc.lastActivityAt) {
score += Math.max(0, 1e9 - (Date.now() - el.vsc.lastActivityAt));
}
if (el.ended) score -= 1e11;
// Stable tie-break: prefer the most recently registered media element.
score += index / 1000;
if (score > bestScore) {
best = el;
bestScore = score;
}
});
return best;
}
var tc = {
settings: {
lastSpeed: getSharedDefault("lastSpeed", 1.0),
enabled: getSharedDefault("enabled", true),
speeds: {},
rememberSpeed: getSharedDefault("rememberSpeed", false),
forceLastSavedSpeed: getSharedDefault("forceLastSavedSpeed", false),
audioBoolean: getSharedDefault("audioBoolean", false),
showAmbientLoopControls: getSharedDefault(
"showAmbientLoopControls",
false
),
startHidden: getSharedDefault("startHidden", false),
hideWithYouTubeControls: getSharedDefault(
"hideWithYouTubeControls",
false
),
hideWithControls: getSharedDefault("hideWithControls", false),
hideWithControlsTimer: getSharedDefault("hideWithControlsTimer", 2.0),
controllerLocation: getSharedDefault("controllerLocation", "top-left"),
controllerOpacity: getSharedDefault("controllerOpacity", 0.3),
controllerMarginTop: getSharedDefault("controllerMarginTop", 0),
controllerMarginRight: getSharedDefault("controllerMarginRight", 0),
controllerMarginBottom: getSharedDefault("controllerMarginBottom", 65),
controllerMarginLeft: getSharedDefault("controllerMarginLeft", 0),
shortcutTargetMode: getSharedDefault("shortcutTargetMode", "closest"),
keyBindings: getSharedDefault("keyBindings", []),
siteRules: getSharedDefault("siteRules", []),
controllerButtons: getSharedDefault("controllerButtons", [
"rewind",
"slower",
"faster",
"advance",
"display"
]),
defaultLogLevel: 3,
logLevel: 3,
enableSubtitleNudge: getSharedDefault("enableSubtitleNudge", false),
subtitleNudgeEnabledByDefault: getSharedDefault(
"subtitleNudgeEnabledByDefault",
true
),
subtitleNudgeInterval: getSharedDefault("subtitleNudgeInterval", 250),
subtitleNudgeAmount: getSharedDefault("subtitleNudgeAmount", 0.001),
customButtonIcons: {}
},
mediaElements: [],
isNudging: false,
pendingLastSpeedSave: null,
pendingLastSpeedValue: null,
lastSpeedSaveRetries: 0,
lastSpeedWriteInFlight: false,
lastSpeedWriteValue: null,
pendingRememberedSpeedsSave: null,
rememberedSpeedsSaveRetries: 0,
rememberedSpeedsWriteEpoch: 0,
rememberedSpeedsWriteInFlight: null,
rememberedSpeedsResetAt: 0,
speedAccessTimes: {},
persistedLastSpeed: 1.0,
activeSiteRule: null,
siteRuleBase: null,
runtimeSettingsHydrated: false,
pendingMediaCandidates: [],
settingsReloadRetries: 0,
lastPointerPosition: null,
lastInteractedMedia: null
};
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet();
var vscObservedRootList = [];
var vscConnectedScannedRoots = new WeakSet();
var vscInitializedDocuments = new WeakSet();
var vscSourceObjectIds = new WeakMap();
var vscNextSourceObjectId = 1;
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
: function(callback, options) {
return setTimeout(callback, (options && options.timeout) || 1);
};
var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
? controllerUtils.controllerLocations.slice()
: [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var defaultControllerLocation =
controllerUtils.defaultControllerLocation || controllerLocations[0];
var controllerLocationStyles = {
"top-left": {
top: "10px",
left: "15px",
transform: "translate(0, 0)"
},
"top-center": {
top: "10px",
left: "50%",
transform: "translate(-50%, 0)"
},
"top-right": {
top: "10px",
left: "calc(100% - 10px)",
transform: "translate(-100%, 0)"
},
"middle-right": {
top: "50%",
left: "calc(100% - 10px)",
transform: "translate(-100%, -50%)"
},
"bottom-right": {
top: "calc(100% - 0px)",
left: "calc(100% - 10px)",
transform: "translate(-100%, -100%)"
},
"bottom-center": {
top: "calc(100% - 0px)",
left: "50%",
transform: "translate(-50%, -100%)"
},
"bottom-left": {
top: "calc(100% - 0px)",
left: "15px",
transform: "translate(0, -100%)"
},
"middle-left": {
top: "50%",
left: "15px",
transform: "translate(0, -50%)"
}
};
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" },
settings: { label: "", className: "" }
};
function createDefaultBinding(action, code, value) {
return {
action: action,
code: code,
value: value,
force: false,
predefined: true
};
}
function defaultKeyBindings(storage) {
return [
createDefaultBinding(
"slower",
"KeyS",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"faster",
"KeyD",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"rewind",
"KeyZ",
Number(storage.rewindTime) || 10
),
createDefaultBinding(
"advance",
"KeyX",
Number(storage.advanceTime) || 10
),
createDefaultBinding(
"reset",
"KeyR",
1.0
),
createDefaultBinding(
"fast",
"KeyG",
Number(storage.fastSpeed) || 1.8
),
createDefaultBinding(
"move",
"KeyP",
0
),
createDefaultBinding(
"toggleSubtitleNudge",
"KeyN",
0
)
];
}
function ensureDefaultKeyBinding(action, code, value) {
if (tc.settings.keyBindings.some((binding) => binding.action === action)) {
return false;
}
tc.settings.keyBindings.push(
createDefaultBinding(action, code, value)
);
return true;
}
function getLegacyKeyCode(binding) {
return keyBindingUtils.getLegacyKeyCode(binding);
}
function normalizeControllerLocation(location) {
return controllerUtils.normalizeControllerLocation(
location,
defaultControllerLocation
);
}
var CONTROLLER_MARGIN_MAX_PX = 200;
function normalizeControllerMarginPx(value, fallback) {
return controllerUtils.clampControllerMarginPx(value, fallback);
}
function applyControllerMargins(controller) {
if (!controller) return;
var d = tc.settings;
var loc = controller.dataset.location;
var manual = controller.dataset.positionMode === "manual";
var isTopAnchored =
!manual &&
(loc === "top-left" ||
loc === "top-center" ||
loc === "top-right");
var isBottomAnchored =
!manual &&
(loc === "bottom-right" ||
loc === "bottom-center" ||
loc === "bottom-left");
var isMiddleRow =
!manual && (loc === "middle-left" || loc === "middle-right");
var mt = normalizeControllerMarginPx(d.controllerMarginTop, 0);
var mb = normalizeControllerMarginPx(d.controllerMarginBottom, 65);
if (isTopAnchored || isBottomAnchored || isMiddleRow) {
mt = 0;
mb = 0;
}
controller.style.marginTop = mt + "px";
var ml = normalizeControllerMarginPx(d.controllerMarginLeft, 0);
var mr = normalizeControllerMarginPx(d.controllerMarginRight, 0);
if (!manual) {
ml = 0;
mr = 0;
}
controller.style.marginRight = mr + "px";
controller.style.marginBottom = mb + "px";
controller.style.marginLeft = ml + "px";
}
function getNextControllerLocation(location) {
return controllerUtils.getNextControllerLocation(location);
}
function getControllerElement(videoOrController) {
if (!videoOrController) return null;
if (
videoOrController.shadowRoot &&
typeof videoOrController.shadowRoot.querySelector === "function"
) {
return videoOrController.shadowRoot.querySelector("#controller");
}
if (
videoOrController.div &&
videoOrController.div.shadowRoot &&
typeof videoOrController.div.shadowRoot.querySelector === "function"
) {
return videoOrController.div.shadowRoot.querySelector("#controller");
}
return null;
}
function applyControllerLocationToElement(controller, location) {
if (!controller) return defaultControllerLocation;
var normalizedLocation = normalizeControllerLocation(location);
var styles = controllerLocationStyles[normalizedLocation];
controller.dataset.location = normalizedLocation;
controller.dataset.positionMode = "anchored";
var top = styles.top;
if (
normalizedLocation === "top-left" ||
normalizedLocation === "top-center" ||
normalizedLocation === "top-right"
) {
var insetTop = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(10px + " + insetTop + "px)";
}
if (
normalizedLocation === "bottom-right" ||
normalizedLocation === "bottom-center" ||
normalizedLocation === "bottom-left"
) {
var lift = normalizeControllerMarginPx(
tc.settings.controllerMarginBottom,
65
);
top = "calc(100% - " + lift + "px)";
}
// If in fullscreen, move the controller down to avoid overlapping video titles
if (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
) {
if (normalizedLocation.startsWith("top-")) {
var insetTopFs = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(63px + " + insetTopFs + "px)";
}
}
controller.style.top = top;
var left = styles.left;
switch (normalizedLocation) {
case "top-left":
case "middle-left":
case "bottom-left":
left = "15px";
break;
case "top-right":
case "middle-right":
case "bottom-right":
left = "calc(100% - 10px)";
break;
case "top-center":
case "bottom-center":
left = "50%";
break;
default:
break;
}
controller.style.left = left;
controller.style.transform = styles.transform;
applyControllerMargins(controller);
return normalizedLocation;
}
function applyControllerLocation(videoController, location) {
if (!videoController) return;
var controller = getControllerElement(videoController);
if (!controller) return;
videoController.controllerLocation = applyControllerLocationToElement(
controller,
location
);
}
function captureSiteRuleBase() {
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
hideWithControls: tc.settings.hideWithControls,
hideWithControlsTimer: tc.settings.hideWithControlsTimer,
controllerLocation: tc.settings.controllerLocation,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
showAmbientLoopControls: tc.settings.showAmbientLoopControls,
controllerOpacity: tc.settings.controllerOpacity,
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
shortcutTargetMode: tc.settings.shortcutTargetMode,
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
controllerButtons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons.slice()
: tc.settings.controllerButtons,
keyBindings: Array.isArray(tc.settings.keyBindings)
? tc.settings.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: tc.settings.keyBindings
};
}
function resetSettingsFromSiteRuleBase() {
if (!tc.siteRuleBase) return;
var base = tc.siteRuleBase;
tc.settings.startHidden = base.startHidden;
tc.settings.hideWithControls = base.hideWithControls;
tc.settings.hideWithControlsTimer = base.hideWithControlsTimer;
tc.settings.controllerLocation = base.controllerLocation;
tc.settings.rememberSpeed = base.rememberSpeed;
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
tc.settings.audioBoolean = base.audioBoolean;
tc.settings.showAmbientLoopControls = base.showAmbientLoopControls;
tc.settings.controllerOpacity = base.controllerOpacity;
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
tc.settings.shortcutTargetMode = base.shortcutTargetMode;
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()
: base.controllerButtons;
tc.settings.keyBindings = Array.isArray(base.keyBindings)
? base.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: base.keyBindings;
}
function clearManualControllerPosition(videoController) {
if (!videoController) return;
applyControllerLocation(
videoController,
videoController.controllerLocation || tc.settings.controllerLocation
);
}
function convertControllerToManualPosition(videoController) {
if (!videoController) return null;
var controller = getControllerElement(videoController);
if (!controller) return null;
controller.dataset.positionMode = "manual";
var offsetParent = controller.offsetParent;
if (offsetParent) {
var controllerRect = controller.getBoundingClientRect();
var offsetParentRect = offsetParent.getBoundingClientRect();
controller.style.setProperty(
"left",
controllerRect.left - offsetParentRect.left + "px",
"important"
);
controller.style.setProperty(
"top",
controllerRect.top - offsetParentRect.top + "px",
"important"
);
} else {
controller.style.setProperty(
"left",
controller.offsetLeft + "px",
"important"
);
controller.style.setProperty(
"top",
controller.offsetTop + "px",
"important"
);
}
controller.style.setProperty("transform", "none", "important");
return controller;
}
function cycleControllerLocation(video) {
if (!video || !video.vsc) return;
video.vsc.controllerLocation = getNextControllerLocation(
video.vsc.controllerLocation || tc.settings.controllerLocation
);
clearManualControllerPosition(video.vsc);
}
function normalizeBindingKey(key) {
return keyBindingUtils.normalizeBindingKey(key);
}
function legacyBindingKeyToCode(key) {
return keyBindingUtils.legacyBindingKeyToCode(key);
}
function legacyKeyCodeToCode(keyCode) {
return keyBindingUtils.legacyKeyCodeToCode(keyCode);
}
function inferBindingCode(binding, fallbackCode) {
return keyBindingUtils.inferBindingCode(binding, fallbackCode);
}
function normalizeStoredBinding(binding, fallbackCode) {
if (!binding) {
if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false,
value: 0,
force: "false",
predefined: false
};
}
if (
binding.disabled === true ||
(binding.code === null &&
binding.key === null &&
binding.keyCode === null)
) {
return {
action: binding.action,
code: null,
disabled: true,
value: keyBindingUtils.sanitizeActionValue(
binding.action,
binding.value,
0
),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
}
var normalizedCode = inferBindingCode(binding, fallbackCode);
if (!normalizedCode) {
return null;
}
var normalized = {
action: binding.action,
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false,
value: keyBindingUtils.sanitizeActionValue(
binding.action,
binding.value,
0
),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
return normalized;
}
function isValidSpeed(speed) {
return !isNaN(speed) && speed >= MIN_SPEED && speed <= MAX_SPEED;
}
function sanitizeSpeed(speed, fallback) {
var numericSpeed = Number(speed);
return isValidSpeed(numericSpeed) ? numericSpeed : fallback;
}
function getVideoSourceKey(video) {
if (!video) return "unknown_src";
var sourceObject = video.srcObject;
if (
sourceObject &&
(typeof sourceObject === "object" || typeof sourceObject === "function")
) {
if (!vscSourceObjectIds.has(sourceObject)) {
vscSourceObjectIds.set(sourceObject, vscNextSourceObjectId++);
}
return "src_object:" + vscSourceObjectIds.get(sourceObject);
}
return video.currentSrc || video.src || "unknown_src";
}
function getControllerTargetSpeed(video) {
if (!video || !video.vsc) return null;
if (!isValidSpeed(video.vsc.targetSpeed)) return null;
var currentSourceKey = getVideoSourceKey(video);
var targetSourceKey = video.vsc.targetSpeedSourceKey;
// SPA sites (e.g. YouTube) can reuse the same <video> element.
// Don't carry controller target speed across a source swap.
if (
targetSourceKey &&
currentSourceKey === "unknown_src" &&
targetSourceKey !== "unknown_src"
) {
return null;
}
if (
targetSourceKey &&
currentSourceKey !== "unknown_src" &&
targetSourceKey !== currentSourceKey
) {
return null;
}
return video.vsc.targetSpeed;
}
function getRememberedSpeed(video) {
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
return null;
}
// Force is deliberately global and must outrank stale per-source memory.
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
var videoSpeed = tc.settings.speeds[sourceKey];
if (isValidSpeed(videoSpeed)) return videoSpeed;
}
// No per-source entry yet — fall back to the most recently used speed.
// This ensures e.g. YouTube Shorts carry the user's chosen speed to the
// next Short even when that exact source URL hasn't been visited before.
if (isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return null;
}
function getDesiredSpeed(video) {
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0;
}
function isOnYouTube() {
return (
location.hostname.includes("youtube.com") ||
location.hostname.includes("youtube-nocookie.com")
);
}
function isYouTubeNativeSpeedRange(speed) {
if (speed < YT_NATIVE_MIN || speed > YT_NATIVE_MAX) return false;
// Check if speed is a multiple of 0.05 (with floating point tolerance)
var remainder = Math.abs((speed * 100) % (YT_NATIVE_STEP * 100));
return remainder < 0.01 || remainder > (YT_NATIVE_STEP * 100 - 0.01);
}
function tryYouTubeNativeSpeed(video, speed) {
if (!isOnYouTube() || !isYouTubeNativeSpeedRange(speed)) return false;
try {
// YouTube's movie_player element exposes setPlaybackRate() but it's a
// page-level JS method, not a native DOM method. Content scripts can't
// see it directly. In Firefox, wrappedJSObject gives access to the
// page's JS context.
var playerEl =
video.closest(".html5-video-player") ||
document.getElementById("movie_player");
if (!playerEl) return false;
// Try wrappedJSObject first (Firefox content script → page context)
var player = playerEl.wrappedJSObject || playerEl;
if (typeof player.setPlaybackRate === "function") {
player.setPlaybackRate(speed);
// Verify YouTube actually accepted the speed (it may silently clamp)
var actualSpeed = video.playbackRate;
if (Math.abs(actualSpeed - speed) > 0.01) {
log("YouTube clamped speed to " + actualSpeed + ", falling back", 4);
return false;
}
log("Used YouTube native setPlaybackRate: " + speed, 4);
return true;
}
} catch (e) {
log("YouTube native speed failed: " + e.message, 3);
}
return false;
}
function isSubtitleNudgeSupported(video) {
return Boolean(video);
}
function isSubtitleNudgeAvailableForVideo(video) {
return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge);
}
function isSubtitleNudgeEnabledForVideo(video) {
if (!isSubtitleNudgeAvailableForVideo(video)) return false;
if (!video || !video.vsc) {
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
}
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
return video.vsc.subtitleNudgeEnabledOverride;
}
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
}
function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (!video || !video.vsc) return false;
if (!isSubtitleNudgeAvailableForVideo(video)) {
video.vsc.subtitleNudgeEnabledOverride = null;
video.vsc.stopSubtitleNudge();
updateSubtitleNudgeIndicator(video);
return false;
}
var normalizedEnabled = Boolean(enabled);
video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled;
if (!normalizedEnabled) {
video.vsc.stopSubtitleNudge();
} else if (!video.paused && video.playbackRate !== 1.0) {
video.vsc.startSubtitleNudge();
}
updateSubtitleNudgeIndicator(video);
// Briefly flash the standalone indicator next to the speed text
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
flashEl.classList.add("visible");
clearTimeout(flashEl._flashTimer);
flashEl._flashTimer = setTimeout(function() {
flashEl.classList.remove("visible");
}, 1500);
}
return normalizedEnabled;
}
function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
vscClearElement(target);
if (custom) {
var customWrap = vscCreateSvgWrap(
target.ownerDocument || document,
custom,
"vsc-btn-icon"
);
if (customWrap) {
target.appendChild(customWrap);
return;
}
}
if (typeof vscIconSvgString !== "function") {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
if (wrap) {
target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
}
function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return;
var isAvailable = isSubtitleNudgeAvailableForVideo(video);
var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var title = !isAvailable
? "Subtitle nudge unavailable on this site"
: isEnabled
? "Subtitle nudge enabled"
: "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = isAvailable ? "true" : "false";
indicator.title = title;
indicator.setAttribute("aria-label", title);
}
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = isAvailable ? "true" : "false";
flashEl.setAttribute("aria-label", title);
}
}
function flushPendingLastSpeed() {
tc.pendingLastSpeedSave = null;
if (tc.lastSpeedWriteInFlight) return;
var speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedValue = null;
if (!isValidSpeed(speedToPersist) || tc.persistedLastSpeed === speedToPersist) {
return;
}
tc.lastSpeedWriteInFlight = true;
tc.lastSpeedWriteValue = speedToPersist;
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
tc.lastSpeedWriteInFlight = false;
tc.lastSpeedWriteValue = null;
var retryDelay = 250;
if (chrome.runtime.lastError) {
if (tc.lastSpeedSaveRetries < 3) {
tc.lastSpeedSaveRetries += 1;
if (!isValidSpeed(tc.pendingLastSpeedValue)) {
tc.pendingLastSpeedValue = speedToPersist;
}
retryDelay = 500 * tc.lastSpeedSaveRetries;
}
} else {
tc.lastSpeedSaveRetries = 0;
tc.persistedLastSpeed = speedToPersist;
}
if (
isValidSpeed(tc.pendingLastSpeedValue) &&
tc.pendingLastSpeedValue !== tc.persistedLastSpeed &&
tc.pendingLastSpeedSave === null
) {
tc.pendingLastSpeedSave = setTimeout(
flushPendingLastSpeed,
retryDelay
);
} else if (tc.pendingLastSpeedValue === tc.persistedLastSpeed) {
tc.pendingLastSpeedValue = null;
}
});
}
function schedulePersistLastSpeed(speed) {
if (!isValidSpeed(speed)) return;
tc.pendingLastSpeedValue = speed;
if (tc.pendingLastSpeedSave !== null || tc.lastSpeedWriteInFlight) return;
tc.pendingLastSpeedSave = setTimeout(flushPendingLastSpeed, 250);
}
var MAX_PERSISTED_SOURCE_SPEEDS = 200;
function isPersistableSourceKey(sourceKey) {
return Boolean(
sourceKey &&
sourceKey !== "unknown_src" &&
sourceKey.indexOf("src_object:") !== 0 &&
sourceKey.indexOf("blob:") !== 0 &&
sourceKey.length <= 2048
);
}
function buildRememberedSpeedsPayload() {
var entries = Object.keys(tc.settings.speeds)
.filter(function(sourceKey) {
return (
isPersistableSourceKey(sourceKey) &&
isValidSpeed(Number(tc.settings.speeds[sourceKey]))
);
})
.map(function(sourceKey) {
return {
sourceKey: sourceKey,
speed: Number(tc.settings.speeds[sourceKey]),
updatedAt: Number(tc.speedAccessTimes[sourceKey]) || 0
};
})
.sort(function(a, b) {
return b.updatedAt - a.updatedAt;
})
.slice(0, MAX_PERSISTED_SOURCE_SPEEDS);
var payload = {};
entries.forEach(function(entry) {
payload[entry.sourceKey] = {
speed: entry.speed,
updatedAt: entry.updatedAt
};
});
return payload;
}
function reconcileRememberedSpeedsAfterStaleWrite() {
var repairEpoch = tc.rememberedSpeedsWriteEpoch;
chrome.storage.local.get({ rememberedSpeeds: {} }, function(localStorage) {
if (
chrome.runtime.lastError ||
repairEpoch !== tc.rememberedSpeedsWriteEpoch
) {
return;
}
mergeRememberedSpeeds(localStorage && localStorage.rememberedSpeeds);
var repairedPayload = buildRememberedSpeedsPayload();
chrome.storage.local.set({ rememberedSpeeds: repairedPayload });
});
}
function schedulePersistRememberedSpeeds(forceSave) {
if (!tc.settings.rememberSpeed && forceSave !== true) return;
if (tc.pendingRememberedSpeedsSave !== null) return;
if (tc.rememberedSpeedsWriteInFlight) {
tc.pendingRememberedSpeedsSave = setTimeout(function() {
tc.pendingRememberedSpeedsSave = null;
schedulePersistRememberedSpeeds(true);
}, 100);
return;
}
tc.pendingRememberedSpeedsSave = setTimeout(function() {
tc.pendingRememberedSpeedsSave = null;
var currentPayload = buildRememberedSpeedsPayload();
var writeEpoch = tc.rememberedSpeedsWriteEpoch;
tc.rememberedSpeedsWriteInFlight = {
epoch: writeEpoch,
payload: null
};
// Merge at write time so independent frame content scripts do not erase
// each other's more recent source entries.
chrome.storage.local.get({ rememberedSpeeds: {} }, function(localStorage) {
if (writeEpoch !== tc.rememberedSpeedsWriteEpoch) {
tc.rememberedSpeedsWriteInFlight = null;
return;
}
if (chrome.runtime.lastError) {
tc.rememberedSpeedsWriteInFlight = null;
if (tc.rememberedSpeedsSaveRetries < 3) {
tc.rememberedSpeedsSaveRetries += 1;
schedulePersistRememberedSpeeds(true);
}
return;
}
var merged = {};
var existing =
localStorage &&
localStorage.rememberedSpeeds &&
typeof localStorage.rememberedSpeeds === "object"
? localStorage.rememberedSpeeds
: {};
[existing, currentPayload].forEach(function(sourceMap) {
Object.keys(sourceMap).forEach(function(sourceKey) {
var entry = sourceMap[sourceKey];
if (
!isPersistableSourceKey(sourceKey) ||
!entry ||
!isValidSpeed(Number(entry.speed)) ||
(tc.rememberedSpeedsResetAt > 0 &&
(Number(entry.updatedAt) || 0) <= tc.rememberedSpeedsResetAt)
) {
return;
}
var previous = merged[sourceKey];
if (
!previous ||
Number(entry.updatedAt) >= Number(previous.updatedAt)
) {
merged[sourceKey] = {
speed: Number(entry.speed),
updatedAt: Number(entry.updatedAt) || 0
};
}
});
});
var pruned = {};
Object.keys(merged)
.sort(function(a, b) {
return merged[b].updatedAt - merged[a].updatedAt;
})
.slice(0, MAX_PERSISTED_SOURCE_SPEEDS)
.forEach(function(sourceKey) {
pruned[sourceKey] = merged[sourceKey];
});
tc.rememberedSpeedsWriteInFlight.payload = pruned;
chrome.storage.local.set({ rememberedSpeeds: pruned }, function() {
var writeBecameStale =
writeEpoch !== tc.rememberedSpeedsWriteEpoch;
tc.rememberedSpeedsWriteInFlight = null;
if (writeBecameStale) {
reconcileRememberedSpeedsAfterStaleWrite();
return;
}
if (chrome.runtime.lastError) {
if (tc.rememberedSpeedsSaveRetries < 3) {
tc.rememberedSpeedsSaveRetries += 1;
schedulePersistRememberedSpeeds(true);
}
return;
}
tc.rememberedSpeedsSaveRetries = 0;
});
});
}, 500);
}
function rememberSourceSpeed(video, speed) {
if (!tc.settings.rememberSpeed || !isValidSpeed(speed)) return;
var sourceKey = getVideoSourceKey(video);
if (sourceKey === "unknown_src") return;
tc.settings.speeds[sourceKey] = Number(speed);
tc.speedAccessTimes[sourceKey] = Math.max(
Date.now(),
tc.rememberedSpeedsResetAt + 1
);
if (isPersistableSourceKey(sourceKey)) {
schedulePersistRememberedSpeeds();
}
}
function suppressNextNudgeRateChanges(controller, count) {
if (!controller) return;
controller.suppressedRateChangeCount =
(controller.suppressedRateChangeCount || 0) + (count || 2);
controller.suppressedRateChangeUntil =
Date.now() + Math.max(250, tc.settings.subtitleNudgeInterval * 4);
}
function shouldIgnoreSuppressedRateChange(video) {
if (!video || !video.vsc) return false;
var controller = video.vsc;
if (
controller.suppressedRateChangeCount > 0 &&
controller.suppressedRateChangeUntil >= Date.now()
) {
controller.suppressedRateChangeCount -= 1;
return true;
}
if (controller.suppressedRateChangeUntil < Date.now()) {
controller.suppressedRateChangeCount = 0;
}
return false;
}
function resolveTargetSpeed(video) {
return getDesiredSpeed(video);
}
function applySourceTransitionPolicy(video, forceUpdate) {
if (!video || !video.vsc) return;
// A same-document navigation can change the applicable playback rule before
// or after the player swaps its source. Always resolve the URL first.
if (!reapplySiteRulesAndControllerGeometry()) return;
if (!video.vsc) return;
var sourceKey = getVideoSourceKey(video);
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
video.vsc.mediaSourceKey = sourceKey;
var rememberedSpeed = getRememberedSpeed(video);
var desiredSpeed = isValidSpeed(rememberedSpeed) ? rememberedSpeed : 1.0;
video.vsc.targetSpeed = desiredSpeed;
video.vsc.targetSpeedSourceKey = sourceKey;
video.vsc.targetSpeedOrigin = isValidSpeed(rememberedSpeed)
? "policy"
: "source-reset";
if (video.vsc.speedIndicator) {
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
}
if (isValidSpeed(rememberedSpeed)) {
extendSpeedRestoreWindow(video);
}
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false);
}
}
function extendSpeedRestoreWindow(video, duration) {
if (!video || !video.vsc) return;
var restoreDuration = Number(duration) || 1500;
var restoreUntil = Date.now() + restoreDuration;
var currentUntil = Number(video.vsc.speedRestoreUntil) || 0;
video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil);
}
function clearAllSpeedRestoreEnforcement() {
tc.mediaElements.slice().forEach(function(video) {
if (!video || !video.vsc) return;
video.vsc.speedRestoreUntil = 0;
if (video.vsc.restoreSpeedTimer) {
clearTimeout(video.vsc.restoreSpeedTimer);
video.vsc.restoreSpeedTimer = null;
}
clearSpeedVerification(video.vsc);
if (video.vsc.targetSpeedOrigin === "policy") {
video.vsc.targetSpeed = null;
video.vsc.targetSpeedSourceKey = null;
video.vsc.targetSpeedOrigin = null;
video.vsc.stopSubtitleNudge();
}
});
}
function clearSpeedVerification(controller) {
if (!controller) return;
if (controller.speedVerificationTimer) {
clearTimeout(controller.speedVerificationTimer);
}
controller.speedVerificationTimer = null;
controller.speedVerificationTarget = null;
controller.speedVerificationAttempts = 0;
}
function scheduleSpeedVerification(video, desiredSpeed, resetAttempts) {
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
var controller = video.vsc;
if (
resetAttempts === true ||
controller.speedVerificationTarget !== desiredSpeed
) {
controller.speedVerificationAttempts = 0;
}
controller.speedVerificationTarget = desiredSpeed;
if (controller.speedVerificationTimer) {
clearTimeout(controller.speedVerificationTimer);
}
controller.speedVerificationTimer = setTimeout(function verifySpeed() {
if (!video.vsc || video.vsc !== controller) return;
controller.speedVerificationTimer = null;
var actualSpeed;
try {
actualSpeed = Number(video.playbackRate);
} catch (error) {
actualSpeed = NaN;
}
if (isValidSpeed(actualSpeed) && Math.abs(actualSpeed - desiredSpeed) <= 0.01) {
clearSpeedVerification(controller);
return;
}
var restoreIsActive =
Number(controller.speedRestoreUntil) > Date.now();
var policyIsActive =
tc.settings.forceLastSavedSpeed ||
(tc.settings.rememberSpeed && controller.targetSpeedOrigin === "policy");
if (
(!restoreIsActive && !policyIsActive) ||
controller.speedVerificationAttempts >= 3
) {
clearSpeedVerification(controller);
log(
`Player rejected playbackRate ${desiredSpeed.toFixed(2)} after verification`,
3
);
return;
}
controller.speedVerificationAttempts += 1;
rememberPendingRateChange(video, desiredSpeed, false);
try {
video.playbackRate = desiredSpeed;
} catch (error) {
controller.pendingRateChange = null;
log(`Playback speed verification failed: ${error.message}`, 3);
}
scheduleSpeedVerification(video, desiredSpeed, false);
}, 125 * (Number(controller.speedVerificationAttempts) + 1));
}
function scheduleSpeedRestore(video, desiredSpeed, reason) {
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
if (video.vsc.restoreSpeedTimer) {
clearTimeout(video.vsc.restoreSpeedTimer);
}
video.vsc.restoreSpeedTimer = setTimeout(function() {
if (!video.vsc) return;
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
log(
`Restoring playbackRate to ${desiredSpeed.toFixed(2)} after ${reason}`,
4
);
setSpeed(video, desiredSpeed, false, false);
}
if (video.vsc) {
video.vsc.restoreSpeedTimer = null;
}
}, 0);
}
function rememberPendingRateChange(video, speed, shouldPersist) {
if (!video || !video.vsc || !isValidSpeed(speed)) return;
video.vsc.pendingRateChange = {
speed: Number(speed),
shouldPersist: shouldPersist === true,
expiresAt: Date.now() + 1000
};
}
function takePendingRateChange(video, currentSpeed) {
if (!video || !video.vsc || !video.vsc.pendingRateChange) return null;
var pendingRateChange = video.vsc.pendingRateChange;
if (
!isValidSpeed(pendingRateChange.speed) ||
pendingRateChange.expiresAt <= Date.now()
) {
video.vsc.pendingRateChange = null;
return null;
}
if (Math.abs(Number(pendingRateChange.speed) - currentSpeed) > 0.01) {
return null;
}
video.vsc.pendingRateChange = null;
return pendingRateChange;
}
function matchesKeyBinding(binding, event) {
return Boolean(
binding &&
binding.disabled !== true &&
typeof binding.code === "string" &&
binding.code.length > 0 &&
binding.code === event.code &&
(binding.shiftKey === true) === event.shiftKey
);
}
function mediaSelector() {
return tc.settings.audioBoolean ? "video,audio" : "video";
}
function isMediaElement(node) {
return (
node &&
node.nodeType === Node.ELEMENT_NODE &&
(node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean))
);
}
function hasInteractivePlayerChrome(node) {
if (!node || node.nodeName !== "VIDEO") return false;
if (node.controls === true) return true;
if (typeof node.closest === "function") {
var knownPlayer = node.closest(
".html5-video-player, .video-js, .jwplayer, .plyr, " +
"[class~='video-player'], [data-testid*='video-player'], " +
"media-player, amp-video"
);
if (knownPlayer) return true;
}
var ancestor = node.parentElement;
var videoRect = node.getBoundingClientRect();
var depth = 0;
while (ancestor && depth < 4) {
var ancestorRect = ancestor.getBoundingClientRect();
if (
videoRect.width > 0 &&
videoRect.height > 0 &&
(ancestorRect.width > Math.max(videoRect.width * 1.75, videoRect.width + 160) ||
ancestorRect.height > Math.max(videoRect.height * 1.75, videoRect.height + 160))
) {
break;
}
if (
(videoRect.width <= 0 || videoRect.height <= 0) &&
depth > 1
) {
break;
}
try {
var controls = ancestor.querySelectorAll(
"button, [role='button'], input[type='range'], [role='slider']"
);
for (var i = 0; i < Math.min(controls.length, 40); i += 1) {
var control = controls[i];
var description = [
control.getAttribute("aria-label"),
control.getAttribute("title"),
control.getAttribute("data-title-no-tooltip"),
control.className
]
.filter(Boolean)
.join(" ");
if (
/(^|\b)(play|pause|mute|volume|seek|captions?|subtitles?|fullscreen|full screen)(\b|$)/i.test(
description
)
) {
return true;
}
}
} catch (_error) {}
ancestor = ancestor.parentElement;
depth += 1;
}
return false;
}
function isAmbientLoopMedia(node) {
var hasAmbientSignature = Boolean(
node &&
node.nodeName === "VIDEO" &&
node.autoplay === true &&
(node.muted === true || node.defaultMuted === true) &&
node.loop === true &&
node.playsInline === true &&
node.controls !== true
);
if (!hasAmbientSignature || tc.settings.showAmbientLoopControls) return false;
return !hasInteractivePlayerChrome(node);
}
function hasUsableMediaSource(node) {
if (!isMediaElement(node) || !node.isConnected) return false;
if (node.currentSrc || node.src || node.srcObject) return true;
if (typeof node.readyState === "number" && node.readyState > 0) return true;
if (
typeof node.networkState === "number" &&
typeof HTMLMediaElement !== "undefined" &&
(node.networkState === HTMLMediaElement.NETWORK_IDLE ||
node.networkState === HTMLMediaElement.NETWORK_LOADING)
) {
return true;
}
if (node.querySelectorAll) {
return Array.from(node.querySelectorAll("source[src]")).some(function(
source
) {
var src = source.getAttribute("src");
return typeof src === "string" && src.trim().length > 0;
});
}
return false;
}
function getControllerStructureSignature() {
return JSON.stringify({
buttons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons
: [],
startHidden: tc.settings.startHidden === true
});
}
function ensureController(node, parent) {
if (!tc.runtimeSettingsHydrated) {
if (
node &&
(node.nodeName === "VIDEO" || node.nodeName === "AUDIO") &&
!tc.pendingMediaCandidates.some(function(candidate) {
return candidate.node === node;
})
) {
tc.pendingMediaCandidates.push({ node: node, parent: parent });
}
return null;
}
if (!isMediaElement(node)) return null;
if (typeof tc.videoController === "undefined") defineVideoController();
// href selects site rules; re-run on every new/usable media so all runtime
// paths agree on activation and effective settings.
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
if (node.vsc) removeController(node);
return null;
}
if (isAmbientLoopMedia(node)) {
if (node.vsc) removeController(node);
return null;
}
if (
node.vsc &&
(
!node.vsc.div ||
typeof node.vsc.remove !== "function" ||
node.vsc.structureSignature !== getControllerStructureSignature()
)
) {
removeController(node);
}
if (node.vsc) {
var existingController = node.vsc;
var hasSource = hasUsableMediaSource(node);
if (existingController.div) {
existingController.div.classList.toggle("vsc-nosource", !hasSource);
}
var fullscreenElement = getFullscreenElement(node.ownerDocument);
var normalMount = getControllerMount(node) || parent || node.parentNode;
if (normalMount) existingController.normalControllerMount = normalMount;
if (
fullscreenElement &&
(fullscreenElement === node ||
isComposedDescendant(node, fullscreenElement))
) {
syncControllerFullscreenMount(existingController);
} else if (normalMount) {
remountControllerHost(existingController, normalMount);
}
if (hasSource) {
applySourceTransitionPolicy(node, false);
}
return existingController;
}
if (!hasUsableMediaSource(node)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
5
);
return null;
}
refreshAllControllerGeometry();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
);
try {
node.vsc = new tc.videoController(
node,
parent || node.parentElement || node.parentNode
);
} catch (error) {
log(`Unable to create media controller: ${error.message}`, 2);
removeController(node);
return null;
}
if (!node.vsc || !node.vsc.div) {
log("Controller initialization returned without a control host", 3);
removeController(node);
return null;
}
if (
node.vsc &&
(tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed)
) {
scheduleSpeedVerification(node, node.vsc.targetSpeed, true);
}
if (node.vsc) syncControllerFullscreenMount(node.vsc);
return node.vsc;
}
function flushPendingMediaCandidates() {
var pending = tc.pendingMediaCandidates.splice(0);
pending.forEach(function(candidate) {
if (!candidate.node || !candidate.node.isConnected) return;
ensureController(
candidate.node,
candidate.node.parentElement || candidate.parent || candidate.node.parentNode
);
});
}
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) return;
var controller = node.vsc;
if (typeof controller.remove === "function") {
try {
controller.remove();
return;
} catch (error) {
log(`Partial controller cleanup failed: ${error.message}`, 3);
}
}
if (controller.div && typeof controller.div.remove === "function") {
controller.div.remove();
}
var index = tc.mediaElements.indexOf(node);
if (index !== -1) tc.mediaElements.splice(index, 1);
delete node.vsc;
}
function scanNodeForMedia(node, parent, added) {
if (!node || typeof node === "function") return;
if (node.nodeType === Node.DOCUMENT_NODE) {
scanNodeForMedia(node.body || node.documentElement, node.body, added);
return;
}
if (
node.nodeType !== Node.ELEMENT_NODE &&
node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
return;
}
var ownerDocument = node.ownerDocument || document;
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return;
// Check if the node itself is a media element
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
} else if (node.nodeName === "SOURCE") {
// Players such as Vidstack often connect an empty <video> and append its
// <source> later. Retry the owning video when that source becomes usable.
var owningMedia = isMediaElement(parent)
? parent
: node.parentElement;
if (owningMedia) {
ensureController(owningMedia, owningMedia.parentNode);
} else if (added) {
ensureControllerForMediaChild(node);
}
}
// Use querySelectorAll instead of recursive child walking — the browser's
// native selector engine is orders of magnitude faster than JS recursion.
if (typeof node.querySelectorAll === "function") {
var selector = mediaSelector();
try {
var mediaElements = node.querySelectorAll(selector);
for (var i = 0; i < mediaElements.length; i++) {
var el = mediaElements[i];
if (added) ensureController(el, el.parentNode || parent);
else removeController(el);
}
} catch (e) {
// querySelectorAll may throw on detached or unusual nodes
}
}
// Still need to observe shadow roots for media inside web components
if (node.shadowRoot) {
observeRoot(node.shadowRoot);
}
}
function getScanNodeForRoot(root) {
if (!root) return null;
if (root.nodeType === Node.DOCUMENT_NODE) {
return root.body || root.documentElement;
}
return 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 {
return Boolean(root.querySelector(mediaSelector()));
} catch (error) {
return true;
}
}
function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return;
scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true);
}
function rescanOpenShadowRoots(root, visited, rescanObserved) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot || typeof scanRoot.querySelectorAll !== "function") return;
var seen = visited || new WeakSet();
var hosts = [];
if (scanRoot.nodeType === Node.ELEMENT_NODE && scanRoot.shadowRoot) {
hosts.push(scanRoot);
}
try {
var elements = scanRoot.querySelectorAll("*");
for (var i = 0; i < elements.length; i++) {
if (elements[i].shadowRoot) hosts.push(elements[i]);
}
} catch (_error) {
return;
}
hosts.forEach(function(host) {
var shadowRoot = host.shadowRoot;
if (!shadowRoot || seen.has(shadowRoot)) return;
seen.add(shadowRoot);
if (rescanObserved !== false || !vscObservedRoots.has(shadowRoot)) {
observeRoot(shadowRoot);
}
rescanOpenShadowRoots(shadowRoot, seen, rescanObserved);
});
}
function rescanObservedMediaRoots(doc) {
var retainedRoots = [];
vscObservedRootList.forEach(function(root) {
if (!root || root.nodeType === Node.DOCUMENT_NODE) return;
var connected = Boolean(root.host ? root.host.isConnected : root.isConnected);
if (!connected) return;
retainedRoots.push(root);
if (
!doc ||
root.ownerDocument === doc ||
(root.host && root.host.ownerDocument === doc)
) {
scanRootForMedia(root);
}
});
vscObservedRootList = retainedRoots;
}
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);
vscObservedRootList.push(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
}
if (
isConnected &&
(!vscConnectedScannedRoots.has(root) || isShadowRootNode(root))
) {
vscConnectedScannedRoots.add(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
}
}
}
function patchAttachShadow() {
if (
window.vscAttachShadowPatched ||
typeof Element === "undefined" ||
typeof Element.prototype.attachShadow !== "function"
) {
return;
}
var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments);
try {
if (shadowRoot) {
observeRoot(shadowRoot);
}
} catch (error) {
log(`Unable to observe shadow root: ${error.message}`, 3);
}
return shadowRoot;
};
window.vscAttachShadowPatched = true;
}
function installPageShadowBridge() {
if (window.vscPageShadowBridgeRequested) return;
if (!window.vscPageBridgeListenersAttached) {
window.vscPageBridgeListenersAttached = true;
document.addEventListener(
"speeder-shadow-root-attached",
function(event) {
var host = event.target;
if (!host || !host.shadowRoot) return;
try {
observeRoot(host.shadowRoot);
} catch (error) {
log(`Unable to observe page-created shadow root: ${error.message}`, 3);
}
},
true
);
document.addEventListener(
"speeder-location-changed",
function() {
scheduleNavigationRescan();
},
true
);
}
function startBoundedShadowFallback() {
if (window.vscBoundedShadowFallbackStarted) return;
window.vscBoundedShadowFallbackStarted = true;
window.vscBoundedShadowFallbackTimers = [3000, 10000, 30000].map(
function(delay) {
return setTimeout(function() {
requestIdle(
function() {
if (
!document.body ||
(delay !== 3000 && window.vscPageShadowBridgeLoaded)
) {
return;
}
rescanOpenShadowRoots(document, undefined, false);
},
{ timeout: 2000 }
);
}, delay);
}
);
}
function handleBridgeFailure() {
window.vscPageShadowBridgeRequested = false;
window.vscPageShadowBridgeRetries =
(Number(window.vscPageShadowBridgeRetries) || 0) + 1;
startBoundedShadowFallback();
if (window.vscPageShadowBridgeRetries > 3) return;
clearTimeout(window.vscPageShadowBridgeRetryTimer);
window.vscPageShadowBridgeRetryTimer = setTimeout(function() {
installPageShadowBridge();
}, 250 * window.vscPageShadowBridgeRetries);
}
var parent = document.documentElement || document.head;
if (!parent) {
if (!window.vscPageShadowBridgeRetryAttached) {
window.vscPageShadowBridgeRetryAttached = true;
var retryBridgeInstall = function() {
if (document.documentElement || document.head) {
document.removeEventListener("readystatechange", retryBridgeInstall);
document.removeEventListener("DOMContentLoaded", retryBridgeInstall);
installPageShadowBridge();
}
};
document.addEventListener("readystatechange", retryBridgeInstall);
document.addEventListener("DOMContentLoaded", retryBridgeInstall);
}
return;
}
try {
var bridge = document.createElement("script");
bridge.src = chrome.runtime.getURL("content/shadow-bridge.js");
bridge.async = false;
bridge.addEventListener("load", function() {
bridge.remove();
window.vscPageShadowBridgeLoaded = true;
window.vscPageShadowBridgeRetries = 0;
clearTimeout(window.vscPageShadowBridgeRetryTimer);
});
bridge.addEventListener("error", function() {
bridge.remove();
handleBridgeFailure();
});
window.vscPageShadowBridgeRequested = true;
parent.appendChild(bridge);
// The bridge reports future roots, but cannot report roots that existed
// before the extension loaded. Keep one bounded initial discovery pass.
startBoundedShadowFallback();
} catch (error) {
handleBridgeFailure();
log(`Unable to install page shadow bridge: ${error.message}`, 3);
}
}
/* Log levels */
function log(message, level) {
verbosity = tc.settings.logLevel;
if (typeof level === "undefined") level = tc.settings.defaultLogLevel;
if (verbosity >= level) {
let prefix = "[VSC] ";
if (level === 2) console.log(prefix + "ERROR: " + message);
else if (level === 3) console.log(prefix + "WARNING: " + message);
else if (level === 4) console.log(prefix + "INFO: " + message);
else if (level === 5) console.log(prefix + "DEBUG: " + message);
else if (level === 6) {
console.log(prefix + "DEBUG (VERBOSE): " + message);
console.trace();
}
}
}
function hydrateRuntimeSettings(rawStorage, options) {
var config = options || {};
var storage =
typeof vscExpandStoredSettings === "function"
? vscExpandStoredSettings(rawStorage || {})
: Object.assign({}, tc.settings, rawStorage || {});
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
tc.settings.keyBindings = storedBindings
.map(function(binding) {
return normalizeStoredBinding(binding);
})
.filter(Boolean);
if (tc.settings.keyBindings.length === 0) {
tc.settings.keyBindings = defaultKeyBindings(storage);
}
var unsavedLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
? Number(tc.pendingLastSpeedValue)
: tc.lastSpeedWriteInFlight && isValidSpeed(tc.lastSpeedWriteValue)
? Number(tc.lastSpeedWriteValue)
: null;
var hasPendingLastSpeed = Boolean(
config.preservePendingLastSpeed && isValidSpeed(unsavedLastSpeed)
);
var storedLastSpeed = Number(storage.lastSpeed);
var nextLastSpeed = hasPendingLastSpeed
? unsavedLastSpeed
: storedLastSpeed;
if (!isValidSpeed(nextLastSpeed)) {
log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3);
nextLastSpeed = 1.0;
if (!hasPendingLastSpeed) {
chrome.storage.sync.set({ lastSpeed: 1.0 });
}
}
tc.settings.lastSpeed = nextLastSpeed;
if (isValidSpeed(storedLastSpeed)) {
tc.persistedLastSpeed = storedLastSpeed;
} else if (!hasPendingLastSpeed) {
tc.persistedLastSpeed = nextLastSpeed;
}
tc.settings.rememberSpeed = storage.rememberSpeed === true;
tc.settings.forceLastSavedSpeed = storage.forceLastSavedSpeed === true;
tc.settings.audioBoolean = storage.audioBoolean === true;
tc.settings.showAmbientLoopControls =
storage.showAmbientLoopControls === true;
tc.settings.enabled = storage.enabled !== false;
tc.settings.startHidden = storage.startHidden === true;
tc.settings.hideWithControls =
typeof storage.hideWithControls !== "undefined"
? Boolean(storage.hideWithControls)
: Boolean(storage.hideWithYouTubeControls);
tc.settings.hideWithControlsTimer = Math.min(
15,
Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0)
);
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
tc.settings.controllerLocation = normalizeControllerLocation(
storage.controllerLocation
);
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
if (!Number.isFinite(tc.settings.controllerOpacity)) {
tc.settings.controllerOpacity = getSharedDefault("controllerOpacity", 0.3);
}
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
storage.controllerMarginTop,
getSharedDefault("controllerMarginTop", 0)
);
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
storage.controllerMarginRight,
getSharedDefault("controllerMarginRight", 0)
);
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
storage.controllerMarginBottom,
getSharedDefault("controllerMarginBottom", 65)
);
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
storage.controllerMarginLeft,
getSharedDefault("controllerMarginLeft", 0)
);
tc.settings.shortcutTargetMode =
storage.shortcutTargetMode === "all" ? "all" : "closest";
tc.settings.siteRules = Array.isArray(storage.siteRules)
? storage.siteRules.map(function(rule) {
return rule && typeof rule === "object" ? Object.assign({}, rule) : rule;
})
: getSharedDefault("siteRules", []);
tc.settings.controllerButtons = Array.isArray(storage.controllerButtons)
? storage.controllerButtons.slice()
: getSharedDefault("controllerButtons", [
"rewind",
"slower",
"faster",
"advance",
"display"
]);
tc.settings.enableSubtitleNudge = Boolean(storage.enableSubtitleNudge);
tc.settings.subtitleNudgeEnabledByDefault =
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
? Boolean(storage.subtitleNudgeEnabledByDefault)
: getSharedDefault("subtitleNudgeEnabledByDefault", true);
tc.settings.subtitleNudgeInterval = Math.min(
1000,
Math.max(250, Number(storage.subtitleNudgeInterval) || 250)
);
tc.settings.subtitleNudgeAmount =
Number(storage.subtitleNudgeAmount) ||
getSharedDefault("subtitleNudgeAmount", 0.001);
var addedDefaultBinding = false;
addedDefaultBinding =
ensureDefaultKeyBinding("display", "KeyV", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding;
if (addedDefaultBinding && config.persistMissingBindings !== false) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
}
// Capture only freshly hydrated global values. Calling this while a current
// route's overrides are still applied would promote those overrides into the
// global base and leak them into the next SPA route.
captureSiteRuleBase();
return storage;
}
function replaceRememberedSpeeds(rememberedSpeeds) {
Object.keys(tc.settings.speeds).forEach(function(sourceKey) {
if (isPersistableSourceKey(sourceKey)) {
delete tc.settings.speeds[sourceKey];
delete tc.speedAccessTimes[sourceKey];
}
});
var sourceMap =
rememberedSpeeds && typeof rememberedSpeeds === "object"
? rememberedSpeeds
: {};
Object.keys(sourceMap).forEach(function(sourceKey) {
var storedEntry = sourceMap[sourceKey];
var storedSpeed =
storedEntry && typeof storedEntry === "object"
? Number(storedEntry.speed)
: Number(storedEntry);
var storedUpdatedAt =
storedEntry && typeof storedEntry === "object"
? Number(storedEntry.updatedAt) || 0
: 0;
if (
!isPersistableSourceKey(sourceKey) ||
!isValidSpeed(storedSpeed) ||
(tc.rememberedSpeedsResetAt > 0 &&
storedUpdatedAt <= tc.rememberedSpeedsResetAt)
) {
return;
}
tc.settings.speeds[sourceKey] = storedSpeed;
tc.speedAccessTimes[sourceKey] = storedUpdatedAt;
});
}
function mergeRememberedSpeeds(rememberedSpeeds) {
var sourceMap =
rememberedSpeeds && typeof rememberedSpeeds === "object"
? rememberedSpeeds
: {};
Object.keys(sourceMap).forEach(function(sourceKey) {
var storedEntry = sourceMap[sourceKey];
var storedSpeed =
storedEntry && typeof storedEntry === "object"
? Number(storedEntry.speed)
: Number(storedEntry);
var storedUpdatedAt =
storedEntry && typeof storedEntry === "object"
? Number(storedEntry.updatedAt) || 0
: 0;
if (
!isPersistableSourceKey(sourceKey) ||
!isValidSpeed(storedSpeed) ||
(tc.rememberedSpeedsResetAt > 0 &&
storedUpdatedAt <= tc.rememberedSpeedsResetAt)
) {
return;
}
var currentUpdatedAt = Number(tc.speedAccessTimes[sourceKey]) || 0;
if (
!isValidSpeed(Number(tc.settings.speeds[sourceKey])) ||
storedUpdatedAt >= currentUpdatedAt
) {
tc.settings.speeds[sourceKey] = storedSpeed;
tc.speedAccessTimes[sourceKey] = storedUpdatedAt;
}
});
}
function rememberedSpeedsPayloadMatches(sourceMap, expectedPayload) {
var actual = sourceMap && typeof sourceMap === "object" ? sourceMap : {};
var expected =
expectedPayload && typeof expectedPayload === "object"
? expectedPayload
: {};
var actualKeys = Object.keys(actual).filter(function(sourceKey) {
return isPersistableSourceKey(sourceKey);
});
var expectedKeys = Object.keys(expected);
if (actualKeys.length !== expectedKeys.length) return false;
return expectedKeys.every(function(sourceKey) {
var actualEntry = actual[sourceKey];
var expectedEntry = expected[sourceKey];
return Boolean(
actualEntry &&
expectedEntry &&
Number(actualEntry.speed) === Number(expectedEntry.speed) &&
(Number(actualEntry.updatedAt) || 0) ===
(Number(expectedEntry.updatedAt) || 0)
);
});
}
function clearRememberedSpeedsInMemory(resetAt) {
tc.rememberedSpeedsWriteEpoch += 1;
if (tc.pendingRememberedSpeedsSave !== null) {
clearTimeout(tc.pendingRememberedSpeedsSave);
tc.pendingRememberedSpeedsSave = null;
}
tc.rememberedSpeedsSaveRetries = 0;
var preserveAfter = Number(resetAt) || 0;
Object.keys(tc.settings.speeds).forEach(function(sourceKey) {
if (!isPersistableSourceKey(sourceKey)) return;
if (
preserveAfter > 0 &&
(Number(tc.speedAccessTimes[sourceKey]) || 0) > preserveAfter
) {
return;
}
delete tc.settings.speeds[sourceKey];
delete tc.speedAccessTimes[sourceKey];
});
}
function applyRememberedSpeedsResetMarker(value) {
var resetAt = Number(value) || 0;
if (resetAt <= tc.rememberedSpeedsResetAt) return false;
tc.rememberedSpeedsResetAt = resetAt;
clearRememberedSpeedsInMemory(resetAt);
return true;
}
function applyCustomButtonIcons(customButtonIcons) {
tc.settings.customButtonIcons =
customButtonIcons && typeof customButtonIcons === "object"
? customButtonIcons
: {};
if (!tc.mediaElements || !tc.mediaElements.length) return;
tc.mediaElements.forEach(function(video) {
if (!video.vsc || !video.vsc.div) return;
var doc = video.ownerDocument;
var shadow = video.vsc.div.shadowRoot;
if (!shadow) return;
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg;
vscClearElement(btn);
if (svg) {
var customWrap = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
if (customWrap) {
btn.appendChild(customWrap);
} else {
var customDef = controllerButtonDefs[act];
btn.textContent = (customDef && customDef.label) || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14);
if (wrap) {
btn.appendChild(wrap);
} else {
var defaultDef = controllerButtonDefs[act];
btn.textContent = (defaultDef && defaultDef.label) || "?";
}
} else {
var fallbackDef = controllerButtonDefs[act];
btn.textContent = (fallbackDef && fallbackDef.label) || "?";
}
});
updateSubtitleNudgeIndicator(video);
});
}
function scheduleRuntimeSettingsReload() {
var reloadGeneration =
(Number(window.vscSettingsReloadGeneration) || 0) + 1;
window.vscSettingsReloadGeneration = reloadGeneration;
clearTimeout(window.vscSettingsReloadTimer);
clearTimeout(window.vscSettingsReloadRetryTimer);
window.vscSettingsReloadTimer = setTimeout(function() {
chrome.storage.sync.get(null, function(rawStorage) {
if (reloadGeneration !== window.vscSettingsReloadGeneration) return;
if (chrome.runtime.lastError) {
if (tc.settingsReloadRetries < 3) {
tc.settingsReloadRetries += 1;
window.vscSettingsReloadRetryTimer = setTimeout(function() {
if (reloadGeneration === window.vscSettingsReloadGeneration) {
scheduleRuntimeSettingsReload();
}
}, 250 * tc.settingsReloadRetries);
}
return;
}
tc.settingsReloadRetries = 0;
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
var wasRememberEnabled = tc.settings.rememberSpeed === true;
hydrateRuntimeSettings(rawStorage || {}, {
preservePendingLastSpeed: true,
persistMissingBindings: false
});
applySiteRuleOverrides();
if (
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
(wasRememberEnabled && !tc.settings.rememberSpeed)
) {
clearAllSpeedRestoreEnforcement();
}
initializeWhenReady(document, true);
});
}, 75);
}
function loadInitialLocalSettings(attempt, callback) {
chrome.storage.local.get(
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
function(localStorage) {
if (chrome.runtime.lastError && attempt < 3) {
setTimeout(function() {
loadInitialLocalSettings(attempt + 1, callback);
}, 100 * (attempt + 1));
return;
}
callback(chrome.runtime.lastError ? {} : localStorage || {});
}
);
}
// 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();
installPageShadowBridge();
function loadInitialRuntimeSettings(attempt) {
chrome.storage.sync.get(null, function(rawStorage) {
if (chrome.runtime.lastError && attempt < 3) {
setTimeout(function() {
loadInitialRuntimeSettings(attempt + 1);
}, 100 * (attempt + 1));
return;
}
hydrateRuntimeSettings(rawStorage || {});
// patchAttachShadow() is now called at top-level before this callback
// Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
sendResponse({ status: "complete" });
return false;
}
if (request.action === "get_speed") {
// Do not sendResponse in frames with no media — only one response is
// accepted tab-wide, and the top frame often wins before an iframe.
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule &&
tc.activeSiteRule.forceLastSavedSpeed !== undefined
)
});
return false;
}
if (request.action === "get_page_context") {
sendResponse({ url: location.href });
return false;
}
if (request.action === "set_force_last_saved_speed") {
var forceEnabled = Boolean(request.enabled);
if (tc.siteRuleBase) {
tc.siteRuleBase.forceLastSavedSpeed = forceEnabled;
}
// Site rules own the effective per-page value. The popup changes the
// global base, then this reapplication keeps both paths consistent.
applySiteRuleOverrides();
var forceVideo = getPrimaryVideoElement();
if (tc.settings.forceLastSavedSpeed) {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
setSpeed(video, tc.settings.lastSpeed, false, false);
video.vsc.targetSpeedOrigin = "policy";
extendSpeedRestoreWindow(video);
});
}
sendResponse({
enabled: tc.settings.forceLastSavedSpeed,
speed: forceVideo ? forceVideo.playbackRate : null
});
return false;
}
if (request.action === "run_action") {
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
) {
return false;
}
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
}
runAction(request.actionName, value);
var videoAfter = getPrimaryVideoElement();
if (!videoAfter) return false;
sendResponse({
speed: videoAfter.playbackRate
});
return false;
}
return false;
}
);
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
loadInitialLocalSettings(0, function(loc) {
applyCustomButtonIcons(loc && loc.customButtonIcons);
applyRememberedSpeedsResetMarker(
loc && loc.rememberedSpeedsResetAt
);
replaceRememberedSpeeds(loc && loc.rememberedSpeeds);
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function(changes, area) {
if (area === "sync") {
var changedKeys = Object.keys(changes || {});
var isLastSpeedOnly =
changedKeys.length === 1 && changedKeys[0] === "lastSpeed";
if (isLastSpeedOnly) {
var changedLastSpeed = Number(changes.lastSpeed.newValue);
if (isValidSpeed(changedLastSpeed)) {
tc.persistedLastSpeed = changedLastSpeed;
var newerLocalLastSpeed = isValidSpeed(tc.pendingLastSpeedValue)
? Number(tc.pendingLastSpeedValue)
: tc.lastSpeedWriteInFlight &&
isValidSpeed(tc.lastSpeedWriteValue)
? Number(tc.lastSpeedWriteValue)
: null;
if (
isValidSpeed(newerLocalLastSpeed) &&
newerLocalLastSpeed !== changedLastSpeed
) {
return;
}
tc.settings.lastSpeed = changedLastSpeed;
if (tc.settings.forceLastSavedSpeed) {
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
}
}
return;
}
var managedKeys =
typeof vscGetManagedSyncKeys === "function"
? vscGetManagedSyncKeys()
: [
"enabled",
"rememberSpeed",
"forceLastSavedSpeed",
"siteRules"
];
if (
changedKeys.some(function(key) {
return managedKeys.indexOf(key) !== -1;
})
) {
// Options persistence may emit a set followed by a remove. Re-read
// the final sparse state after the burst instead of reconstructing
// settings from partial change records.
scheduleRuntimeSettingsReload();
}
return;
}
if (area !== "local") return;
if (changes.rememberedSpeedsResetAt) {
window.vscRememberedSpeedsGeneration =
(Number(window.vscRememberedSpeedsGeneration) || 0) + 1;
applyRememberedSpeedsResetMarker(
changes.rememberedSpeedsResetAt.newValue
);
}
if (changes.rememberedSpeeds) {
window.vscRememberedSpeedsGeneration =
(Number(window.vscRememberedSpeedsGeneration) || 0) + 1;
var changedRememberedSpeeds = changes.rememberedSpeeds.newValue;
var inFlightRememberedSpeedsWrite =
tc.rememberedSpeedsWriteInFlight;
if (
inFlightRememberedSpeedsWrite &&
inFlightRememberedSpeedsWrite.epoch !==
tc.rememberedSpeedsWriteEpoch &&
inFlightRememberedSpeedsWrite.payload &&
rememberedSpeedsPayloadMatches(
changedRememberedSpeeds,
inFlightRememberedSpeedsWrite.payload
)
) {
return;
}
if (
!changedRememberedSpeeds ||
typeof changedRememberedSpeeds !== "object" ||
Object.keys(changedRememberedSpeeds).length === 0
) {
clearRememberedSpeedsInMemory(tc.rememberedSpeedsResetAt);
if (Object.keys(buildRememberedSpeedsPayload()).length > 0) {
schedulePersistRememberedSpeeds(true);
}
} else {
// Merge rather than replace: another frame can write while this
// frame still has a newer per-source speed queued. If the storage
// snapshot is missing one of our newer entries, self-heal it with a
// merged write instead of silently losing either frame's intent.
mergeRememberedSpeeds(changedRememberedSpeeds);
var expectedRememberedSpeeds = buildRememberedSpeedsPayload();
if (
!rememberedSpeedsPayloadMatches(
changedRememberedSpeeds,
expectedRememberedSpeeds
)
) {
schedulePersistRememberedSpeeds(true);
}
}
}
if (!changes.customButtonIcons) return;
window.vscCustomIconGeneration =
(Number(window.vscCustomIconGeneration) || 0) + 1;
var nv = changes.customButtonIcons.newValue;
applyCustomButtonIcons(nv);
});
}
// Close the small gap between the initial sync/local reads and listener
// registration. A final read also converges if Options saved while this
// content script was starting.
scheduleRuntimeSettingsReload();
var customIconGeneration =
Number(window.vscCustomIconGeneration) || 0;
var rememberedSpeedsGeneration =
Number(window.vscRememberedSpeedsGeneration) || 0;
chrome.storage.local.get(
["customButtonIcons", "rememberedSpeeds", "rememberedSpeedsResetAt"],
function(latestLocal) {
if (chrome.runtime.lastError) return;
if (
customIconGeneration ===
(Number(window.vscCustomIconGeneration) || 0)
) {
applyCustomButtonIcons(latestLocal && latestLocal.customButtonIcons);
}
if (
rememberedSpeedsGeneration ===
(Number(window.vscRememberedSpeedsGeneration) || 0)
) {
applyRememberedSpeedsResetMarker(
latestLocal && latestLocal.rememberedSpeedsResetAt
);
mergeRememberedSpeeds(latestLocal && latestLocal.rememberedSpeeds);
var latestRememberedSpeeds =
latestLocal && latestLocal.rememberedSpeeds;
if (
!rememberedSpeedsPayloadMatches(
latestRememberedSpeeds,
buildRememberedSpeedsPayload()
)
) {
schedulePersistRememberedSpeeds(true);
}
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
}
}
}
);
tc.runtimeSettingsHydrated = true;
initializeWhenReady(document);
});
});
}
// Install before async settings hydration so SPA-owned window capture handlers
// cannot hide later key events from Speeder.
attachKeydownListeners(document);
loadInitialRuntimeSettings(0);
function getKeyBindings(action, what = "value") {
try {
return tc.settings.keyBindings.find((item) => item.action === action)[what];
} catch (e) {
return false;
}
}
function setKeyBindings(action, value) {
var binding = tc.settings.keyBindings.find(function(item) {
return item.action === action;
});
if (!binding) return false;
binding.value = value;
return true;
}
function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button");
button.dataset.action = action;
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
if (customWrap) {
button.appendChild(customWrap);
} else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14);
if (wrap) {
button.appendChild(wrap);
} else {
button.textContent = label || "?";
}
} else {
button.textContent = label || "?";
}
if (className) {
button.className = 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 getSharedOverlayMount(mount, videoRect) {
if (!mount || !mount.parentElement || !videoRect) return null;
if (videoRect.width <= 0 || videoRect.height <= 0) return null;
var parent = mount.parentElement;
var parentRect = parent.getBoundingClientRect();
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
var tightlyContainsVideo =
parentRect.left <= videoRect.left + 1 &&
parentRect.top <= videoRect.top + 1 &&
parentRect.right >= videoRect.right - 1 &&
parentRect.bottom >= videoRect.bottom - 1 &&
parentRect.width <= widthLimit &&
parentRect.height <= heightLimit;
if (!tightlyContainsVideo) return null;
var siblings = Array.from(parent.children || []);
var hasCoveringSibling = siblings.some(function(sibling) {
if (sibling === mount || sibling.classList.contains("vsc-controller")) {
return false;
}
var rect = sibling.getBoundingClientRect();
var overlapWidth = Math.max(
0,
Math.min(rect.right, videoRect.right) - Math.max(rect.left, videoRect.left)
);
var overlapHeight = Math.max(
0,
Math.min(rect.bottom, videoRect.bottom) - Math.max(rect.top, videoRect.top)
);
return (
overlapWidth >= videoRect.width * 0.75 &&
overlapHeight >= videoRect.height * 0.75
);
});
return hasCoveringSibling ? parent : null;
}
function isShadowRootNode(node) {
return Boolean(
node &&
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
node.host &&
node.host.nodeType === Node.ELEMENT_NODE
);
}
function isComposedDescendant(node, ancestor) {
if (!node || !ancestor) return false;
if (node === ancestor) return true;
if (typeof ancestor.contains === "function" && ancestor.contains(node)) {
return true;
}
var current = node;
var depth = 0;
while (current && depth < 20) {
if (current === ancestor) return true;
if (current.parentElement) {
current = current.parentElement;
} else {
var root =
typeof current.getRootNode === "function" ? current.getRootNode() : null;
current = root && root.host ? root.host : null;
}
depth += 1;
}
return false;
}
function getControllerGeometryMount(mount) {
return isShadowRootNode(mount) ? mount.host : mount;
}
function getControllerMount(video, boundary) {
if (!video) return null;
// In fullscreen, mount at the promoted top-layer boundary. The normal size
// heuristic is intentionally skipped: letterboxed/portrait video providers
// can be much smaller than a full-player gesture pane sibling.
if (
boundary &&
(boundary === video || isComposedDescendant(video, boundary))
) {
// A VIDEO is a replaced element; author children appended inside it are
// fallback content and are not painted. Direct-media fullscreen uses a
// top-layer popover instead, while this function keeps a renderable DOM
// mount as its ownership/geometry anchor.
if (boundary === video) {
return video.vsc && video.vsc.normalControllerMount
? video.vsc.normalControllerMount
: getControllerMount(video);
}
if (
boundary.shadowRoot &&
isComposedDescendant(video, boundary.shadowRoot)
) {
return boundary.shadowRoot;
}
return boundary;
}
// YouTube's .html5-video-container is often absolutely laid out and can
// report a zero-height box even while its video is visibly playing. The
// surrounding .html5-video-player is the real player/overlay boundary for
// watch pages, Shorts, and hover-preview #inline-preview-player instances.
// Keep every subsequent remount on that canonical boundary; otherwise an
// event-driven ensureController call can undo the constructor's YouTube
// mount and hide the controller in the zero-height inner container.
if (isOnYouTube() && typeof video.closest === "function") {
var youTubePlayerMount = video.closest(".html5-video-player");
if (youTubePlayerMount) return youTubePlayerMount;
}
if (!video.parentElement) {
var directRoot =
typeof video.getRootNode === "function" ? video.getRootNode() : null;
return isShadowRootNode(directRoot) ? directRoot : null;
}
var mountBoundary = null;
var videoRect = video.getBoundingClientRect();
var mount = video.parentElement;
var candidate = mount;
var depth = 0;
// Usually stay inside the immediate local stacking boundary so a max-z-index
// host cannot escape over page headers. The exception is a tightly-sized
// shared player whose covering gesture/click pane is a sibling of that
// boundary; mount one level up so the controller can win hit-testing.
if (createsControllerStackingContext(mount)) {
return getSharedOverlayMount(mount, videoRect) || mount;
}
// 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 geometryMount = getControllerGeometryMount(mount);
if (!geometryMount || typeof geometryMount.getBoundingClientRect !== "function") {
return;
}
var videoRect = video.getBoundingClientRect();
if (wrapper.classList.contains("vsc-fullscreen-popover")) {
if (videoRect.width <= 0 || videoRect.height <= 0) {
wrapper.classList.add("vsc-geometry-hidden");
wrapper.style.setProperty("display", "none", "important");
return;
}
wrapper.classList.remove("vsc-geometry-hidden");
wrapper.style.removeProperty("display");
wrapper.style.setProperty("left", videoRect.left + "px", "important");
wrapper.style.setProperty("top", videoRect.top + "px", "important");
wrapper.style.setProperty("width", videoRect.width + "px", "important");
wrapper.style.setProperty("height", videoRect.height + "px", "important");
return;
}
var mountRect = geometryMount.getBoundingClientRect();
if (
videoRect.width <= 0 ||
videoRect.height <= 0 ||
mountRect.width <= 0 ||
mountRect.height <= 0
) {
wrapper.classList.add("vsc-geometry-hidden");
wrapper.style.setProperty("display", "none", "important");
wrapper.style.setProperty("width", "0px", "important");
wrapper.style.setProperty("height", "0px", "important");
return;
}
wrapper.classList.remove("vsc-geometry-hidden");
wrapper.style.removeProperty("display");
// 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 = geometryMount.offsetWidth || mountRect.width || 1;
var mountHeight = geometryMount.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 -
(geometryMount.clientLeft || 0) +
(geometryMount.scrollLeft || 0);
var top =
(videoRect.top - mountRect.top) / scaleY -
(geometryMount.clientTop || 0) +
(geometryMount.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 configureControllerAutoHide(videoController, wrapper, force) {
if (!videoController || !wrapper) return;
var signature = JSON.stringify([
tc.settings.hideWithControls === true,
Number(tc.settings.hideWithControlsTimer) || 2
]);
if (force !== true && videoController.autoHideSettingsSignature === signature) {
return;
}
if (videoController.youTubeAutoHideObserver) {
videoController.youTubeAutoHideObserver.disconnect();
videoController.youTubeAutoHideObserver = null;
}
if (videoController.youTubeAutoHideCleanup) {
videoController.youTubeAutoHideCleanup();
videoController.youTubeAutoHideCleanup = null;
}
if (videoController.genericAutoHideCleanup) {
videoController.genericAutoHideCleanup();
videoController.genericAutoHideCleanup = null;
}
wrapper.classList.remove("ytp-autohide", "vsc-idle-hidden");
videoController.autoHideSettingsSignature = signature;
if (!tc.settings.hideWithControls) return;
if (isOnYouTube()) {
videoController.setupYouTubeAutoHide(wrapper);
} else {
videoController.setupGenericAutoHide(wrapper);
}
}
function setupControllerHostTracking(videoController, wrapper, mount) {
if (!videoController || !wrapper || !mount) return;
var doc = videoController.video.ownerDocument;
var win = doc.defaultView || window;
var geometryMount = getControllerGeometryMount(mount);
if (!geometryMount || !geometryMount.style) return;
var frameId = null;
var geometryRetryTimer = null;
var geometryRetryAttempts = 0;
var maxGeometryRetryAttempts = 12;
var update = function() {
frameId = null;
positionControllerHost(wrapper, videoController.video, mount);
// Content scripts run at document_start, so a connected player can still
// report a 0x0 rect while its stylesheet, route, or custom element is
// settling. ResizeObserver normally repairs this, but it is not guaranteed
// to exist (and some players only change ancestor layout). Keep a bounded
// fallback alive long enough for those players instead of leaving the
// inline display:none geometry guard stuck forever.
if (
wrapper.classList.contains("vsc-geometry-hidden") &&
wrapper.isConnected &&
videoController.video.isConnected &&
geometryRetryAttempts < maxGeometryRetryAttempts
) {
geometryRetryAttempts += 1;
if (geometryRetryTimer === null) {
geometryRetryTimer = win.setTimeout(function() {
geometryRetryTimer = null;
schedule();
}, Math.min(500, 25 * geometryRetryAttempts));
}
} else if (!wrapper.classList.contains("vsc-geometry-hidden")) {
geometryRetryAttempts = 0;
if (geometryRetryTimer !== null) {
win.clearTimeout(geometryRetryTimer);
geometryRetryTimer = null;
}
}
};
var schedule = function() {
if (frameId !== null) return;
frameId = win.requestAnimationFrame(update);
};
if (win.getComputedStyle(geometryMount).position === "static") {
geometryMount.dataset.vscPositionOwner = "true";
geometryMount.dataset.vscOriginalPosition =
geometryMount.style.getPropertyValue("position");
geometryMount.dataset.vscOriginalPositionPriority =
geometryMount.style.getPropertyPriority("position");
geometryMount.dataset.vscAppliedPosition = "relative";
geometryMount.style.setProperty("position", "relative");
}
if (!createsControllerStackingContext(geometryMount)) {
geometryMount.dataset.vscIsolationOwner = "true";
geometryMount.dataset.vscOriginalIsolation =
geometryMount.style.getPropertyValue("isolation");
geometryMount.dataset.vscOriginalIsolationPriority =
geometryMount.style.getPropertyPriority("isolation");
geometryMount.dataset.vscAppliedIsolation = "isolate";
geometryMount.style.setProperty("isolation", "isolate");
}
var resizeObserver = null;
if (typeof win.ResizeObserver === "function") {
resizeObserver = new win.ResizeObserver(schedule);
resizeObserver.observe(videoController.video);
resizeObserver.observe(geometryMount);
}
win.addEventListener("resize", schedule, { passive: true });
doc.addEventListener("fullscreenchange", schedule, { passive: true });
geometryMount.addEventListener("scroll", schedule, { passive: true });
update();
videoController.controllerHostMount = mount;
videoController.controllerHostCleanup = function(forceRestore) {
if (resizeObserver) resizeObserver.disconnect();
if (frameId !== null) win.cancelAnimationFrame(frameId);
if (geometryRetryTimer !== null) win.clearTimeout(geometryRetryTimer);
win.removeEventListener("resize", schedule);
doc.removeEventListener("fullscreenchange", schedule);
geometryMount.removeEventListener("scroll", schedule);
var hasOtherController = false;
try {
hasOtherController = Array.from(
mount.querySelectorAll(".vsc-controller")
).some(function(controllerHost) {
return controllerHost !== wrapper;
});
} catch (e) {}
if (
geometryMount.dataset.vscPositionOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (
geometryMount.style.getPropertyValue("position") ===
geometryMount.dataset.vscAppliedPosition &&
geometryMount.style.getPropertyPriority("position") === ""
) {
if (geometryMount.dataset.vscOriginalPosition) {
geometryMount.style.setProperty(
"position",
geometryMount.dataset.vscOriginalPosition,
geometryMount.dataset.vscOriginalPositionPriority || ""
);
} else {
geometryMount.style.removeProperty("position");
}
}
delete geometryMount.dataset.vscPositionOwner;
delete geometryMount.dataset.vscOriginalPosition;
delete geometryMount.dataset.vscOriginalPositionPriority;
delete geometryMount.dataset.vscAppliedPosition;
}
if (
geometryMount.dataset.vscIsolationOwner === "true" &&
!hasOtherController &&
(forceRestore === true || !wrapper.isConnected)
) {
if (
geometryMount.style.getPropertyValue("isolation") ===
geometryMount.dataset.vscAppliedIsolation &&
geometryMount.style.getPropertyPriority("isolation") === ""
) {
if (geometryMount.dataset.vscOriginalIsolation) {
geometryMount.style.setProperty(
"isolation",
geometryMount.dataset.vscOriginalIsolation,
geometryMount.dataset.vscOriginalIsolationPriority || ""
);
} else {
geometryMount.style.removeProperty("isolation");
}
}
delete geometryMount.dataset.vscIsolationOwner;
delete geometryMount.dataset.vscOriginalIsolation;
delete geometryMount.dataset.vscOriginalIsolationPriority;
delete geometryMount.dataset.vscAppliedIsolation;
}
};
configureControllerAutoHide(videoController, wrapper, true);
}
function remountControllerHost(videoController, mount) {
if (
!videoController ||
!videoController.div ||
!mount ||
!(mount.isConnected || (isShadowRootNode(mount) && mount.host.isConnected))
) {
return false;
}
if (
mount === videoController.controllerHostMount &&
videoController.div.isConnected &&
videoController.div.parentNode === mount
) {
// Keep the max-z-index host last among equal-z-index player overlays.
if (videoController.div.nextSibling) {
mount.appendChild(videoController.div);
return true;
}
positionControllerHost(
videoController.div,
videoController.video,
mount
);
return false;
}
if (videoController.controllerHostCleanup) {
videoController.controllerHostCleanup(true);
videoController.controllerHostCleanup = null;
}
mount.appendChild(videoController.div);
setupControllerHostTracking(videoController, videoController.div, mount);
return true;
}
function disableDirectFullscreenPopover(videoController) {
if (!videoController || !videoController.div) return;
var wrapper = videoController.div;
if (
!wrapper.vscFullscreenPopoverOpen &&
!wrapper.classList.contains("vsc-fullscreen-popover")
) {
return;
}
if (typeof wrapper.hidePopover === "function") {
try {
wrapper.hidePopover();
} catch (_error) {}
}
wrapper.vscFullscreenPopoverOpen = false;
wrapper.classList.remove("vsc-fullscreen-popover");
wrapper.removeAttribute("popover");
}
function enableFullscreenPopover(videoController, preferredMount) {
if (!videoController || !videoController.video || !videoController.div) {
return false;
}
var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false;
var normalMount = preferredMount || videoController.normalControllerMount;
var normalMountIsConnected = Boolean(
normalMount &&
(normalMount.isConnected ||
(isShadowRootNode(normalMount) && normalMount.host.isConnected))
);
if (!normalMountIsConnected) {
normalMount = getControllerMount(videoController.video);
videoController.normalControllerMount = normalMount;
}
if (!normalMount) return false;
if (wrapper.parentNode !== normalMount) {
remountControllerHost(videoController, normalMount);
}
wrapper.setAttribute("popover", "manual");
wrapper.classList.add("vsc-fullscreen-popover");
if (!wrapper.vscFullscreenPopoverOpen) {
try {
wrapper.showPopover();
wrapper.vscFullscreenPopoverOpen = true;
} catch (_error) {
wrapper.classList.remove("vsc-fullscreen-popover");
wrapper.removeAttribute("popover");
return false;
}
}
positionControllerHost(wrapper, videoController.video, normalMount);
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 ||
isComposedDescendant(video, fullscreenElement))
) {
targetMount = getControllerMount(video, fullscreenElement);
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
targetMount = getControllerMount(video);
videoController.normalControllerMount = targetMount;
}
if (!targetMount) return false;
if (fullscreenElement) {
// Fullscreen elements and popovers both participate in the browser's top
// layer. Showing Speeder's host after the player enters fullscreen keeps it
// above provider-owned surfaces even when the provider clips descendants or
// creates a new fullscreen stacking context (notably Firefox + YouTube).
// Browsers without the Popover API retain the player-local remount fallback.
if (enableFullscreenPopover(videoController, targetMount)) return true;
} else {
disableDirectFullscreenPopover(videoController);
}
return remountControllerHost(videoController, targetMount);
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
tc.mediaElements.push(target);
target.vsc = this;
this.video = target;
this.parent = target.parentElement || parent;
this.nudgeAnimationId = null;
this.restoreSpeedTimer = null;
this.pendingRateChange = null;
this.speedRestoreUntil = 0;
this.speedVerificationTimer = null;
this.speedVerificationTarget = null;
this.speedVerificationAttempts = 0;
this.subtitleNudgeEnabledOverride = null;
this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null;
this.resetToggleArmed = false;
this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation
);
this.structureSignature = getControllerStructureSignature();
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
this.targetSpeed = storedSpeed;
this.targetSpeedSourceKey = getVideoSourceKey(target);
this.targetSpeedOrigin =
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
? "policy"
: "initial";
this.mediaSourceKey = getVideoSourceKey(target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
try {
target.playbackRate = storedSpeed;
} catch (error) {
log(`Unable to set initial playbackRate: ${error.message}`, 3);
}
this.div = this.initializeControls();
if (!this.div) {
log("ERROR: Failed to create controller div!", 2);
return;
}
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
var mediaEventAction = function(event) {
this.lastActivityAt = Date.now();
if (event.type === "emptied") {
var sourceAvailable = hasUsableMediaSource(event.target);
if (this.div) {
this.div.classList.toggle("vsc-nosource", !sourceAvailable);
}
this.stopSubtitleNudge();
if (!sourceAvailable) {
this.mediaSourceKey = "unknown_src";
this.targetSpeedSourceKey = "unknown_src";
return;
}
applySourceTransitionPolicy(event.target, true);
}
if (event.type === "loadstart") {
if (this.div) {
this.div.classList.toggle(
"vsc-nosource",
!hasUsableMediaSource(event.target)
);
}
if (hasUsableMediaSource(event.target)) {
applySourceTransitionPolicy(event.target, true);
}
}
if (
event.type === "loadedmetadata" ||
event.type === "loadeddata" ||
event.type === "canplay"
) {
if (this.div) this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(event.target, false);
}
if (event.type === "play") {
applySourceTransitionPolicy(event.target, false);
extendSpeedRestoreWindow(event.target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
var playSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
if (Math.abs(event.target.playbackRate - playSpeed) > 0.01) {
log("Play event: setting playbackRate to: " + playSpeed, 4);
setSpeed(event.target, playSpeed, false, false);
} else if (playSpeed === 1.0 || event.target.paused) {
this.stopSubtitleNudge();
} else {
this.startSubtitleNudge();
}
} else if (event.type === "pause") {
extendSpeedRestoreWindow(event.target);
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeking") {
extendSpeedRestoreWindow(event.target);
} else if (event.type === "ended") {
this.speedRestoreUntil = 0;
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeked") {
extendSpeedRestoreWindow(event.target);
var expectedSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
var currentSpeed = event.target.playbackRate;
if (
Math.abs(currentSpeed - expectedSpeed) > 0.01
) {
log(
`Seeked: speed changed from ${expectedSpeed} to ${currentSpeed}, restoring`,
4
);
setSpeed(event.target, expectedSpeed, false, false);
}
if (isUserSeek) {
isUserSeek = false;
}
}
};
target.addEventListener(
"loadstart",
(this.handleLoadStart = mediaEventAction.bind(this))
);
target.addEventListener(
"emptied",
(this.handleEmptied = mediaEventAction.bind(this))
);
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = mediaEventAction.bind(this))
);
target.addEventListener(
"loadeddata",
(this.handleLoadedData = mediaEventAction.bind(this))
);
target.addEventListener(
"canplay",
(this.handleCanPlay = mediaEventAction.bind(this))
);
target.addEventListener(
"play",
(this.handlePlay = mediaEventAction.bind(this))
);
target.addEventListener(
"pause",
(this.handlePause = mediaEventAction.bind(this))
);
target.addEventListener(
"seeking",
(this.handleSeeking = mediaEventAction.bind(this))
);
target.addEventListener(
"ended",
(this.handleEnded = mediaEventAction.bind(this))
);
target.addEventListener(
"seeked",
(this.handleSeek = mediaEventAction.bind(this))
);
var srcObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
log("mutation of A/V element", 5);
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.toggle(
"vsc-nosource",
!hasUsableMediaSource(mutation.target)
);
} else {
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
if (!mutation.target.paused) this.startSubtitleNudge();
}
updateSubtitleNudgeIndicator(this.video);
}
}
});
});
this.srcObserver = srcObserver;
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
if (!target.paused && target.playbackRate !== 1.0)
this.startSubtitleNudge();
};
tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge();
disableDirectFullscreenPopover(this);
if (
this.visibilityResumeHandler &&
this.video &&
this.video.ownerDocument
) {
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
}
if (this.youTubeAutoHideObserver) {
this.youTubeAutoHideObserver.disconnect();
this.youTubeAutoHideObserver = null;
}
if (this.youTubeAutoHideCleanup) {
this.youTubeAutoHideCleanup();
this.youTubeAutoHideCleanup = null;
}
if (this.genericAutoHideCleanup) {
this.genericAutoHideCleanup();
this.genericAutoHideCleanup = null;
}
if (this.div) this.div.remove();
if (this.controllerHostCleanup) {
this.controllerHostCleanup();
this.controllerHostCleanup = null;
}
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
clearSpeedVerification(this);
if (this.video) {
this.video.removeEventListener("loadstart", this.handleLoadStart);
this.video.removeEventListener("emptied", this.handleEmptied);
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
this.video.removeEventListener("loadeddata", this.handleLoadedData);
this.video.removeEventListener("canplay", this.handleCanPlay);
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause);
this.video.removeEventListener("seeking", this.handleSeeking);
this.video.removeEventListener("ended", this.handleEnded);
this.video.removeEventListener("seeked", this.handleSeek);
delete this.video.vsc;
}
if (this.srcObserver) this.srcObserver.disconnect();
let idx = tc.mediaElements.indexOf(this.video);
if (idx != -1) tc.mediaElements.splice(idx, 1);
};
tc.videoController.prototype.startSubtitleNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
this.nudgeAnimationId !== null ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0
) {
return;
}
// Store the target speed so we can always revert to it
this.targetSpeed = this.video.playbackRate;
const performNudge = () => {
// Check if we should stop
if (!this.video || this.video.paused || this.video.playbackRate === 1.0) {
this.stopSubtitleNudge();
return;
}
// CRITICAL: Don't nudge if tab is hidden - prevents speed drift
if (document.hidden) {
if (!this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.visibilityResumeHandler = () => {
if (this.video.ownerDocument.hidden) return;
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
this.startSubtitleNudge();
};
this.video.ownerDocument.addEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
}
this.nudgeAnimationId = null;
return;
}
// Set flag to prevent ratechange listener from interfering
tc.isNudging = true;
suppressNextNudgeRateChanges(this, 2);
// Cache values to avoid repeated property access
const targetSpeed = this.targetSpeed;
const nudgeAmount = Math.min(
0.1,
Math.max(0.000001, Number(tc.settings.subtitleNudgeAmount) || 0.001)
);
// Apply nudge from the stored target speed (not current rate)
try {
this.video.playbackRate = targetSpeed + nudgeAmount;
} catch (error) {
tc.isNudging = false;
this.suppressedRateChangeCount = 0;
this.stopSubtitleNudge();
log(`Subtitle nudge failed: ${error.message}`, 3);
return;
}
// Revert synchronously after a microtask to ensure it happens immediately
Promise.resolve().then(() => {
try {
if (this.video && targetSpeed) {
this.video.playbackRate = targetSpeed;
}
} catch (error) {
this.suppressedRateChangeCount = 0;
this.stopSubtitleNudge();
log(`Subtitle nudge restore failed: ${error.message}`, 3);
} finally {
tc.isNudging = false;
}
});
// Schedule next nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
};
// Start the first nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
};
tc.videoController.prototype.stopSubtitleNudge = function() {
if (this.nudgeAnimationId !== null) {
clearTimeout(this.nudgeAnimationId);
this.nudgeAnimationId = null;
log(`Nudge: Stopping.`, 5);
}
if (this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
}
// Clear the animation state, but PRESERVE targetSpeed so getDesiredSpeed
// doesn't lose the user's intended speed if the site hijacks it.
};
tc.videoController.prototype.performImmediateNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0 ||
document.hidden
) {
return;
}
const targetRate = this.targetSpeed || this.video.playbackRate;
const nudgeAmount = Math.min(
0.1,
Math.max(0.000001, Number(tc.settings.subtitleNudgeAmount) || 0.001)
);
tc.isNudging = true;
suppressNextNudgeRateChanges(this, 2);
try {
this.video.playbackRate = targetRate + nudgeAmount;
} catch (error) {
tc.isNudging = false;
this.suppressedRateChangeCount = 0;
log(`Immediate subtitle nudge failed: ${error.message}`, 3);
return;
}
// Revert synchronously via microtask
Promise.resolve().then(() => {
try {
if (this.video) {
this.video.playbackRate = targetRate;
}
} catch (error) {
this.suppressedRateChangeCount = 0;
log(`Immediate subtitle nudge restore failed: ${error.message}`, 3);
} finally {
tc.isNudging = false;
}
});
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
};
tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) {
if (!wrapper || !isOnYouTube()) return;
const video = this.video;
const ytPlayer = video.closest(".html5-video-player");
if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4);
return;
}
const syncControllerVisibility = () => {
// YouTube adds ytp-autohide class to the player when controls should be hidden
// We mirror this class state to enable CSS-based hiding
// The vsc-hidden class (from V key) takes precedence via CSS specificity
if (
ytPlayer.classList.contains("ytp-autohide") &&
!this.controllerInteractionActive
) {
wrapper.classList.add("ytp-autohide");
// Preserve an active pointer reveal or shortcut-forced reveal. The
// ytp-autohide class remains in place, so CSS hides the host as soon as
// vsc-show's bounded timer expires.
if (
!wrapper.classList.contains("vsc-forced-show") &&
!wrapper.classList.contains("vsc-show")
) {
wrapper.classList.remove("vsc-show");
if (wrapper.showTimeOut) {
clearTimeout(wrapper.showTimeOut);
wrapper.showTimeOut = undefined;
}
}
log("YouTube controls hidden, hiding controller", 5);
} else {
wrapper.classList.remove("ytp-autohide");
log("YouTube controls visible, showing controller", 5);
}
};
// Initial sync
syncControllerVisibility();
// Observe YouTube player class changes
this.youTubeAutoHideObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "class") {
syncControllerVisibility();
}
});
});
this.youTubeAutoHideObserver.observe(ytPlayer, {
attributes: true,
attributeFilter: ["class"]
});
log("YouTube auto-hide observer setup complete", 4);
// Also reveal on hover/activity independently of YouTube's own controls
// for immediate responsiveness, UNLESS Speeder is actually toggled hidden (vsc-hidden)
const resetTimer = () => {
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
};
const handleControllerInteractionChange = (active) => {
if (active) {
wrapper.classList.remove("ytp-autohide");
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
} else {
syncControllerVisibility();
}
};
this.controllerInteractionChanged = handleControllerInteractionChange;
const activityEvents = ["mousemove", "mousedown", "touchstart"];
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
ytPlayer.addEventListener(type, resetTimer, { passive: true });
});
// Store a cleanup function
this.youTubeAutoHideCleanup = () => {
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
ytPlayer.removeEventListener(type, resetTimer);
});
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
this.controllerInteractionChanged = null;
}
};
};
tc.videoController.prototype.setupGenericAutoHide = function(wrapper) {
if (!wrapper) return;
const video = this.video;
let timer = null;
const resetTimer = () => {
wrapper.classList.remove("vsc-idle-hidden");
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
if (this.controllerInteractionActive) return;
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000);
};
const handleControllerInteractionChange = (active) => {
if (active) {
wrapper.classList.remove("vsc-idle-hidden");
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
if (timer) {
clearTimeout(timer);
timer = null;
}
} else {
resetTimer();
}
};
this.controllerInteractionChanged = handleControllerInteractionChange;
// Initial show/timer
resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles,
// but we listen on both the video and the wrapper for maximum coverage.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl =
getControllerGeometryMount(this.controllerHostMount) ||
video.parentElement;
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
if (parentEl) {
parentEl.addEventListener(type, resetTimer, { passive: true });
}
});
// Also reset timer on play/pause events to ensure sync when player state changes
video.addEventListener("play", resetTimer, { passive: true });
video.addEventListener("pause", resetTimer, { passive: true });
// Store a cleanup function
this.genericAutoHideCleanup = () => {
if (timer) clearTimeout(timer);
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
if (parentEl) {
parentEl.removeEventListener(type, resetTimer);
}
});
video.removeEventListener("play", resetTimer);
video.removeEventListener("pause", resetTimer);
if (this.controllerInteractionChanged === handleControllerInteractionChange) {
this.controllerInteractionChanged = null;
}
};
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
};
tc.videoController.prototype.initializeControls = function() {
const doc = this.video.ownerDocument;
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!hasUsableMediaSource(this.video))
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 (2147483647).
// 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.
var shadow = wrapper.attachShadow({ mode: "open" });
var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet";
shadowStylesheet.href = chrome.runtime.getURL("content/shadow.css");
shadow.appendChild(shadowStylesheet);
var controller = doc.createElement("div");
controller.id = "controller";
controller.style.opacity = String(tc.settings.controllerOpacity);
this.controllerLocation = applyControllerLocationToElement(
controller,
this.controllerLocation
);
var dragHandle = doc.createElement("span");
dragHandle.dataset.action = "drag";
dragHandle.className = "draggable";
dragHandle.textContent = speed;
var controls = doc.createElement("span");
controls.id = "controls";
var buttonConfig = Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons
: ["rewind", "slower", "faster", "advance", "display"];
var subtitleNudgeIndicator = null;
buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator.id = "nudge-indicator";
subtitleNudgeIndicator.setAttribute("role", "button");
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
subtitleNudgeIndicator.setAttribute("tabindex", "0");
controls.appendChild(subtitleNudgeIndicator);
} else {
var def = controllerButtonDefs[btnId];
if (def) {
controls.appendChild(
createControllerButton(doc, btnId, def.label, def.className)
);
}
}
});
var nudgeFlashIndicator = doc.createElement("span");
nudgeFlashIndicator.id = "nudge-flash-indicator";
nudgeFlashIndicator.setAttribute("aria-hidden", "true");
controller.appendChild(dragHandle);
controller.appendChild(controls);
/* Flash sits after #controls so it never inserts space between speed and buttons. */
controller.appendChild(nudgeFlashIndicator);
shadow.appendChild(controller);
const setControllerInteractionActive = (active) => {
this.controllerInteractionActive = active === true;
controller.classList.toggle(
"vsc-controls-hovered",
this.controllerInteractionActive
);
wrapper.classList.toggle(
"vsc-controls-hovered",
this.controllerInteractionActive
);
if (typeof this.controllerInteractionChanged === "function") {
this.controllerInteractionChanged(this.controllerInteractionActive);
}
};
controller.addEventListener("pointerenter", () => {
setControllerInteractionActive(true);
});
controller.addEventListener("pointerleave", () => {
setControllerInteractionActive(false);
});
this.speedIndicator = dragHandle;
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
shadow.querySelector("button[data-action=\"reset\"]") || null;
this.resetToggleArmed = false;
if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video);
}
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
requestAnimationFrame(function() {
target.blur();
});
}
}
dragHandle.addEventListener(
"mousedown",
(e) => {
var dragAction = dragHandle.dataset.action;
runAction(dragAction, getKeyBindings(dragAction, "value"), e);
e.stopPropagation();
},
true
);
shadow.querySelectorAll("button").forEach((button) => {
button.addEventListener(
"click",
(e) => {
var action = button.dataset.action;
runAction(action, getKeyBindings(action), e);
blurAfterPointerTap(button, e);
e.stopPropagation();
},
true
);
});
if (subtitleNudgeIndicator) {
subtitleNudgeIndicator.addEventListener(
"click",
(e) => {
var video = this.video;
if (video) {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
);
}
controller.addEventListener("click", (e) => e.stopPropagation(), false);
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
const parentEl =
this.parent || this.video.parentElement || this.video.parentNode;
var mountEl = getControllerMount(this.video) || parentEl;
log(`Inserting controller: parentEl=${!!parentEl}, hostname=${location.hostname}`, 4);
if (
!mountEl ||
!(mountEl.isConnected ||
(isShadowRootNode(mountEl) && mountEl.host.isConnected))
) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(wrapper);
this.normalControllerMount = doc.body;
setupControllerHostTracking(this, wrapper, doc.body);
return wrapper;
}
try {
switch (true) {
case location.hostname === "www.amazon.com":
case location.hostname === "www.reddit.com":
case /hbogo\./.test(location.hostname):
mountEl = parentEl.parentElement || mountEl;
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;
}
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 && parentEl.closest(".html5-video-player")) ||
mountEl;
break;
}
mountEl.appendChild(wrapper);
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);
}
return wrapper;
};
}
function applySiteRuleOverrides() {
var wasForceEnabled = tc.settings.forceLastSavedSpeed === true;
var wasRememberEnabled = tc.settings.rememberSpeed === true;
function finishSiteRuleApplication(result) {
if (
(wasForceEnabled && !tc.settings.forceLastSavedSpeed) ||
(wasRememberEnabled && !tc.settings.rememberSpeed)
) {
clearAllSpeedRestoreEnforcement();
}
return result;
}
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return finishSiteRuleApplication(false);
}
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) {
return finishSiteRuleApplication(false);
}
tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4);
// Check if extension should be enabled/disabled on this site
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
return finishSiteRuleApplication(true);
}
// Override general settings with site-specific overrides
const siteSettings = [
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"shortcutTargetMode",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval"
];
siteSettings.forEach((key) => {
if (matchedRule[key] !== undefined) {
log(`Overriding ${key} for site: ${matchedRule[key]}`, 4);
tc.settings[key] = matchedRule[key];
}
});
[
"controllerMarginTop",
"controllerMarginBottom"
].forEach(function(key) {
tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0);
});
if (Array.isArray(matchedRule.controllerButtons)) {
log(`Overriding controllerButtons for site`, 4);
tc.settings.controllerButtons = matchedRule.controllerButtons;
}
// Override key bindings with site-specific shortcuts
if (Array.isArray(matchedRule.shortcuts) && matchedRule.shortcuts.length > 0) {
var overriddenActions = new Set();
matchedRule.shortcuts.forEach((shortcut) => {
overriddenActions.add(shortcut.action);
});
// Keep global bindings that aren't overridden, add site-specific ones
tc.settings.keyBindings = tc.settings.keyBindings
.filter((binding) => !overriddenActions.has(binding.action))
.concat(
matchedRule.shortcuts.map((shortcut) =>
normalizeStoredBinding(shortcut)
).filter(Boolean)
);
}
return finishSiteRuleApplication(false);
}
function removeIneligibleMediaControllers() {
tc.mediaElements.slice().forEach(function(media) {
if (
media &&
((media.nodeName === "AUDIO" && !tc.settings.audioBoolean) ||
isAmbientLoopMedia(media))
) {
removeController(media);
}
});
}
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
configureControllerAutoHide(video.vsc, video.vsc.div, false);
});
}
/** 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)) {
tc.mediaElements.slice().forEach(function(video) {
removeController(video);
});
return false;
}
removeIneligibleMediaControllers();
refreshAllControllerGeometry();
return true;
}
function applyRememberedSpeedPolicy(video) {
if (!video || !video.vsc) return false;
var rememberedSpeed = getRememberedSpeed(video);
if (!isValidSpeed(rememberedSpeed)) return false;
video.vsc.targetSpeed = rememberedSpeed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
video.vsc.targetSpeedOrigin = "policy";
if (video.vsc.speedIndicator) {
video.vsc.speedIndicator.textContent = rememberedSpeed.toFixed(2);
}
extendSpeedRestoreWindow(video);
if (Math.abs(video.playbackRate - rememberedSpeed) > 0.01) {
setSpeed(video, rememberedSpeed, false, false);
}
return true;
}
function shouldPreserveDesiredSpeed(video, speed) {
if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video);
if (!isValidSpeed(desiredSpeed) || Math.abs(speed - desiredSpeed) <= 0.01) {
return false;
}
var preservesPausedTarget =
video.paused === true &&
(video.vsc.targetSpeedOrigin === "user" ||
video.vsc.targetSpeedOrigin === "policy");
return (
preservesPausedTarget ||
(typeof video.vsc.speedRestoreUntil === "number" &&
video.vsc.speedRestoreUntil > Date.now())
);
}
function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video, options) {
if (!video.vsc || !video.vsc.speedIndicator) return;
var config = options || {};
if (config.skipResetDisarm !== true) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.01)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
// A native ratechange has no trustworthy user-origin signal; sites emit the
// same event when they clamp/reset speed. Durable intent is recorded only
// by explicit Speeder actions (which pass shouldPersist).
var shouldPersist = config.shouldPersist === true;
if (config.preserveTargetOrigin !== true) {
video.vsc.targetSpeedOrigin = shouldPersist ? "user" : "external";
}
if (shouldPersist) {
rememberSourceSpeed(video, speed);
tc.settings.lastSpeed = speed;
schedulePersistLastSpeed(speed);
}
if (video.vsc) {
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
}
}
root.addEventListener(
"ratechange",
function(event) {
if (tc.isNudging) return;
var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
if (shouldIgnoreSuppressedRateChange(video)) return;
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (tc.settings.forceLastSavedSpeed) {
if (pendingRateChange) {
updateSpeedFromEvent(video, {
skipResetDisarm: true,
shouldPersist: pendingRateChange.shouldPersist === true,
preserveTargetOrigin: true
});
} else {
var forcedSpeed = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
if (Math.abs(currentSpeed - forcedSpeed) > 0.001) {
setSpeed(video, forcedSpeed, false, false);
} else {
updateSpeedFromEvent(video, {
shouldPersist: false,
preserveTargetOrigin: true
});
}
}
event.stopImmediatePropagation();
} else {
var desiredSpeed = getDesiredSpeed(video);
if (pendingRateChange) {
updateSpeedFromEvent(video, {
skipResetDisarm: true,
shouldPersist: pendingRateChange.shouldPersist === true,
preserveTargetOrigin: true
});
return;
}
if (shouldPreserveDesiredSpeed(video, currentSpeed)) {
log(
`Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`,
4
);
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
return;
}
updateSpeedFromEvent(video, { shouldPersist: false });
}
},
true
);
root.vscRateListenerAttached = true;
}
function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return;
var handler = doc.vscPendingInitializeHandler;
doc.removeEventListener("DOMContentLoaded", handler);
doc.removeEventListener("readystatechange", handler);
if (doc.defaultView) {
doc.defaultView.removeEventListener("load", handler);
}
delete doc.vscPendingInitializeHandler;
doc.vscPendingForceReinit = false;
}
function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false;
if (
!tc.runtimeSettingsHydrated ||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
!doc.body
) {
return false;
}
initializeNow(doc, forceReinit);
clearPendingInitialization(doc);
return true;
}
function initializeWhenReady(doc, forceReinit = false) {
if (!doc) return;
doc.vscPendingForceReinit = doc.vscPendingForceReinit === true || forceReinit;
if (tryInitializeDocument(doc, doc.vscPendingForceReinit)) {
return;
}
if (doc.vscPendingInitializeHandler) return;
var pendingInitializeHandler = function() {
tryInitializeDocument(doc, doc.vscPendingForceReinit === true);
};
doc.vscPendingInitializeHandler = pendingInitializeHandler;
doc.addEventListener("DOMContentLoaded", pendingInitializeHandler);
doc.addEventListener("readystatechange", pendingInitializeHandler);
if (doc.defaultView) {
doc.defaultView.addEventListener("load", pendingInitializeHandler);
doc.defaultView.setTimeout(pendingInitializeHandler, 0);
} else {
setTimeout(pendingInitializeHandler, 0);
}
}
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function attachKeydownListeners(doc) {
// Content scripts already run in every frame. Keeping each listener scoped
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
var docs = [doc];
docs.forEach(function(keyDoc) {
var keyTarget = keyDoc.defaultView || keyDoc;
if (keyTarget.vscKeydownListenerAttached) return;
keyTarget.addEventListener(
"keydown",
function(event) {
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
) {
return;
}
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return;
}
if (
!siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
)
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event);
});
if (item) {
runAction(item.action, item.value, event);
// The per-site "Block site from capturing keypress" option owns
// propagation. Speeder still runs unblocked shortcuts, but the page
// can receive them too when the user left that option off.
if (item.force === true || item.force === "true") {
event.preventDefault();
event.stopImmediatePropagation();
}
}
},
true
);
keyTarget.vscKeydownListenerAttached = true;
});
}
function attachMediaTargetTracking(doc) {
if (!doc || doc.vscMediaTargetTrackingAttached) return;
doc.addEventListener(
"mousemove",
function(event) {
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
return;
}
tc.lastPointerPosition = {
document: doc,
x: event.clientX,
y: event.clientY
};
},
{ capture: true, passive: true }
);
doc.vscMediaTargetTrackingAttached = true;
}
function attachMutationObserver(root) {
if (root.vscMutationObserverAttached) return;
var pendingMutations = [];
var mutationProcessingScheduled = false;
var observer = new MutationObserver(function(mutations) {
pendingMutations.push(...mutations);
if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true;
requestIdle(
function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false;
var controllerMountTargets = new Set();
mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") {
controllerMountTargets.add(mutation.target);
mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, true);
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
return;
}
if (mutation.type !== "attributes") return;
var target = mutation.target;
if (
isMediaElement(target) &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
ensureController(target, target.parentElement || target.parentNode);
return;
}
if (
target.nodeName === "SOURCE" &&
mutation.attributeName === "src"
) {
ensureControllerForMediaChild(target);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
target.attributes["aria-hidden"].value === "false"
) {
scanNodeForMedia(
target,
target.parentNode || root.host || mutation.target,
true
);
}
});
// Reconcile once per batch, not once per mutation. Large news/social
// pages can emit hundreds of childList records in one render pass.
if (controllerMountTargets.size > 0) {
tc.mediaElements.slice().forEach(function(video) {
if (!video || !video.vsc || !video.vsc.div) return;
var mount = video.vsc.controllerHostMount;
if (!controllerMountTargets.has(mount)) return;
remountControllerHost(video.vsc, mount);
});
}
// Document selectors do not cross shadow boundaries. A detached
// custom-element host can therefore hide connected-looking media from
// the removed subtree scan; prune by composed connectivity as a final
// reconciliation step for every mutation batch.
tc.mediaElements.slice().forEach(function(video) {
if (!video || video.isConnected) return;
removeController(video);
});
},
{ timeout: 1000 }
);
});
observer.observe(root, {
attributeFilter: ["aria-hidden", "src", "currentSrc"],
childList: true,
subtree: true,
attributes: true
});
root.vscMutationObserverAttached = true;
}
function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return;
var handleDetectedMedia = function(event) {
var target = event.target;
if (!isMediaElement(target)) return;
ensureController(target, target.parentElement || target.parentNode);
};
[
"loadstart",
"loadeddata",
"loadedmetadata",
"canplay",
"playing",
"play"
].forEach(function(eventName) {
root.addEventListener(eventName, handleDetectedMedia, true);
});
root.vscMediaEventListenersAttached = true;
}
function scheduleNavigationRescan() {
var nextHref = location.href;
if (window.vscLastObservedHref !== nextHref) {
// Pointer and controller interaction state belongs to the previous route.
// YouTube keeps hover-preview and Shorts players connected across SPA
// navigations, so retaining either target can send shortcuts to an
// off-screen/stale player even though the new watch controller works.
tc.lastPointerPosition = null;
tc.lastInteractedMedia = null;
}
window.vscLastObservedHref = nextHref;
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
}
function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return;
["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return;
var original = history[method];
history[method] = function() {
var result = original.apply(this, arguments);
scheduleNavigationRescan();
return result;
};
});
window.addEventListener("popstate", scheduleNavigationRescan);
window.addEventListener("hashchange", scheduleNavigationRescan);
/* YouTube often navigates without a history API call the extension can see first */
if (typeof document !== "undefined" && isOnYouTube()) {
document.addEventListener("yt-navigate-finish", scheduleNavigationRescan);
}
window.vscLastObservedHref = location.href;
if (!window.vscLocationWatchTimer) {
window.vscLocationWatchTimer = setInterval(function() {
if (window.vscLastObservedHref !== location.href) {
scheduleNavigationRescan();
}
}, 1000);
}
window.vscNavigationListenersAttached = true;
}
function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
// Navigation must remain observable even while this URL is disabled, or a
// later whitelisted SPA route can never reactivate the extension.
attachNavigationListeners();
if (typeof tc.videoController === "undefined") defineVideoController();
applySiteRuleOverrides();
var isActive = siteRuleUtils.isSpeederActiveForSite(
tc.settings.enabled,
tc.activeSiteRule
);
// Keep observing while inactive so dynamically-created media/shadow roots
// are available to the next forced SPA rescan, but remove stale controls.
observeRoot(doc);
if (!isActive) {
tc.mediaElements.slice().forEach(function(video) {
removeController(video);
});
vscInitializedDocuments.add(doc);
return;
}
removeIneligibleMediaControllers();
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
}
attachKeydownListeners(doc);
attachMediaTargetTracking(doc);
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);
rescanObservedMediaRoots(doc);
refreshAllControllerGeometry();
if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) {
tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy);
}
}
vscInitializedDocuments.add(doc);
flushPendingMediaCandidates();
}
function setSpeed(
video,
speed,
isInitialCall = false,
isUserKeyPress = false,
fromResetSpeedToggle = false
) {
const numericSpeed = Number(speed);
if (!isValidSpeed(numericSpeed)) {
log(
`Invalid speed rejected: ${speed}, must be between ${MIN_SPEED} and ${MAX_SPEED}`,
2
);
return;
}
if (!video || !video.vsc || !video.vsc.speedIndicator) return;
if (isUserKeyPress && !fromResetSpeedToggle) {
video.vsc.resetToggleArmed = false;
}
log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
);
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
if (isUserKeyPress) {
tc.settings.lastSpeed = numericSpeed;
rememberSourceSpeed(video, numericSpeed);
schedulePersistLastSpeed(numericSpeed);
}
// Update the target speed for nudge so it knows what to revert to
video.vsc.targetSpeed = numericSpeed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
if (isUserKeyPress) {
video.vsc.targetSpeedOrigin = "user";
}
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
runAction("blink", 1000, null, video); // Pass video to blink
extendSpeedRestoreWindow(video); // Protect against immediate site-driven resets
}
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
var usedNativeSpeed = false;
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
rememberPendingRateChange(video, numericSpeed, isUserKeyPress);
if (!tc.settings.forceLastSavedSpeed) {
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
}
if (!usedNativeSpeed) {
try {
video.playbackRate = numericSpeed;
} catch (error) {
if (video.vsc) video.vsc.pendingRateChange = null;
log(`Unable to set playbackRate to ${numericSpeed}: ${error.message}`, 2);
if (
tc.settings.forceLastSavedSpeed ||
(tc.settings.rememberSpeed && video.vsc.targetSpeedOrigin === "policy") ||
Number(video.vsc.speedRestoreUntil) > Date.now()
) {
scheduleSpeedVerification(video, numericSpeed, true);
}
return false;
}
}
}
if (
tc.settings.forceLastSavedSpeed ||
(tc.settings.rememberSpeed && video.vsc.targetSpeedOrigin === "policy") ||
Number(video.vsc.speedRestoreUntil) > Date.now()
) {
scheduleSpeedVerification(video, numericSpeed, true);
}
if (video.vsc) {
if (numericSpeed === 1.0 || video.paused) {
video.vsc.stopSubtitleNudge();
} else if (usedNativeSpeed) {
// YouTube's native API handles subtitle sync — no nudge needed
video.vsc.stopSubtitleNudge();
} else {
video.vsc.startSubtitleNudge();
}
}
return true;
}
function getMediaFromControllerEvent(event, candidates) {
if (!event || !event.target || !event.target.getRootNode) return null;
var root = event.target.getRootNode();
var controllerHost = root && root.host;
if (!controllerHost) return null;
return candidates.find(function(video) {
return video.vsc && video.vsc.div === controllerHost;
}) || null;
}
function distanceSquaredToRect(x, y, rect) {
if (!rect) return Infinity;
var dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
var dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
return dx * dx + dy * dy;
}
function getClosestMediaToPointer(candidates, pointerPosition) {
if (!pointerPosition || !Array.isArray(candidates)) return null;
var best = null;
var bestDistance = Infinity;
candidates.forEach(function(video) {
if (!video || !video.vsc || !video.isConnected) return;
var target = getControllerElement(video.vsc) || video;
var rect = null;
try {
rect = target.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
rect = video.getBoundingClientRect();
}
} catch (_error) {
return;
}
if (!rect || rect.width <= 0 || rect.height <= 0) return;
var distance = distanceSquaredToRect(
pointerPosition.x,
pointerPosition.y,
rect
);
if (distance < bestDistance) {
best = video;
bestDistance = distance;
}
});
return best;
}
function resolveActionMediaTargets(event, specificVideo) {
if (specificVideo && specificVideo.vsc) return [specificVideo];
var docContext =
event && event.target && event.target.ownerDocument
? event.target.ownerDocument
: document;
var candidates = tc.mediaElements.filter(function(video) {
return Boolean(
video &&
video.vsc &&
video.isConnected &&
video.ownerDocument === docContext
);
});
if (candidates.length <= 1) return candidates;
var controllerMedia = getMediaFromControllerEvent(event, candidates);
if (controllerMedia) {
tc.lastInteractedMedia = controllerMedia;
return [controllerMedia];
}
if (tc.settings.shortcutTargetMode === "all") return candidates;
if (
isOnYouTube() &&
/^\/(watch|live)(\/|$)/.test(location.pathname)
) {
var youtubeMain = candidates.find(function(video) {
return video.closest && video.closest("#movie_player");
});
if (youtubeMain) return [youtubeMain];
}
var pointer =
tc.lastPointerPosition && tc.lastPointerPosition.document === docContext
? tc.lastPointerPosition
: null;
var closest = getClosestMediaToPointer(candidates, pointer);
if (closest) {
tc.lastInteractedMedia = closest;
return [closest];
}
if (
tc.lastInteractedMedia &&
candidates.indexOf(tc.lastInteractedMedia) !== -1
) {
return [tc.lastInteractedMedia];
}
var primary = getPrimaryVideoElement(candidates);
return primary ? [primary] : [];
}
function runAction(action, value, e) {
log("runAction Begin", 5);
const specificVideo = arguments[3] || null;
var subtitleNudgeToggleValue = null;
var mediaTagsToProcess = resolveActionMediaTargets(e, specificVideo);
if (action === "settings") {
chrome.runtime.sendMessage({ action: "openOptions" });
return;
}
if (mediaTagsToProcess.length === 0 && action !== "display") return;
if (action === "toggleSubtitleNudge" && mediaTagsToProcess.length > 0) {
subtitleNudgeToggleValue = !isSubtitleNudgeEnabledForVideo(
mediaTagsToProcess[0]
);
}
mediaTagsToProcess.forEach(function(v) {
if (!v.vsc) return; // Don't process videos without a controller
var controller = v.vsc.div;
const userDrivenActionsThatShowController = [
"rewind",
"advance",
"faster",
"slower",
"louder",
"softer",
"reset",
"fast",
"move",
"pause",
"muted",
"mark",
"jump",
"drag",
"nudge",
"toggleSubtitleNudge",
"display"
];
var subtitleNudgeActionBlocked =
(action === "toggleSubtitleNudge" || action === "nudge") &&
!isSubtitleNudgeAvailableForVideo(v);
if (
userDrivenActionsThatShowController.includes(action) &&
action !== "display" &&
!subtitleNudgeActionBlocked
) {
showController(controller, 2000, true);
}
if (v.classList.contains("vsc-cancelled")) return;
const numValue = keyBindingUtils.sanitizeActionValue(
action,
value,
getKeyBindings(action, "value")
);
switch (action) {
case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime -= numValue;
break;
case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime += numValue;
break;
case "faster":
var fasterStep = numValue;
// Use grid-snapping: always move to the next multiple of fasterStep
// Add a tiny epsilon (1% of step) to jump clear of the current point
var newFasterSpeed = Math.ceil((v.playbackRate + (fasterStep * 0.01)) / fasterStep) * fasterStep;
// Clean up JS floating point math (e.g. 0.30000000000000004 -> 0.30)
newFasterSpeed = Math.round(newFasterSpeed * 1000) / 1000;
setSpeed(v, Math.min(newFasterSpeed, MAX_SPEED), false, true);
break;
case "slower":
var slowerStep = numValue;
// Use grid-snapping: always move to the previous multiple of slowerStep
// Subtract a tiny epsilon (1% of step) to jump clear of the current point
var newSlowerSpeed = Math.floor((v.playbackRate - (slowerStep * 0.01)) / slowerStep) * slowerStep;
// Clean up JS floating point math
newSlowerSpeed = Math.round(newSlowerSpeed * 1000) / 1000;
setSpeed(v, Math.max(newSlowerSpeed, MIN_SPEED), false, true);
break;
case "reset":
resetSpeed(v, 1.0, false); // Use enhanced resetSpeed
break;
case "fast":
var preferredSpeed = numValue;
// Apply site-specific preferred speed override if available
if (tc.activeSiteRule && tc.activeSiteRule.preferredSpeed !== undefined) {
preferredSpeed = keyBindingUtils.sanitizeActionValue(
"fast",
tc.activeSiteRule.preferredSpeed,
preferredSpeed
);
}
resetSpeed(v, preferredSpeed, true);
break;
case "display":
if (controller.classList.contains("vsc-hidden")) {
controller.classList.remove("vsc-hidden");
showController(controller, 2000, true);
} else {
controller.classList.add("vsc-hidden");
// Clear any show state when explicitly hiding
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.showTimeOut) {
clearTimeout(controller.showTimeOut);
controller.showTimeOut = undefined;
}
}
break;
case "blink":
log(`Blink action: controller hidden=${controller.classList.contains("vsc-hidden")}, timeout=${controller.blinkTimeOut !== undefined}, duration=${numValue}`, 5);
if (
controller.classList.contains("vsc-hidden") ||
controller.blinkTimeOut !== undefined
) {
var restoreHidden =
controller.restoreHiddenAfterBlink === true ||
controller.classList.contains("vsc-hidden");
if (controller.blinkTimeOut !== undefined) {
clearTimeout(controller.blinkTimeOut);
}
controller.restoreHiddenAfterBlink = restoreHidden;
controller.classList.remove("vsc-hidden");
log(`Controller shown, setting timeout for ${numValue || 1000}ms`, 5);
controller.blinkTimeOut = setTimeout(() => {
if (controller.restoreHiddenAfterBlink === true) {
controller.classList.add("vsc-hidden");
log("Controller auto-hidden after blink timeout", 5);
} else {
log("Controller kept visible", 5);
}
controller.restoreHiddenAfterBlink = false;
controller.blinkTimeOut = undefined;
}, numValue || 1000);
}
break;
case "drag":
if (e) handleDrag(v, e);
break;
case "move":
cycleControllerLocation(v);
break;
case "pause":
pause(v);
break;
case "muted":
muted(v);
break;
case "louder":
volumeUp(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "softer":
volumeDown(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "mark":
setMark(v);
break;
case "jump":
jumpToMark(v);
break;
case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
}
});
log("runAction End", 5);
}
function pause(v) {
if (v.paused) v.play().catch((e) => log(`Play err:${e.message}`, 2));
else v.pause();
}
function resetSpeed(v, target, isFastKey = false) {
const videoId = getVideoSourceKey(v);
const currentSpeed = v.playbackRate;
if (isFastKey) {
// G key: Toggle between current speed and preferred speed (e.g., 1.8)
const preferredSpeed = target;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - preferredSpeed) < 0.01) {
// Currently at preferred speed, toggle to the last speed
setSpeed(v, lastToggle, false, true);
} else {
// Not at preferred speed, save current as toggle speed and go to preferred
lastToggleSpeed[videoId] = currentSpeed;
setSpeed(v, preferredSpeed, false, true);
}
} else {
// R key: Toggle between current speed and 1.0
const resetSpeedValue = 1.0;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - resetSpeedValue) < 0.01) {
// Currently at 1.0, toggle to the last speed (or 1.8 if no history)
const speedToRestore =
Math.abs(lastToggle - 1.0) < 0.01
? getKeyBindings("fast") || 1.8
: lastToggle;
setSpeed(v, speedToRestore, false, true, true);
} else {
// Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed;
v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true);
}
}
}
function muted(v) {
var nextMuted = !v.muted;
v.muted = nextMuted;
if (!isOnYouTube()) return;
var ytApi = getYouTubePlayerApi(v);
if (!ytApi) return;
if (nextMuted && typeof ytApi.mute === "function") ytApi.mute();
if (!nextMuted && typeof ytApi.unMute === "function") ytApi.unMute();
}
function getYouTubePlayerApi(video) {
if (!isOnYouTube()) return null;
var playerEl =
(video && video.closest ? video.closest(".html5-video-player") : null) ||
document.getElementById("movie_player") ||
document.querySelector(".html5-video-player");
if (!playerEl) return null;
return playerEl.wrappedJSObject || playerEl;
}
function syncYouTubePlayerVolume(video, volume) {
var ytApi = getYouTubePlayerApi(video);
if (!ytApi || typeof ytApi.setVolume !== "function") return;
ytApi.setVolume(Math.round(volume * 100));
if (volume > 0 && typeof ytApi.unMute === "function") {
ytApi.unMute();
}
}
function setVideoVolume(video, targetVolume) {
var nextVolume = Math.max(0, Math.min(1, Number(targetVolume.toFixed(2))));
video.volume = nextVolume;
if (nextVolume > 0 && video.muted) {
video.muted = false;
}
syncYouTubePlayerVolume(video, nextVolume);
}
function volumeUp(v, value) {
setVideoVolume(v, v.volume + value);
}
function volumeDown(v, value) {
setVideoVolume(v, v.volume - value);
}
function setMark(v) {
v.vsc.mark = v.currentTime;
}
function jumpToMark(v) {
if (v.vsc && typeof v.vsc.mark === "number") {
extendSpeedRestoreWindow(v);
v.currentTime = v.vsc.mark;
}
}
function handleDrag(video, e) {
const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc);
if (!sC) return;
var pE =
getControllerGeometryMount(video.vsc.controllerHostMount) ||
c.parentElement ||
(c.parentNode && c.parentNode.host);
if (!pE || typeof pE.addEventListener !== "function") return;
var nextParent = pE.parentElement;
while (
nextParent &&
nextParent.offsetHeight === pE.offsetHeight &&
nextParent.offsetWidth === pE.offsetWidth
) {
pE = nextParent;
nextParent = pE.parentElement;
}
video.classList.add("vcs-dragging");
sC.classList.add("dragging");
const iXY = [e.clientX, e.clientY],
iCXY = [parseInt(sC.style.left), parseInt(sC.style.top)];
const sD = (e) => {
sC.style.setProperty(
"left",
iCXY[0] + e.clientX - iXY[0] + "px",
"important"
);
sC.style.setProperty(
"top",
iCXY[1] + e.clientY - iXY[1] + "px",
"important"
);
};
const eD = () => {
pE.removeEventListener("mousemove", sD);
pE.removeEventListener("mouseup", eD);
pE.removeEventListener("mouseleave", eD);
sC.classList.remove("dragging");
video.classList.remove("vcs-dragging");
};
pE.addEventListener("mouseup", eD);
pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD);
}
function showController(controller, duration = 2000, forced = false) {
if (!controller || typeof controller.classList === "undefined") return;
var restoreHidden =
controller.restoreHiddenAfterShow === true ||
controller.classList.contains("vsc-hidden");
controller.restoreHiddenAfterShow = restoreHidden;
controller.classList.remove("vsc-hidden");
controller.classList.add("vsc-show");
if (forced) {
controller.classList.add("vsc-forced-show");
}
if (controller.showTimeOut !== undefined) {
clearTimeout(controller.showTimeOut);
}
controller.showTimeOut = setTimeout(function() {
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.restoreHiddenAfterShow === true) {
controller.classList.add("vsc-hidden");
}
controller.restoreHiddenAfterShow = false;
controller.showTimeOut = undefined;
}, 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() {
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);
});
-49
View File
@@ -1,49 +0,0 @@
(function() {
"use strict";
if (window.__speederPageShadowBridgeInstalled) return;
window.__speederPageShadowBridgeInstalled = true;
function notifyLocationChanged() {
try {
document.dispatchEvent(
new Event("speeder-location-changed", {
bubbles: true,
composed: true
})
);
} catch (_error) {}
}
if (
typeof Element !== "undefined" &&
typeof Element.prototype.attachShadow === "function"
) {
var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments);
try {
this.dispatchEvent(
new Event("speeder-shadow-root-attached", {
bubbles: true,
composed: true
})
);
} catch (_error) {}
return shadowRoot;
};
}
["pushState", "replaceState"].forEach(function(method) {
if (typeof history === "undefined" || typeof history[method] !== "function") {
return;
}
var original = history[method];
history[method] = function() {
var result = original.apply(this, arguments);
notifyLocationChanged();
return result;
};
});
})();
-282
View File
@@ -1,282 +0,0 @@
// Import/Export functionality for Video Speed Controller settings
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var importExportUtils = speederShared.importExport || {};
function generateBackupFilename() {
return importExportUtils.generateBackupFilename(new Date());
}
function normalizedSettingsForExport(rawStorage) {
var raw = rawStorage && typeof rawStorage === "object" ? rawStorage : {};
var expanded = vscExpandStoredSettings(raw);
var normalized = vscBuildStoredSettingsDiff(expanded);
// Backups should remain readable outside the extension too. Storage stays
// sparse, but explicitly include built-in rule titles in the exported v2
// patches. Custom rule titles are already part of their normal patches.
var titledRulePatches = Array.isArray(normalized.siteRules)
? normalized.siteRules.slice()
: [];
var orderedRulePatches = [];
var claimedRulePatches = new Set();
vscGetSettingsDefaults().siteRules.forEach(function(defaultRule) {
var expandedRule = expanded.siteRules.find(function(rule) {
return rule && rule.pattern === defaultRule.pattern;
});
if (!expandedRule) return;
var patch = titledRulePatches.find(function(candidate) {
return (
candidate &&
candidate.pattern === defaultRule.pattern &&
!claimedRulePatches.has(candidate)
);
});
if (!patch && typeof expandedRule.title === "string") {
patch = { pattern: defaultRule.pattern };
titledRulePatches.push(patch);
}
if (!patch) return;
if (typeof expandedRule.title === "string") {
patch.title = expandedRule.title;
}
claimedRulePatches.add(patch);
orderedRulePatches.push(patch);
});
titledRulePatches.forEach(function(patch) {
if (!claimedRulePatches.has(patch)) orderedRulePatches.push(patch);
});
if (orderedRulePatches.length > 0) {
normalized.siteRules = orderedRulePatches;
normalized.siteRulesFormat = vscGetSiteRulesDiffFormat();
}
// lastSpeed is useful user data, but it is intentionally outside the
// managed options diff because content scripts update it at runtime.
if (Object.prototype.hasOwnProperty.call(raw, "lastSpeed")) {
if (Number.isFinite(expanded.lastSpeed)) {
normalized.lastSpeed = expanded.lastSpeed;
}
}
return normalized;
}
function exportSettings() {
chrome.storage.sync.get(null, function (storage) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to read settings - " + chrome.runtime.lastError.message,
true
);
return;
}
chrome.storage.local.get(null, function (localStorage) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to read local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
const backup = importExportUtils.buildBackupPayload(
normalizedSettingsForExport(storage),
importExportUtils.filterLocalSettingsForExport(localStorage),
new Date()
);
const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = generateBackupFilename();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("Settings exported successfully");
});
});
}
function persistImportedSyncSettings(currentRaw, importedRaw, callback) {
var expanded = vscExpandStoredSettings(importedRaw || {});
var mutation = vscBuildManagedStorageMutation(currentRaw || {}, expanded);
if (
importedRaw &&
Object.prototype.hasOwnProperty.call(importedRaw, "lastSpeed")
) {
if (Number.isFinite(expanded.lastSpeed)) {
mutation.set.lastSpeed = expanded.lastSpeed;
}
}
function removeStaleSettings() {
if (!mutation.remove.length) {
callback(null);
return;
}
chrome.storage.sync.remove(mutation.remove, function () {
callback(chrome.runtime.lastError || null);
});
}
if (!Object.keys(mutation.set).length) {
removeStaleSettings();
return;
}
// Write the replacement first. A failed write must not leave the user with
// their old managed settings already deleted.
chrome.storage.sync.set(mutation.set, function () {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
removeStaleSettings();
});
}
function importLocalSettings(localToImport, callback) {
if (localToImport === null) {
callback(null);
return;
}
var icons = localToImport.customButtonIcons;
if (icons && typeof icons === "object" && !Array.isArray(icons)) {
chrome.storage.local.set({ customButtonIcons: icons }, function () {
callback(chrome.runtime.lastError || null);
});
return;
}
// Backups only own customButtonIcons. Keep Lucide caches, remembered speed
// history, and any future local runtime data private to the current install.
chrome.storage.local.remove("customButtonIcons", function () {
callback(chrome.runtime.lastError || null);
});
}
function importSettings() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = function (event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const parsedBackup = importExportUtils.parseImportText(e.target.result);
if (!parsedBackup) {
showStatus("Error: Invalid backup file format", true);
return;
}
var settingsToImport = parsedBackup.settings;
var localToImport = parsedBackup.localSettings;
chrome.storage.sync.get(null, function (currentRaw) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to read current settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
persistImportedSyncSettings(
currentRaw || {},
settingsToImport,
function (syncError) {
if (syncError) {
showStatus(
"Error: Failed to save imported settings - " +
syncError.message,
true
);
return;
}
var scopedLocalSettings =
parsedBackup.isWrappedBackup === true ? localToImport : null;
importLocalSettings(scopedLocalSettings, function (localError) {
if (localError) {
showStatus(
"Error: Failed to save local extension data - " +
localError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
});
}
);
});
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
};
reader.onerror = function () {
showStatus("Error: Failed to read file", true);
};
reader.readAsText(file);
};
input.click();
}
function showStatus(message, isError = false) {
const status = document.getElementById("status");
if (status) {
status.textContent = message;
status.style.color = isError ? "#d32f2f" : "";
setTimeout(function () {
status.textContent = "";
status.style.color = "";
}, 3000);
}
}
// Initialize import/export buttons when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initImportExport);
} else {
initImportExport();
}
function initImportExport() {
const exportBtn = document.getElementById("exportSettings");
const importBtn = document.getElementById("importSettings");
if (exportBtn) {
exportBtn.addEventListener("click", exportSettings);
}
if (importBtn) {
importBtn.addEventListener("click", importSettings);
}
}
-111
View File
@@ -1,111 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="../shared/settings-core.js"></script>
<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>
-1086
View File
@@ -1,1086 +0,0 @@
(function (global) {
"use strict";
var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2";
var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys";
var MIN_SPEED = 0.1;
var MAX_SPEED = 16;
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var LEGACY_SYNC_KEYS = [
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"displayKeyCode",
"blacklist"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginRight",
"controllerMarginBottom",
"controllerMarginLeft",
"shortcutTargetMode",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat(
["hideWithYouTubeControls"],
LEGACY_SYNC_KEYS
);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
rememberSpeed: false,
audioBoolean: false,
showAmbientLoopControls: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
shortcutTargetMode: "closest",
keyBindings: [
{
action: "display",
code: "KeyV",
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
code: "KeyP",
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
code: "KeyS",
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
code: "KeyD",
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
code: "KeyZ",
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
code: "KeyX",
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
code: "KeyR",
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
code: "KeyG",
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
code: "KeyN",
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
title: "YouTube videos",
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true
},
{
title: "YouTube Shorts",
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
},
{
title: "YouTube Shorts (mobile)",
pattern: "/^https:\\/\\/m\\.youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeEnabledByDefault: true,
subtitleNudgeInterval: 250,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function coerceBoolean(value, fallback) {
if (value === true || value === false) return value;
if (value === "true" || value === 1 || value === "1") return true;
if (value === "false" || value === 0 || value === "0") return false;
return fallback;
}
function clampFiniteNumber(value, minimum, maximum, fallback) {
if (
value === null ||
typeof value === "boolean" ||
(typeof value === "string" && value.trim() === "") ||
(typeof value !== "number" && typeof value !== "string")
) {
return fallback;
}
var numericValue = Number(value);
if (!Number.isFinite(numericValue)) return fallback;
return Math.min(maximum, Math.max(minimum, numericValue));
}
var SITE_RULE_BOOLEAN_KEYS = new Set([
"enabled",
"startHidden",
"hideWithControls",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault",
"showPopupControlBar",
"popupMatchHoverControls"
]);
function normalizeSiteRuleValue(key, value) {
if (key === "title") {
if (typeof value !== "string") return undefined;
var title = value.trim();
return title || undefined;
}
if (SITE_RULE_BOOLEAN_KEYS.has(key)) {
return coerceBoolean(value, undefined);
}
if (key === "shortcutTargetMode") {
return value === "all" || value === "closest" ? value : undefined;
}
if (key === "hideWithControlsTimer") {
return clampFiniteNumber(value, 0.1, 15, undefined);
}
if (key === "controllerOpacity") {
return clampFiniteNumber(value, 0, 1, undefined);
}
if (
key === "controllerMarginTop" ||
key === "controllerMarginRight" ||
key === "controllerMarginBottom" ||
key === "controllerMarginLeft"
) {
return clampFiniteNumber(value, 0, 200, undefined);
}
if (key === "subtitleNudgeInterval") {
return clampFiniteNumber(value, 250, 1000, undefined);
}
if (key === "preferredSpeed") {
return clampFiniteNumber(value, MIN_SPEED, MAX_SPEED, undefined);
}
if (key === "shortcuts" && Array.isArray(value)) {
return sanitizeStoredBindingValues(value).map(function(shortcut) {
shortcut.disabled = coerceBoolean(shortcut.disabled, false);
shortcut.force = coerceBoolean(shortcut.force, false);
return shortcut;
});
}
return clonePlainData(value);
}
function normalizeSiteRule(rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "disableExtension" ||
rule[key] === undefined
) {
return;
}
var normalizedValue = normalizeSiteRuleValue(key, rule[key]);
if (normalizedValue !== undefined) {
normalized[key] = normalizedValue;
}
});
if (!hasOwn(normalized, "enabled") && hasOwn(rule, "disableExtension")) {
normalized.enabled = !coerceBoolean(rule.disableExtension, false);
}
return normalized;
}
function compressSiteRules(siteRules) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var claimedDefaultPatterns = new Set();
var exportRules = [];
var removedDefaultRuleKeys = [];
siteRules.forEach(function (rule) {
var normalizedRule = normalizeSiteRule(rule);
if (!normalizedRule) return;
var pattern = normalizedRule.pattern;
var defaultRule = defaultRulesByPattern[pattern];
if (defaultRule && !claimedDefaultPatterns.has(pattern)) {
claimedDefaultPatterns.add(pattern);
var normalizedDefaultRule = normalizeSiteRule(defaultRule);
var patch = { pattern: pattern };
var removedKeys = [];
Object.keys(normalizedRule).forEach(function (key) {
if (key === "pattern") return;
if (
!hasOwn(normalizedDefaultRule, key) ||
!areComparableValuesEqual(
normalizedRule[key],
normalizedDefaultRule[key]
)
) {
patch[key] = clonePlainData(normalizedRule[key]);
}
});
Object.keys(normalizedDefaultRule).forEach(function (key) {
if (key === "pattern" || hasOwn(normalizedRule, key)) return;
removedKeys.push(key);
});
if (Object.keys(patch).length > 1) {
exportRules.push(patch);
}
if (removedKeys.length > 0) {
removedDefaultRuleKeys.push({
pattern: pattern,
keys: removedKeys.sort()
});
}
return;
}
// A custom pattern-only rule is meaningful: it can whitelist a site while
// global Speeder is disabled. Never discard it as an "empty" rule.
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !claimedDefaultPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (
removedDefaultPatterns.length > 0 ||
removedDefaultRuleKeys.length > 0
) {
result.siteRulesMeta = {};
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta.removedDefaultPatterns = removedDefaultPatterns;
}
if (removedDefaultRuleKeys.length > 0) {
result.siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META] =
removedDefaultRuleKeys;
}
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function getRemovedDefaultRuleKeysByPattern(siteRulesMeta) {
var byPattern = Object.create(null);
var entries =
siteRulesMeta &&
Array.isArray(siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META])
? siteRulesMeta[REMOVED_DEFAULT_RULE_KEYS_META]
: [];
entries.forEach(function (entry) {
if (
!entry ||
typeof entry.pattern !== "string" ||
!Array.isArray(entry.keys)
) {
return;
}
byPattern[entry.pattern] = entry.keys.filter(function (key) {
return typeof key === "string" && key !== "pattern";
});
});
return byPattern;
}
function expandSiteRules(siteRules, siteRulesMeta, supportsRemovedKeys) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var removedKeysByPattern = supportsRemovedKeys
? getRemovedDefaultRuleKeysByPattern(siteRulesMeta)
: Object.create(null);
var modifiedDefaultRules = Object.create(null);
var claimedDefaultPatterns = new Set();
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern) &&
!claimedDefaultPatterns.has(pattern)
) {
claimedDefaultPatterns.add(pattern);
modifiedDefaultRules[pattern] = normalizeSiteRule(rule);
return;
}
var customRule = normalizeSiteRule(rule);
if (customRule) customRules.push(customRule);
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
var mergedRule = Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
);
(removedKeysByPattern[pattern] || []).forEach(function (key) {
delete mergedRule[key];
});
mergedRules.push(mergedRule);
return;
}
var unmodifiedRule = clonePlainData(rule);
(removedKeysByPattern[pattern] || []).forEach(function (key) {
delete unmodifiedRule[key];
});
mergedRules.push(unmodifiedRule);
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function migrateLegacyFullSiteRules(siteRules) {
var rules = clonePlainData(siteRules) || [];
var defaults = getDefaultSiteRules();
var mobileShortsDefault = defaults[2];
if (!mobileShortsDefault || !mobileShortsDefault.pattern) return rules;
// Full-rule backups from versions before rule titles should receive the
// labels now attached to the built-ins. New saves use the sparse format,
// where an intentionally removed title is represented by a tombstone.
defaults.forEach(function(defaultRule) {
var matchingRule = rules.find(function(rule) {
return rule && rule.pattern === defaultRule.pattern;
});
if (
matchingRule &&
!hasOwn(matchingRule, "title") &&
typeof defaultRule.title === "string"
) {
matchingRule.title = defaultRule.title;
}
});
var alreadyHasMobileRule = rules.some(function(rule) {
return rule && rule.pattern === mobileShortsDefault.pattern;
});
if (alreadyHasMobileRule) return rules;
// Older Options versions persisted the entire then-default list without a
// format marker and sometimes filled in extra false/default-valued fields.
// Presence of both known old default patterns is therefore the reliable
// signal; the mobile rule did not exist yet, so it could not have been
// intentionally removed from one of those legacy lists.
var hasOldDefaultPatterns = defaults.slice(0, 2).every(function(defaultRule) {
return rules.some(function(rule) {
return rule && rule.pattern === defaultRule.pattern;
});
});
if (hasOldDefaultPatterns) {
rules.push(clonePlainData(mobileShortsDefault));
}
return rules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function legacyKeyCodeToCode(keyCode) {
var numericCode = Number(keyCode);
if (!Number.isInteger(numericCode)) return null;
if (numericCode >= 48 && numericCode <= 57) {
return "Digit" + String.fromCharCode(numericCode);
}
if (numericCode >= 65 && numericCode <= 90) {
return "Key" + String.fromCharCode(numericCode);
}
if (numericCode >= 96 && numericCode <= 105) {
return "Numpad" + (numericCode - 96);
}
if (numericCode >= 112 && numericCode <= 123) {
return "F" + (numericCode - 111);
}
var keyCodeMap = {
32: "Space",
37: "ArrowLeft",
38: "ArrowUp",
39: "ArrowRight",
40: "ArrowDown",
59: "Semicolon",
61: "Equal",
106: "NumpadMultiply",
107: "NumpadAdd",
109: "NumpadSubtract",
110: "NumpadDecimal",
111: "NumpadDivide",
173: "Minus",
186: "Semicolon",
187: "Equal",
188: "Comma",
189: "Minus",
190: "Period",
191: "Slash",
192: "Backquote",
219: "BracketLeft",
220: "Backslash",
221: "BracketRight",
222: "Quote"
};
return keyCodeMap[numericCode] || null;
}
function finiteNumberOr(value, fallback) {
var numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function sanitizeStoredBindingValues(bindings) {
var defaultValues = Object.create(null);
DEFAULT_SETTINGS.keyBindings.forEach(function(binding) {
defaultValues[binding.action] = Number(binding.value) || 0;
});
defaultValues.louder = 0.1;
defaultValues.softer = 0.1;
return (Array.isArray(bindings) ? bindings : [])
.filter(function(binding) {
return (
isPlainObject(binding) &&
typeof binding.action === "string" &&
binding.action.length > 0
);
})
.map(function(binding) {
var normalized = clonePlainData(binding) || {};
var action = normalized.action;
var value = Number(normalized.value);
var invalid = !Number.isFinite(value);
if (action === "slower" || action === "faster") {
invalid = invalid || value <= 0 || value > MAX_SPEED;
} else if (action === "fast") {
invalid = invalid || value < MIN_SPEED || value > MAX_SPEED;
} else if (action === "rewind" || action === "advance") {
invalid = invalid || value < 0;
} else if (action === "louder" || action === "softer") {
invalid = invalid || value <= 0 || value > 1;
}
normalized.value = invalid
? (hasOwn(defaultValues, action) ? defaultValues[action] : 0)
: value;
normalized.disabled = coerceBoolean(normalized.disabled, false);
normalized.force = coerceBoolean(normalized.force, false);
return normalized;
});
}
function hasLegacyShortcutSettings(raw) {
return [
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"displayKeyCode"
].some(function (key) {
return hasOwn(raw, key);
});
}
function migrateLegacyKeyBindings(raw) {
var bindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
var byAction = Object.create(null);
bindings.forEach(function (binding) {
byAction[binding.action] = binding;
});
var speedStep = finiteNumberOr(raw.speedStep, byAction.faster.value);
if (speedStep > 0) {
byAction.slower.value = speedStep;
byAction.faster.value = speedStep;
}
var rewindTime = finiteNumberOr(raw.rewindTime, byAction.rewind.value);
if (rewindTime >= 0) byAction.rewind.value = rewindTime;
var advanceTime = finiteNumberOr(raw.advanceTime, byAction.advance.value);
if (advanceTime >= 0) byAction.advance.value = advanceTime;
var fastSpeed = finiteNumberOr(raw.fastSpeed, byAction.fast.value);
if (fastSpeed > 0) byAction.fast.value = fastSpeed;
[
["display", "displayKeyCode"],
["reset", "resetKeyCode"],
["slower", "slowerKeyCode"],
["faster", "fasterKeyCode"],
["rewind", "rewindKeyCode"],
["advance", "advanceKeyCode"],
["fast", "fastKeyCode"]
].forEach(function (mapping) {
if (!hasOwn(raw, mapping[1])) return;
var code = legacyKeyCodeToCode(raw[mapping[1]]);
if (code) byAction[mapping[0]].code = code;
});
return bindings;
}
function migrateLegacyBlacklist(raw) {
if (typeof raw.blacklist !== "string") return [];
return raw.blacklist
.split("\n")
.map(function (line) {
return line.trim();
})
.filter(Boolean)
.map(function (pattern) {
return { pattern: pattern, enabled: false };
});
}
function buildManagedStorageMutation(currentStorage, currentSettings) {
var raw = isPlainObject(currentStorage) ? currentStorage : {};
var nextSettings = buildStoredSettingsDiff(currentSettings);
var removeKeys = [];
MANAGED_SYNC_KEYS.forEach(function (key) {
if (!hasOwn(nextSettings, key) && hasOwn(raw, key)) {
removeKeys.push(key);
}
});
return {
set: nextSettings,
remove: Array.from(new Set(removeKeys))
};
}
function expandStoredSettings(storage) {
var raw = isPlainObject(storage) ? clonePlainData(storage) : {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = coerceBoolean(
raw.hideWithYouTubeControls,
DEFAULT_SETTINGS.hideWithControls
);
}
[
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"showAmbientLoopControls",
"enabled",
"startHidden",
"hideWithControls",
"showPopupControlBar",
"popupMatchHoverControls",
"enableSubtitleNudge",
"subtitleNudgeEnabledByDefault"
].forEach(function(key) {
expanded[key] = coerceBoolean(expanded[key], DEFAULT_SETTINGS[key]);
});
expanded.hideWithYouTubeControls = expanded.hideWithControls;
expanded.shortcutTargetMode =
expanded.shortcutTargetMode === "all" ? "all" : "closest";
expanded.lastSpeed = clampFiniteNumber(
expanded.lastSpeed,
MIN_SPEED,
MAX_SPEED,
DEFAULT_SETTINGS.lastSpeed
);
expanded.hideWithControlsTimer = clampFiniteNumber(
expanded.hideWithControlsTimer,
0.1,
15,
DEFAULT_SETTINGS.hideWithControlsTimer
);
expanded.controllerOpacity = clampFiniteNumber(
expanded.controllerOpacity,
0,
1,
DEFAULT_SETTINGS.controllerOpacity
);
[
"controllerMarginTop",
"controllerMarginRight",
"controllerMarginBottom",
"controllerMarginLeft"
].forEach(function(key) {
expanded[key] = clampFiniteNumber(
expanded[key],
0,
200,
DEFAULT_SETTINGS[key]
);
});
expanded.subtitleNudgeInterval = clampFiniteNumber(
expanded.subtitleNudgeInterval,
250,
1000,
DEFAULT_SETTINGS.subtitleNudgeInterval
);
expanded.subtitleNudgeAmount = clampFiniteNumber(
expanded.subtitleNudgeAmount,
0.000001,
0.1,
DEFAULT_SETTINGS.subtitleNudgeAmount
);
if (Array.isArray(raw.keyBindings) && raw.keyBindings.length > 0) {
expanded.keyBindings = clonePlainData(raw.keyBindings);
} else if (hasLegacyShortcutSettings(raw)) {
expanded.keyBindings = migrateLegacyKeyBindings(raw);
} else {
expanded.keyBindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
}
expanded.keyBindings = sanitizeStoredBindingValues(expanded.keyBindings);
if (expanded.keyBindings.length === 0) {
expanded.keyBindings = clonePlainData(DEFAULT_SETTINGS.keyBindings);
}
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(
raw.siteRules,
raw.siteRulesMeta,
true
);
} else if (raw.siteRulesFormat === LEGACY_SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(
raw.siteRules,
raw.siteRulesMeta,
false
);
} else if (hasOwn(raw, "siteRules") && Array.isArray(raw.siteRules)) {
expanded.siteRules = migrateLegacyFullSiteRules(raw.siteRules);
} else if (hasOwn(raw, "blacklist")) {
expanded.siteRules = getDefaultSiteRules().concat(
migrateLegacyBlacklist(raw)
);
} else {
expanded.siteRules = getDefaultSiteRules();
}
expanded.siteRules = expanded.siteRules
.map(normalizeSiteRule)
.filter(Boolean);
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscBuildManagedStorageMutation = buildManagedStorageMutation;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscGetLegacySiteRulesDiffFormat = function () {
return LEGACY_SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = function (url, rules) {
var shared = global.SpeederShared && global.SpeederShared.siteRules;
return shared && typeof shared.matchSiteRule === "function"
? shared.matchSiteRule(url, rules)
: mergeMatchingSiteRules(url, rules);
};
global.vscSiteRuleMatchesUrl = function (rule, url) {
var shared = global.SpeederShared && global.SpeederShared.siteRules;
return shared && typeof shared.siteRuleMatchesUrl === "function"
? shared.siteRuleMatchesUrl(rule, url)
: siteRuleMatchesUrl(rule, url);
};
global.vscIsSiteRuleDisabled = function (rule) {
var shared = global.SpeederShared && global.SpeederShared.siteRules;
return shared && typeof shared.isSiteRuleDisabled === "function"
? shared.isSiteRuleDisabled(rule)
: isSiteRuleDisabled(rule);
};
})(typeof globalThis !== "undefined" ? globalThis : this);
-314
View File
@@ -1,314 +0,0 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.siteRules = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
function escapeStringRegExp(str) {
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
}
// Decode valid percent-encoded runs without letting one malformed escape make
// the entire URL unusable. This also preserves incomplete UTF-8 bytes instead
// of replacing them with lossy placeholder characters.
function safelyDecodeUrlText(value) {
return String(value).replace(/(?:%[\da-f]{2})+/gi, function(encodedRun) {
var decoded = "";
var tripletCount = encodedRun.length / 3;
var tripletIndex = 0;
while (tripletIndex < tripletCount) {
var tripletOffset = tripletIndex * 3;
var firstByte = parseInt(
encodedRun.substring(tripletOffset + 1, tripletOffset + 3),
16
);
var sequenceLength = firstByte <= 0x7f
? 1
: firstByte >= 0xc2 && firstByte <= 0xdf
? 2
: firstByte >= 0xe0 && firstByte <= 0xef
? 3
: firstByte >= 0xf0 && firstByte <= 0xf4
? 4
: 0;
var encodedSequence = sequenceLength
? encodedRun.substring(
tripletOffset,
tripletOffset + sequenceLength * 3
)
: "";
if (
sequenceLength &&
tripletIndex + sequenceLength <= tripletCount
) {
try {
decoded += decodeURIComponent(encodedSequence);
tripletIndex += sequenceLength;
continue;
} catch (_error) {
// Preserve this byte below, then retry at the next triplet so a
// later valid character in the same run can still be decoded.
}
}
decoded += encodedRun.substring(tripletOffset, tripletOffset + 3);
tripletIndex += 1;
}
return decoded;
});
}
function decodedUrlMatchCandidate(url) {
var parsedUrl;
try {
parsedUrl = new URL(String(url));
} catch (_error) {
return null;
}
var authority = "";
if (parsedUrl.host || parsedUrl.protocol === "file:") {
authority = "//";
if (parsedUrl.username) {
authority += parsedUrl.username;
if (parsedUrl.password) authority += ":" + parsedUrl.password;
authority += "@";
}
authority += parsedUrl.host;
}
return (
parsedUrl.protocol +
authority +
safelyDecodeUrlText(parsedUrl.pathname || "") +
(parsedUrl.search
? "?" + safelyDecodeUrlText(parsedUrl.search.substring(1))
: "") +
(parsedUrl.hash
? "#" + safelyDecodeUrlText(parsedUrl.hash.substring(1))
: "")
);
}
function parsePlainSitePattern(pattern) {
var normalized = String(pattern).replace(regStrip, "");
if (!normalized) return null;
var hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(normalized);
var wildcardSubdomains = false;
var parsed;
try {
if (hasScheme) {
parsed = new URL(normalized);
} else {
var slashIndex = normalized.indexOf("/");
var authority = slashIndex === -1
? normalized
: normalized.substring(0, slashIndex);
var path = slashIndex === -1 ? "" : normalized.substring(slashIndex);
if (authority.indexOf("@") !== -1 || /[?#]/.test(authority)) return null;
if (authority.indexOf("*.") === 0) {
wildcardSubdomains = true;
authority = authority.substring(2);
}
parsed = new URL("http://" + authority + path);
}
} catch (_error) {
return null;
}
var hostname = String(parsed.hostname || "").toLowerCase().replace(/\.$/, "");
if (!hostname && parsed.protocol !== "file:") return null;
var pathPrefix = parsed.pathname || "";
if (!hasScheme && pathPrefix === "/") pathPrefix = "";
if (parsed.search) pathPrefix += parsed.search;
if (parsed.hash) pathPrefix += parsed.hash;
return {
protocol: hasScheme ? parsed.protocol : null,
hostname: hostname,
port: parsed.port || "",
pathPrefix: pathPrefix,
wildcardSubdomains: wildcardSubdomains
};
}
function plainSitePatternMatchesUrl(pattern, url) {
var parsedPattern = parsePlainSitePattern(pattern);
if (!parsedPattern) return false;
var parsedUrl;
try {
parsedUrl = new URL(String(url));
} catch (_error) {
return false;
}
if (parsedPattern.protocol && parsedPattern.protocol !== parsedUrl.protocol) {
return false;
}
if (parsedPattern.port && parsedPattern.port !== parsedUrl.port) {
return false;
}
var currentHost = String(parsedUrl.hostname || "").toLowerCase().replace(/\.$/, "");
var hostMatches =
currentHost === parsedPattern.hostname ||
currentHost.endsWith("." + parsedPattern.hostname);
if (!hostMatches) return false;
if (
parsedPattern.wildcardSubdomains &&
currentHost === parsedPattern.hostname
) {
return false;
}
if (parsedPattern.pathPrefix) {
var currentPath =
(parsedUrl.pathname || "/") +
(parsedUrl.search || "") +
(parsedUrl.hash || "");
var decodedPatternPath = safelyDecodeUrlText(parsedPattern.pathPrefix);
if (
!currentPath.startsWith(parsedPattern.pathPrefix) &&
!safelyDecodeUrlText(currentPath).startsWith(decodedPatternPath)
) {
return false;
}
}
return true;
}
function compileSiteRulePattern(pattern) {
if (typeof pattern !== "string") return null;
var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0
) {
var lastSlash = normalizedPattern.lastIndexOf("/");
var regularExpression = new RegExp(
normalizedPattern.substring(1, lastSlash),
normalizedPattern.substring(lastSlash + 1)
);
return {
test: function(url) {
var rawUrl = String(url);
var decodedUrl = decodedUrlMatchCandidate(rawUrl);
var candidates = decodedUrl && decodedUrl !== rawUrl
? [rawUrl, decodedUrl]
: [rawUrl];
for (var i = 0; i < candidates.length; i++) {
// Global and sticky expressions retain state between test() calls.
// Site matching must be deterministic across navigation checks.
regularExpression.lastIndex = 0;
if (regularExpression.test(candidates[i])) {
regularExpression.lastIndex = 0;
return true;
}
}
regularExpression.lastIndex = 0;
return false;
}
};
}
return {
test: function(url) {
return plainSitePatternMatchesUrl(normalizedPattern, url);
}
};
}
function siteRuleMatchesUrl(rule, url) {
if (!rule || !rule.pattern || !url) return false;
try {
var matcher = compileSiteRulePattern(rule.pattern);
return Boolean(matcher && matcher.test(url));
} catch (_error) {
return false;
}
}
function cloneRuleValue(value) {
if (!value || typeof value !== "object") return value;
return JSON.parse(JSON.stringify(value));
}
function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null;
var matches = [];
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (siteRuleMatchesUrl(rule, url)) matches.push(rule);
}
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0];
var mergedRule = {};
matches.forEach(function(rule) {
Object.keys(rule).forEach(function(key) {
if (rule[key] !== undefined) {
mergedRule[key] = cloneRuleValue(rule[key]);
}
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
/**
* 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,
plainSitePatternMatchesUrl: plainSitePatternMatchesUrl,
safelyDecodeUrlText: safelyDecodeUrlText,
siteRuleMatchesUrl: siteRuleMatchesUrl
};
});
@@ -8,15 +8,7 @@
if (!v) return null;
return {
speed: v.playbackRate,
preferred: !v.paused,
forceLastSavedSpeed: Boolean(
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
),
forceLastSavedSpeedControlledBySiteRule: Boolean(
typeof tc === "object" &&
tc.activeSiteRule &&
tc.activeSiteRule.forceLastSavedSpeed !== undefined
)
preferred: !v.paused
};
} catch (e) {
return null;

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

+162
View File
@@ -0,0 +1,162 @@
// Import/Export functionality for Video Speed Controller settings
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var importExportUtils = speederShared.importExport || {};
function generateBackupFilename() {
return importExportUtils.generateBackupFilename(new Date());
}
function exportSettings() {
chrome.storage.sync.get(null, function (storage) {
chrome.storage.local.get(null, function (localStorage) {
const backup = importExportUtils.buildBackupPayload(
storage,
localStorage,
new Date()
);
const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = generateBackupFilename();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("Settings exported successfully");
});
});
}
function importSettings() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = function (event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const parsedBackup = importExportUtils.parseImportText(e.target.result);
if (!parsedBackup) {
showStatus("Error: Invalid backup file format", true);
return;
}
var settingsToImport = parsedBackup.settings;
var localToImport = parsedBackup.localSettings;
function importLocalSettings(callback) {
if (parsedBackup.isWrappedBackup !== true) {
callback();
return;
}
chrome.storage.local.clear(function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to clear local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
if (localToImport && Object.keys(localToImport).length > 0) {
chrome.storage.local.set(localToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
callback();
});
return;
}
callback();
});
}
function afterLocalImport() {
chrome.storage.sync.clear(function () {
chrome.storage.sync.set(settingsToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
});
});
}
importLocalSettings(afterLocalImport);
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
};
reader.onerror = function () {
showStatus("Error: Failed to read file", true);
};
reader.readAsText(file);
};
input.click();
}
function showStatus(message, isError = false) {
const status = document.getElementById("status");
if (status) {
status.textContent = message;
status.style.color = isError ? "#d32f2f" : "";
setTimeout(function () {
status.textContent = "";
status.style.color = "";
}, 3000);
}
}
// Initialize import/export buttons when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initImportExport);
} else {
initImportExport();
}
function initImportExport() {
const exportBtn = document.getElementById("exportSettings");
const importBtn = document.getElementById("importSettings");
if (exportBtn) {
exportBtn.addEventListener("click", exportSettings);
}
if (importBtn) {
importBtn.addEventListener("click", importSettings);
}
}
+4 -18
View File
@@ -6,28 +6,14 @@
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;
}
/* Native media controls can fullscreen the replaced <video> element without
calling the page's requestFullscreen method. A manual popover keeps the
controller in the browser top layer without nesting it inside <video>. */
.vsc-controller.vsc-fullscreen-popover {
position: fixed !important;
inset: auto !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
overflow: visible !important;
}
.vsc-controller.vsc-fullscreen-popover::backdrop {
pointer-events: none !important;
background: transparent !important;
/* Use minimal z-index for non-YouTube sites to avoid overlapping modals */
.vsc-controller.vsc-non-youtube {
z-index: 1 !important;
}
.vsc-nosource {
+2863
View File
@@ -0,0 +1,2863 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var siteRuleUtils = speederShared.siteRules || {};
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
for (var i = 0; i < tc.mediaElements.length; i++) {
var el = tc.mediaElements[i];
if (el && !el.paused) return el;
}
return tc.mediaElements[0];
}
var tc = {
settings: {
lastSpeed: 1.0,
enabled: true,
speeds: {},
rememberSpeed: false,
forceLastSavedSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [],
siteRules: [],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3,
logLevel: 3,
enableSubtitleNudge: false,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001,
customButtonIcons: {}
},
mediaElements: [],
isNudging: false,
pendingLastSpeedSave: null,
pendingLastSpeedValue: null,
persistedLastSpeed: 1.0,
activeSiteRule: null,
siteRuleBase: null
};
var MIN_SPEED = 0.0625;
var MAX_SPEED = 16;
var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet();
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
: function(callback, options) {
return setTimeout(callback, (options && options.timeout) || 1);
};
var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
? controllerUtils.controllerLocations.slice()
: [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var defaultControllerLocation =
controllerUtils.defaultControllerLocation || controllerLocations[0];
var controllerLocationStyles = {
"top-left": {
top: "10px",
left: "15px",
transform: "translate(0, 0)"
},
"top-center": {
top: "10px",
left: "50%",
transform: "translate(-50%, 0)"
},
"top-right": {
top: "10px",
left: "calc(100% - 10px)",
transform: "translate(-100%, 0)"
},
"middle-right": {
top: "50%",
left: "calc(100% - 10px)",
transform: "translate(-100%, -50%)"
},
"bottom-right": {
top: "calc(100% - 0px)",
left: "calc(100% - 10px)",
transform: "translate(-100%, -100%)"
},
"bottom-center": {
top: "calc(100% - 0px)",
left: "50%",
transform: "translate(-50%, -100%)"
},
"bottom-left": {
top: "calc(100% - 0px)",
left: "15px",
transform: "translate(0, -100%)"
},
"middle-left": {
top: "50%",
left: "15px",
transform: "translate(0, -50%)"
}
};
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
settings: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" }
};
function createDefaultBinding(action, code, value) {
return {
action: action,
code: code,
value: value,
force: false,
predefined: true
};
}
function defaultKeyBindings(storage) {
return [
createDefaultBinding(
"slower",
"KeyS",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"faster",
"KeyD",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"rewind",
"KeyZ",
Number(storage.rewindTime) || 10
),
createDefaultBinding(
"advance",
"KeyX",
Number(storage.advanceTime) || 10
),
createDefaultBinding(
"reset",
"KeyR",
1.0
),
createDefaultBinding(
"fast",
"KeyG",
Number(storage.fastSpeed) || 1.8
),
createDefaultBinding(
"move",
"KeyP",
0
),
createDefaultBinding(
"toggleSubtitleNudge",
"KeyN",
0
)
];
}
function ensureDefaultKeyBinding(action, code, value) {
if (tc.settings.keyBindings.some((binding) => binding.action === action)) {
return false;
}
tc.settings.keyBindings.push(
createDefaultBinding(action, code, value)
);
return true;
}
function getLegacyKeyCode(binding) {
return keyBindingUtils.getLegacyKeyCode(binding);
}
function normalizeControllerLocation(location) {
return controllerUtils.normalizeControllerLocation(
location,
defaultControllerLocation
);
}
var CONTROLLER_MARGIN_MAX_PX = 200;
function normalizeControllerMarginPx(value, fallback) {
return controllerUtils.clampControllerMarginPx(value, fallback);
}
function applyControllerMargins(controller) {
if (!controller) return;
var d = tc.settings;
var loc = controller.dataset.location;
var manual = controller.dataset.positionMode === "manual";
var isTopAnchored =
!manual &&
(loc === "top-left" ||
loc === "top-center" ||
loc === "top-right");
var isBottomAnchored =
!manual &&
(loc === "bottom-right" ||
loc === "bottom-center" ||
loc === "bottom-left");
var isMiddleRow =
!manual && (loc === "middle-left" || loc === "middle-right");
var mt = normalizeControllerMarginPx(d.controllerMarginTop, 0);
var mb = normalizeControllerMarginPx(d.controllerMarginBottom, 65);
if (isTopAnchored || isBottomAnchored || isMiddleRow) {
mt = 0;
mb = 0;
}
controller.style.marginTop = mt + "px";
var ml = normalizeControllerMarginPx(d.controllerMarginLeft, 0);
var mr = normalizeControllerMarginPx(d.controllerMarginRight, 0);
if (!manual) {
ml = 0;
mr = 0;
}
controller.style.marginRight = mr + "px";
controller.style.marginBottom = mb + "px";
controller.style.marginLeft = ml + "px";
}
function getNextControllerLocation(location) {
return controllerUtils.getNextControllerLocation(location);
}
function getControllerElement(videoOrController) {
if (!videoOrController) return null;
if (
videoOrController.shadowRoot &&
typeof videoOrController.shadowRoot.querySelector === "function"
) {
return videoOrController.shadowRoot.querySelector("#controller");
}
if (
videoOrController.div &&
videoOrController.div.shadowRoot &&
typeof videoOrController.div.shadowRoot.querySelector === "function"
) {
return videoOrController.div.shadowRoot.querySelector("#controller");
}
return null;
}
function applyControllerLocationToElement(controller, location) {
if (!controller) return defaultControllerLocation;
var normalizedLocation = normalizeControllerLocation(location);
var styles = controllerLocationStyles[normalizedLocation];
controller.dataset.location = normalizedLocation;
controller.dataset.positionMode = "anchored";
var top = styles.top;
if (
normalizedLocation === "top-left" ||
normalizedLocation === "top-center" ||
normalizedLocation === "top-right"
) {
var insetTop = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(10px + " + insetTop + "px)";
}
if (
normalizedLocation === "bottom-right" ||
normalizedLocation === "bottom-center" ||
normalizedLocation === "bottom-left"
) {
var lift = normalizeControllerMarginPx(
tc.settings.controllerMarginBottom,
65
);
top = "calc(100% - " + lift + "px)";
}
// If in fullscreen, move the controller down to avoid overlapping video titles
if (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
) {
if (normalizedLocation.startsWith("top-")) {
var insetTopFs = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(63px + " + insetTopFs + "px)";
}
}
controller.style.top = top;
var left = styles.left;
switch (normalizedLocation) {
case "top-left":
case "middle-left":
case "bottom-left":
left = "15px";
break;
case "top-right":
case "middle-right":
case "bottom-right":
left = "calc(100% - 10px)";
break;
case "top-center":
case "bottom-center":
left = "50%";
break;
default:
break;
}
controller.style.left = left;
controller.style.transform = styles.transform;
applyControllerMargins(controller);
return normalizedLocation;
}
function applyControllerLocation(videoController, location) {
if (!videoController) return;
var controller = getControllerElement(videoController);
if (!controller) return;
videoController.controllerLocation = applyControllerLocationToElement(
controller,
location
);
}
function captureSiteRuleBase() {
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
hideWithControls: tc.settings.hideWithControls,
hideWithControlsTimer: tc.settings.hideWithControlsTimer,
controllerLocation: tc.settings.controllerLocation,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
controllerOpacity: tc.settings.controllerOpacity,
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
controllerButtons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons.slice()
: tc.settings.controllerButtons,
keyBindings: Array.isArray(tc.settings.keyBindings)
? tc.settings.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: tc.settings.keyBindings
};
}
function resetSettingsFromSiteRuleBase() {
if (!tc.siteRuleBase) return;
var base = tc.siteRuleBase;
tc.settings.startHidden = base.startHidden;
tc.settings.hideWithControls = base.hideWithControls;
tc.settings.hideWithControlsTimer = base.hideWithControlsTimer;
tc.settings.controllerLocation = base.controllerLocation;
tc.settings.rememberSpeed = base.rememberSpeed;
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
tc.settings.audioBoolean = base.audioBoolean;
tc.settings.controllerOpacity = base.controllerOpacity;
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
tc.settings.controllerButtons = Array.isArray(base.controllerButtons)
? base.controllerButtons.slice()
: base.controllerButtons;
tc.settings.keyBindings = Array.isArray(base.keyBindings)
? base.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: base.keyBindings;
}
function clearManualControllerPosition(videoController) {
if (!videoController) return;
applyControllerLocation(
videoController,
videoController.controllerLocation || tc.settings.controllerLocation
);
}
function convertControllerToManualPosition(videoController) {
if (!videoController) return null;
var controller = getControllerElement(videoController);
if (!controller) return null;
controller.dataset.positionMode = "manual";
var offsetParent = controller.offsetParent;
if (offsetParent) {
var controllerRect = controller.getBoundingClientRect();
var offsetParentRect = offsetParent.getBoundingClientRect();
controller.style.setProperty(
"left",
controllerRect.left - offsetParentRect.left + "px",
"important"
);
controller.style.setProperty(
"top",
controllerRect.top - offsetParentRect.top + "px",
"important"
);
} else {
controller.style.setProperty(
"left",
controller.offsetLeft + "px",
"important"
);
controller.style.setProperty(
"top",
controller.offsetTop + "px",
"important"
);
}
controller.style.setProperty("transform", "none", "important");
return controller;
}
function cycleControllerLocation(video) {
if (!video || !video.vsc) return;
video.vsc.controllerLocation = getNextControllerLocation(
video.vsc.controllerLocation || tc.settings.controllerLocation
);
clearManualControllerPosition(video.vsc);
}
function normalizeBindingKey(key) {
return keyBindingUtils.normalizeBindingKey(key);
}
function legacyBindingKeyToCode(key) {
return keyBindingUtils.legacyBindingKeyToCode(key);
}
function legacyKeyCodeToCode(keyCode) {
return keyBindingUtils.legacyKeyCodeToCode(keyCode);
}
function inferBindingCode(binding, fallbackCode) {
return keyBindingUtils.inferBindingCode(binding, fallbackCode);
}
function normalizeStoredBinding(binding, fallbackCode) {
if (!binding) {
if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false,
value: 0,
force: "false",
predefined: false
};
}
if (
binding.disabled === true ||
(binding.code === null &&
binding.key === null &&
binding.keyCode === null)
) {
return {
action: binding.action,
code: null,
disabled: true,
value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
}
var normalizedCode = inferBindingCode(binding, fallbackCode);
if (!normalizedCode) {
return null;
}
var normalized = {
action: binding.action,
code: normalizedCode,
disabled: false,
value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
return normalized;
}
function isValidSpeed(speed) {
return !isNaN(speed) && speed >= MIN_SPEED && speed <= MAX_SPEED;
}
function sanitizeSpeed(speed, fallback) {
var numericSpeed = Number(speed);
return isValidSpeed(numericSpeed) ? numericSpeed : fallback;
}
function getVideoSourceKey(video) {
return (video && (video.currentSrc || video.src)) || "unknown_src";
}
function getControllerTargetSpeed(video) {
if (!video || !video.vsc) return null;
if (!isValidSpeed(video.vsc.targetSpeed)) return null;
var currentSourceKey = getVideoSourceKey(video);
var targetSourceKey = video.vsc.targetSpeedSourceKey;
// SPA sites (e.g. YouTube) can reuse the same <video> element.
// Don't carry controller target speed across a source swap.
if (
targetSourceKey &&
currentSourceKey === "unknown_src" &&
targetSourceKey !== "unknown_src"
) {
return null;
}
if (
targetSourceKey &&
currentSourceKey !== "unknown_src" &&
targetSourceKey !== currentSourceKey
) {
return null;
}
return video.vsc.targetSpeed;
}
function getRememberedSpeed(video) {
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
return null;
}
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
var videoSpeed = tc.settings.speeds[sourceKey];
if (isValidSpeed(videoSpeed)) return videoSpeed;
}
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
if (tc.settings.rememberSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return null;
}
function getDesiredSpeed(video) {
return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0;
}
function isOnYouTube() {
return (
location.hostname.includes("youtube.com") ||
location.hostname.includes("youtube-nocookie.com")
);
}
function isYouTubeNativeSpeedRange(speed) {
if (speed < YT_NATIVE_MIN || speed > YT_NATIVE_MAX) return false;
// Check if speed is a multiple of 0.05 (with floating point tolerance)
var remainder = Math.abs((speed * 100) % (YT_NATIVE_STEP * 100));
return remainder < 0.01 || remainder > (YT_NATIVE_STEP * 100 - 0.01);
}
function tryYouTubeNativeSpeed(video, speed) {
if (!isOnYouTube() || !isYouTubeNativeSpeedRange(speed)) return false;
try {
// YouTube's movie_player element exposes setPlaybackRate() but it's a
// page-level JS method, not a native DOM method. Content scripts can't
// see it directly. In Firefox, wrappedJSObject gives access to the
// page's JS context.
var playerEl =
video.closest(".html5-video-player") ||
document.getElementById("movie_player");
if (!playerEl) return false;
// Try wrappedJSObject first (Firefox content script → page context)
var player = playerEl.wrappedJSObject || playerEl;
if (typeof player.setPlaybackRate === "function") {
player.setPlaybackRate(speed);
// Verify YouTube actually accepted the speed (it may silently clamp)
var actualSpeed = video.playbackRate;
if (Math.abs(actualSpeed - speed) > 0.01) {
log("YouTube clamped speed to " + actualSpeed + ", falling back", 4);
return false;
}
log("Used YouTube native setPlaybackRate: " + speed, 4);
return true;
}
} catch (e) {
log("YouTube native speed failed: " + e.message, 3);
}
return false;
}
function isSubtitleNudgeSupported(video) {
return Boolean(video);
}
function isSubtitleNudgeAvailableForVideo(video) {
return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge);
}
function isSubtitleNudgeEnabledForVideo(video) {
if (!isSubtitleNudgeAvailableForVideo(video)) return false;
if (!video || !video.vsc) return true;
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
return video.vsc.subtitleNudgeEnabledOverride;
}
return true;
}
function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (!video || !video.vsc) return false;
if (!isSubtitleNudgeAvailableForVideo(video)) {
video.vsc.subtitleNudgeEnabledOverride = null;
video.vsc.stopSubtitleNudge();
updateSubtitleNudgeIndicator(video);
return false;
}
var normalizedEnabled = Boolean(enabled);
video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled;
if (!normalizedEnabled) {
video.vsc.stopSubtitleNudge();
} else if (!video.paused && video.playbackRate !== 1.0) {
video.vsc.startSubtitleNudge();
}
updateSubtitleNudgeIndicator(video);
// Briefly flash the standalone indicator next to the speed text
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
flashEl.classList.add("visible");
clearTimeout(flashEl._flashTimer);
flashEl._flashTimer = setTimeout(function() {
flashEl.classList.remove("visible");
}, 1500);
}
return normalizedEnabled;
}
function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
vscClearElement(target);
if (custom) {
var customWrap = vscCreateSvgWrap(
target.ownerDocument || document,
custom,
"vsc-btn-icon"
);
if (customWrap) {
target.appendChild(customWrap);
return;
}
}
if (typeof vscIconSvgString !== "function") {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
if (wrap) {
target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
}
function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return;
var isAvailable = isSubtitleNudgeAvailableForVideo(video);
var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var title = !isAvailable
? "Subtitle nudge unavailable on this site"
: isEnabled
? "Subtitle nudge enabled"
: "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = isAvailable ? "true" : "false";
indicator.title = title;
indicator.setAttribute("aria-label", title);
}
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = isAvailable ? "true" : "false";
flashEl.setAttribute("aria-label", title);
}
}
function schedulePersistLastSpeed(speed) {
if (!isValidSpeed(speed)) return;
tc.pendingLastSpeedValue = speed;
if (tc.pendingLastSpeedSave !== null) return;
tc.pendingLastSpeedSave = setTimeout(function() {
var speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedSave = null;
if (!isValidSpeed(speedToPersist) || tc.persistedLastSpeed === speedToPersist) {
return;
}
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
});
tc.persistedLastSpeed = speedToPersist;
}, 250);
}
function suppressNextNudgeRateChanges(controller, count) {
if (!controller) return;
controller.suppressedRateChangeCount =
(controller.suppressedRateChangeCount || 0) + (count || 2);
controller.suppressedRateChangeUntil =
Date.now() + Math.max(250, tc.settings.subtitleNudgeInterval * 4);
}
function shouldIgnoreSuppressedRateChange(video) {
if (!video || !video.vsc) return false;
var controller = video.vsc;
if (
controller.suppressedRateChangeCount > 0 &&
controller.suppressedRateChangeUntil >= Date.now()
) {
controller.suppressedRateChangeCount -= 1;
return true;
}
if (controller.suppressedRateChangeUntil < Date.now()) {
controller.suppressedRateChangeCount = 0;
}
return false;
}
function resolveTargetSpeed(video) {
return getDesiredSpeed(video);
}
function applySourceTransitionPolicy(video, forceUpdate) {
if (!video || !video.vsc) return;
var sourceKey = getVideoSourceKey(video);
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
video.vsc.mediaSourceKey = sourceKey;
var desiredSpeed =
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
? sanitizeSpeed(tc.settings.lastSpeed, 1.0)
: 1.0;
video.vsc.targetSpeed = desiredSpeed;
video.vsc.targetSpeedSourceKey = sourceKey;
if (video.vsc.speedIndicator) {
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
}
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false);
}
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed
// already ran on src mutation — re-apply margins / location / opacity for new rules.
reapplySiteRulesAndControllerGeometry();
}
function extendSpeedRestoreWindow(video, duration) {
if (!video || !video.vsc) return;
var restoreDuration = Number(duration) || 1500;
var restoreUntil = Date.now() + restoreDuration;
var currentUntil = Number(video.vsc.speedRestoreUntil) || 0;
video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil);
}
function scheduleSpeedRestore(video, desiredSpeed, reason) {
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
if (video.vsc.restoreSpeedTimer) {
clearTimeout(video.vsc.restoreSpeedTimer);
}
video.vsc.restoreSpeedTimer = setTimeout(function() {
if (!video.vsc) return;
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
log(
`Restoring playbackRate to ${desiredSpeed.toFixed(2)} after ${reason}`,
4
);
setSpeed(video, desiredSpeed, false, false);
}
if (video.vsc) {
video.vsc.restoreSpeedTimer = null;
}
}, 0);
}
function rememberPendingRateChange(video, speed) {
if (!video || !video.vsc || !isValidSpeed(speed)) return;
video.vsc.pendingRateChange = {
speed: Number(speed),
expiresAt: Date.now() + 1000
};
}
function takePendingRateChange(video, currentSpeed) {
if (!video || !video.vsc || !video.vsc.pendingRateChange) return null;
var pendingRateChange = video.vsc.pendingRateChange;
if (
!isValidSpeed(pendingRateChange.speed) ||
pendingRateChange.expiresAt <= Date.now()
) {
video.vsc.pendingRateChange = null;
return null;
}
if (Math.abs(Number(pendingRateChange.speed) - currentSpeed) > 0.01) {
return null;
}
video.vsc.pendingRateChange = null;
return pendingRateChange;
}
function matchesKeyBinding(binding, event) {
return Boolean(
binding &&
binding.disabled !== true &&
typeof binding.code === "string" &&
binding.code.length > 0 &&
binding.code === event.code
);
}
function mediaSelector() {
return tc.settings.audioBoolean ? "video,audio" : "video";
}
function isMediaElement(node) {
return (
node &&
node.nodeType === Node.ELEMENT_NODE &&
(node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean))
);
}
function hasUsableMediaSource(node) {
if (!isMediaElement(node) || !node.isConnected) return false;
if (node.currentSrc || node.src || node.srcObject) return true;
if (typeof node.readyState === "number" && node.readyState > 0) return true;
if (
typeof node.networkState === "number" &&
typeof HTMLMediaElement !== "undefined" &&
(node.networkState === HTMLMediaElement.NETWORK_IDLE ||
node.networkState === HTMLMediaElement.NETWORK_LOADING)
) {
return true;
}
if (node.querySelectorAll) {
return Array.from(node.querySelectorAll("source[src]")).some(function(
source
) {
var src = source.getAttribute("src");
return typeof src === "string" && src.trim().length > 0;
});
}
return false;
}
function ensureController(node, parent) {
if (!isMediaElement(node) || node.vsc) return node && node.vsc;
if (!hasUsableMediaSource(node)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
5
);
return null;
}
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) {
return null;
}
refreshAllControllerGeometry();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
);
node.vsc = new tc.videoController(
node,
parent || node.parentElement || node.parentNode
);
return node.vsc;
}
function removeController(node) {
if (node && node.vsc) node.vsc.remove();
}
function scanNodeForMedia(node, parent, added) {
if (!node || typeof node === "function") return;
if (node.nodeType === Node.DOCUMENT_NODE) {
scanNodeForMedia(node.body || node.documentElement, node.body, added);
return;
}
if (
node.nodeType !== Node.ELEMENT_NODE &&
node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
return;
}
var ownerDocument = node.ownerDocument || document;
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return;
// Check if the node itself is a media element
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
}
// Use querySelectorAll instead of recursive child walking — the browser's
// native selector engine is orders of magnitude faster than JS recursion.
if (typeof node.querySelectorAll === "function") {
var selector = mediaSelector();
try {
var mediaElements = node.querySelectorAll(selector);
for (var i = 0; i < mediaElements.length; i++) {
var el = mediaElements[i];
if (added) ensureController(el, el.parentNode || parent);
else removeController(el);
}
} catch (e) {
// querySelectorAll may throw on detached or unusual nodes
}
}
// Still need to observe shadow roots for media inside web components
if (node.shadowRoot) {
observeRoot(node.shadowRoot);
}
}
function getScanNodeForRoot(root) {
if (!root) return null;
if (root.nodeType === Node.DOCUMENT_NODE) {
return root.body || root.documentElement;
}
return root;
}
function rootMayContainMedia(root) {
if (!root) return false;
if (root.nodeType === Node.DOCUMENT_NODE) return true;
if (typeof root.querySelector !== "function") return true;
try {
return Boolean(root.querySelector(mediaSelector()));
} catch (error) {
return true;
}
}
function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return;
scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true);
if (root.nodeType === Node.DOCUMENT_NODE) {
attachIframeListeners(root);
}
}
function observeRoot(root) {
if (!root || vscObservedRoots.has(root)) return;
vscObservedRoots.add(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
}
}
function patchAttachShadow() {
if (
window.vscAttachShadowPatched ||
typeof Element === "undefined" ||
typeof Element.prototype.attachShadow !== "function"
) {
return;
}
var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments);
try {
if (shadowRoot) {
observeRoot(shadowRoot);
}
} catch (error) {
log(`Unable to observe shadow root: ${error.message}`, 3);
}
return shadowRoot;
};
window.vscAttachShadowPatched = true;
}
/* Log levels */
function log(message, level) {
verbosity = tc.settings.logLevel;
if (typeof level === "undefined") level = tc.settings.defaultLogLevel;
if (verbosity >= level) {
let prefix = "[VSC] ";
if (level === 2) console.log(prefix + "ERROR: " + message);
else if (level === 3) console.log(prefix + "WARNING: " + message);
else if (level === 4) console.log(prefix + "INFO: " + message);
else if (level === 5) console.log(prefix + "DEBUG: " + message);
else if (level === 6) {
console.log(prefix + "DEBUG (VERBOSE): " + message);
console.trace();
}
}
}
chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
tc.settings.keyBindings = storedBindings
.map((binding) => normalizeStoredBinding(binding))
.filter(Boolean);
if (tc.settings.keyBindings.length === 0) {
tc.settings.keyBindings = defaultKeyBindings(storage);
tc.settings.version = "0.5.3";
chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings,
version: tc.settings.version,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
startHidden: tc.settings.startHidden,
enabled: tc.settings.enabled,
controllerLocation: tc.settings.controllerLocation,
controllerOpacity: tc.settings.controllerOpacity
});
}
tc.settings.lastSpeed = Number(storage.lastSpeed);
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3);
tc.settings.lastSpeed = 1.0;
tc.persistedLastSpeed = 1.0;
chrome.storage.sync.set({ lastSpeed: 1.0 });
} else if (!isValidSpeed(tc.settings.lastSpeed)) {
tc.settings.lastSpeed = 1.0;
}
tc.persistedLastSpeed = tc.settings.lastSpeed;
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
tc.settings.enabled = Boolean(storage.enabled);
tc.settings.startHidden = Boolean(storage.startHidden);
tc.settings.hideWithControls =
typeof storage.hideWithControls !== "undefined"
? Boolean(storage.hideWithControls)
: Boolean(storage.hideWithYouTubeControls);
tc.settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0));
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
tc.settings.controllerLocation = normalizeControllerLocation(
storage.controllerLocation
);
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
storage.controllerMarginTop,
0
);
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
0,
0
);
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
storage.controllerMarginBottom,
typeof storage.controllerMarginBottom !== "undefined" ? 0 : 65
);
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
0,
0
);
tc.settings.siteRules = Array.isArray(storage.siteRules)
? storage.siteRules
: [];
tc.settings.controllerButtons = Array.isArray(storage.controllerButtons)
? storage.controllerButtons
: tc.settings.controllerButtons;
tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
: tc.settings.enableSubtitleNudge;
tc.settings.subtitleNudgeInterval = Math.min(
1000,
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
);
tc.settings.subtitleNudgeAmount =
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
var addedDefaultBinding = false;
addedDefaultBinding =
ensureDefaultKeyBinding(
"display",
"KeyV",
0
) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding;
if (addedDefaultBinding) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
}
captureSiteRuleBase();
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) {
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
sendResponse({ status: "complete" });
return false;
}
if (request.action === "get_speed") {
// Do not sendResponse in frames with no media — only one response is
// accepted tab-wide, and the top frame often wins before an iframe.
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate
});
return false;
}
if (request.action === "get_page_context") {
sendResponse({ url: location.href });
return false;
}
if (request.action === "run_action") {
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
}
runAction(request.actionName, value);
var videoAfter = getPrimaryVideoElement();
if (!videoAfter) return false;
sendResponse({
speed: videoAfter.playbackRate
});
return false;
}
return false;
}
);
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
chrome.storage.local.get(["customButtonIcons"], function(loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function(changes, area) {
if (area !== "local" || !changes.customButtonIcons) return;
var nv = changes.customButtonIcons.newValue;
tc.settings.customButtonIcons =
nv && typeof nv === "object" ? nv : {};
if (tc.mediaElements && tc.mediaElements.length) {
tc.mediaElements.forEach(function(video) {
if (!video.vsc || !video.vsc.div) return;
var doc = video.ownerDocument;
var shadow = video.vsc.div.shadowRoot;
if (!shadow) return;
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg;
vscClearElement(btn);
if (svg) {
var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
if (cw) {
btn.appendChild(cw);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14);
if (wrap) {
btn.appendChild(wrap);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else {
var cdf2 = controllerButtonDefs[act];
btn.textContent = (cdf2 && cdf2.label) || "?";
}
});
updateSubtitleNudgeIndicator(video);
});
}
});
}
initializeWhenReady(document);
});
});
function getKeyBindings(action, what = "value") {
try {
return tc.settings.keyBindings.find((item) => item.action === action)[what];
} catch (e) {
return false;
}
}
function setKeyBindings(action, value) {
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value;
}
function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button");
button.dataset.action = action;
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
if (customWrap) {
button.appendChild(customWrap);
} else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14);
if (wrap) {
button.appendChild(wrap);
} else {
button.textContent = label || "?";
}
} else {
button.textContent = label || "?";
}
if (className) {
button.className = className;
}
return button;
}
function defineVideoController() {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
tc.mediaElements.push(target);
target.vsc = this;
this.video = target;
this.parent = target.parentElement || parent;
this.nudgeAnimationId = null;
this.restoreSpeedTimer = null;
this.pendingRateChange = null;
this.speedRestoreUntil = 0;
this.subtitleNudgeEnabledOverride = null;
this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null;
this.resetToggleArmed = false;
this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation
);
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
this.targetSpeed = storedSpeed;
this.targetSpeedSourceKey = getVideoSourceKey(target);
this.mediaSourceKey = getVideoSourceKey(target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
target.playbackRate = storedSpeed;
this.div = this.initializeControls();
if (!this.div) {
log("ERROR: Failed to create controller div!", 2);
return;
}
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
var mediaEventAction = function(event) {
if (
event.type === "loadedmetadata" ||
event.type === "loadeddata" ||
event.type === "canplay"
) {
applySourceTransitionPolicy(event.target, false);
}
if (event.type === "play") {
applySourceTransitionPolicy(event.target, false);
extendSpeedRestoreWindow(event.target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
var playSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
if (Math.abs(event.target.playbackRate - playSpeed) > 0.01) {
log("Play event: setting playbackRate to: " + playSpeed, 4);
setSpeed(event.target, playSpeed, false, false);
} else if (playSpeed === 1.0 || event.target.paused) {
this.stopSubtitleNudge();
} else {
this.startSubtitleNudge();
}
} else if (event.type === "pause") {
extendSpeedRestoreWindow(event.target);
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeking") {
extendSpeedRestoreWindow(event.target);
} else if (event.type === "ended") {
this.speedRestoreUntil = 0;
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeked") {
extendSpeedRestoreWindow(event.target);
var expectedSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
var currentSpeed = event.target.playbackRate;
if (
Math.abs(currentSpeed - expectedSpeed) > 0.01
) {
log(
`Seeked: speed changed from ${expectedSpeed} to ${currentSpeed}, restoring`,
4
);
setSpeed(event.target, expectedSpeed, false, false);
}
if (isUserSeek) {
isUserSeek = false;
}
}
};
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = mediaEventAction.bind(this))
);
target.addEventListener(
"loadeddata",
(this.handleLoadedData = mediaEventAction.bind(this))
);
target.addEventListener(
"canplay",
(this.handleCanPlay = mediaEventAction.bind(this))
);
target.addEventListener(
"play",
(this.handlePlay = mediaEventAction.bind(this))
);
target.addEventListener(
"pause",
(this.handlePause = mediaEventAction.bind(this))
);
target.addEventListener(
"seeking",
(this.handleSeeking = mediaEventAction.bind(this))
);
target.addEventListener(
"ended",
(this.handleEnded = mediaEventAction.bind(this))
);
target.addEventListener(
"seeked",
(this.handleSeek = mediaEventAction.bind(this))
);
var srcObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
log("mutation of A/V element", 5);
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
} else {
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
if (!mutation.target.paused) this.startSubtitleNudge();
}
updateSubtitleNudgeIndicator(this.video);
}
}
});
});
this.srcObserver = srcObserver;
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
if (!target.paused && target.playbackRate !== 1.0)
this.startSubtitleNudge();
};
tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge();
if (this.youTubeAutoHideObserver) {
this.youTubeAutoHideObserver.disconnect();
this.youTubeAutoHideObserver = null;
}
if (this.youTubeAutoHideCleanup) {
this.youTubeAutoHideCleanup();
this.youTubeAutoHideCleanup = null;
}
if (this.genericAutoHideCleanup) {
this.genericAutoHideCleanup();
this.genericAutoHideCleanup = null;
}
if (this.div) this.div.remove();
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
if (this.video) {
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
this.video.removeEventListener("loadeddata", this.handleLoadedData);
this.video.removeEventListener("canplay", this.handleCanPlay);
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause);
this.video.removeEventListener("seeking", this.handleSeeking);
this.video.removeEventListener("ended", this.handleEnded);
this.video.removeEventListener("seeked", this.handleSeek);
delete this.video.vsc;
}
if (this.srcObserver) this.srcObserver.disconnect();
let idx = tc.mediaElements.indexOf(this.video);
if (idx != -1) tc.mediaElements.splice(idx, 1);
};
tc.videoController.prototype.startSubtitleNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
this.nudgeAnimationId !== null ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0
) {
return;
}
// Store the target speed so we can always revert to it
this.targetSpeed = this.video.playbackRate;
const performNudge = () => {
// Check if we should stop
if (!this.video || this.video.paused || this.video.playbackRate === 1.0) {
this.stopSubtitleNudge();
return;
}
// CRITICAL: Don't nudge if tab is hidden - prevents speed drift
if (document.hidden) {
if (!this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.visibilityResumeHandler = () => {
if (this.video.ownerDocument.hidden) return;
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
this.startSubtitleNudge();
};
this.video.ownerDocument.addEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
}
this.nudgeAnimationId = null;
return;
}
// Set flag to prevent ratechange listener from interfering
tc.isNudging = true;
suppressNextNudgeRateChanges(this, 2);
// Cache values to avoid repeated property access
const targetSpeed = this.targetSpeed;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
// Apply nudge from the stored target speed (not current rate)
this.video.playbackRate = targetSpeed + nudgeAmount;
// Revert synchronously after a microtask to ensure it happens immediately
Promise.resolve().then(() => {
if (this.video && targetSpeed) {
this.video.playbackRate = targetSpeed;
}
tc.isNudging = false;
});
// Schedule next nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
};
// Start the first nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
};
tc.videoController.prototype.stopSubtitleNudge = function() {
if (this.nudgeAnimationId !== null) {
clearTimeout(this.nudgeAnimationId);
this.nudgeAnimationId = null;
log(`Nudge: Stopping.`, 5);
}
if (this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
}
// Clear the animation state, but PRESERVE targetSpeed so getDesiredSpeed
// doesn't lose the user's intended speed if the site hijacks it.
};
tc.videoController.prototype.performImmediateNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0 ||
document.hidden
) {
return;
}
const targetRate = this.targetSpeed || this.video.playbackRate;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
tc.isNudging = true;
suppressNextNudgeRateChanges(this, 2);
this.video.playbackRate = targetRate + nudgeAmount;
// Revert synchronously via microtask
Promise.resolve().then(() => {
if (this.video) {
this.video.playbackRate = targetRate;
}
tc.isNudging = false;
});
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
};
tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) {
if (!wrapper || !isOnYouTube()) return;
const video = this.video;
const ytPlayer = video.closest(".html5-video-player");
if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4);
return;
}
const syncControllerVisibility = () => {
// YouTube adds ytp-autohide class to the player when controls should be hidden
// We mirror this class state to enable CSS-based hiding
// The vsc-hidden class (from V key) takes precedence via CSS specificity
if (ytPlayer.classList.contains("ytp-autohide")) {
wrapper.classList.add("ytp-autohide");
// Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) {
wrapper.classList.remove("vsc-show");
if (wrapper.showTimeOut) {
clearTimeout(wrapper.showTimeOut);
wrapper.showTimeOut = undefined;
}
}
log("YouTube controls hidden, hiding controller", 5);
} else {
wrapper.classList.remove("ytp-autohide");
log("YouTube controls visible, showing controller", 5);
}
};
// Initial sync
syncControllerVisibility();
// Observe YouTube player class changes
this.youTubeAutoHideObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "class") {
syncControllerVisibility();
}
});
});
this.youTubeAutoHideObserver.observe(ytPlayer, {
attributes: true,
attributeFilter: ["class"]
});
log("YouTube auto-hide observer setup complete", 4);
// Also reveal on hover/activity independently of YouTube's own controls
// for immediate responsiveness, UNLESS Speeder is actually toggled hidden (vsc-hidden)
const resetTimer = () => {
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
};
const activityEvents = ["mousemove", "mousedown", "touchstart"];
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
ytPlayer.addEventListener(type, resetTimer, { passive: true });
});
// Store a cleanup function
this.youTubeAutoHideCleanup = () => {
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
ytPlayer.removeEventListener(type, resetTimer);
});
};
};
tc.videoController.prototype.setupGenericAutoHide = function(wrapper) {
if (!wrapper) return;
const video = this.video;
let timer = null;
const resetTimer = () => {
wrapper.classList.remove("vsc-idle-hidden");
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000);
};
// Initial show/timer
resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles,
// but we listen on both the video and the wrapper for maximum coverage.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl = video.parentElement;
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
if (parentEl) {
parentEl.addEventListener(type, resetTimer, { passive: true });
}
});
// Also reset timer on play/pause events to ensure sync when player state changes
video.addEventListener("play", resetTimer, { passive: true });
video.addEventListener("pause", resetTimer, { passive: true });
// Store a cleanup function
this.genericAutoHideCleanup = () => {
if (timer) clearTimeout(timer);
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
if (parentEl) {
parentEl.removeEventListener(type, resetTimer);
}
});
video.removeEventListener("play", resetTimer);
video.removeEventListener("pause", resetTimer);
};
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
};
tc.videoController.prototype.initializeControls = function() {
const doc = this.video.ownerDocument;
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!this.video.src && !this.video.currentSrc)
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// 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("shadow.css");
shadow.appendChild(shadowStylesheet);
var controller = doc.createElement("div");
controller.id = "controller";
controller.style.opacity = String(tc.settings.controllerOpacity);
this.controllerLocation = applyControllerLocationToElement(
controller,
this.controllerLocation
);
var dragHandle = doc.createElement("span");
dragHandle.dataset.action = "drag";
dragHandle.className = "draggable";
dragHandle.textContent = speed;
var controls = doc.createElement("span");
controls.id = "controls";
var buttonConfig = Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons
: ["rewind", "slower", "faster", "advance", "display"];
var subtitleNudgeIndicator = null;
buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator.id = "nudge-indicator";
subtitleNudgeIndicator.setAttribute("role", "button");
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
subtitleNudgeIndicator.setAttribute("tabindex", "0");
controls.appendChild(subtitleNudgeIndicator);
} else {
var def = controllerButtonDefs[btnId];
if (def) {
controls.appendChild(
createControllerButton(doc, btnId, def.label, def.className)
);
}
}
});
var nudgeFlashIndicator = doc.createElement("span");
nudgeFlashIndicator.id = "nudge-flash-indicator";
nudgeFlashIndicator.setAttribute("aria-hidden", "true");
controller.appendChild(dragHandle);
controller.appendChild(controls);
/* Flash sits after #controls so it never inserts space between speed and buttons. */
controller.appendChild(nudgeFlashIndicator);
shadow.appendChild(controller);
this.speedIndicator = dragHandle;
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
shadow.querySelector("button[data-action=\"reset\"]") || null;
this.resetToggleArmed = false;
if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video);
}
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
requestAnimationFrame(function() {
target.blur();
});
}
}
dragHandle.addEventListener(
"mousedown",
(e) => {
var dragAction = dragHandle.dataset.action;
runAction(dragAction, getKeyBindings(dragAction, "value"), e);
e.stopPropagation();
},
true
);
shadow.querySelectorAll("button").forEach((button) => {
button.addEventListener(
"click",
(e) => {
var action = button.dataset.action;
runAction(action, getKeyBindings(action), e);
blurAfterPointerTap(button, e);
e.stopPropagation();
},
true
);
});
if (subtitleNudgeIndicator) {
subtitleNudgeIndicator.addEventListener(
"click",
(e) => {
var video = this.video;
if (video) {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
);
}
controller.addEventListener("click", (e) => e.stopPropagation(), false);
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
// Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) {
if (isOnYouTube()) {
this.setupYouTubeAutoHide(wrapper);
} else {
this.setupGenericAutoHide(wrapper);
}
}
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
const parentEl = this.parent || this.video.parentElement;
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
if (!parentEl || !parentEl.parentNode) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(fragment);
return wrapper;
}
try {
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case /hbogo\./.test(location.hostname):
log("Using parentElement.parentElement insertion", 5);
parentEl.parentElement.insertBefore(fragment, parentEl);
break;
case location.hostname == "www.facebook.com":
log("Using Facebook-specific insertion", 5);
let p =
parentEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "tv.apple.com":
log("Using Apple TV-specific insertion", 5);
const r = parentEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "www.youtube.com":
case location.hostname == "m.youtube.com":
case location.hostname == "music.youtube.com":
// YouTube's player DOM has .html5-video-container (video's parent) as a
// low layer with overlay siblings (.ytp-player-content, etc.) on top that
// intercept mouse events. Insert into .html5-video-player (the player
// root) so the controller sits above all overlay layers.
log("Using YouTube-specific insertion", 5);
var ytPlayer = parentEl.closest(".html5-video-player");
if (ytPlayer) {
ytPlayer.insertBefore(fragment, ytPlayer.firstChild);
} else {
parentEl.insertBefore(fragment, parentEl.firstChild);
}
break;
default:
log("Using default insertion method", 5);
parentEl.insertBefore(fragment, parentEl.firstChild);
}
log("Controller successfully inserted into DOM", 4);
} catch (error) {
log(`Error inserting controller: ${error.message}`, 2);
// Fallback to body insertion
doc.body.appendChild(fragment);
}
return wrapper;
};
}
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
}
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) return false;
tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4);
// Check if extension should be enabled/disabled on this site
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
return true;
}
// Override general settings with site-specific overrides
const siteSettings = [
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"enableSubtitleNudge",
"subtitleNudgeInterval"
];
siteSettings.forEach((key) => {
if (matchedRule[key] !== undefined) {
log(`Overriding ${key} for site: ${matchedRule[key]}`, 4);
tc.settings[key] = matchedRule[key];
}
});
[
"controllerMarginTop",
"controllerMarginBottom"
].forEach(function(key) {
tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0);
});
if (Array.isArray(matchedRule.controllerButtons)) {
log(`Overriding controllerButtons for site`, 4);
tc.settings.controllerButtons = matchedRule.controllerButtons;
}
// Override key bindings with site-specific shortcuts
if (Array.isArray(matchedRule.shortcuts) && matchedRule.shortcuts.length > 0) {
var overriddenActions = new Set();
matchedRule.shortcuts.forEach((shortcut) => {
overriddenActions.add(shortcut.action);
});
// Keep global bindings that aren't overridden, add site-specific ones
tc.settings.keyBindings = tc.settings.keyBindings
.filter((binding) => !overriddenActions.has(binding.action))
.concat(
matchedRule.shortcuts.map((shortcut) =>
normalizeStoredBinding(shortcut)
).filter(Boolean)
);
}
return false;
}
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
}
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
refreshAllControllerGeometry();
}
function shouldPreserveDesiredSpeed(video, speed) {
if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video);
if (!isValidSpeed(desiredSpeed) || Math.abs(speed - desiredSpeed) <= 0.01) {
return false;
}
return (
video.paused === true ||
(typeof video.vsc.speedRestoreUntil === "number" &&
video.vsc.speedRestoreUntil > Date.now())
);
}
function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video, skipResetDisarm) {
if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
tc.settings.speeds[sourceKey] = speed;
}
tc.settings.lastSpeed = speed;
schedulePersistLastSpeed(speed);
if (video.vsc) {
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
}
}
root.addEventListener(
"ratechange",
function(event) {
if (tc.isNudging) return;
var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
if (shouldIgnoreSuppressedRateChange(video)) return;
if (tc.settings.forceLastSavedSpeed) {
if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video, true);
} else {
video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
}
event.stopImmediatePropagation();
} else {
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
var desiredSpeed = getDesiredSpeed(video);
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (pendingRateChange) {
updateSpeedFromEvent(video, true);
return;
}
if (shouldPreserveDesiredSpeed(video, currentSpeed)) {
log(
`Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`,
4
);
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
return;
}
updateSpeedFromEvent(video);
}
},
true
);
root.vscRateListenerAttached = true;
}
var vscInitializedDocuments = new Set();
function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return;
var handler = doc.vscPendingInitializeHandler;
doc.removeEventListener("DOMContentLoaded", handler);
doc.removeEventListener("readystatechange", handler);
if (doc.defaultView) {
doc.defaultView.removeEventListener("load", handler);
}
delete doc.vscPendingInitializeHandler;
doc.vscPendingForceReinit = false;
}
function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false;
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
return false;
}
initializeNow(doc, forceReinit);
clearPendingInitialization(doc);
return true;
}
function initializeWhenReady(doc, forceReinit = false) {
if (!doc) return;
doc.vscPendingForceReinit = doc.vscPendingForceReinit === true || forceReinit;
if (tryInitializeDocument(doc, doc.vscPendingForceReinit)) {
return;
}
if (doc.vscPendingInitializeHandler) return;
var pendingInitializeHandler = function() {
tryInitializeDocument(doc, doc.vscPendingForceReinit === true);
};
doc.vscPendingInitializeHandler = pendingInitializeHandler;
doc.addEventListener("DOMContentLoaded", pendingInitializeHandler);
doc.addEventListener("readystatechange", pendingInitializeHandler);
if (doc.defaultView) {
doc.defaultView.addEventListener("load", pendingInitializeHandler);
doc.defaultView.setTimeout(pendingInitializeHandler, 0);
} else {
setTimeout(pendingInitializeHandler, 0);
}
}
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function attachKeydownListeners(doc) {
var docs = [doc];
try {
if (inIframe() && window.top.document !== doc) docs.push(window.top.document);
} catch (e) {
}
docs.forEach(function(keyDoc) {
if (keyDoc.vscKeydownListenerAttached) return;
keyDoc.addEventListener(
"keydown",
function(event) {
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
) {
return;
}
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event);
});
if (item) {
runAction(item.action, item.value, event);
// Always prevent default and stop propagation for Speeder shortcuts
// to prevent the website (e.g. YouTube) from reacting to these keys.
event.preventDefault();
event.stopPropagation();
}
},
true
);
keyDoc.vscKeydownListenerAttached = true;
});
}
function attachMutationObserver(root) {
if (root.vscMutationObserverAttached) return;
var pendingMutations = [];
var mutationProcessingScheduled = false;
var observer = new MutationObserver(function(mutations) {
pendingMutations.push(...mutations);
if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true;
requestIdle(
function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false;
mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, true);
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
return;
}
if (mutation.type !== "attributes") return;
var target = mutation.target;
if (
isMediaElement(target) &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
ensureController(target, target.parentElement || target.parentNode);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
target.attributes["aria-hidden"].value === "false"
) {
scanNodeForMedia(
target,
target.parentNode || root.host || mutation.target,
true
);
}
});
},
{ timeout: 1000 }
);
});
observer.observe(root, {
attributeFilter: ["aria-hidden", "src", "currentSrc"],
childList: true,
subtree: true,
attributes: true
});
root.vscMutationObserverAttached = true;
}
function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return;
var handleDetectedMedia = function(event) {
var target = event.target;
if (!isMediaElement(target)) return;
ensureController(target, target.parentElement || target.parentNode);
};
[
"loadstart",
"loadeddata",
"loadedmetadata",
"canplay",
"playing",
"play"
].forEach(function(eventName) {
root.addEventListener(eventName, handleDetectedMedia, true);
});
root.vscMediaEventListenersAttached = true;
}
function attachIframeListeners(doc) {
Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
if (!frame.vscLoadListenerAttached) {
frame.addEventListener("load", function() {
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument, true);
}
} catch (e) {
}
});
frame.vscLoadListenerAttached = true;
}
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument);
}
} catch (e) {
}
});
}
function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return;
var scheduleRescan = function() {
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
};
["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return;
var original = history[method];
history[method] = function() {
var result = original.apply(this, arguments);
scheduleRescan();
return result;
};
});
window.addEventListener("popstate", scheduleRescan);
window.addEventListener("hashchange", scheduleRescan);
/* YouTube often navigates without a history API call the extension can see first */
if (typeof document !== "undefined" && isOnYouTube()) {
document.addEventListener("yt-navigate-finish", scheduleRescan);
}
window.vscNavigationListenersAttached = true;
}
function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
}
if (typeof tc.videoController === "undefined") defineVideoController();
attachKeydownListeners(doc);
attachNavigationListeners();
observeRoot(doc);
if (forceReinit) {
log("Force re-initialization requested", 4);
refreshAllControllerGeometry();
}
vscInitializedDocuments.add(doc);
}
function setSpeed(
video,
speed,
isInitialCall = false,
isUserKeyPress = false,
fromResetSpeedToggle = false
) {
const numericSpeed = Number(speed);
if (!isValidSpeed(numericSpeed)) {
log(
`Invalid speed rejected: ${speed}, must be between ${MIN_SPEED} and ${MAX_SPEED}`,
2
);
return;
}
if (!video || !video.vsc || !video.vsc.speedIndicator) return;
if (isUserKeyPress && !fromResetSpeedToggle) {
video.vsc.resetToggleArmed = false;
}
log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
);
tc.settings.lastSpeed = numericSpeed;
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
// Update the target speed for nudge so it knows what to revert to
video.vsc.targetSpeed = numericSpeed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
runAction("blink", 1000, null, video); // Pass video to blink
extendSpeedRestoreWindow(video); // Protect against immediate site-driven resets
}
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
var usedNativeSpeed = false;
if (tc.settings.forceLastSavedSpeed) {
video.dispatchEvent(
new CustomEvent("ratechange", {
detail: {
origin: "videoSpeed",
speed: numericSpeed.toFixed(2),
fromUserInput: isUserKeyPress
}
})
);
} else {
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
rememberPendingRateChange(video, numericSpeed);
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
if (!usedNativeSpeed) {
video.playbackRate = numericSpeed;
}
}
}
if (video.vsc) {
if (numericSpeed === 1.0 || video.paused) {
video.vsc.stopSubtitleNudge();
} else if (usedNativeSpeed) {
// YouTube's native API handles subtitle sync — no nudge needed
video.vsc.stopSubtitleNudge();
} else {
video.vsc.startSubtitleNudge();
}
}
}
function runAction(action, value, e) {
log("runAction Begin", 5);
var mediaTagsToProcess;
const specificVideo = arguments[3] || null;
var subtitleNudgeToggleValue = null;
if (specificVideo) {
mediaTagsToProcess = [specificVideo];
} else if (e && e.target && e.target.getRootNode) {
// Event-driven action
const docContext = e.target.ownerDocument || document;
mediaTagsToProcess = tc.mediaElements.filter(
(v) => v.ownerDocument === docContext
);
const targetController = e.target.getRootNode().host;
if (targetController) {
// If it's a click on a controller, only use that one video
const videoFromController = tc.mediaElements.find(
(v) => v.vsc && v.vsc.div === targetController
);
if (videoFromController) mediaTagsToProcess = [videoFromController];
}
} else {
mediaTagsToProcess = tc.mediaElements;
}
if (action === "settings") {
chrome.runtime.sendMessage({ action: "openOptions" });
return;
}
if (mediaTagsToProcess.length === 0 && action !== "display") return;
if (action === "toggleSubtitleNudge" && mediaTagsToProcess.length > 0) {
subtitleNudgeToggleValue = !isSubtitleNudgeEnabledForVideo(
mediaTagsToProcess[0]
);
}
mediaTagsToProcess.forEach(function(v) {
if (!v.vsc) return; // Don't process videos without a controller
var controller = v.vsc.div;
const userDrivenActionsThatShowController = [
"rewind",
"advance",
"faster",
"slower",
"reset",
"fast",
"move",
"pause",
"muted",
"mark",
"jump",
"drag",
"nudge",
"toggleSubtitleNudge",
"display"
];
var subtitleNudgeActionBlocked =
(action === "toggleSubtitleNudge" || action === "nudge") &&
!isSubtitleNudgeAvailableForVideo(v);
if (
userDrivenActionsThatShowController.includes(action) &&
action !== "display" &&
!subtitleNudgeActionBlocked
) {
showController(controller, 2000, true);
}
if (v.classList.contains("vsc-cancelled")) return;
const numValue = parseFloat(value);
switch (action) {
case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime -= numValue;
break;
case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime += numValue;
break;
case "faster":
var fasterStep = numValue;
// Use grid-snapping: always move to the next multiple of fasterStep
// Add a tiny epsilon (1% of step) to jump clear of the current point
var newFasterSpeed = Math.ceil((v.playbackRate + (fasterStep * 0.01)) / fasterStep) * fasterStep;
// Clean up JS floating point math (e.g. 0.30000000000000004 -> 0.30)
newFasterSpeed = Math.round(newFasterSpeed * 1000) / 1000;
setSpeed(v, Math.min(newFasterSpeed, MAX_SPEED), false, true);
break;
case "slower":
var slowerStep = numValue;
// Use grid-snapping: always move to the previous multiple of slowerStep
// Subtract a tiny epsilon (1% of step) to jump clear of the current point
var newSlowerSpeed = Math.floor((v.playbackRate - (slowerStep * 0.01)) / slowerStep) * slowerStep;
// Clean up JS floating point math
newSlowerSpeed = Math.round(newSlowerSpeed * 1000) / 1000;
setSpeed(v, Math.max(newSlowerSpeed, MIN_SPEED), false, true);
break;
case "reset":
resetSpeed(v, 1.0, false); // Use enhanced resetSpeed
break;
case "fast":
var preferredSpeed = numValue;
// Apply site-specific preferred speed override if available
if (tc.activeSiteRule && typeof tc.activeSiteRule.preferredSpeed === "number") {
preferredSpeed = tc.activeSiteRule.preferredSpeed;
}
resetSpeed(v, preferredSpeed, true);
break;
case "display":
if (controller.classList.contains("vsc-hidden")) {
controller.classList.remove("vsc-hidden");
showController(controller, 2000, true);
} else {
controller.classList.add("vsc-hidden");
// Clear any show state when explicitly hiding
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.showTimeOut) {
clearTimeout(controller.showTimeOut);
controller.showTimeOut = undefined;
}
}
break;
case "blink":
log(`Blink action: controller hidden=${controller.classList.contains("vsc-hidden")}, timeout=${controller.blinkTimeOut !== undefined}, duration=${numValue}`, 5);
if (
controller.classList.contains("vsc-hidden") ||
controller.blinkTimeOut !== undefined
) {
var restoreHidden =
controller.restoreHiddenAfterBlink === true ||
controller.classList.contains("vsc-hidden");
if (controller.blinkTimeOut !== undefined) {
clearTimeout(controller.blinkTimeOut);
}
controller.restoreHiddenAfterBlink = restoreHidden;
controller.classList.remove("vsc-hidden");
log(`Controller shown, setting timeout for ${numValue || 1000}ms`, 5);
controller.blinkTimeOut = setTimeout(() => {
if (controller.restoreHiddenAfterBlink === true) {
controller.classList.add("vsc-hidden");
log("Controller auto-hidden after blink timeout", 5);
} else {
log("Controller kept visible", 5);
}
controller.restoreHiddenAfterBlink = false;
controller.blinkTimeOut = undefined;
}, numValue || 1000);
}
break;
case "drag":
if (e) handleDrag(v, e);
break;
case "move":
cycleControllerLocation(v);
break;
case "pause":
pause(v);
break;
case "muted":
muted(v);
break;
case "mark":
setMark(v);
break;
case "jump":
jumpToMark(v);
break;
case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
}
});
log("runAction End", 5);
}
function pause(v) {
if (v.paused) v.play().catch((e) => log(`Play err:${e.message}`, 2));
else v.pause();
}
function resetSpeed(v, target, isFastKey = false) {
const videoId = getVideoSourceKey(v);
const currentSpeed = v.playbackRate;
if (isFastKey) {
// G key: Toggle between current speed and preferred speed (e.g., 1.8)
const preferredSpeed = target;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - preferredSpeed) < 0.01) {
// Currently at preferred speed, toggle to the last speed
setSpeed(v, lastToggle, false, true);
} else {
// Not at preferred speed, save current as toggle speed and go to preferred
lastToggleSpeed[videoId] = currentSpeed;
setSpeed(v, preferredSpeed, false, true);
}
} else {
// R key: Toggle between current speed and 1.0
const resetSpeedValue = 1.0;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - resetSpeedValue) < 0.01) {
// Currently at 1.0, toggle to the last speed (or 1.8 if no history)
const speedToRestore =
Math.abs(lastToggle - 1.0) < 0.01
? getKeyBindings("fast") || 1.8
: lastToggle;
setSpeed(v, speedToRestore, false, true, true);
} else {
// Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed;
v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true);
}
}
}
function muted(v) {
v.muted = !v.muted;
}
function setMark(v) {
v.vsc.mark = v.currentTime;
}
function jumpToMark(v) {
if (v.vsc && typeof v.vsc.mark === "number") {
extendSpeedRestoreWindow(v);
v.currentTime = v.vsc.mark;
}
}
function handleDrag(video, e) {
const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc);
if (!sC) return;
var pE = c.parentElement;
while (
pE.parentNode &&
pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth
)
pE = pE.parentNode;
video.classList.add("vcs-dragging");
sC.classList.add("dragging");
const iXY = [e.clientX, e.clientY],
iCXY = [parseInt(sC.style.left), parseInt(sC.style.top)];
const sD = (e) => {
sC.style.setProperty(
"left",
iCXY[0] + e.clientX - iXY[0] + "px",
"important"
);
sC.style.setProperty(
"top",
iCXY[1] + e.clientY - iXY[1] + "px",
"important"
);
};
const eD = () => {
pE.removeEventListener("mousemove", sD);
pE.removeEventListener("mouseup", eD);
pE.removeEventListener("mouseleave", eD);
sC.classList.remove("dragging");
video.classList.remove("vcs-dragging");
};
pE.addEventListener("mouseup", eD);
pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD);
}
function showController(controller, duration = 2000, forced = false) {
if (!controller || typeof controller.classList === "undefined") return;
var restoreHidden =
controller.restoreHiddenAfterShow === true ||
controller.classList.contains("vsc-hidden");
controller.restoreHiddenAfterShow = restoreHidden;
controller.classList.remove("vsc-hidden");
controller.classList.add("vsc-show");
if (forced) {
controller.classList.add("vsc-forced-show");
}
if (controller.showTimeOut !== undefined) {
clearTimeout(controller.showTimeOut);
}
controller.showTimeOut = setTimeout(function() {
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.restoreHiddenAfterShow === true) {
controller.classList.add("vsc-hidden");
}
controller.restoreHiddenAfterShow = false;
controller.showTimeOut = undefined;
}, duration);
}
// Add global listener to handle fullscreen transitions and adjust controller positions
document.addEventListener("fullscreenchange", () => {
tc.mediaElements.forEach((video) => {
if (video.vsc) {
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
}
});
});
+15 -19
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "6.0.5",
"version": "5.1.8.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -16,31 +16,30 @@
}
},
"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": [
"activeTab",
"storage",
"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": [
{
@@ -51,28 +50,25 @@
"file:///*"
],
"match_about_blank": true,
"run_at": "document_start",
"exclude_matches": [
"https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*",
"https://meet.google.com/*"
],
"css": [
"content/inject.css"
"inject.css"
],
"js": [
"shared/controller-utils.js",
"shared/key-bindings.js",
"shared/settings-core.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-bridge.js",
"content/shadow.css"
"inject.css",
"shadow.css"
]
}
+15 -180
View File
@@ -322,7 +322,7 @@ label em {
.shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
grid-template-columns: minmax(0, 1fr) 120px 120px;
gap: 12px;
align-items: center;
padding: 10px 0;
@@ -334,45 +334,20 @@ label em {
border-top: 0;
}
.shortcut-row.customs {
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
}
.shortcut-label {
color: var(--text);
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;
@@ -514,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;
@@ -527,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,
@@ -901,19 +871,8 @@ button.lucide-result-tile.lucide-picked {
align-items: center;
}
.site-rule-identity {
display: grid;
grid-template-columns: minmax(140px, 0.45fr) minmax(220px, 1fr);
gap: 8px;
min-width: 0;
}
.site-title,
.site-pattern {
min-width: 0;
}
.site-pattern {
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
}
@@ -976,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;
@@ -1014,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;
@@ -1057,103 +1006,21 @@ button.lucide-result-tile.lucide-picked {
font-weight: 500;
}
#status:not(:empty) {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
min-height: 0;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.16);
}
#status:empty {
display: none;
}
.support-cta {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel-subtle);
}
.support-cta-text {
margin: 0 0 12px;
.support-footer {
padding: 16px 20px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.support-cta-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
.support-footer p {
margin: 0;
}
.support-cta-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.support-cta-link:hover {
background: var(--toggle-open-bg);
border-color: var(--toggle-open-border);
}
.support-cta-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.support-cta-link svg {
width: 24px;
height: 24px;
display: block;
}
.support-cta-link--kofi {
background: #fff4ef;
}
.support-cta-link--kofi:hover {
background: #ffe8de;
}
.support-cta-kofi-img {
display: block;
height: 26px;
width: auto;
}
.support-cta-link--bmc {
color: #0d0c22;
background: #ffdd00;
}
.support-cta-link--bmc:hover {
background: #f7d500;
}
.support-cta-link--bmc svg {
width: 22px;
height: 22px;
display: block;
.support-footer a {
font-weight: 600;
}
@media (max-width: 720px) {
@@ -1185,8 +1052,7 @@ button.lucide-result-tile.lucide-picked {
}
.action-row button,
#addShortcutSelector,
.site-add-shortcut-selector {
#addShortcutSelector {
width: 100%;
}
@@ -1194,10 +1060,6 @@ button.lucide-result-tile.lucide-picked {
grid-template-columns: 36px minmax(0, 1fr);
}
.site-rule-identity {
grid-template-columns: 1fr;
}
.remove-site-rule {
grid-column: 1 / -1;
}
@@ -1223,10 +1085,6 @@ button.lucide-result-tile.lucide-picked {
.site-rule-header {
grid-template-columns: 1fr;
}
.site-rule-identity {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
@@ -1278,12 +1136,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 {
@@ -1310,21 +1162,4 @@ button.lucide-result-tile.lucide-picked {
filter: brightness(0) invert(1);
opacity: 0.92;
}
.support-cta-link--kofi {
background: #2c241f;
}
.support-cta-link--kofi:hover {
background: #3a312a;
}
.support-cta-link--bmc {
color: #ffdd00;
background: #2a2618;
}
.support-cta-link--bmc:hover {
background: #3d3510;
}
}
+72 -398
View File
@@ -5,15 +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/settings-core.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">
@@ -25,83 +24,13 @@
</div>
<div class="version">v<span id="app-version"></span></div>
</div>
<div class="support-cta" role="region" aria-label="Support Speeder">
<p class="support-cta-text">
If Speeder has been useful, please consider supporting its development!
</p>
<div class="support-cta-links">
<a
class="support-cta-link support-cta-link--kofi"
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Ko-fi (opens in new tab)"
>
<img
class="support-cta-kofi-img"
src="../assets/images/kofi_symbol.svg"
width="241"
height="194"
alt=""
decoding="async"
/>
</a>
<a
class="support-cta-link support-cta-link--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="24"
height="24"
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
class="support-cta-link support-cta-link--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="24"
height="24"
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>
</header>
<main class="settings-stack">
<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">
@@ -137,10 +66,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"
@@ -154,10 +80,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"
@@ -250,7 +173,7 @@
</div>
</div>
<select id="addShortcutSelector" aria-label="Add another shortcut">
<select id="addShortcutSelector">
<option value="">Add shortcut&hellip;</option>
</select>
</section>
@@ -264,48 +187,19 @@
<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>
<div class="row row-checkbox">
<label for="showAmbientLoopControls"
>Show controls on ambient/slideshow videos<br />
<em
>Also shows Speeder on muted autoplay loops used as animated
cards or slideshow artwork. Genuine video players are always
controlled regardless of this setting.</em
>
</label>
<input id="showAmbientLoopControls" type="checkbox" />
</div>
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Playback</h4>
<div class="row row-checkbox">
<label for="rememberSpeed"
>Remember playback speed<br />
<em
>Stores speeds you set with Speeder per source so revisiting the
same media can restore them. 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">
@@ -318,42 +212,16 @@
</label>
<input id="forceLastSavedSpeed" type="checkbox" />
</div>
<div class="row">
<label for="shortcutTargetMode"
>Keyboard shortcut target<br />
<em
>Closest changes the video nearest the pointer (falling back to
the most visible active player). Change all keeps the legacy
behavior on pages with multiple players.</em
>
</label>
<select id="shortcutTargetMode">
<option value="closest">Closest active video</option>
<option value="all">All videos on the page</option>
</select>
</div>
<div class="defaults-divider"></div>
<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>
@@ -366,13 +234,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">
@@ -420,33 +282,23 @@
<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
>How often to nudge: 250&ndash;1000. Smaller values are more
frequent. Default: 250.</em
>How often to nudge: 10&ndash;1000. Smaller values are more
frequent. Default: 50.</em
>
</label>
<input
id="subtitleNudgeInterval"
type="text"
value=""
placeholder="250"
placeholder="50"
/>
</div>
</section>
@@ -498,23 +350,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">
@@ -633,9 +473,7 @@
>
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
row only when you want that group to override the general defaults
above. Regex and plain-path rules test both the literal URL and a
safely decoded path, query, and hash, so readable text can match
<code>%xx</code>-encoded URLs too.
above.
</p>
</div>
<div id="siteRulesContainer"></div>
@@ -654,55 +492,29 @@
>
<span class="site-rule-toggle-icon" aria-hidden="true">&hellip;</span>
</button>
<div class="site-rule-identity">
<input
type="text"
class="site-title"
placeholder="Rule title (optional)"
aria-label="Site rule title"
/>
<input
type="text"
class="site-pattern"
placeholder="e.g., youtube.com or /regex/gi"
aria-label="Site rule URL pattern"
/>
</div>
<input
type="text"
class="site-pattern"
placeholder="e.g., youtube.com or /regex/gi"
/>
<button type="button" class="remove-site-rule">Remove</button>
</div>
<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>
@@ -717,8 +529,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">
@@ -736,217 +547,85 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override shortcut target for this site<br /><em
>Choose whether keyboard and popup control-bar actions
affect the closest active video or every video.</em
></span
>
<input type="checkbox" class="override-shortcut-target" />
</label>
<div class="site-shortcut-target-container">
<div class="site-rule-option site-rule-option-field">
<label>Shortcut target:</label>
<select class="site-shortcutTargetMode">
<option value="closest">Closest active video</option>
<option value="all">All videos on the page</option>
</select>
</div>
</div>
</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 / media eligibility
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 speeds you set with Speeder per source so
revisiting the same media can restore them. 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-field">
<label
>Preferred speed (0.1&ndash;16):<br /><em
>Overrides the Preferred speed action for matching
URLs. It does not force every video to start at this
speed.</em
></label
>
<input
type="text"
inputmode="decimal"
class="site-preferredSpeed"
placeholder="1.80"
/>
</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 class="site-rule-option site-rule-option-checkbox">
<label
>Show controls on ambient/slideshow videos:<br /><em
>Also includes muted autoplay loops used as animated
cards or slideshow artwork. Genuine video players are
always included.</em
></label
>
<input type="checkbox" class="site-showAmbientLoopControls" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<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 (250&ndash;1000ms):<br /><em
>How often to nudge: 250&ndash;1000. Smaller values are
more frequent. Default: 250.</em
></label
>
<input type="text" class="site-subtitleNudgeInterval" placeholder="250" />
<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">
@@ -964,22 +643,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">
@@ -996,23 +665,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>
@@ -1022,10 +678,10 @@
<section class="settings-card action-card">
<div class="section-heading">
<h3>Actions</h3>
<p class="section-intro">Changes save automatically.</p>
<p class="section-intro">Save, restore, export, or import settings.</p>
</div>
<div class="action-row">
<button id="save">Save Now</button>
<button id="save">Save Changes</button>
<button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button>
@@ -1045,6 +701,24 @@
</p>
</section>
<footer class="support-footer settings-card">
<p>
If Speeder has been useful, consider supporting its development via
<a
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
>GitHub Sponsor</a
>
or
<a
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
>Ko-Fi</a
>.
</p>
</footer>
</main>
</div>
</body>
+204 -567
View File
@@ -74,13 +74,11 @@ var controllerButtonDefs = {
reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" },
pause: { icon: "\u23EF", name: "Play / Pause" },
muted: { icon: "M", name: "Mute / Unmute" },
louder: { icon: "+", name: "Increase volume" },
softer: { icon: "\u2212", name: "Decrease volume" },
mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" },
settings: { icon: "\u2699", name: "Settings" },
pause: { icon: "\u23EF", name: "Pause / Play" },
muted: { icon: "M", name: "Mute / Unmute" },
mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" }
};
var popupExcludedButtonIds = new Set(["settings"]);
@@ -162,96 +160,57 @@ function createDefaultBinding(action, code, value) {
};
}
var tcDefaults = vscGetSettingsDefaults();
var optionsSyncSettingsLoaded = false;
var autoSaveTimer = null;
var saveStatusTimer = null;
function scheduleAutoSave() {
if (!optionsSyncSettingsLoaded) return;
clearTimeout(autoSaveTimer);
var sourceSaveButton = document.getElementById("save");
var scheduledTimer = setTimeout(function () {
if (
autoSaveTimer !== scheduledTimer ||
!sourceSaveButton ||
!sourceSaveButton.isConnected
) {
return;
var tcDefaults = {
speed: 1.0,
lastSpeed: 1.0,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
createDefaultBinding("display", "KeyV", 0),
createDefaultBinding("move", "KeyP", 0),
createDefaultBinding("slower", "KeyS", 0.1),
createDefaultBinding("faster", "KeyD", 0.1),
createDefaultBinding("rewind", "KeyZ", 10),
createDefaultBinding("advance", "KeyX", 10),
createDefaultBinding("reset", "KeyR", 1),
createDefaultBinding("fast", "KeyG", 1.8),
createDefaultBinding("toggleSubtitleNudge", "KeyN", 0)
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
autoSaveTimer = null;
save_options(true);
}, 300);
autoSaveTimer = scheduledTimer;
}
function setOptionsSyncSettingsLoaded(loaded) {
optionsSyncSettingsLoaded = loaded === true;
if (!optionsSyncSettingsLoaded) {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
}
var saveButton = document.getElementById("save");
if (saveButton) {
saveButton.disabled = !optionsSyncSettingsLoaded;
saveButton.setAttribute(
"aria-busy",
optionsSyncSettingsLoaded ? "false" : "true"
);
}
}
function persistManagedSyncSettings(settings, callback, existingStorage) {
function persistFromRaw(rawStorage) {
// Preserve canonical values that this version of the Options UI does not
// expose (for example legacy left/right placement margins). A caller can
// still reset every managed value by passing the complete defaults object.
var completeSettings = vscDeepMergeDefaults(
vscExpandStoredSettings(rawStorage || {}),
settings || {}
);
var mutation = vscBuildManagedStorageMutation(
rawStorage,
completeSettings
);
function removeStaleKeys() {
if (!mutation.remove.length) {
callback(null);
return;
}
chrome.storage.sync.remove(mutation.remove, function () {
callback(chrome.runtime.lastError || null);
});
}
if (Object.keys(mutation.set).length === 0) {
removeStaleKeys();
return;
}
chrome.storage.sync.set(mutation.set, function () {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
removeStaleKeys();
});
}
if (existingStorage && typeof existingStorage === "object") {
persistFromRaw(existingStorage);
return;
}
chrome.storage.sync.get(null, function (rawStorage) {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
persistFromRaw(rawStorage || {});
});
}
],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
const actionLabels = {
display: "Show/hide controller",
@@ -262,17 +221,14 @@ const actionLabels = {
advance: "Advance",
reset: "Reset speed",
fast: "Preferred speed",
toggleSubtitleNudge: "Toggle subtitle nudge",
pause: "Play / Pause",
muted: "Mute / Unmute",
louder: "Increase volume",
softer: "Decrease volume",
muted: "Mute",
pause: "Pause",
mark: "Set marker",
jump: "Jump to marker"
jump: "Jump to marker",
toggleSubtitleNudge: "Toggle subtitle nudge"
};
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
const requiredShortcutActions = new Set(["slower", "faster"]);
const speedBindingActions = ["slower", "faster", "fast"];
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
@@ -285,27 +241,6 @@ function formatSpeedBindingDisplay(action, value) {
return n.toFixed(2);
}
function getDefaultShortcutValue(action) {
if (action === "louder" || action === "softer") {
return 0.1;
}
var defaultBinding = tcDefaults.keyBindings.find(function (binding) {
return binding.action === action;
});
if (defaultBinding && Number.isFinite(Number(defaultBinding.value))) {
return Number(defaultBinding.value);
}
return 0;
}
function resolveShortcutValue(action, value) {
return keyBindingUtils.sanitizeActionValue(
action,
value,
getDefaultShortcutValue(action)
);
}
const customActionsNoValues = [
"reset",
"display",
@@ -326,66 +261,33 @@ function refreshAddShortcutSelector() {
selector.remove(1);
}
Object.keys(actionLabels).forEach((action) => {
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
});
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
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);
}
Object.keys(actionLabels).forEach(function (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;
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 };
// Find all currently used actions
const usedActions = new Set();
document.querySelectorAll(".shortcut-row").forEach((row) => {
const action = row.dataset.action;
if (action) {
usedActions.add(action);
}
}
var def = tcDefaults.keyBindings.find(function (b) {
return b.action === action;
});
if (def) {
return {
binding: normalizeStoredBinding(def),
value: def.value
};
// Add all unused actions
Object.keys(actionLabels).forEach((action) => {
if (!usedActions.has(action)) {
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
}
});
// If no available actions, hide or disable the selector
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
return { binding: null, value: undefined };
}
function ensureDefaultBinding(storage, action, code, value) {
@@ -410,19 +312,6 @@ function parseFiniteNumberOrFallback(value, fallback) {
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function readOptionalPreferredSpeedInput(input) {
var rawValue = input && typeof input.value === "string"
? input.value.trim()
: "";
if (!rawValue) return undefined;
var parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return undefined;
return Math.min(
keyBindingUtils.MAX_SPEED,
Math.max(keyBindingUtils.MIN_SPEED, parsed)
);
}
function updateSiteRuleToggleIcon(toggleButton, action) {
if (!toggleButton) return;
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
@@ -556,7 +445,6 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false
};
@@ -575,7 +463,7 @@ function formatBindingCode(code) {
function getBindingLabel(binding) {
if (!binding) return "";
if (binding.disabled) return "";
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
return formatBindingCode(binding.code);
}
function setShortcutInputBinding(input, binding) {
@@ -588,7 +476,6 @@ function captureBindingFromEvent(event) {
if (typeof event.code !== "string" || event.code.length === 0) return null;
return {
code: event.code,
shiftKey: event.shiftKey === true,
disabled: false
};
}
@@ -598,7 +485,6 @@ function recordKeyPress(event) {
if (event.key === "Backspace") {
setShortcutInputBinding(event.target, null);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -606,7 +492,6 @@ function recordKeyPress(event) {
if (event.key === "Escape") {
setShortcutInputBinding(event.target, createDisabledBinding());
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -616,7 +501,6 @@ function recordKeyPress(event) {
if (!binding) return;
setShortcutInputBinding(event.target, binding);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
}
@@ -696,10 +580,7 @@ function add_shortcut(action, value) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
}
var removeButton = document.createElement("button");
@@ -726,54 +607,19 @@ function createKeyBindings(item) {
var binding = normalizeStoredBinding(input.vscBinding);
if (!binding) {
if (requiredShortcutActions.has(action)) {
return {
valid: false,
message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
};
}
binding = createDisabledBinding();
}
if (binding.disabled === true && requiredShortcutActions.has(action)) {
return {
valid: false,
message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
message: "Error: Shortcut for " + action + " is invalid. Unable to save"
};
}
var bindingValue = 0;
if (!customActionsNoValues.includes(action)) {
var valueError = keyBindingUtils.getActionValueError(
action,
valueInput.value
);
if (valueError) {
return {
valid: false,
message:
"Error: Value for " +
(actionLabels[action] || action) +
" " +
valueError +
". Unable to save"
};
}
bindingValue = Number(valueInput.value);
}
keyBindings.push({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: bindingValue,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
force: false,
predefined: predefined
});
@@ -808,19 +654,11 @@ function validate() {
return valid;
}
function save_options(isAutoSave) {
clearTimeout(autoSaveTimer);
clearTimeout(saveStatusTimer);
autoSaveTimer = null;
var status = document.getElementById("status");
if (!optionsSyncSettingsLoaded) {
status.textContent =
"Error: Settings have not loaded. Retry before saving.";
return;
}
function save_options() {
if (validate() === false) return;
keyBindings = [];
var status = document.getElementById("status");
var saveError = null;
// Collect shortcuts from the main shortcuts section (both default and custom)
@@ -840,14 +678,8 @@ function save_options(isAutoSave) {
settings.forceLastSavedSpeed =
document.getElementById("forceLastSavedSpeed").checked;
settings.audioBoolean = document.getElementById("audioBoolean").checked;
settings.showAmbientLoopControls =
document.getElementById("showAmbientLoopControls").checked;
settings.enabled = document.getElementById("enabled").checked;
settings.startHidden = document.getElementById("startHidden").checked;
settings.shortcutTargetMode =
document.getElementById("shortcutTargetMode").value === "all"
? "all"
: "closest";
settings.hideWithControls = document.getElementById("hideWithControls").checked;
settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, parseFloat(document.getElementById("hideWithControlsTimer").value) || tcDefaults.hideWithControlsTimer));
@@ -879,14 +711,13 @@ function save_options(isAutoSave) {
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;
settings.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
if (settings.subtitleNudgeInterval < 250) {
settings.subtitleNudgeInterval = 250;
if (settings.subtitleNudgeInterval < 10) {
settings.subtitleNudgeInterval = 10;
}
if (settings.subtitleNudgeInterval > 1000) {
settings.subtitleNudgeInterval = 1000;
@@ -905,25 +736,11 @@ function save_options(isAutoSave) {
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
if (pattern.length === 0) return;
var rule = vscClonePlainData(ruleEl.vscOriginalRule) || {};
rule.pattern = pattern;
var title = ruleEl.querySelector(".site-title").value.trim();
if (title) rule.title = title;
else delete rule.title;
delete rule.disableExtension;
var rule = { pattern: pattern };
// Handle Enable toggle
rule.enabled = ruleEl.querySelector(".site-enabled").checked;
if (ruleEl.querySelector(".override-shortcut-target").checked) {
rule.shortcutTargetMode =
ruleEl.querySelector(".site-shortcutTargetMode").value === "all"
? "all"
: "closest";
} else {
delete rule.shortcutTargetMode;
}
if (ruleEl.querySelector(".override-placement").checked) {
rule.controllerLocation = normalizeControllerLocation(
ruleEl.querySelector(".site-controllerLocation").value
@@ -942,16 +759,10 @@ function save_options(isAutoSave) {
tcDefaults.controllerMarginBottom
)
);
} else {
delete rule.controllerLocation;
delete rule.controllerMarginTop;
delete rule.controllerMarginBottom;
}
if (ruleEl.querySelector(".override-visibility").checked) {
rule.startHidden = ruleEl.querySelector(".site-startHidden").checked;
} else {
delete rule.startHidden;
}
if (ruleEl.querySelector(".override-autohide").checked) {
@@ -963,33 +774,13 @@ function save_options(isAutoSave) {
15,
Math.max(0.1, Number.isFinite(st) ? st : settings.hideWithControlsTimer)
);
} else {
delete rule.hideWithControls;
delete rule.hideWithControlsTimer;
}
if (ruleEl.querySelector(".override-playback").checked) {
rule.rememberSpeed = ruleEl.querySelector(".site-rememberSpeed").checked;
rule.forceLastSavedSpeed =
ruleEl.querySelector(".site-forceLastSavedSpeed").checked;
var preferredSpeed = readOptionalPreferredSpeedInput(
ruleEl.querySelector(".site-preferredSpeed")
);
if (preferredSpeed === undefined) {
delete rule.preferredSpeed;
} else {
rule.preferredSpeed = preferredSpeed;
}
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
rule.showAmbientLoopControls = ruleEl.querySelector(
".site-showAmbientLoopControls"
).checked;
} else {
delete rule.rememberSpeed;
delete rule.forceLastSavedSpeed;
delete rule.preferredSpeed;
delete rule.audioBoolean;
delete rule.showAmbientLoopControls;
}
if (ruleEl.querySelector(".override-opacity").checked) {
@@ -998,15 +789,11 @@ function save_options(isAutoSave) {
ruleEl.querySelector(".site-controllerOpacity").value,
settings.controllerOpacity
);
} else {
delete rule.controllerOpacity;
}
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
@@ -1014,14 +801,10 @@ function save_options(isAutoSave) {
rule.subtitleNudgeInterval = Math.min(
1000,
Math.max(
250,
10,
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
)
);
} else {
delete rule.enableSubtitleNudge;
delete rule.subtitleNudgeEnabledByDefault;
delete rule.subtitleNudgeInterval;
}
if (ruleEl.querySelector(".override-controlbar").checked) {
@@ -1029,8 +812,6 @@ function save_options(isAutoSave) {
if (activeZone) {
rule.controllerButtons = readControlBarOrder(activeZone);
}
} else {
delete rule.controllerButtons;
}
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
@@ -1042,91 +823,49 @@ function save_options(isAutoSave) {
readControlBarOrder(popupActiveZone)
);
}
} else {
delete rule.showPopupControlBar;
delete rule.popupControllerButtons;
}
if (ruleEl.querySelector(".override-shortcuts").checked) {
var shortcuts = [];
ruleEl.querySelectorAll(".site-shortcuts-container .customs").forEach((shortcutRow) => {
if (saveError) return;
var action = shortcutRow.dataset.action;
var keyInput = shortcutRow.querySelector(".customKey");
var valueInput = shortcutRow.querySelector(".customValue");
var forceCheckbox = shortcutRow.querySelector(".customForce");
var binding = normalizeStoredBinding(keyInput.vscBinding);
if (!binding) {
if (requiredShortcutActions.has(action)) {
saveError =
"Error: Site rule shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save";
return;
}
binding = createDisabledBinding();
if (binding) {
shortcuts.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
force: forceCheckbox ? forceCheckbox.checked : false
});
}
if (binding.disabled === true && requiredShortcutActions.has(action)) {
saveError =
"Error: Site rule shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save";
return;
}
var shortcutValue = 0;
if (!customActionsNoValues.includes(action)) {
var shortcutValueError = keyBindingUtils.getActionValueError(
action,
valueInput.value
);
if (shortcutValueError) {
saveError =
"Error: Site rule value for " +
(actionLabels[action] || action) +
" " +
shortcutValueError +
". Unable to save";
return;
}
shortcutValue = Number(valueInput.value);
}
shortcuts.push({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: shortcutValue,
force: forceCheckbox ? forceCheckbox.checked : false
});
});
if (saveError) return;
if (shortcuts.length > 0) rule.shortcuts = shortcuts;
else delete rule.shortcuts;
} else {
delete rule.shortcuts;
}
settings.siteRules.push(rule);
});
if (saveError) {
status.textContent = saveError;
return;
}
// Legacy keys to remove
const legacyKeys = [
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
"advanceKeyCode", "fastKeyCode", "blacklist"
];
persistManagedSyncSettings(settings, function (error) {
if (error) {
status.textContent = "Error: Unable to save options - " + error.message;
return;
}
status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
saveStatusTimer = setTimeout(function () {
status.textContent = "";
}, 1000);
chrome.storage.sync.remove(legacyKeys, function () {
chrome.storage.sync.set(settings, function () {
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
});
}
@@ -1136,18 +875,33 @@ 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",
muted: "Mute",
pause: "Pause",
mark: "Set marker",
jump: "Jump to marker",
toggleSubtitleNudge: "Toggle subtitle nudge"
};
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)";
@@ -1169,10 +923,7 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
}
var forceLabel = document.createElement("label");
@@ -1191,30 +942,21 @@ 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) {
var template = document.getElementById("siteRuleTemplate");
var clone = template.content.cloneNode(true);
var ruleEl = clone.querySelector(".site-rule");
ruleEl.vscOriginalRule = vscClonePlainData(rule) || {};
var pattern = rule && rule.pattern ? rule.pattern : "";
ruleEl.querySelector(".site-pattern").value = pattern;
ruleEl.querySelector(".site-title").value =
rule && typeof rule.title === "string" ? rule.title : "";
// Make the rule body collapsed by default
setSiteRuleExpandedState(ruleEl, false);
@@ -1245,17 +987,6 @@ function createSiteRule(rule) {
}
updateDisabledState();
ruleEl.querySelector(".override-shortcut-target").checked = Boolean(
rule && rule.shortcutTargetMode !== undefined
);
ruleEl.querySelector(".site-shortcutTargetMode").value =
rule && rule.shortcutTargetMode === "all" ? "all" : "closest";
applySiteRuleOverrideState(
ruleEl,
"override-shortcut-target",
"site-shortcut-target-container"
);
var placementKeys = [
"controllerLocation",
"controllerMarginTop",
@@ -1289,19 +1020,12 @@ function createSiteRule(rule) {
rule &&
(rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined ||
rule.preferredSpeed !== undefined ||
rule.audioBoolean !== undefined ||
rule.showAmbientLoopControls !== undefined)
rule.audioBoolean !== undefined)
);
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true);
ruleEl.querySelector(".site-preferredSpeed").value =
rule && Number.isFinite(Number(rule.preferredSpeed))
? String(rule.preferredSpeed)
: "";
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
syncSiteRuleField(ruleEl, rule, "showAmbientLoopControls", true);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
ruleEl.querySelector(".override-opacity").checked = Boolean(
@@ -1313,12 +1037,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,
@@ -1363,24 +1085,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;
@@ -1557,7 +1311,6 @@ function initControlBarEditor() {
var zone = e.target.closest(".cb-dropzone");
if (zone) {
e.preventDefault();
scheduleAutoSave();
}
clearControlBarDropTargets(null);
@@ -1768,26 +1521,11 @@ function initLucideButtonIconsUI() {
}
}
function restore_options(callback) {
var done = typeof callback === "function" ? callback : function () {};
var status = document.getElementById("status");
setOptionsSyncSettingsLoaded(false);
chrome.storage.sync.get(null, function (rawStorage) {
var syncError = chrome.runtime.lastError;
if (syncError) {
status.textContent =
"Error: Unable to load options - " + syncError.message;
done(syncError);
return;
}
var storage = vscExpandStoredSettings(rawStorage || {});
function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) {
chrome.storage.local.get(["customButtonIcons"], function (loc) {
var localError = chrome.runtime.lastError;
customButtonIconsLive =
!localError &&
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
@@ -1795,15 +1533,15 @@ function restore_options(callback) {
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("showAmbientLoopControls").checked =
storage.showAmbientLoopControls;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
document.getElementById("shortcutTargetMode").value =
storage.shortcutTargetMode === "all" ? "all" : "closest";
document.getElementById("hideWithControls").checked =
storage.hideWithControls;
// Migration/Normalization for hideWithControls
const hideWithControls = typeof storage.hideWithControls !== "undefined"
? storage.hideWithControls
: storage.hideWithYouTubeControls;
document.getElementById("hideWithControls").checked = hideWithControls;
document.getElementById("hideWithControlsTimer").value =
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
@@ -1819,8 +1557,6 @@ function restore_options(callback) {
storage.showPopupControlBar !== false;
document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge;
document.getElementById("subtitleNudgeEnabledByDefault").checked =
storage.subtitleNudgeEnabledByDefault;
document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval;
@@ -1832,16 +1568,13 @@ function restore_options(callback) {
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
var usedDefaultShortcutRows = new Set();
storage.keyBindings.forEach((item) => {
var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item);
if (!row || usedDefaultShortcutRows.has(item.action)) {
if (!row) {
add_shortcut(item.action, item.value);
row = document.querySelector(".shortcut-row.customs:last-of-type");
} else {
usedDefaultShortcutRows.add(item.action);
}
if (!row) return;
@@ -1864,7 +1597,11 @@ function restore_options(callback) {
refreshAddShortcutSelector();
var siteRules = Array.isArray(storage.siteRules) ? storage.siteRules : [];
// Load site rules (use defaults if none in storage or empty array)
var siteRules =
Array.isArray(storage.siteRules) && storage.siteRules.length > 0
? storage.siteRules
: tcDefaults.siteRules || [];
vscClearElement(document.getElementById("siteRulesContainer"));
if (siteRules.length > 0) {
@@ -1890,87 +1627,25 @@ function restore_options(callback) {
updatePopupEditorDisabledState();
initLucideButtonIconsUI();
setOptionsSyncSettingsLoaded(true);
if (localError) {
status.textContent =
"Warning: Options loaded, but custom icons could not be read - " +
localError.message;
}
done(localError || null);
});
});
}
function restore_defaults() {
var status = document.getElementById("status");
var restoreButton = document.getElementById("restore");
setOptionsSyncSettingsLoaded(false);
if (restoreButton) restoreButton.disabled = true;
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
function finishRestore(error) {
// Leave the storage callback before rereading. Besides avoiding nested API
// calls, this prevents a callback-scoped runtime.lastError from being
// mistaken for an error from the fresh read.
Promise.resolve().then(function () {
restore_options(function (reloadError) {
if (restoreButton) restoreButton.disabled = false;
if (error) {
status.textContent =
"Error: Unable to restore all defaults - " + error.message;
return;
}
if (reloadError) {
status.textContent =
"Defaults restored, but the options page could not reload - " +
reloadError.message;
return;
}
status.textContent = "Default options restored";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
});
}
chrome.storage.local.remove(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
persistManagedSyncSettings(tcDefaults, function (error) {
if (error) {
finishRestore(error);
return;
}
chrome.storage.sync.set({ lastSpeed: tcDefaults.lastSpeed }, function () {
var lastSpeedError = chrome.runtime.lastError;
if (lastSpeedError) {
finishRestore(lastSpeedError);
return;
}
chrome.storage.local.set(
{
rememberedSpeeds: {},
rememberedSpeedsResetAt: Date.now()
},
function () {
var speedResetError = chrome.runtime.lastError;
if (speedResetError) {
finishRestore(speedResetError);
return;
}
chrome.storage.local.remove(
[
"customButtonIcons",
"lucideTagsCacheV1",
"lucideTagsCacheV1At",
"rememberedSpeeds"
],
function () {
finishRestore(chrome.runtime.lastError || null);
}
);
}
);
});
chrome.storage.sync.set(tcDefaults, function () {
restore_options();
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
}
@@ -2033,20 +1708,13 @@ 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);
}
scheduleAutoSave();
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
if (removeSiteRuleButton) {
removeSiteRuleButton.closest(".site-rule").remove();
scheduleAutoSave();
return;
}
var toggleButton = targetEl.closest(".toggle-site-rule");
@@ -2068,26 +1736,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",
@@ -2109,19 +1757,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (targetBox) {
setSiteOverrideContainerState(targetBox, event.target.checked);
}
if (ocb === "override-shortcuts") {
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
return;
}
}
});
document.addEventListener("change", (event) => {
if (
event.target.id !== "addShortcutSelector" &&
!event.target.closest("#lucideIconSettings")
) {
scheduleAutoSave();
}
});
});
+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>
+63 -221
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: "" },
@@ -14,74 +14,24 @@ document.addEventListener("DOMContentLoaded", function () {
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
settings: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" },
settings: { label: "", className: "" }
jump: { label: "", className: "" }
};
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: []
};
var renderToken = 0;
var forceLastSavedSpeedControlledBySiteRule = null;
function persistExpandedSettings(rawStorage, settings, callback) {
var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
function removeStaleKeys() {
if (!mutation.remove.length) {
callback(null);
return;
}
chrome.storage.sync.remove(mutation.remove, function () {
callback(chrome.runtime.lastError || null);
});
}
if (!Object.keys(mutation.set).length) {
removeStaleKeys();
return;
}
chrome.storage.sync.set(mutation.set, function () {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
removeStaleKeys();
});
}
function updateStoredSettings(update, callback) {
chrome.storage.sync.get(null, function (rawStorage) {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
var settings = vscExpandStoredSettings(rawStorage || {});
update(settings);
persistExpandedSettings(rawStorage || {}, settings, callback);
});
}
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 setForceButtonLoading(loading) {
var button = document.getElementById("forceLastSavedSpeed");
if (!button) return;
button.disabled = loading === true;
button.setAttribute("aria-busy", loading ? "true" : "false");
}
function matchSiteRule(url, siteRules) {
return siteRuleUtils.matchSiteRule(url, siteRules);
@@ -168,32 +118,28 @@ document.addEventListener("DOMContentLoaded", function () {
return popupControlUtils.pickBestFrameSpeedResult(results);
}
function querySpeed(callback) {
function querySpeed() {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs[0] || tabs[0].id == null) {
if (callback) callback(null);
return;
}
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) {
applySpeedAndResetFromResponse(response || { speed: 1 });
if (callback) callback(response || null);
});
return;
}
var best = pickBestFrameSpeedResult(results);
if (best) {
applySpeedAndResetFromResponse(best);
if (callback) callback(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
if (callback) callback(response || null);
});
}
}
@@ -244,7 +190,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(
@@ -262,11 +208,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 () {
@@ -282,6 +228,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);
});
@@ -304,75 +258,8 @@ document.addEventListener("DOMContentLoaded", function () {
});
});
var forceLastSavedSpeedButton = document.querySelector(
"#forceLastSavedSpeed"
);
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
forceLastSavedSpeedButton.addEventListener("click", function () {
if (forceLastSavedSpeedControlledBySiteRule === null) {
setStatusMessage("Loading page settings...");
return;
}
if (forceLastSavedSpeedControlledBySiteRule) {
setStatusMessage("Force setting is controlled by this site rule.");
return;
}
var button = this;
var enabled = button.getAttribute("aria-pressed") !== "true";
updateStoredSettings(
function (settings) {
settings.forceLastSavedSpeed = enabled;
},
function (error) {
if (error) {
setStatusMessage("Could not update speed forcing: " + error.message);
return;
}
sendToActiveTab(
{
action: "set_force_last_saved_speed",
enabled: enabled
},
function (response) {
var effectiveEnabled =
response && typeof response.enabled === "boolean"
? response.enabled
: enabled;
updateForceButton(effectiveEnabled);
if (effectiveEnabled !== enabled) {
setStatusMessage("Force setting is controlled by this site rule.");
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
return;
}
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
setStatusMessage(
effectiveEnabled
? "Saved speed is now forced."
: "Speed forcing is off."
);
} else {
setStatusMessage(
effectiveEnabled
? "Force enabled. No video found on this page."
: "Speed forcing is off."
);
}
}
);
}
);
});
}
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
forceLastSavedSpeedControlledBySiteRule = null;
setForceButtonLoading(true);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return;
@@ -381,75 +268,42 @@ document.addEventListener("DOMContentLoaded", function () {
? loc.customButtonIcons
: {};
chrome.storage.sync.get(null, function (rawStorage) {
chrome.storage.sync.get(storageDefaults, function (storage) {
if (currentRenderToken !== renderToken) return;
var storage = vscExpandStoredSettings(rawStorage || {});
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
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 showBar = storage.showPopupControlBar !== false;
forceLastSavedSpeedControlledBySiteRule = Boolean(
siteRule && siteRule.forceLastSavedSpeed !== undefined
);
var effectiveForceLastSavedSpeed =
forceLastSavedSpeedControlledBySiteRule
? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true;
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = storage.enabled !== false && !siteDisabled;
var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
toggleEnabledUI(storage.enabled !== false);
updateForceButton(effectiveForceLastSavedSpeed);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
toggleEnabledUI(storage.enabled !== false);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
if (siteDisabled) {
setForceButtonLoading(false);
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
clearStatusMessage();
if (siteAvailable) {
querySpeed(function(frameContext) {
if (currentRenderToken !== renderToken) return;
if (
frameContext &&
typeof frameContext.forceLastSavedSpeed === "boolean"
) {
effectiveForceLastSavedSpeed =
frameContext.forceLastSavedSpeed;
}
if (
frameContext &&
typeof frameContext.forceLastSavedSpeedControlledBySiteRule ===
"boolean"
) {
forceLastSavedSpeedControlledBySiteRule =
frameContext.forceLastSavedSpeedControlledBySiteRule;
}
updateForceButton(effectiveForceLastSavedSpeed);
setForceButtonLoading(false);
});
} else {
updateSpeedDisplay(1);
setForceButtonLoading(false);
}
});
clearStatusMessage();
if (siteAvailable) {
querySpeed();
} else {
updateSpeedDisplay(1);
}
});
});
});
}
@@ -473,14 +327,11 @@ document.addEventListener("DOMContentLoaded", function () {
if (areaName !== "sync") return;
if (
changes.enabled ||
changes.forceLastSavedSpeed ||
changes.showPopupControlBar ||
changes.controllerButtons ||
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules ||
changes.siteRulesMeta ||
changes.siteRulesFormat
changes.siteRules
) {
renderForActiveTab();
}
@@ -489,19 +340,10 @@ document.addEventListener("DOMContentLoaded", function () {
renderForActiveTab();
function toggleEnabled(enabled, callback) {
updateStoredSettings(
function (settings) {
settings.enabled = enabled;
},
function (error) {
if (error) {
setStatusMessage("Could not update Speeder: " + error.message);
return;
}
toggleEnabledUI(enabled);
if (callback) callback(enabled);
}
);
chrome.storage.sync.set({ enabled: enabled }, function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
});
}
function toggleEnabledUI(enabled) {
@@ -511,9 +353,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
}
});
}
@@ -527,12 +369,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
+644
View File
@@ -0,0 +1,644 @@
(function (global) {
"use strict";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function normalizeSiteRuleForDiff(rule, baseSettings) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
var baseEnabled = hasOwn(baseSettings, "enabled")
? Boolean(baseSettings.enabled)
: true;
var ruleEnabled = hasOwn(rule, "enabled")
? Boolean(rule.enabled)
: hasOwn(rule, "disableExtension")
? !Boolean(rule.disableExtension)
: baseEnabled;
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
normalized.enabled = ruleEnabled;
}
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
var baseValue = clonePlainData(baseSettings[key]);
var effectiveValue = hasOwn(rule, key)
? clonePlainData(rule[key])
: baseValue;
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
normalized[key] = effectiveValue;
}
});
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
return;
}
normalized[key] = clonePlainData(rule[key]);
});
return normalized;
}
function compressSiteRules(siteRules, baseSettings) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var exportRules = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
}
return;
}
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function expandSiteRules(siteRules, siteRulesMeta) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var modifiedDefaultRules = Object.create(null);
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
) {
modifiedDefaultRules[pattern] = clonePlainData(rule);
return;
}
customRules.push(clonePlainData(rule));
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
);
return;
}
mergedRules.push(clonePlainData(rule));
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function expandStoredSettings(storage) {
var raw = clonePlainData(storage) || {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
}
expanded.hideWithYouTubeControls = expanded.hideWithControls;
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
} else {
expanded.siteRules = getDefaultSiteRules();
}
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
})(typeof globalThis !== "undefined" ? globalThis : this);
@@ -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;
@@ -44,7 +14,6 @@
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
margin) turned off when the pointer left the bar. */
#controller:hover #controls,
#controller.vsc-controls-hovered #controls,
#controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls {
display: inline-flex;
@@ -86,10 +55,6 @@
margin-right: 5px;
}
#controller.vsc-controls-hovered > .draggable {
margin-right: 5px;
}
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
transform: translate(-50%, 0) !important;
@@ -30,26 +30,10 @@
"rememberSpeed",
"showPopupControlBar",
"siteRules",
"siteRulesFormat",
"siteRulesMeta",
"speed",
"startHidden",
"subtitleNudgeAmount",
"subtitleNudgeEnabledByDefault",
"subtitleNudgeInterval",
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"displayKeyCode",
"blacklist"
"subtitleNudgeInterval"
]);
function isRecognizedRawSettingsObject(backup) {
@@ -62,25 +46,6 @@
});
}
/**
* Only user-authored local settings belong in backups. Disposable caches,
* per-source speed history, and future runtime data stay installation-local.
*/
function filterLocalSettingsForExport(local) {
if (!local || typeof local !== "object" || Array.isArray(local)) {
return {};
}
var out = {};
if (
local.customButtonIcons &&
typeof local.customButtonIcons === "object" &&
!Array.isArray(local.customButtonIcons)
) {
out.customButtonIcons = local.customButtonIcons;
}
return out;
}
function generateBackupFilename(now) {
var date = now instanceof Date ? now : new Date(now || Date.now());
var year = date.getFullYear();
@@ -119,64 +84,27 @@
function extractImportSettings(backup) {
var settingsToImport = null;
var isWrappedBackup = false;
var backupIsObject =
backup && typeof backup === "object" && !Array.isArray(backup);
var hasWrappedSettings =
backupIsObject &&
Object.prototype.hasOwnProperty.call(backup, "settings");
if (hasWrappedSettings) {
if (
!backup.settings ||
typeof backup.settings !== "object" ||
Array.isArray(backup.settings)
) {
return null;
}
if (backup && backup.settings && typeof backup.settings === "object") {
settingsToImport = backup.settings;
isWrappedBackup = true;
} else if (backupIsObject && isRecognizedRawSettingsObject(backup)) {
} else if (
backup &&
typeof backup === "object" &&
isRecognizedRawSettingsObject(backup)
) {
settingsToImport = backup;
}
if (!settingsToImport) return null;
if (
isWrappedBackup &&
Object.prototype.hasOwnProperty.call(backup, "localSettings") &&
(
!backup.localSettings ||
typeof backup.localSettings !== "object" ||
Array.isArray(backup.localSettings)
)
) {
return null;
}
if (
isWrappedBackup &&
backup.localSettings &&
Object.prototype.hasOwnProperty.call(
backup.localSettings,
"customButtonIcons"
) &&
(
!backup.localSettings.customButtonIcons ||
typeof backup.localSettings.customButtonIcons !== "object" ||
Array.isArray(backup.localSettings.customButtonIcons)
)
) {
return null;
}
return {
isWrappedBackup: isWrappedBackup,
settings: settingsToImport,
localSettings:
backup &&
backup.localSettings &&
typeof backup.localSettings === "object" &&
!Array.isArray(backup.localSettings)
typeof backup.localSettings === "object"
? backup.localSettings
: null
};
@@ -189,7 +117,6 @@
return {
buildBackupPayload: buildBackupPayload,
extractImportSettings: extractImportSettings,
filterLocalSettingsForExport: filterLocalSettingsForExport,
generateBackupFilename: generateBackupFilename,
isRecognizedRawSettingsObject: isRecognizedRawSettingsObject,
parseImportText: parseImportText
@@ -8,9 +8,6 @@
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var MIN_SPEED = 0.1;
var MAX_SPEED = 16;
function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null;
if (key === "Spacebar") return " ";
@@ -115,64 +112,11 @@
: null;
}
var defaultActionValues = {
slower: 0.1,
faster: 0.1,
rewind: 10,
advance: 10,
fast: 1.8,
louder: 0.1,
softer: 0.1
};
function getActionValueError(action, value) {
var numericValue = Number(value);
if (!Number.isFinite(numericValue)) return "must be a finite number";
if (
(action === "slower" || action === "faster") &&
(numericValue <= 0 || numericValue > MAX_SPEED)
) {
return "must be greater than 0 and no more than " + MAX_SPEED;
}
if (
action === "fast" &&
(numericValue < MIN_SPEED || numericValue > MAX_SPEED)
) {
return "must be between " + MIN_SPEED + " and " + MAX_SPEED;
}
if (
(action === "rewind" || action === "advance") &&
numericValue < 0
) {
return "must be zero or greater";
}
if (
(action === "louder" || action === "softer") &&
(numericValue <= 0 || numericValue > 1)
) {
return "must be greater than 0 and no more than 1";
}
return null;
}
function sanitizeActionValue(action, value, fallback) {
if (!getActionValueError(action, value)) return Number(value);
if (!getActionValueError(action, fallback)) return Number(fallback);
if (Object.prototype.hasOwnProperty.call(defaultActionValues, action)) {
return defaultActionValues[action];
}
return 0;
}
return {
MAX_SPEED: MAX_SPEED,
MIN_SPEED: MIN_SPEED,
getActionValueError: getActionValueError,
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
legacyBindingKeyToCode: legacyBindingKeyToCode,
legacyKeyCodeToCode: legacyKeyCodeToCode,
normalizeBindingKey: normalizeBindingKey,
sanitizeActionValue: sanitizeActionValue
normalizeBindingKey: normalizeBindingKey
};
});
@@ -66,26 +66,12 @@
function pickBestFrameSpeedResult(results) {
if (!results || !results.length) return null;
function normalizeResult(result) {
var normalized = { speed: result.speed };
if (typeof result.forceLastSavedSpeed === "boolean") {
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
}
if (
typeof result.forceLastSavedSpeedControlledBySiteRule === "boolean"
) {
normalized.forceLastSavedSpeedControlledBySiteRule =
result.forceLastSavedSpeedControlledBySiteRule;
}
return normalized;
}
var fallback = null;
for (var i = 0; i < results.length; i++) {
var result = results[i];
if (!result || typeof result.speed !== "number") continue;
if (result.preferred) return normalizeResult(result);
if (!fallback) fallback = normalizeResult(result);
if (result.preferred) return { speed: result.speed };
if (!fallback) fallback = { speed: result.speed };
}
return fallback;
+69
View File
@@ -0,0 +1,69 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.siteRules = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
function escapeStringRegExp(str) {
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
}
function compileSiteRulePattern(pattern) {
if (typeof pattern !== "string") return null;
var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0
) {
var lastSlash = normalizedPattern.lastIndexOf("/");
return new RegExp(
normalizedPattern.substring(1, lastSlash),
normalizedPattern.substring(lastSlash + 1)
);
}
return new RegExp(escapeStringRegExp(normalizedPattern));
}
function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null;
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
try {
var re = compileSiteRulePattern(rule.pattern);
if (re && re.test(url)) {
return rule;
}
} catch (e) {
}
}
return null;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
}
return {
compileSiteRulePattern: compileSiteRulePattern,
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
matchSiteRule: matchSiteRule
};
});
+8 -64
View File
@@ -1,9 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { vi } from "vitest";
import { applyJSDOMWindow } from "./jsdom-globals.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -13,73 +11,19 @@ function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
}
/**
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
* top-level `const` redeclaration errors (avoids document.write).
*/
export function loadHtmlString(html, options = {}) {
const dom = new JSDOM(html, {
url: options.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
export function loadHtml(relPath) {
loadHtmlString(readRepoFile(relPath));
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
document.open();
document.write(readRepoFile(relPath));
document.close();
}
export function loadScript(relPath) {
const source =
window.eval(
"var chrome = window.chrome || globalThis.chrome;\n" +
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath
);
}
export async function flushAsyncWork() {
+13 -87
View File
@@ -1,7 +1,6 @@
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, "..", "..");
@@ -19,91 +18,22 @@ function readWorkspaceFile(relPath) {
}
function loadHtmlFile(relPath) {
loadHtmlString(readWorkspaceFile(relPath));
document.open();
document.write(readWorkspaceFile(relPath));
document.close();
}
function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
function loadHtmlString(html, options) {
const config = options || {};
const dom = new JSDOM(html, {
url: config.url || "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
function loadHtmlString(html) {
document.open();
document.write(html);
document.close();
}
function evaluateScript(relPath) {
const absPath = workspacePath(relPath);
const source =
"var chrome = window.chrome || globalThis.chrome;\n" +
readWorkspaceFile(relPath) +
`\n//# sourceURL=${absPath.replace(/\\/g, "/")}`;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
const source = readWorkspaceFile(relPath);
window.eval(
`${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}`
);
}
function fireDOMContentLoaded() {
@@ -119,11 +49,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));
}
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Point Vitest/jsdom test globals at a new JSDOM window (no document.write).
* Call after creating `new JSDOM(html, options).window`.
*/
import { vi } from "vitest";
export function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
// Vitest fake timers patch host `Date`; jsdoms window keeps its own otherwise.
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
+31 -143
View File
@@ -6,51 +6,26 @@ 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/settings-core.js");
loadScript("extension/shared/import-export.js");
loadScript("extension/options/import-export.js");
globalThis.restore_options = vi.fn();
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
return globalThis.chrome;
}
describe("import/export flows", () => {
it("keeps editable rule titles in normalized exports", async () => {
await setupImportExport();
const settings = globalThis.vscGetSettingsDefaults();
settings.siteRules[1].title = "My Shorts rule";
settings.siteRules[1].shortcutTargetMode = "all";
settings.siteRules[1].showAmbientLoopControls = true;
const exported = globalThis.normalizedSettingsForExport(
globalThis.vscBuildStoredSettingsDiff(settings)
);
const restored = globalThis.vscExpandStoredSettings(exported);
expect(restored.siteRules[1].title).toBe("My Shorts rule");
expect(restored.siteRules[1].shortcutTargetMode).toBe("all");
expect(restored.siteRules[1].showAmbientLoopControls).toBe(true);
expect(exported.siteRules.map((rule) => rule.title)).toEqual([
"YouTube videos",
"My Shorts rule",
"YouTube Shorts (mobile)"
]);
});
it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const chrome = await setupImportExport({
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
sync: { rememberSpeed: true },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = window.Blob;
class TestBlob {
const OriginalBlob = globalThis.Blob;
globalThis.Blob = class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
@@ -59,28 +34,24 @@ describe("import/export flows", () => {
async text() {
return this.parts.join("");
}
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
};
let capturedBlob = null;
let clickedDownload = null;
Object.defineProperty(window.URL, "createObjectURL", {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(window.URL, "revokeObjectURL", {
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
function () {
clickedDownload = this.download;
}
);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () {
clickedDownload = this.download;
});
document.getElementById("exportSettings").click();
@@ -90,16 +61,7 @@ describe("import/export flows", () => {
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: {
rememberSpeed: true,
shortcutTargetMode: "all",
siteRulesFormat: "defaults-diff-v2",
siteRules: [
expect.objectContaining({ title: "YouTube videos" }),
expect.objectContaining({ title: "YouTube Shorts" }),
expect.objectContaining({ title: "YouTube Shorts (mobile)" })
]
},
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
});
expect(document.getElementById("status").textContent).toBe(
@@ -108,55 +70,6 @@ describe("import/export flows", () => {
expect(chrome.storage.sync.get).toHaveBeenCalled();
expect(chrome.storage.local.get).toHaveBeenCalled();
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("export strips lucideTagsCacheV1 from localSettings", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
await setupImportExport({
sync: { rememberSpeed: true },
local: {
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 42
}
});
const OriginalBlob = window.Blob;
class TestBlob {
constructor(parts) {
this.parts = parts;
}
async text() {
return this.parts.join("");
}
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
let capturedBlob = null;
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
document.getElementById("exportSettings").click();
await flushAsyncWork();
expect(JSON.parse(await capturedBlob.text()).localSettings).toEqual({
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } }
});
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("imports wrapped backup payloads and refreshes options", async () => {
@@ -174,7 +87,7 @@ describe("import/export flows", () => {
return el;
});
class MockFileReader {
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -182,9 +95,7 @@ describe("import/export flows", () => {
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
};
globalThis.importSettings();
createdInput.onchange({
@@ -204,7 +115,7 @@ describe("import/export flows", () => {
{ customButtonIcons: { faster: { slug: "rocket" } } },
expect.any(Function)
);
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
@@ -234,7 +145,7 @@ describe("import/export flows", () => {
return el;
});
class MockFileReader {
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -242,9 +153,7 @@ describe("import/export flows", () => {
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
};
globalThis.importSettings();
createdInput.onchange({
@@ -253,13 +162,7 @@ describe("import/export flows", () => {
{
__text: JSON.stringify({
enabled: false,
siteRules: [
{
title: "Example videos",
pattern: "example.com",
enabled: false
}
]
siteRules: [{ pattern: "example.com", enabled: false }]
})
}
]
@@ -268,17 +171,13 @@ describe("import/export flows", () => {
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
const imported = globalThis.vscExpandStoredSettings(
chrome.storage.sync.__state
);
expect(imported.enabled).toBe(false);
expect(imported.siteRules).toEqual([
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{
title: "Example videos",
pattern: "example.com",
enabled: false
}
]);
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
},
expect.any(Function)
);
});
it("clears stale local data when a wrapped backup has empty local settings", async () => {
@@ -301,7 +200,7 @@ describe("import/export flows", () => {
return el;
});
class MockFileReader {
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -309,9 +208,7 @@ describe("import/export flows", () => {
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
};
globalThis.importSettings();
createdInput.onchange({
@@ -327,15 +224,8 @@ describe("import/export flows", () => {
}
});
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).toHaveBeenCalledWith(
"customButtonIcons",
expect.any(Function)
);
expect(chrome.storage.local.clear).toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.__state.lucideTagsCacheV1).toEqual({
stale: true
});
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
@@ -357,7 +247,7 @@ describe("import/export flows", () => {
return el;
});
class MockFileReader {
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
@@ -365,9 +255,7 @@ describe("import/export flows", () => {
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
};
globalThis.importSettings();
createdInput.onchange({
+13 -124
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,39 +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/settings-core.js");
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();
});
@@ -74,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,
@@ -96,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];
@@ -103,40 +82,11 @@ 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()
});
const { createObjectURL } = bootImportExport({
syncData: { rememberSpeed: true },
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
},
lucideTagsCacheV1: { "a-arrow-down": ["letter", "text"] },
lucideTagsCacheV1At: 999
}
});
document.querySelector("#exportSettings").click();
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
expect(backup.localSettings).toEqual({
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
});
expect(backup.localSettings.lucideTagsCacheV1).toBeUndefined();
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
@@ -179,7 +129,6 @@ describe("options/import-export.js", () => {
}
vi.stubGlobal("FileReader", FakeFileReader);
window.FileReader = FakeFileReader;
document.querySelector("#importSettings").click();
await flushAsyncWork();
@@ -192,7 +141,7 @@ describe("options/import-export.js", () => {
},
expect.any(Function)
);
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true, enabled: false },
expect.any(Function)
@@ -229,7 +178,6 @@ describe("options/import-export.js", () => {
}
vi.stubGlobal("FileReader", FakeFileReader);
window.FileReader = FakeFileReader;
document.querySelector("#importSettings").click();
await flushAsyncWork();
@@ -238,63 +186,4 @@ describe("options/import-export.js", () => {
"Failed to parse backup file"
);
});
it("rejects malformed custom icons without changing stored settings", async () => {
const { chrome } = bootImportExport({
syncData: { rememberSpeed: false },
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
});
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [
{
__contents: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: "not-an-icon-map" }
})
}
]
});
fakeInput.click = vi.fn(() => {
fakeInput.onchange({ target: fakeInput });
});
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (String(tagName).toLowerCase() === "input") return fakeInput;
return realCreateElement(tagName);
});
class FakeFileReader {
readAsText(file) {
this.onload({ target: { result: file.__contents } });
}
}
vi.stubGlobal("FileReader", FakeFileReader);
window.FileReader = FakeFileReader;
chrome.storage.sync.set.mockClear();
chrome.storage.local.set.mockClear();
chrome.storage.local.remove.mockClear();
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(document.querySelector("#status").textContent).toContain(
"Invalid backup file format"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
expect(chrome.storage.local._dump().customButtonIcons).toEqual({
faster: { slug: "rocket", svg: "<svg></svg>" }
});
});
});
-1186
View File
@@ -1,1186 +0,0 @@
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootInject(options) {
const config = options || {};
loadHtmlString("<!doctype html><html><body></body></html>", {
url: config.url || "https://example.org/"
});
window.history.replaceState({}, "", config.path || "/");
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
if (config.syncGetDelayMs) {
chrome.storage.sync.get.mockImplementation((keys, callback) => {
setTimeout(() => {
callback(chrome.storage.sync._dump());
}, config.syncGetDelayMs);
});
}
global.chrome = chrome;
window.chrome = chrome;
// Keep extension timers under Vitest's clock when a test uses fake timers.
window.setTimeout = global.setTimeout.bind(global);
window.clearTimeout = global.clearTimeout.bind(global);
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
window.requestIdleCallback = (callback) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
0
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("extension/shared/controller-utils.js");
evaluateScript("extension/shared/key-bindings.js");
evaluateScript("extension/shared/settings-core.js");
evaluateScript("extension/shared/site-rules.js");
evaluateScript("extension/shared/ui-icons.js");
evaluateScript("extension/content/inject.js");
return chrome;
}
async function settleLifecycle(turns = 4) {
await flushAsyncWork(turns);
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0);
await flushAsyncWork(2);
}
}
function makeRect(left, top, width, height) {
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
toJSON() {
return this;
}
};
}
function setRect(element, rect) {
element.getBoundingClientRect = () => rect;
}
function setBoxMetrics(element, width, height) {
Object.defineProperties(element, {
offsetWidth: { configurable: true, value: width },
offsetHeight: { configurable: true, value: height },
clientLeft: { configurable: true, value: 0 },
clientTop: { configurable: true, value: 0 }
});
}
function createControlledVideo(options) {
const config = options || {};
const mount = config.mount || document.createElement("div");
const video = document.createElement("video");
const mountRect = config.mountRect || makeRect(0, 0, 640, 360);
const videoRect = config.videoRect || mountRect;
setRect(mount, mountRect);
setBoxMetrics(mount, mountRect.width, mountRect.height);
setRect(video, videoRect);
video.src = config.src || "https://example.org/video.mp4";
mount.appendChild(video);
if (!mount.isConnected) document.body.appendChild(mount);
window.ensureController(video, mount);
return { mount, video, controller: video.vsc, wrapper: video.vsc.div };
}
function hostIsGeometrySuppressed(host) {
const width = host.style.getPropertyValue("width");
const height = host.style.getPropertyValue("height");
return Boolean(
host.hidden ||
host.getAttribute("aria-hidden") === "true" ||
host.classList.contains("vsc-geometry-hidden") ||
host.style.display === "none" ||
host.style.visibility === "hidden" ||
((width === "0" || width === "0px") &&
(height === "0" || height === "0px"))
);
}
describe("inject.js media/controller lifecycle regressions", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("bridges page-world history and shadow creation events", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
let locationEvents = 0;
let shadowEvents = 0;
document.addEventListener("speeder-location-changed", () => {
locationEvents += 1;
});
document.addEventListener("speeder-shadow-root-attached", () => {
shadowEvents += 1;
});
evaluateScript("extension/content/shadow-bridge.js");
window.history.pushState({}, "", "/new-route");
const bridgePlayer = document.createElement("bridge-player");
document.body.appendChild(bridgePlayer);
bridgePlayer.attachShadow({ mode: "open" });
expect(locationEvents).toBe(1);
expect(shadowEvents).toBe(1);
});
it("retries a blocked page bridge with bounded shadow fallback scans", async () => {
vi.useFakeTimers();
bootInject();
const firstBridge = document.querySelector(
'script[src$="content/shadow-bridge.js"]'
);
expect(firstBridge).not.toBeNull();
firstBridge.dispatchEvent(new Event("error"));
expect(window.vscPageShadowBridgeRequested).toBe(false);
expect(window.vscBoundedShadowFallbackStarted).toBe(true);
expect(window.vscBoundedShadowFallbackTimers).toHaveLength(3);
expect(window.vscPersistentShadowFallbackTimer).toBeUndefined();
await vi.advanceTimersByTimeAsync(250);
expect(
document.querySelector('script[src$="content/shadow-bridge.js"]')
).not.toBeNull();
});
it("stops at an immediate player stacking boundary", async () => {
bootInject();
await settleLifecycle();
const outside = document.createElement("section");
const isolatedPlayer = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(10, 70, 640, 360);
isolatedPlayer.style.isolation = "isolate";
isolatedPlayer.appendChild(video);
outside.appendChild(isolatedPlayer);
document.body.appendChild(outside);
[outside, isolatedPlayer, video].forEach((element) => setRect(element, rect));
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
});
it("escapes a video wrapper when a sibling gesture pane covers the player", async () => {
bootInject();
await settleLifecycle();
const player = document.createElement("div");
const videoLayer = document.createElement("div");
const gesturePane = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(20, 70, 640, 360);
videoLayer.style.transform = "translateZ(0)";
video.src = "https://example.org/sibling-pane.mp4";
videoLayer.appendChild(video);
player.append(videoLayer, gesturePane);
document.body.appendChild(player);
[player, videoLayer, gesturePane, video].forEach((element) => {
setRect(element, rect);
});
setBoxMetrics(player, rect.width, rect.height);
expect(window.getControllerMount(video)).toBe(player);
window.ensureController(video, videoLayer);
expect(video.vsc.controllerHostMount).toBe(player);
expect(player.lastElementChild).toBe(video.vsc.div);
});
it("recovers a controller measured before the player receives layout", async () => {
vi.useFakeTimers();
bootInject();
await settleLifecycle();
const mount = document.createElement("div");
const video = document.createElement("video");
let mountRect = makeRect(0, 0, 0, 0);
let videoRect = makeRect(0, 0, 0, 0);
setBoxMetrics(mount, 640, 360);
mount.getBoundingClientRect = () => mountRect;
video.getBoundingClientRect = () => videoRect;
video.src = "https://example.org/late-layout.mp4";
mount.appendChild(video);
document.body.appendChild(mount);
window.ensureController(video, mount);
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(true);
mountRect = makeRect(20, 80, 640, 360);
videoRect = makeRect(20, 80, 640, 360);
await vi.advanceTimersByTimeAsync(100);
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
expect(video.vsc.div.style.display).not.toBe("none");
expect(video.vsc.div.style.getPropertyValue("width")).toBe("640px");
expect(video.vsc.div.style.getPropertyValue("height")).toBe("360px");
});
it("keeps YouTube watch, Shorts, and hover controls on the real player boundary", async () => {
bootInject({ url: "https://www.youtube.com/" });
await settleLifecycle();
const player = document.createElement("div");
player.id = "inline-preview-player";
player.className = "html5-video-player";
const zeroHeightVideoContainer = document.createElement("div");
zeroHeightVideoContainer.className = "html5-video-container";
const video = document.createElement("video");
const playerRect = makeRect(40, 120, 402, 226);
setRect(player, playerRect);
setBoxMetrics(player, playerRect.width, playerRect.height);
setRect(zeroHeightVideoContainer, makeRect(40, 120, 402, 0));
setBoxMetrics(zeroHeightVideoContainer, 402, 0);
setRect(video, playerRect);
video.src = "blob:https://www.youtube.com/hover-preview";
zeroHeightVideoContainer.appendChild(video);
player.appendChild(zeroHeightVideoContainer);
document.body.appendChild(player);
window.ensureController(video, zeroHeightVideoContainer);
window.ensureController(video, zeroHeightVideoContainer);
expect(video.vsc.div.parentElement).toBe(player);
expect(video.vsc.controllerHostMount).toBe(player);
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
expect(video.vsc.div.style.getPropertyValue("width")).toBe("402px");
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
});
it("targets the controller nearest the pointer unless change-all is selected", async () => {
bootInject();
await settleLifecycle();
const first = createControlledVideo({
src: "https://example.org/first.mp4",
mountRect: makeRect(0, 0, 320, 180)
});
const second = createControlledVideo({
src: "https://example.org/second.mp4",
mountRect: makeRect(500, 0, 320, 180)
});
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
);
window.runAction("faster", 0.1);
expect(first.video.playbackRate).toBe(1);
expect(second.video.playbackRate).toBe(1.1);
window.tc.settings.shortcutTargetMode = "all";
window.runAction("faster", 0.1);
expect(first.video.playbackRate).toBe(1.1);
expect(second.video.playbackRate).toBe(1.2);
});
it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({
url: "https://www.youtube.com/",
path: "/"
});
await settleLifecycle();
const preview = createControlledVideo({
src: "blob:https://www.youtube.com/hover-preview",
mountRect: makeRect(0, 0, 320, 180)
});
preview.mount.id = "inline-preview-player";
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
);
expect(window.tc.lastPointerPosition).toEqual(
expect.objectContaining({ document, x: 100, y: 90 })
);
window.history.pushState({}, "", "/watch?v=next-video");
const main = createControlledVideo({
src: "blob:https://www.youtube.com/main-player",
mountRect: makeRect(0, 0, 1280, 720)
});
main.mount.id = "movie_player";
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
);
document.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyD",
key: "d"
})
);
expect(window.tc.lastPointerPosition).not.toBeNull();
expect(preview.video.playbackRate).toBe(1);
expect(main.video.playbackRate).toBe(1.1);
});
it("captures SPA shortcuts before later page handlers and distinguishes Shift", async () => {
vi.useFakeTimers();
bootInject({
syncGetDelayMs: 25,
syncData: {
keyBindings: [
{ action: "advance", code: "KeyX", value: 10 },
{ action: "advance", code: "KeyX", shiftKey: true, value: 3 }
]
}
});
expect(window.vscKeydownListenerAttached).toBe(true);
window.addEventListener(
"keydown",
(event) => event.stopImmediatePropagation(),
true
);
await vi.advanceTimersByTimeAsync(25);
await settleLifecycle();
const { video } = createControlledVideo();
video.currentTime = 50;
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyX",
key: "x"
})
);
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyX",
key: "X",
shiftKey: true
})
);
expect(video.currentTime).toBe(63);
});
it("finishes a forced SPA initialization after settings hydration", async () => {
vi.useFakeTimers();
bootInject({
url: "https://www.youtube.com/",
syncGetDelayMs: 1000
});
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 1280, 720);
mount.id = "movie_player";
video.src = "blob:https://www.youtube.com/main-player";
setRect(mount, rect);
setBoxMetrics(mount, rect.width, rect.height);
setRect(video, rect);
mount.appendChild(video);
document.body.appendChild(mount);
document.dispatchEvent(new Event("speeder-location-changed"));
await vi.advanceTimersByTimeAsync(300);
expect(window.tc.runtimeSettingsHydrated).toBe(false);
expect(video.vsc).toBeUndefined();
await vi.advanceTimersByTimeAsync(700);
await settleLifecycle();
expect(window.tc.runtimeSettingsHydrated).toBe(true);
expect(video.vsc).toBeTruthy();
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyD",
key: "d"
})
);
expect(video.playbackRate).toBe(1.1);
});
it("skips ambient loops by default and includes them when explicitly enabled", async () => {
bootInject();
await settleLifecycle();
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 480, 270);
video.autoplay = true;
video.defaultMuted = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.controls = false;
video.src = "https://example.org/card-loop.mp4";
setRect(mount, rect);
setBoxMetrics(mount, rect.width, rect.height);
setRect(video, rect);
mount.appendChild(video);
document.body.appendChild(mount);
expect(window.ensureController(video, mount)).toBeNull();
expect(video.vsc).toBeUndefined();
window.tc.settings.showAmbientLoopControls = true;
window.tc.siteRuleBase.showAmbientLoopControls = true;
expect(window.ensureController(video, mount)).toBeTruthy();
expect(video.vsc.div.isConnected).toBe(true);
});
it("always includes a genuine player even when its video has an ambient-loop signature", async () => {
bootInject();
await settleLifecycle();
const player = document.createElement("media-player");
const video = document.createElement("video");
const playButton = document.createElement("button");
const rect = makeRect(0, 0, 640, 360);
playButton.setAttribute("aria-label", "Play");
video.autoplay = true;
video.defaultMuted = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.src = "https://example.org/interactive-loop.mp4";
player.append(video, playButton);
document.body.appendChild(player);
[player, video].forEach((element) => setRect(element, rect));
setBoxMetrics(player, rect.width, rect.height);
expect(window.tc.settings.showAmbientLoopControls).toBe(false);
expect(window.ensureController(video, player)).toBeTruthy();
expect(video.vsc.div.isConnected).toBe(true);
});
it("applies the ambient-loop setting from a matching site rule", async () => {
bootInject({
url: "https://news.example.org/story",
syncData: {
showAmbientLoopControls: false,
siteRules: [
{
pattern: "news.example.org",
enabled: true,
showAmbientLoopControls: true
}
]
}
});
await settleLifecycle();
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 480, 270);
video.autoplay = true;
video.defaultMuted = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.src = "https://news.example.org/card.mp4";
mount.appendChild(video);
document.body.appendChild(mount);
[mount, video].forEach((element) => setRect(element, rect));
setBoxMetrics(mount, rect.width, rect.height);
expect(window.ensureController(video, mount)).toBeTruthy();
expect(window.tc.settings.showAmbientLoopControls).toBe(true);
});
it("moves the host into an ancestor fullscreen subtree and restores it", async () => {
bootInject();
await settleLifecycle();
const normalMount = document.createElement("div");
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 640, 360);
normalMount.style.isolation = "isolate";
provider.appendChild(video);
fullscreenPlayer.appendChild(provider);
normalMount.append(fullscreenPlayer, wrapper);
document.body.appendChild(normalMount);
[normalMount, fullscreenPlayer, provider, video].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
const controller = {
video,
div: wrapper,
normalControllerMount: normalMount
};
window.setupControllerHostTracking(controller, wrapper, normalMount);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
window.syncControllerFullscreenMount(controller);
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.parentElement).toBe(normalMount);
wrapper.remove();
controller.controllerHostCleanup();
});
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 1280, 720);
fullscreenPlayer.append(video, wrapper);
document.body.appendChild(fullscreenPlayer);
[fullscreenPlayer, video].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
const controller = {
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentNode).toBe(fullscreenPlayer);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
wrapper.remove();
controller.controllerHostCleanup();
});
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
bootInject();
await settleLifecycle();
const fullscreenRequests = [];
Object.defineProperty(window.Element.prototype, "requestFullscreen", {
configurable: true,
value: function(options) {
fullscreenRequests.push({ target: this, options });
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: this
});
return Promise.resolve();
}
});
evaluateScript("extension/content/shadow-bridge.js");
const { mount, video, controller, wrapper } = createControlledVideo();
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
await video.requestFullscreen({ navigationUI: "hide" });
document.dispatchEvent(new Event("fullscreenchange"));
window.syncControllerFullscreenMount(controller);
expect(fullscreenRequests).toEqual([
{
target: video,
options: { navigationUI: "hide" }
}
]);
expect(document.fullscreenElement).toBe(video);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.parentNode).toBe(mount);
expect(video.contains(wrapper)).toBe(false);
expect(wrapper.isConnected).toBe(true);
});
it("uses a top-layer popover when native controls fullscreen the video element", async () => {
bootInject();
await settleLifecycle();
const { mount, video, controller, wrapper } = createControlledVideo();
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: video
});
expect(window.getControllerMount(video, video)).toBe(mount);
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.parentNode).toBe(mount);
expect(video.contains(wrapper)).toBe(false);
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
expect(wrapper.hasAttribute("popover")).toBe(false);
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(false);
});
it("uses the fullscreen boundary for a portrait video beneath a gesture pane", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("media-player");
const provider = document.createElement("media-provider");
const video = document.createElement("video");
const gesturePane = document.createElement("div");
const playerRect = makeRect(0, 0, 1280, 720);
const portraitRect = makeRect(440, 10, 400, 700);
gesturePane.className = "gesture-pane";
provider.appendChild(video);
fullscreenPlayer.append(provider, gesturePane);
document.body.appendChild(fullscreenPlayer);
setRect(fullscreenPlayer, playerRect);
setRect(gesturePane, playerRect);
setRect(provider, portraitRect);
setRect(video, portraitRect);
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
fullscreenPlayer
);
});
it("repairs a controller host removed independently of its video", async () => {
bootInject();
await settleLifecycle();
const { mount, video, wrapper } = createControlledVideo();
wrapper.remove();
expect(video.isConnected).toBe(true);
window.ensureController(video, mount);
expect(video.vsc).toBeDefined();
expect(video.vsc.div.isConnected).toBe(true);
expect(video.vsc.div.parentElement).toBe(mount);
});
it("still creates controls when a custom player throws from playbackRate", async () => {
bootInject();
await settleLifecycle();
const mount = document.createElement("div");
const video = document.createElement("video");
video.src = "https://example.org/rejecting-player.mp4";
Object.defineProperty(video, "playbackRate", {
configurable: true,
get() {
return 1;
},
set() {
throw new Error("rate rejected");
}
});
mount.appendChild(video);
document.body.appendChild(mount);
expect(() => window.ensureController(video, mount)).not.toThrow();
expect(video.vsc).toBeDefined();
expect(video.vsc.div.isConnected).toBe(true);
});
it("remounts an existing controller when its video moves between players", async () => {
bootInject();
await settleLifecycle();
const mountA = document.createElement("div");
const mountB = document.createElement("div");
const rect = makeRect(0, 0, 640, 360);
[mountA, mountB].forEach((mount) => {
setRect(mount, rect);
setBoxMetrics(mount, rect.width, rect.height);
document.body.appendChild(mount);
});
const { video, wrapper } = createControlledVideo({ mount: mountA });
mountB.appendChild(video);
window.ensureController(video, mountB);
expect(video.vsc.div).toBe(wrapper);
expect(wrapper.parentElement).toBe(mountB);
expect(video.vsc.controllerHostMount).toBe(mountB);
expect(mountA.querySelector(".vsc-controller")).toBeNull();
});
it("reconciles a burst of player mutations only once per idle batch", async () => {
bootInject();
await settleLifecycle();
const { mount, wrapper } = createControlledVideo();
const originalAppendChild = mount.appendChild.bind(mount);
let controllerReorders = 0;
mount.appendChild = function(node) {
if (node === wrapper) controllerReorders += 1;
return originalAppendChild(node);
};
for (let index = 0; index < 25; index += 1) {
mount.appendChild(document.createElement("span"));
}
await settleLifecycle(8);
expect(controllerReorders).toBe(1);
expect(mount.lastElementChild).toBe(wrapper);
});
it("keeps expanded controls visible and disables auto-hide while hovered", async () => {
vi.useFakeTimers();
bootInject({
syncData: {
hideWithControls: true,
hideWithControlsTimer: 1
}
});
await settleLifecycle();
const { controller, wrapper } = createControlledVideo();
const controllerElement = window.getControllerElement(controller);
controllerElement.dispatchEvent(new Event("pointerenter"));
expect(controller.controllerInteractionActive).toBe(true);
expect(controllerElement.classList.contains("vsc-controls-hovered")).toBe(true);
expect(wrapper.classList.contains("vsc-controls-hovered")).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(false);
controllerElement.dispatchEvent(new Event("pointerleave"));
expect(controller.controllerInteractionActive).toBe(false);
await vi.advanceTimersByTimeAsync(1000);
expect(wrapper.classList.contains("vsc-idle-hidden")).toBe(true);
});
it("does not let YouTube auto-hide collapse controls under the pointer", async () => {
vi.useFakeTimers();
bootInject({
url: "https://www.youtube.com/watch?v=hover-lock",
syncData: {
hideWithControls: true,
hideWithControlsTimer: 1
}
});
await settleLifecycle();
const player = document.createElement("div");
player.className = "html5-video-player ytp-autohide";
const { controller, wrapper } = createControlledVideo({ mount: player });
const controllerElement = window.getControllerElement(controller);
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
controllerElement.dispatchEvent(new Event("pointerenter"));
expect(wrapper.classList.contains("ytp-autohide")).toBe(false);
controllerElement.dispatchEvent(new Event("pointerleave"));
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
player.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
expect(wrapper.classList.contains("vsc-show")).toBe(true);
player.classList.remove("ytp-autohide");
player.classList.add("ytp-autohide");
await settleLifecycle();
expect(wrapper.classList.contains("ytp-autohide")).toBe(true);
expect(wrapper.classList.contains("vsc-show")).toBe(true);
});
it("suppresses a zero-size host and reveals it when the video becomes visible", async () => {
bootInject();
await settleLifecycle();
const mount = document.createElement("div");
const mountRect = makeRect(100, 50, 640, 360);
let videoRect = makeRect(100, 50, 0, 0);
setRect(mount, mountRect);
setBoxMetrics(mount, mountRect.width, mountRect.height);
const video = document.createElement("video");
video.src = "https://example.org/preload.mp4";
video.getBoundingClientRect = () => videoRect;
mount.appendChild(video);
document.body.appendChild(mount);
window.ensureController(video, mount);
const wrapper = video.vsc.div;
expect(hostIsGeometrySuppressed(wrapper)).toBe(true);
videoRect = makeRect(140, 80, 320, 180);
window.positionControllerHost(wrapper, video, mount);
expect(hostIsGeometrySuppressed(wrapper)).toBe(false);
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
});
it("chooses visible media over an offscreen player for popup actions", async () => {
bootInject();
await settleLifecycle();
const offscreen = createControlledVideo({
mountRect: makeRect(0, 2000, 1280, 720),
videoRect: makeRect(0, 2000, 1280, 720),
src: "https://example.org/offscreen.mp4"
}).video;
const visible = createControlledVideo({
mountRect: makeRect(40, 40, 640, 360),
videoRect: makeRect(40, 40, 640, 360),
src: "https://example.org/visible.mp4"
}).video;
expect(window.getPrimaryVideoElement()).toBe(visible);
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
});
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
bootInject();
await settleLifecycle();
const host = document.createElement("custom-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
const rect = makeRect(20, 30, 640, 360);
video.src = "https://example.org/shadow.mp4";
setRect(host, rect);
setBoxMetrics(host, rect.width, rect.height);
setRect(video, rect);
shadow.appendChild(video);
document.body.appendChild(host);
window.observeRoot(shadow);
window.scanRootForMedia(shadow);
expect(video.vsc).toBeDefined();
expect(video.vsc.div.getRootNode()).toBe(shadow);
expect(shadow.contains(video.vsc.div)).toBe(true);
});
it("keeps a shadow player controller in its visible root during host fullscreen", async () => {
bootInject();
await settleLifecycle();
const host = document.createElement("fullscreen-shadow-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
const rect = makeRect(0, 0, 640, 360);
video.src = "https://example.org/shadow-fullscreen.mp4";
setRect(host, rect);
setBoxMetrics(host, rect.width, rect.height);
setRect(video, rect);
shadow.appendChild(video);
document.body.appendChild(host);
window.observeRoot(shadow);
window.scanRootForMedia(shadow);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: host
});
window.syncControllerFullscreenMount(video.vsc);
expect(window.getControllerMount(video, host)).toBe(shadow);
expect(video.vsc.div.parentNode).toBe(shadow);
expect(host.contains(video.vsc.div)).toBe(false);
});
it("removes controllers when their open-shadow host is detached", async () => {
bootInject();
await settleLifecycle();
const host = document.createElement("detached-shadow-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
video.src = "https://example.org/detached-shadow.mp4";
shadow.appendChild(video);
document.body.appendChild(host);
window.observeRoot(shadow);
window.scanRootForMedia(shadow);
const wrapper = video.vsc.div;
host.remove();
await settleLifecycle(6);
expect(video.vsc).toBeUndefined();
expect(window.tc.mediaElements).not.toContain(video);
expect(wrapper.isConnected).toBe(false);
});
it("starts and ends a controller drag mounted directly in an open ShadowRoot", async () => {
bootInject();
await settleLifecycle();
const host = document.createElement("drag-shadow-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
const rect = makeRect(20, 30, 640, 360);
video.src = "https://example.org/drag-shadow.mp4";
setRect(host, rect);
setBoxMetrics(host, rect.width, rect.height);
setRect(video, rect);
shadow.appendChild(video);
document.body.appendChild(host);
window.observeRoot(shadow);
window.scanRootForMedia(shadow);
expect(() =>
window.handleDrag(video, { clientX: 30, clientY: 40 })
).not.toThrow();
expect(video.classList.contains("vcs-dragging")).toBe(true);
host.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(video.classList.contains("vcs-dragging")).toBe(false);
});
it("queues early shadow media until async settings hydration completes", async () => {
vi.useFakeTimers();
bootInject({ syncGetDelayMs: 100 });
const host = document.createElement("early-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
video.src = "https://example.org/early.mp4";
shadow.appendChild(video);
document.body.appendChild(host);
await settleLifecycle();
expect(video.vsc).toBeUndefined();
await vi.advanceTimersByTimeAsync(120);
await settleLifecycle();
expect(video.vsc).toBeDefined();
expect(video.vsc.div.isConnected).toBe(true);
expect(video.vsc.div.getRootNode()).toBe(shadow);
});
it("reconciles source children and the emptied lifecycle on an existing controller", async () => {
bootInject();
await settleLifecycle();
const { video, wrapper } = createControlledVideo();
video.preload = "none";
video.removeAttribute("src");
video.dispatchEvent(new Event("emptied"));
await settleLifecycle();
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
const source = document.createElement("source");
source.src = "https://example.org/later.mp4";
video.appendChild(source);
await settleLifecycle();
expect(wrapper.classList.contains("vsc-nosource")).toBe(false);
source.remove();
video.dispatchEvent(new Event("emptied"));
await settleLifecycle();
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
});
it("applies the newly matched SPA rule before choosing source-transition speed", async () => {
bootInject({
path: "/watch/one",
syncData: {
lastSpeed: 1.8,
rememberSpeed: false,
siteRules: [
{
pattern: "example.org/watch/",
enabled: true,
rememberSpeed: false
},
{
pattern: "example.org/shorts/",
enabled: true,
rememberSpeed: true
}
]
},
localData: {
rememberedSpeeds: {
"https://example.org/short-two.mp4": {
speed: 1.8,
updatedAt: 100
}
}
}
});
await settleLifecycle();
const { video } = createControlledVideo({
src: "https://example.org/watch-one.mp4"
});
expect(video.playbackRate).toBe(1);
window.history.replaceState({}, "", "/shorts/two");
video.src = "https://example.org/short-two.mp4";
window.applySourceTransitionPolicy(video, true);
expect(window.tc.activeSiteRule.pattern).toBe("example.org/shorts/");
expect(window.tc.settings.rememberSpeed).toBe(true);
expect(video.vsc.targetSpeed).toBe(1.8);
expect(video.playbackRate).toBe(1.8);
});
it("activates after an initially disabled SPA route becomes enabled", async () => {
vi.useFakeTimers();
bootInject({
path: "/disabled",
syncData: {
siteRules: [
{ pattern: "example.org/disabled", enabled: false },
{ pattern: "example.org/enabled", enabled: true }
]
}
});
const video = document.createElement("video");
video.src = "https://example.org/enabled.mp4";
document.body.appendChild(video);
expect(video.vsc).toBeUndefined();
window.history.pushState({}, "", "/enabled");
window.dispatchEvent(new window.PopStateEvent("popstate"));
await vi.advanceTimersByTimeAsync(350);
await settleLifecycle();
expect(video.vsc).toBeDefined();
expect(video.vsc.div.isConnected).toBe(true);
});
it("rescans an existing open-shadow player when an SPA route becomes enabled", async () => {
vi.useFakeTimers();
bootInject({
path: "/disabled",
syncData: {
siteRules: [
{ pattern: "example.org/disabled", enabled: false },
{ pattern: "example.org/enabled", enabled: true }
]
}
});
const host = document.createElement("route-shadow-player");
const shadow = host.attachShadow({ mode: "open" });
const video = document.createElement("video");
video.src = "https://example.org/route-shadow.mp4";
shadow.appendChild(video);
document.body.appendChild(host);
window.observeRoot(shadow);
expect(video.vsc).toBeUndefined();
window.history.pushState({}, "", "/enabled");
window.dispatchEvent(new window.PopStateEvent("popstate"));
await vi.advanceTimersByTimeAsync(350);
await settleLifecycle();
expect(video.vsc).toBeDefined();
expect(video.vsc.div.getRootNode()).toBe(shadow);
expect(video.vsc.div.isConnected).toBe(true);
});
it("tears controllers down when an enabled SPA route becomes disabled", async () => {
vi.useFakeTimers();
bootInject({
path: "/enabled",
syncData: {
siteRules: [
{ pattern: "example.org/enabled", enabled: true },
{ pattern: "example.org/disabled", enabled: false }
]
}
});
const { video } = createControlledVideo();
expect(video.vsc).toBeDefined();
window.history.pushState({}, "", "/disabled");
window.dispatchEvent(new window.PopStateEvent("popstate"));
await vi.advanceTimersByTimeAsync(350);
await settleLifecycle();
expect(video.vsc).toBeUndefined();
expect(document.querySelector(".vsc-controller")).toBeNull();
});
it("removes existing audio controllers when live settings disable audio", async () => {
vi.useFakeTimers();
const chrome = bootInject({ syncData: { audioBoolean: true } });
await settleLifecycle();
const mount = document.createElement("div");
const audio = document.createElement("audio");
audio.src = "https://example.org/podcast.mp3";
mount.appendChild(audio);
document.body.appendChild(mount);
window.ensureController(audio, mount);
expect(audio.vsc).toBeDefined();
chrome.storage.sync.set({ audioBoolean: false });
await vi.advanceTimersByTimeAsync(100);
await settleLifecycle();
expect(audio.vsc).toBeUndefined();
});
});
+5 -252
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,
@@ -139,250 +137,5 @@ describe("inject.js helper logic", () => {
expect(window.tc.settings.controllerMarginTop).toBe(200);
expect(window.tc.settings.controllerMarginBottom).toBe(0);
expect(window.tc.settings.rememberSpeed).toBe(true);
window.resetSettingsFromSiteRuleBase();
window.tc.settings.shortcutTargetMode = "closest";
window.tc.settings.siteRules = [
{ pattern: "example.org", shortcutTargetMode: "all" }
];
window.captureSiteRuleBase();
window.applySiteRuleOverrides();
expect(window.tc.settings.shortcutTargetMode).toBe("all");
});
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();
});
it("only blocks page key handlers when the shortcut force option is enabled", async () => {
bootInject({
syncData: {
keyBindings: [
{
action: "faster",
code: "KeyD",
value: 0.1,
force: false
}
]
}
});
await flushAsyncWork(3);
const video = document.createElement("video");
video.src = "https://example.org/shortcut.mp4";
document.body.appendChild(video);
window.ensureController(video, document.body);
let pageKeydowns = 0;
let pageCaptureKeydowns = 0;
document.addEventListener(
"keydown",
function() {
pageCaptureKeydowns += 1;
},
true
);
document.body.addEventListener("keydown", function() {
pageKeydowns += 1;
});
const unblocked = new KeyboardEvent("keydown", {
key: "d",
code: "KeyD",
bubbles: true,
cancelable: true
});
video.dispatchEvent(unblocked);
expect(video.playbackRate).toBeCloseTo(1.1, 5);
expect(pageKeydowns).toBe(1);
expect(pageCaptureKeydowns).toBe(1);
expect(unblocked.defaultPrevented).toBe(false);
window.tc.settings.keyBindings[0].force = "true";
const blocked = new KeyboardEvent("keydown", {
key: "d",
code: "KeyD",
bubbles: true,
cancelable: true
});
video.dispatchEvent(blocked);
expect(video.playbackRate).toBeCloseTo(1.2, 5);
expect(pageKeydowns).toBe(1);
expect(pageCaptureKeydowns).toBe(1);
expect(blocked.defaultPrevented).toBe(true);
});
});
+9 -186
View File
@@ -1,13 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import {
createChromeMock,
flushAsyncWork,
loadHtmlString,
loadScript
} from "./helpers/browser.js";
import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js";
function loadBlankDocument() {
loadHtmlString("<!doctype html><html><body></body></html>");
document.open();
document.write("<!doctype html><html><body></body></html>");
document.close();
}
async function bootInject({ sync = {}, local = {} } = {}) {
@@ -38,11 +35,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 +48,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,156 +87,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
await vi.advanceTimersByTimeAsync(3000);
// The bounded discovery pass yields to an idle callback before traversing.
await vi.advanceTimersByTimeAsync(2000);
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(
{
+13 -166
View File
@@ -7,17 +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/settings-core.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,10 +28,7 @@ describe("options page", () => {
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
showAmbientLoopControls: true,
enabled: false,
shortcutTargetMode: "all",
subtitleNudgeEnabledByDefault: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
keyBindings: [
@@ -41,13 +37,8 @@ describe("options page", () => {
],
siteRules: [
{
title: "YouTube testing",
pattern: "youtube.com",
enabled: true,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
showAmbientLoopControls: false,
subtitleNudgeEnabledByDefault: false,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
}
@@ -57,37 +48,13 @@ describe("options page", () => {
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("showAmbientLoopControls").checked).toBe(true);
expect(document.getElementById("enabled").checked).toBe(false);
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
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-preferredSpeed").value).toBe(
"2.4"
);
expect(
document.querySelector(".site-rule .site-showAmbientLoopControls").checked
).toBe(false);
expect(
document.querySelector(".site-rule .site-shortcutTargetMode").value
).toBe("all");
expect(document.querySelector(".site-rule .site-title").value).toBe(
"YouTube testing"
);
expect(
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
.checked
).toBe(false);
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
@@ -106,48 +73,6 @@ describe("options page", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds and restores duplicate actions with Shift bindings", async () => {
const chrome = await setupOptions();
const selector = document.getElementById("addShortcutSelector");
expect(selector.querySelector('option[value="rewind"]')).not.toBeNull();
selector.value = "rewind";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const duplicate = document.querySelector(
'.shortcut-row.customs[data-action="rewind"]'
);
duplicate.querySelector(".customKey").dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Z",
code: "KeyZ",
shiftKey: true,
bubbles: true
})
);
duplicate.querySelector(".customValue").value = "3";
globalThis.save_options();
expect(
chrome.storage.sync.__state.keyBindings.filter(
(binding) => binding.action === "rewind"
)
).toEqual([
expect.objectContaining({ value: 10, shiftKey: false }),
expect.objectContaining({ value: 3, shiftKey: true })
]);
globalThis.restore_options();
await flushAsyncWork();
expect(
document.querySelectorAll('.shortcut-row[data-action="rewind"]')
).toHaveLength(2);
expect(
document.querySelector(
'.shortcut-row.customs[data-action="rewind"] .customKey'
).value
).toBe("Shift+Z");
});
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
await setupOptions({ sync: { siteRules: [] } });
@@ -171,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: [] } });
@@ -229,13 +128,11 @@ describe("options page", () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("showAmbientLoopControls").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0";
document.getElementById("controllerMarginTop").value = "250";
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;
@@ -249,20 +146,11 @@ describe("options page", () => {
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-title").value = "My YouTube rule";
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").checked = true;
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".site-showAmbientLoopControls").checked = false;
rule.querySelector(".site-preferredSpeed").value = "2.4";
rule.querySelector(".override-shortcut-target").checked = true;
rule.querySelector(".site-shortcutTargetMode").value = "all";
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(
@@ -276,72 +164,31 @@ describe("options page", () => {
globalThis.save_options();
const savedSettings = globalThis.vscExpandStoredSettings(
chrome.storage.sync.__state
);
expect(chrome.storage.sync.remove).toHaveBeenCalled();
const savedSettings =
chrome.storage.sync.set.mock.calls[
chrome.storage.sync.set.mock.calls.length - 1
][0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.showAmbientLoopControls).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
expect(savedSettings.subtitleNudgeInterval).toBe(250);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "My YouTube rule",
pattern: "youtube.com",
rememberSpeed: true,
showAmbientLoopControls: false,
shortcutTargetMode: "all",
preferredSpeed: 2.4,
controllerOpacity: 0,
enableSubtitleNudge: true,
subtitleNudgeEnabledByDefault: false,
subtitleNudgeInterval: 250,
showPopupControlBar: false,
popupControllerButtons: ["advance"]
})
])
);
});
it("restores managed defaults and clears private local UI/runtime data", async () => {
const chrome = await setupOptions({
sync: {
rememberSpeed: true,
siteRules: [],
lastSpeed: 1.6,
futureRuntimeValue: "keep"
},
local: {
customButtonIcons: { faster: { slug: "rocket" } },
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
unrelatedLocalValue: "keep"
}
});
globalThis.restore_defaults();
await flushAsyncWork();
const raw = chrome.storage.sync.__state;
const restored = globalThis.vscExpandStoredSettings(raw);
expect(restored.rememberSpeed).toBe(false);
expect(restored.siteRules).toEqual(
globalThis.vscGetSettingsDefaults().siteRules
);
expect(raw.lastSpeed).toBe(1);
expect(raw.futureRuntimeValue).toBe("keep");
expect(chrome.storage.local.__state.customButtonIcons).toBeUndefined();
expect(chrome.storage.local.__state.rememberedSpeeds).toBeUndefined();
expect(
chrome.storage.local.__state.rememberedSpeedsResetAt
).toEqual(expect.any(Number));
expect(chrome.storage.local.__state.unrelatedLocalValue).toBe("keep");
});
});
+37 -256
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,13 +31,9 @@ function bootOptions(options) {
);
window.fetch = global.fetch;
evaluateScript("extension/shared/controller-utils.js");
evaluateScript("extension/shared/key-bindings.js");
evaluateScript("extension/shared/settings-core.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;
@@ -50,234 +47,6 @@ describe("options.js", () => {
delete global.fetch;
});
it("rejects invalid zero-valued speed steps instead of saving broken shortcuts", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
const fasterRow = Array.from(
document.querySelectorAll("#customs .shortcut-row")
).find((row) => row.dataset.action === "faster");
fasterRow.querySelector(".customValue").value = "0";
chrome.storage.sync.set.mockClear();
window.save_options();
expect(document.getElementById("status").textContent).toContain(
"must be greater than 0"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("automatically saves changed settings", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
vi.useFakeTimers();
chrome.storage.sync.set.mockClear();
const rememberSpeed = document.getElementById("rememberSpeed");
rememberSpeed.checked = true;
rememberSpeed.dispatchEvent(new Event("change", { bubbles: true }));
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
expect(document.getElementById("status").textContent).toBe("Auto-saved");
});
it("does not partially save options when a required site shortcut is invalid", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
window.createSiteRule({
pattern: "example.org",
shortcuts: [
{
action: "faster",
code: "KeyD",
value: 0.1,
disabled: false
}
]
});
const rule = document.getElementById("siteRulesContainer").lastElementChild;
rule.querySelector('.shortcut-row[data-action="faster"] .customKey').vscBinding =
null;
document.getElementById("rememberSpeed").checked = true;
chrome.storage.sync.set.mockClear();
chrome.storage.sync.remove.mockClear();
window.save_options();
expect(document.getElementById("status").textContent).toContain(
"cannot be empty"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
});
it("preserves the supported unexposed subtitle nudge amount when saving", async () => {
const chrome = bootOptions({ syncData: { subtitleNudgeAmount: 0.004 } });
await flushAsyncWork(3);
window.save_options();
expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump())
.subtitleNudgeAmount
).toBe(0.004);
});
it("does not turn a blank per-site preferred speed into the 1.8 fast default", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
const shortsRule = Array.from(document.querySelectorAll(".site-rule")).find(
(rule) => rule.querySelector(".site-title").value === "YouTube Shorts"
);
expect(shortsRule).toBeTruthy();
expect(shortsRule.querySelector(".site-preferredSpeed").value).toBe("");
window.save_options();
const expanded = window.vscExpandStoredSettings(chrome.storage.sync._dump());
const savedShortsRule = expanded.siteRules.find(
(rule) => rule.title === "YouTube Shorts"
);
expect(savedShortsRule.preferredSpeed).toBeUndefined();
});
it("keeps the loaded UI and blocks saves when a settings reload fails", async () => {
const chrome = bootOptions({
syncData: {
rememberSpeed: true,
siteRules: [{ pattern: "example.org", enabled: true }]
}
});
await flushAsyncWork(3);
const originalRuleCount = document.querySelectorAll(".site-rule").length;
chrome.storage.sync.get.mockImplementationOnce(function (_keys, callback) {
chrome.runtime.lastError = { message: "temporary read failure" };
callback({});
chrome.runtime.lastError = null;
});
chrome.storage.sync.set.mockClear();
chrome.storage.sync.remove.mockClear();
window.restore_options();
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.querySelectorAll(".site-rule")).toHaveLength(
originalRuleCount
);
expect(document.getElementById("save").disabled).toBe(true);
expect(document.getElementById("status").textContent).toContain(
"temporary read failure"
);
document.getElementById("rememberSpeed").checked = false;
window.save_options();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
expect(document.getElementById("status").textContent).toContain(
"Settings have not loaded"
);
});
it("does not clear local data or live rows when the sync reset fails", async () => {
const chrome = bootOptions({
syncData: {
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
]
},
localData: {
customButtonIcons: { faster: { slug: "rocket" } },
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
}
});
await flushAsyncWork(3);
expect(
document.querySelector('.shortcut-row.customs[data-action="pause"]')
).not.toBeNull();
chrome.storage.local.remove.mockClear();
chrome.storage.sync.remove.mockImplementationOnce(function (_keys, callback) {
chrome.runtime.lastError = { message: "sync reset failed" };
callback();
chrome.runtime.lastError = null;
});
window.restore_defaults();
await flushAsyncWork(3);
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
expect(
document.querySelector('.shortcut-row.customs[data-action="pause"]')
).not.toBeNull();
expect(document.getElementById("save").disabled).toBe(false);
expect(document.getElementById("status").textContent).toContain(
"sync reset failed"
);
});
it("reports a local reset failure after resetting sync defaults", async () => {
const chrome = bootOptions({
syncData: { rememberSpeed: true, lastSpeed: 1.75 },
localData: { customButtonIcons: { faster: { slug: "rocket" } } }
});
await flushAsyncWork(3);
chrome.storage.local.remove.mockImplementationOnce(function (_keys, callback) {
chrome.runtime.lastError = { message: "local reset failed" };
callback();
chrome.runtime.lastError = null;
});
window.restore_defaults();
await flushAsyncWork(3);
expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump()).rememberSpeed
).toBe(false);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1);
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
expect(document.getElementById("save").disabled).toBe(false);
expect(document.getElementById("status").textContent).toContain(
"local reset failed"
);
});
it("does not remove local data when the speed reset marker cannot be written", async () => {
const chrome = bootOptions({
localData: {
customButtonIcons: { faster: { slug: "rocket" } },
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
}
});
await flushAsyncWork(3);
chrome.storage.local.remove.mockClear();
chrome.storage.local.set.mockImplementationOnce(function (_items, callback) {
chrome.runtime.lastError = { message: "reset marker failed" };
callback();
chrome.runtime.lastError = null;
});
window.restore_defaults();
await flushAsyncWork(3);
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
expect(document.getElementById("status").textContent).toContain(
"reset marker failed"
);
});
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
bootOptions({
syncData: {
@@ -350,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";
@@ -374,31 +142,44 @@ describe("options.js", () => {
window.save_options();
const savedSettings = window.vscExpandStoredSettings(
chrome.storage.sync._dump()
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
[
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"blacklist"
],
expect.any(Function)
);
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerLocation).toBe("bottom-left");
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
expect(savedSettings.siteRules).toHaveLength(4);
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 -87
View File
@@ -7,14 +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/settings-core.js");
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;
@@ -30,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: {
@@ -61,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");
@@ -86,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"
}
});
});
@@ -106,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);
@@ -135,62 +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
},
expect.any(Function)
);
});
it("keeps the global force setting unchanged when a site rule controls it", async () => {
const chrome = await setupPopup({
sync: {
forceLastSavedSpeed: false,
siteRules: [
{
pattern: "example.com",
enabled: true,
forceLastSavedSpeed: true
}
]
}
});
const forceButton = document.getElementById("forceLastSavedSpeed");
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
const storageBeforeClick = JSON.parse(
JSON.stringify(chrome.storage.sync.__state)
);
chrome.storage.sync.set.mockClear();
chrome.storage.sync.remove.mockClear();
chrome.tabs.sendMessage.mockClear();
forceButton.click();
await flushAsyncWork();
expect(chrome.storage.sync.__state).toEqual(storageBeforeClick);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
expect(document.getElementById("status").textContent).toBe(
"Force setting is controlled by this site rule."
);
});
});
+25 -31
View File
@@ -1,3 +1,4 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
@@ -6,12 +7,11 @@ const {
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
const manifest = require("../extension/manifest.json");
function bootPopup(options) {
const config = options || {};
loadHtmlFile("extension/popup/popup.html");
loadHtmlFile("popup.html");
installCommonWindowMocks();
const chrome = createChromeMock({
@@ -47,11 +47,8 @@ function bootPopup(options) {
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/shared/settings-core.js");
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;
@@ -64,10 +61,6 @@ describe("popup.js", () => {
delete global.chrome;
});
it("declares activeTab for the popup all-frame speed snapshot", () => {
expect(manifest.permissions).toContain("activeTab");
});
it("renders the popup disabled state when a site rule disables Speeder", async () => {
bootPopup({
syncData: {
@@ -97,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 });
@@ -125,6 +117,7 @@ describe("popup.js", () => {
callback({ speed: 1.0 });
});
document.dispatchEvent(new window.Event("DOMContentLoaded"));
await flushAsyncWork();
const buttons = Array.from(
@@ -145,6 +138,8 @@ describe("popup.js", () => {
});
it("toggles enablement and closes after a successful refresh", async () => {
vi.useFakeTimers();
const chrome = bootPopup({
syncData: {
enabled: false
@@ -152,21 +147,20 @@ 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);
document.querySelector("#enable").click();
expect(chrome.storage.sync._dump()).not.toHaveProperty("enabled");
expect(
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
).toBe(true);
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: true },
expect.any(Function)
);
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"
}
});
-398
View File
@@ -1,398 +0,0 @@
const {
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootSettingsCore() {
loadHtmlString("<!doctype html><html><head></head><body></body></html>");
evaluateScript("extension/shared/settings-core.js");
evaluateScript("extension/shared/site-rules.js");
}
function youtubeShortsRule(settings) {
return settings.siteRules.find(function (rule) {
return String(rule.pattern).includes("\\/shorts\\/");
});
}
describe("canonical settings storage", () => {
beforeEach(() => {
bootSettingsCore();
});
it("expands missing settings to the built-in Shorts remember-speed rule", () => {
const settings = window.vscExpandStoredSettings({});
const shortsRule = youtubeShortsRule(settings);
expect(shortsRule).toEqual(
expect.objectContaining({
title: "YouTube Shorts",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
})
);
expect(settings.shortcutTargetMode).toBe("closest");
expect(settings.showAmbientLoopControls).toBe(false);
expect(settings.subtitleNudgeInterval).toBe(250);
expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined();
});
it("round-trips global and site-specific ambient-loop visibility", () => {
const settings = window.vscExpandStoredSettings({
showAmbientLoopControls: true,
siteRules: [
{
pattern: "cnn.com",
showAmbientLoopControls: false
}
]
});
const restored = window.vscExpandStoredSettings(
window.vscBuildStoredSettingsDiff(settings)
);
expect(restored.showAmbientLoopControls).toBe(true);
expect(restored.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "cnn.com",
showAmbientLoopControls: false
})
])
);
});
it("round-trips global and site-specific shortcut targeting", () => {
const settings = window.vscExpandStoredSettings({
shortcutTargetMode: "all",
siteRules: [
{
title: "One player at a time",
pattern: "example.org",
shortcutTargetMode: "closest"
}
]
});
const stored = window.vscBuildStoredSettingsDiff(settings);
const restored = window.vscExpandStoredSettings(stored);
expect(restored.shortcutTargetMode).toBe("all");
expect(restored.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "example.org",
shortcutTargetMode: "closest"
})
])
);
});
it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
const settings = window.vscExpandStoredSettings({});
expect(settings.siteRules.map((rule) => rule.title)).toEqual([
"YouTube videos",
"YouTube Shorts",
"YouTube Shorts (mobile)"
]);
settings.siteRules[1].title = "Short-form videos";
const stored = window.vscBuildStoredSettingsDiff(settings);
expect(stored.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: settings.siteRules[1].pattern,
title: "Short-form videos"
})
])
);
expect(window.vscExpandStoredSettings(stored).siteRules[1].title).toBe(
"Short-form videos"
);
});
it("keeps an intentionally cleared built-in title cleared", () => {
const settings = window.vscExpandStoredSettings({});
const pattern = settings.siteRules[0].pattern;
delete settings.siteRules[0].title;
const stored = window.vscBuildStoredSettingsDiff(settings);
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
(entry) => entry.pattern === pattern
);
expect(tombstone.keys).toContain("title");
expect(window.vscExpandStoredSettings(stored).siteRules[0]).not.toHaveProperty(
"title"
);
});
it("round-trips an intentionally empty site-rule list", () => {
const expanded = window.vscExpandStoredSettings({ siteRules: [] });
expect(expanded.siteRules).toEqual([]);
const stored = window.vscBuildStoredSettingsDiff(expanded);
expect(stored.siteRulesFormat).toBe("defaults-diff-v2");
expect(stored.siteRules).toBeUndefined();
expect(stored.siteRulesMeta.removedDefaultPatterns).toHaveLength(3);
expect(window.vscExpandStoredSettings(stored).siteRules).toEqual([]);
});
it("keeps removals from built-in rules through sparse persistence", () => {
const settings = window.vscExpandStoredSettings({});
const shortsRule = youtubeShortsRule(settings);
delete shortsRule.rememberSpeed;
const stored = window.vscBuildStoredSettingsDiff(settings);
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
function (entry) {
return entry.pattern === shortsRule.pattern;
}
);
expect(tombstone.keys).toContain("rememberSpeed");
const restoredRule = youtubeShortsRule(
window.vscExpandStoredSettings(stored)
);
expect(restoredRule).not.toHaveProperty("rememberSpeed");
expect(restoredRule.controllerMarginTop).toBe(60);
expect(restoredRule.controllerMarginBottom).toBe(85);
});
it("expands legacy defaults-diff-v1 site-rule patches", () => {
const defaults = window.vscGetSettingsDefaults();
const shortsPattern = youtubeShortsRule(defaults).pattern;
const settings = window.vscExpandStoredSettings({
siteRulesFormat: "defaults-diff-v1",
siteRules: [{ pattern: shortsPattern, controllerMarginTop: 72 }]
});
const shortsRule = youtubeShortsRule(settings);
expect(shortsRule.rememberSpeed).toBe(true);
expect(shortsRule.controllerMarginTop).toBe(72);
expect(shortsRule.controllerMarginBottom).toBe(85);
});
it("migrates legacy shortcut, hide, and blacklist values", () => {
const settings = window.vscExpandStoredSettings({
speedStep: 0.25,
rewindTime: 7,
advanceTime: 14,
fastSpeed: 2.25,
displayKeyCode: 81,
fasterKeyCode: 70,
hideWithYouTubeControls: true,
blacklist: "bad.test\nhttps://blocked.test/watch"
});
const bindings = Object.fromEntries(
settings.keyBindings.map(function (binding) {
return [binding.action, binding];
})
);
expect(bindings.slower.value).toBe(0.25);
expect(bindings.faster).toEqual(
expect.objectContaining({ code: "KeyF", value: 0.25 })
);
expect(bindings.rewind.value).toBe(7);
expect(bindings.advance.value).toBe(14);
expect(bindings.fast.value).toBe(2.25);
expect(bindings.display.code).toBe("KeyQ");
expect(settings.hideWithControls).toBe(true);
expect(settings.siteRules).toEqual(
expect.arrayContaining([
{ pattern: "bad.test", enabled: false },
{ pattern: "https://blocked.test/watch", enabled: false }
])
);
});
it("adds the mobile Shorts default to legacy full default-rule lists", () => {
const defaults = window.vscGetSettingsDefaults();
const legacyRules = defaults.siteRules.slice(0, 2).map(function(rule) {
return Object.assign({}, rule, {
forceLastSavedSpeed: false,
audioBoolean: false
});
});
const expanded = window.vscExpandStoredSettings({ siteRules: legacyRules });
expect(
expanded.siteRules.some(function(rule) {
return String(rule.pattern).includes("m\\.youtube") &&
rule.rememberSpeed === true;
})
).toBe(true);
expect(expanded.siteRules.map((rule) => rule.title)).toEqual([
"YouTube videos",
"YouTube Shorts",
"YouTube Shorts (mobile)"
]);
});
it("normalizes imported rule types and unsafe numeric ranges", () => {
const expanded = window.vscExpandStoredSettings({
enabled: "false",
siteRules: [
{
title: " Example videos ",
pattern: "example.org",
enabled: "false",
rememberSpeed: "false",
forceLastSavedSpeed: "true",
controllerOpacity: 3,
subtitleNudgeInterval: -5,
preferredSpeed: 500
}
]
});
const rule = expanded.siteRules[0];
expect(expanded.enabled).toBe(false);
expect(rule.title).toBe("Example videos");
expect(rule.enabled).toBe(false);
expect(rule.rememberSpeed).toBe(false);
expect(rule.forceLastSavedSpeed).toBe(true);
expect(rule.controllerOpacity).toBe(1);
expect(rule.subtitleNudgeInterval).toBe(250);
expect(rule.preferredSpeed).toBe(16);
});
it("falls back for null and blank numeric settings instead of coercing zero", () => {
const expanded = window.vscExpandStoredSettings({
lastSpeed: null,
controllerMarginBottom: " ",
siteRules: [
{
pattern: "example.org",
preferredSpeed: "",
controllerMarginBottom: null
}
]
});
expect(expanded.lastSpeed).toBe(1);
expect(expanded.controllerMarginBottom).toBe(65);
expect(expanded.siteRules[0].preferredSpeed).toBeUndefined();
expect(expanded.siteRules[0].controllerMarginBottom).toBeUndefined();
});
it("repairs invalid imported shortcut values to safe action defaults", () => {
const expanded = window.vscExpandStoredSettings({
keyBindings: [
{ action: "faster", code: "KeyD", value: 0 },
{ action: "fast", code: "KeyG", value: "not-a-number" },
{ action: "rewind", code: "KeyZ", value: -2 }
]
});
expect(expanded.keyBindings.map((binding) => binding.value)).toEqual([
0.1,
1.8,
10
]);
});
it("drops malformed primitive shortcut entries instead of crashing hydration", () => {
expect(() =>
window.vscExpandStoredSettings({
keyBindings: [null, "bad", 7, { action: "faster", code: "KeyD", value: 0.2 }]
})
).not.toThrow();
expect(
window.vscExpandStoredSettings({
keyBindings: [null, "bad", { action: "faster", code: "KeyD", value: 0.2 }]
}).keyBindings
).toEqual([
expect.objectContaining({ action: "faster", value: 0.2 })
]);
});
it("removes stale managed keys without touching runtime or future data", () => {
const raw = {
rememberSpeed: true,
speedStep: 0.2,
lastSpeed: 1.75,
futureRuntimeValue: { keep: true }
};
const settings = window.vscExpandStoredSettings(raw);
settings.rememberSpeed = false;
const mutation = window.vscBuildManagedStorageMutation(raw, settings);
expect(mutation.remove).toEqual(
expect.arrayContaining(["rememberSpeed", "speedStep"])
);
expect(mutation.remove).not.toContain("lastSpeed");
expect(mutation.remove).not.toContain("futureRuntimeValue");
expect(mutation.set.keyBindings).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: "faster", value: 0.2 })
])
);
});
});
describe("site-rule matching", () => {
beforeEach(() => {
bootSettingsCore();
});
it("merges all matches in order so a later specific rule wins", () => {
const matched = window.vscMatchSiteRule(
"https://www.youtube.com/shorts/abc",
[
{
pattern: "youtube.com",
enabled: false,
rememberSpeed: false,
controllerOpacity: 0.2
},
{
pattern: "youtube.com/shorts",
enabled: true,
rememberSpeed: true
}
]
);
expect(matched).toEqual(
expect.objectContaining({
enabled: true,
rememberSpeed: true,
controllerOpacity: 0.2
})
);
});
it("matches plain host patterns only on safe hostname boundaries", () => {
const matcher = window.SpeederShared.siteRules;
expect(
matcher.plainSitePatternMatchesUrl(
"example.com",
"https://media.example.com/watch"
)
).toBe(true);
expect(
matcher.plainSitePatternMatchesUrl(
"example.com",
"https://notexample.com/watch"
)
).toBe(false);
expect(
matcher.plainSitePatternMatchesUrl(
"example.com",
"https://example.com.evil.test/watch"
)
).toBe(false);
expect(
matcher.plainSitePatternMatchesUrl(
"example.com",
"https://safe.test/watch?next=example.com"
)
).toBe(false);
});
});
+1 -5
View File
@@ -16,11 +16,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
delete globalThis.SpeederShared;
try {
delete globalThis.restore_options;
} catch {
globalThis.restore_options = undefined;
}
delete globalThis.restore_options;
if (typeof document !== "undefined") {
document.head.innerHTML = "";
document.body.innerHTML = "";
+5 -118
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,67 +24,6 @@ describe("shared helpers", () => {
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
it("matches plain and regex rules against safely decoded URL text", () => {
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "example.com/path?title=Hello World#part one" },
"https://example.com/%70ath?title=Hello%20World#part%20one"
)
).toBe(true);
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "/example\\.com\\/path\\?title=Hello World#part one/i" },
"https://example.com/%70ath?title=Hello%20World#part%20one"
)
).toBe(true);
// Raw matching remains available for rules intentionally written against
// the encoded form.
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "/%70ath\\?title=Hello%20World/" },
"https://example.com/%70ath?title=Hello%20World"
)
).toBe(true);
});
it("tolerates malformed URL escapes and keeps regex matching deterministic", () => {
expect(siteRules.safelyDecodeUrlText("/%70%ZZ%E2/end")).toBe(
"/p%ZZ%E2/end"
);
expect(siteRules.safelyDecodeUrlText("/%E2%82%AC%FF%70")).toBe(
"/€%FFp"
);
expect(
siteRules.siteRuleMatchesUrl(
{ pattern: "/example\\.com\\/p%ZZ%E2/" },
"https://example.com/%70%ZZ%E2"
)
).toBe(true);
const globalRule = { pattern: "/example\\.com\\/watch/g" };
expect(
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
).toBe(true);
expect(
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
).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: {},
@@ -158,14 +97,6 @@ describe("shared helpers", () => {
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
"between 0.1 and 16"
);
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
"between 0.1 and 16"
);
});
it("builds and parses import/export payloads", () => {
@@ -207,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({
@@ -228,39 +149,5 @@ describe("shared helpers", () => {
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false
);
expect(
importExportUtils.isRecognizedRawSettingsObject({
siteRulesFormat: "defaults-diff-v2"
})
).toBe(true);
expect(
importExportUtils.extractImportSettings({ settings: [] })
).toBeNull();
expect(
importExportUtils.extractImportSettings({
settings: { enabled: true },
localSettings: []
})
).toBeNull();
[null, "bad", []].forEach((customButtonIcons) => {
expect(
importExportUtils.extractImportSettings({
settings: { enabled: true },
localSettings: { customButtonIcons }
})
).toBeNull();
});
expect(
importExportUtils.filterLocalSettingsForExport({
customButtonIcons: { faster: { slug: "zap" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 123,
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
futureRuntimeData: true
})
).toEqual({
customButtonIcons: { faster: { slug: "zap" } }
});
});
});
-528
View File
@@ -1,528 +0,0 @@
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootInject(syncData, url, localData) {
loadHtmlString("<!doctype html><html><body></body></html>", {
url: url || "https://example.org/"
});
const chrome = createChromeMock({
syncData: syncData || {},
localData: localData || {}
});
global.chrome = chrome;
window.chrome = chrome;
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
window.requestIdleCallback = (callback) =>
setTimeout(
() => callback({ didTimeout: false, timeRemaining: () => 1 }),
0
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("extension/shared/settings-core.js");
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");
return chrome;
}
async function createVideo() {
await flushAsyncWork(4);
const mount = document.createElement("div");
const video = document.createElement("video");
video.src = "https://example.org/a.mp4";
mount.appendChild(video);
document.body.appendChild(mount);
window.ensureController(video, mount);
return video;
}
describe("durable playback-speed intent", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("activates the built-in Shorts remember-speed rule on empty storage", async () => {
bootInject({}, "https://www.youtube.com/shorts/abc123");
await flushAsyncWork(4);
expect(window.tc.settings.siteRules).toHaveLength(3);
expect(window.tc.activeSiteRule).toEqual(
expect.objectContaining({ rememberSpeed: true })
);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
it("also activates the built-in remember rule on mobile YouTube Shorts", async () => {
bootInject({}, "https://m.youtube.com/shorts/mobile123");
await flushAsyncWork(4);
expect(window.tc.activeSiteRule).toEqual(
expect.objectContaining({ rememberSpeed: true })
);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
it("seeds a brand-new Short from lastSpeed when remember is active", async () => {
bootInject(
{ lastSpeed: 1.8 },
"https://www.youtube.com/shorts/new-source"
);
const video = await createVideo();
expect(window.tc.settings.rememberSpeed).toBe(true);
expect(window.getRememberedSpeed(video)).toBe(1.8);
expect(video.playbackRate).toBe(1.8);
});
it("prefers per-source speed over lastSpeed fallback", async () => {
bootInject(
{ lastSpeed: 2.0, rememberSpeed: true },
"https://example.org/watch",
{
rememberedSpeeds: {
"https://example.org/a.mp4": { speed: 1.4, updatedAt: 100 }
}
}
);
const video = await createVideo();
expect(window.getRememberedSpeed(video)).toBe(1.4);
expect(video.playbackRate).toBe(1.4);
});
it("does not fall back to lastSpeed when remember is off", async () => {
bootInject(
{ lastSpeed: 1.8, rememberSpeed: false },
"https://example.org/watch"
);
const video = await createVideo();
expect(window.getRememberedSpeed(video)).toBeNull();
expect(video.playbackRate).toBe(1);
});
it("does not let an automatic remember-off reset overwrite lastSpeed", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
const video = await createVideo();
video.src = "https://example.org/b.mp4";
window.applySourceTransitionPolicy(video, true);
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(300);
expect(video.playbackRate).toBe(1);
expect(window.tc.settings.lastSpeed).toBe(1.8);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
});
it("persists an explicit Speeder speed even before a native event arrives", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: false });
const video = await createVideo();
window.setSpeed(video, 1.75, false, true);
await vi.advanceTimersByTimeAsync(300);
expect(window.tc.settings.lastSpeed).toBe(1.75);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.75);
});
it("does not treat an external ratechange as saved intent when remember is off", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
const video = await createVideo();
video.playbackRate = 0.75;
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(300);
expect(window.tc.settings.lastSpeed).toBe(1.8);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
});
it("does not let a site reset corrupt saved intent when remember is on", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: true });
const video = await createVideo();
video.playbackRate = 1;
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(600);
expect(window.tc.settings.lastSpeed).toBe(1.8);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
});
it("gives force mode precedence over per-source memory", async () => {
bootInject({
lastSpeed: 1.8,
rememberSpeed: true,
forceLastSavedSpeed: true
});
const video = await createVideo();
const sourceKey = window.getVideoSourceKey(video);
window.tc.settings.speeds[sourceKey] = 1.25;
video.vsc.targetSpeed = 1.25;
video.vsc.targetSpeedSourceKey = sourceKey;
expect(window.getRememberedSpeed(video)).toBe(1.8);
expect(window.getDesiredSpeed(video)).toBe(1.8);
});
it("persists a bounded per-source speed map in local storage", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
const video = await createVideo();
window.setSpeed(video, 1.6, false, true);
await vi.advanceTimersByTimeAsync(600);
const remembered = chrome.storage.local._dump().rememberedSpeeds;
expect(remembered["https://example.org/a.mp4"]).toEqual(
expect.objectContaining({ speed: 1.6 })
);
});
it("hydrates per-source speed before creating the first controller", async () => {
bootInject(
{ lastSpeed: 1.8, rememberSpeed: true },
"https://example.org/watch",
{
rememberedSpeeds: {
"https://example.org/a.mp4": {
speed: 1.4,
updatedAt: 100
}
}
}
);
const video = await createVideo();
expect(video.playbackRate).toBe(1.4);
expect(window.getRememberedSpeed(video)).toBe(1.4);
});
it("merges another frame's source map without losing a queued local speed", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
const video = await createVideo();
window.setSpeed(video, 1.6, false, true);
chrome.storage.local.set({
rememberedSpeeds: {
"https://example.org/other.mp4": {
speed: 1.3,
updatedAt: Date.now() + 1
}
}
});
await vi.advanceTimersByTimeAsync(700);
const remembered = chrome.storage.local._dump().rememberedSpeeds;
expect(remembered["https://example.org/a.mp4"].speed).toBe(1.6);
expect(remembered["https://example.org/other.mp4"].speed).toBe(1.3);
});
it("drops a stale pre-reset write without deleting a newer cross-frame speed", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
const video = await createVideo();
const originalSet = chrome.storage.local.set.getMockImplementation();
let finishStaleSet;
chrome.storage.local.set.mockImplementationOnce((items, callback) => {
finishStaleSet = () => originalSet(items, callback);
});
window.setSpeed(video, 1.6, false, true);
await vi.advanceTimersByTimeAsync(500);
expect(finishStaleSet).toBeTypeOf("function");
const resetAt = Date.now();
chrome.storage.local.set({
rememberedSpeedsResetAt: resetAt,
rememberedSpeeds: {}
});
chrome.storage.local.set({
rememberedSpeeds: {
"https://example.org/new-after-reset.mp4": {
speed: 1.4,
updatedAt: resetAt + 1
}
}
});
finishStaleSet();
await vi.advanceTimersByTimeAsync(0);
expect(chrome.storage.local._dump().rememberedSpeeds).toEqual({
"https://example.org/new-after-reset.mp4": {
speed: 1.4,
updatedAt: resetAt + 1
}
});
expect(window.tc.settings.speeds).not.toHaveProperty(
"https://example.org/a.mp4"
);
expect(window.tc.settings.speeds).toHaveProperty(
"https://example.org/new-after-reset.mp4",
1.4
);
});
it("reapplies the Shorts rule on same-source SPA navigation and resists an immediate reset", async () => {
vi.useFakeTimers();
bootInject(
{ lastSpeed: 1.8 },
"https://www.youtube.com/watch?v=same-source",
{
rememberedSpeeds: {
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
}
}
);
const video = await createVideo();
Object.defineProperty(video, "paused", {
configurable: true,
value: false
});
window.history.pushState({}, "", "/shorts/same-source");
await vi.advanceTimersByTimeAsync(350);
await flushAsyncWork(2);
expect(window.tc.settings.rememberSpeed).toBe(true);
expect(video.playbackRate).toBe(1.8);
video.playbackRate = 1;
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(0);
expect(video.playbackRate).toBe(1.8);
});
it("disarms remembered policy when same-source SPA navigation leaves Shorts", async () => {
vi.useFakeTimers();
bootInject(
{ lastSpeed: 1.8 },
"https://www.youtube.com/shorts/leaving",
{
rememberedSpeeds: {
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
}
}
);
const video = await createVideo();
expect(video.vsc.targetSpeedOrigin).toBe("policy");
window.history.pushState({}, "", "/watch?v=leaving");
await vi.advanceTimersByTimeAsync(350);
await flushAsyncWork(2);
expect(window.tc.settings.rememberSpeed).toBe(false);
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
video.playbackRate = 1;
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(0);
expect(video.playbackRate).toBe(1);
});
it("live-reloads sparse settings without leaking a route override into the global base", async () => {
vi.useFakeTimers();
const chrome = bootInject(
{
rememberSpeed: false,
siteRules: [
{
pattern: "example.org/shorts/",
enabled: true,
rememberSpeed: true
}
]
},
"https://example.org/shorts/one"
);
await createVideo();
expect(window.tc.settings.rememberSpeed).toBe(true);
chrome.storage.sync.set({ controllerOpacity: 0.7 });
await vi.advanceTimersByTimeAsync(100);
expect(window.tc.siteRuleBase.rememberSpeed).toBe(false);
expect(window.tc.settings.rememberSpeed).toBe(true);
window.history.pushState({}, "", "/watch/two");
await vi.advanceTimersByTimeAsync(350);
expect(window.tc.activeSiteRule).toBeNull();
expect(window.tc.settings.rememberSpeed).toBe(false);
});
it("does not trust forged force-mode ratechange metadata as user intent", async () => {
vi.useFakeTimers();
const chrome = bootInject({
lastSpeed: 1.8,
forceLastSavedSpeed: true
});
const video = await createVideo();
video.vsc.pendingRateChange = null;
video.dispatchEvent(
new CustomEvent("ratechange", {
detail: {
origin: "videoSpeed",
speed: 0.5,
fromUserInput: true
}
})
);
await vi.advanceTimersByTimeAsync(300);
expect(video.playbackRate).toBe(1.8);
expect(window.tc.settings.lastSpeed).toBe(1.8);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
});
it("ignores a stale popup speed payload and can still persist that value later", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 2 });
const video = await createVideo();
const listener = chrome.runtime.onMessage.listeners[0];
let response;
listener(
{
action: "set_force_last_saved_speed",
enabled: true,
speed: 1.5
},
{},
(value) => {
response = value;
}
);
expect(response.enabled).toBe(true);
expect(window.tc.settings.lastSpeed).toBe(2);
expect(window.tc.persistedLastSpeed).toBe(2);
window.setSpeed(video, 1.5, false, true);
await vi.advanceTimersByTimeAsync(300);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.5);
});
it("clears the anti-reset window when force mode is turned off", async () => {
const chrome = bootInject({
lastSpeed: 1.8,
forceLastSavedSpeed: true
});
const video = await createVideo();
video.vsc.speedRestoreUntil = Date.now() + 5000;
video.vsc.restoreSpeedTimer = setTimeout(() => {}, 5000);
const listener = chrome.runtime.onMessage.listeners[0];
listener(
{ action: "set_force_last_saved_speed", enabled: false },
{},
() => {}
);
expect(window.tc.settings.forceLastSavedSpeed).toBe(false);
expect(video.vsc.speedRestoreUntil).toBe(0);
expect(video.vsc.restoreSpeedTimer).toBeNull();
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
video.playbackRate = 1;
video.dispatchEvent(new Event("ratechange"));
expect(video.playbackRate).toBe(1);
});
it("keeps force ownership through a redundant event so disabling force fully releases it", async () => {
const chrome = bootInject({
lastSpeed: 1.8,
forceLastSavedSpeed: true
});
const video = await createVideo();
const listener = chrome.runtime.onMessage.listeners[0];
video.dispatchEvent(new Event("ratechange"));
expect(video.vsc.targetSpeedOrigin).toBe("policy");
listener(
{ action: "set_force_last_saved_speed", enabled: false },
{},
() => {}
);
video.playbackRate = 1;
video.dispatchEvent(new Event("ratechange"));
expect(video.playbackRate).toBe(1);
expect(video.vsc.targetSpeedOrigin).toBe("external");
});
it("accepts a native speed change while paused when no Speeder policy owns the target", async () => {
vi.useFakeTimers();
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
const video = await createVideo();
expect(video.paused).toBe(true);
expect(video.vsc.targetSpeedOrigin).toBe("initial");
video.playbackRate = 1.5;
video.dispatchEvent(new Event("ratechange"));
await vi.advanceTimersByTimeAsync(300);
expect(video.playbackRate).toBe(1.5);
expect(video.vsc.targetSpeedOrigin).toBe("external");
expect(window.tc.settings.lastSpeed).toBe(1.8);
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
});
it("defensively replaces an invalid zero speed step instead of jumping to 16x", async () => {
bootInject({
keyBindings: [
{
action: "faster",
code: "KeyD",
value: 0,
disabled: false
}
]
});
const video = await createVideo();
window.runAction("faster", 0, null, video);
expect(video.playbackRate).toBeCloseTo(1.1, 5);
expect(video.playbackRate).not.toBe(16);
});
it("bounds verification retries when a player silently rejects forced speed", async () => {
vi.useFakeTimers();
bootInject({ lastSpeed: 1.8, forceLastSavedSpeed: true });
const video = await createVideo();
await vi.advanceTimersByTimeAsync(200);
let assignmentCount = 0;
Object.defineProperty(video, "playbackRate", {
configurable: true,
get() {
return 1;
},
set() {
assignmentCount += 1;
}
});
window.setSpeed(video, 1.8, false, false);
await vi.advanceTimersByTimeAsync(2000);
expect(assignmentCount).toBe(4);
expect(video.vsc.speedVerificationTimer).toBeNull();
expect(video.vsc.speedVerificationAttempts).toBe(0);
});
});
+2 -6
View File
@@ -16,10 +16,6 @@ var vscUiIconPaths = {
slower: '<line x1="5" y1="12" x2="19" y2="12"/>',
faster:
'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
softer:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="16" y1="12" x2="22" y2="12"/>',
louder:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="17" y1="9" x2="17" y2="15"/><line x1="14" y1="12" x2="20" y2="12"/>',
moreHorizontal:
'<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',
@@ -76,7 +72,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 +90,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"]
}
});